From b3d3558b312065b6d281f0c67ffc8c6cb0b8aa84 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 21:31:50 +0000 Subject: [PATCH] cred: stage T27 core fixes and security regressions before T26 merge (WIP) --- host/src/cred/edits.rs | 11 +- host/src/cred/grants.rs | 79 +++++--- host/src/cred/inject.rs | 60 +++--- host/src/cred/items.rs | 81 ++++++-- host/src/cred/schema.rs | 7 +- host/src/cred/types.rs | 6 +- host/src/cred/webauthn.rs | 326 ++++++++++++------------------- host/tests/cred.rs | 27 +-- host/tests/cred_authorization.rs | 36 ++++ host/tests/cred_challenges.rs | 26 +++ host/tests/cred_core.rs | 22 ++- host/tests/cred_edits.rs | 28 +-- host/tests/cred_grants.rs | 21 +- host/tests/cred_policy.rs | 44 +++++ host/tests/cred_presentation.rs | 16 ++ host/tests/cred_regressions.rs | 45 +++-- host/tests/cred_replay.rs | 36 ++-- host/tests/cred_security.rs | 53 +++++ host/tests/cred_storage.rs | 30 +++ host/tests/cred_summary.rs | 6 +- 20 files changed, 594 insertions(+), 366 deletions(-) create mode 100644 host/tests/cred_authorization.rs create mode 100644 host/tests/cred_challenges.rs create mode 100644 host/tests/cred_policy.rs create mode 100644 host/tests/cred_presentation.rs create mode 100644 host/tests/cred_security.rs create mode 100644 host/tests/cred_storage.rs diff --git a/host/src/cred/edits.rs b/host/src/cred/edits.rs index e369474..21d7e7d 100644 --- a/host/src/cred/edits.rs +++ b/host/src/cred/edits.rs @@ -564,10 +564,13 @@ impl super::Cred { self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff})) } /// Replace a pending edit's base version after the UI reviews the current item. - pub fn resolve_stale(&self, id: &str, base_version: i64) -> Result<()> { + pub fn resolve_stale(&self, id: &str, base_version: i64, caller: &Caller, proof: impl Into) -> Result<()> { let mut s = self.access()?; let tx = s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::ResolveStale, + &serde_json::json!({"request_id":id,"base_version":base_version}))?; + let approver = proof.into().check(&tx, &binding)?; let (payload, state): (String, String) = tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| { Ok((r.get(0)?, r.get(1)?)) @@ -595,10 +598,14 @@ impl super::Cred { payload["edit"] = serde_json::to_value(edit)?; payload["diff"] = serde_json::to_value(diff)?; } + let encoded = serde_json::to_string(&payload)?; tx.execute( "UPDATE requests SET payload=?2 WHERE id=?1", - params![id, serde_json::to_string(&payload)?], + params![id, encoded], )?; + audit::record(&tx, "request.resolve_stale", caller, + payload.get("edit").or_else(|| payload.get("revert")).and_then(|p| p.get("item")).and_then(|v| v.as_str()), approver.as_deref())?; + tx.execute("UPDATE audit SET payload_sha256=?1 WHERE id=?2", params![super::webauthn::digest(encoded.as_bytes()), tx.last_insert_rowid()])?; tx.commit()?; Ok(()) } diff --git a/host/src/cred/grants.rs b/host/src/cred/grants.rs index 3d04654..27da8c2 100644 --- a/host/src/cred/grants.rs +++ b/host/src/cred/grants.rs @@ -24,7 +24,9 @@ pub struct Grant { } pub(crate) fn list(db: &Connection) -> Result> { let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?; - Ok(q.query_map([], |r| { + Ok(q.query_map([], read)?.collect::>()?) +} +fn read(r: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Grant { id: r.get(0)?, items: serde_json::from_str(&r.get::<_, String>(1)?) @@ -42,8 +44,10 @@ pub(crate) fn list(db: &Connection) -> Result> { validator: r.get(7)?, note: r.get(8)?, }) - })? - .collect::>()?) +} +fn active(db: &Connection, item: &str, caller: &Caller) -> Result> { + let mut q = db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants WHERE revoked=0 AND (exp IS NULL OR exp>?1) AND (max_uses IS NULL OR uses>()?) } pub(crate) fn insert( db: &Connection, @@ -53,7 +57,7 @@ pub(crate) fn insert( ) -> Result<()> { validate(db, grant)?; db.execute( - "INSERT INTO grants VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)", + "INSERT INTO grants(id,items,subject,exp,max_uses,uses,revoked,validator,note) VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)", params![ grant.id, serde_json::to_string(&grant.items)?, @@ -92,8 +96,9 @@ impl Cred { &self, mut grant: Grant, caller: &Caller, - approver: Option<&str>, + proof: impl Into, ) -> Result { + let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::GrantCreate, &grant)?; grant.id = seal::identity(); grant.uses = 0; grant.revoked = false; @@ -102,7 +107,8 @@ impl Cred { let mut s = self.access()?; let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; - insert(&tx, &grant, caller, approver)?; + let approver = proof.into().check(&tx, &binding)?; + insert(&tx, &grant, caller, approver.as_deref())?; tx.commit()?; Ok(grant) } @@ -115,23 +121,45 @@ impl Cred { .find(|g| g.id == id) .ok_or(Error("grant_missing")) } + /// The harness supplies every conversation in the subtree, not only its root. pub fn revoke_subjects( &self, subjects: &[String], caller: &Caller, - approver: Option<&str>, + proof: impl Into, ) -> Result<()> { let mut s = self.access()?; - let tx = s.db.transaction()?; + let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::RevokeSubjects, &subjects)?; + let approver = proof.into().check(&tx, &binding)?; for subject in subjects { tx.execute( - "UPDATE grants SET revoked=1 WHERE subject=?", + "UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE subject=?", [serde_json::to_string(&Subject::Conversation( subject.clone(), ))?], )?; } - audit::record(&tx, "grant.revoke", caller, None, approver)?; + audit::record(&tx, "grant.revoke", caller, None, approver.as_deref())?; + tx.commit()?; + Ok(()) + } + /// Removing access needs no assertion, including after passkey registration. + pub fn revoke_grant(&self, id: &str, caller: &Caller) -> Result<()> { + let mut s = self.access()?; + let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + if tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE id=?", [id])? != 1 { + return Err(Error("grant_missing")); + } + audit::record(&tx, "grant.revoke", caller, None, None)?; + tx.commit()?; + Ok(()) + } + pub fn revoke_program(&self, program: &str, caller: &Caller) -> Result<()> { + let mut s = self.access()?; + let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE subject=?", [serde_json::to_string(&Subject::Program(program.into()))?])?; + audit::record(&tx, "grant.revoke", caller, None, None)?; tx.commit()?; Ok(()) } @@ -139,32 +167,23 @@ impl Cred { &self, item: &super::items::Item, caller: &Caller, - ) -> Result> { + ) -> Result, serde_json::Value, i64)>> { let mut s = self.access()?; let master = s.master.clone(); let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; - let grant = list(&tx)?.into_iter().find(|g| { - g.items.iter().any(|i| i == &item.id) - && g.subject.covers(caller) - && !g.revoked - && g.exp.is_none_or(|e| e > now()) - && g.max_uses.is_none_or(|m| g.uses < m) - }); - if let Some(mut g) = grant { - if super::items::load(&tx, &item.id)?.version != item.version { - return Err(Error("stale")); - } - let credential = super::items::credential(&tx, &master, item, true)?; + let mut grant = if item.free_use { None } else { active(&tx, &item.id, caller)?.into_iter().next() }; + if !item.free_use && grant.is_none() { return Ok(None); } + if super::items::load(&tx, &item.id)?.version != item.version { return Err(Error("stale")); } + let credential = super::items::credential(&tx, &master, item, true)?; + if let Some(g) = &mut grant { let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses bool { - item.rules.iter().any(|r| { - super::items::rule_matches( + matches_rules(&item.rules, p) +} +fn matches_rules(rules: &[super::items::Rule], p: &Parts) -> bool { + if !safe_uri(&p.uri) { return false; } + rules.iter().any(|r| { + super::items::rule_matches_port( r, p.uri.scheme_str().unwrap_or(""), p.uri.host().unwrap_or(""), p.uri.path(), p.method.as_str(), + p.uri.port_u16(), ) }) } +fn safe_uri(uri: &http::Uri) -> bool { + uri.authority().is_some_and(|a| !a.as_str().contains('@')) + && super::items::safe_path(uri.path()) +} fn request(p: &Parts) -> Result { let mut headers = Vec::new(); for (name, value) in &p.headers { @@ -45,17 +54,20 @@ impl Cred { caller: &Caller, script: &dyn Script, ) -> Result { - let candidates: Vec<_> = self - .item_list()? - .into_iter() - .filter(|i| matches(i, &parts)) - .collect(); + if !safe_uri(&parts.uri) { return Err(Error("invalid_url")); } + let candidates: Vec<_> = { + let s = self.access()?; + s.rules.iter().filter(|(_, (_, rules))| matches_rules(rules, &parts)) + .map(|(id, (version, _))| (id.clone(), *version)).collect() + }; if candidates.len() > 1 { return Err(Error("ambiguous_item")); } - let Some(item) = candidates.into_iter().next() else { + let Some((id, version)) = candidates.into_iter().next() else { return Ok(Outcome::Forward(parts)); }; + let item = self.item_get(&id)?; + if item.version != version { return Err(Error("stale")); } let original_request = request(&parts)?; if item .fields @@ -64,13 +76,11 @@ impl Cred { { return Err(Error("ssh_item")); } - let prepared = if item.free_use { - Some((None, self.credential(&item, true)?)) - } else { - self.reserve(&item, caller)? - .map(|(grant, credential)| (Some(grant), credential)) - }; - let Some((grant, secret_credential)) = prepared else { + if item.fields.iter().any(|f| f.field_type == super::types::FieldType::Oauth2RefreshToken) { + return Err(Error("oauth2_item")); + } + let prepared = self.reserve(&item, caller)?; + let Some((grant, secret_credential, audit_id)) = prepared else { let request = self.file_request( caller, &format!("inject:{}", item.id), @@ -142,10 +152,12 @@ impl Cred { .get("value") .and_then(Value::as_str) .ok_or(Error("transform"))?; + let mut value = http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?; + value.set_sensitive(true); new.append( http::header::HeaderName::from_bytes(name.as_bytes()) .map_err(|_| Error("transform"))?, - http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?, + value, ); } parts.headers = new; @@ -154,19 +166,13 @@ impl Cred { if !matches(&item, &parts) { return Err(Error("rule_recheck")); } + let authority = parts.uri.authority().ok_or(Error("rule_recheck"))?; + parts.headers.insert(http::header::HOST, + http::HeaderValue::from_str(authority.as_str()).map_err(|_| Error("rule_recheck"))?); Ok(()) })(); - super::audit::record( - &self.access()?.db, - if result.is_ok() { - "fetch.use" - } else { - "fetch.denied" - }, - caller, - Some(&item.id), - None, - )?; + self.access()?.db.execute("UPDATE audit SET event=?1 WHERE id=?2", + rusqlite::params![if result.is_ok() { "fetch.use" } else { "fetch.denied" }, audit_id])?; result?; Ok(Outcome::Forward(parts)) } diff --git a/host/src/cred/items.rs b/host/src/cred/items.rs index 4ba8579..2e5ef07 100644 --- a/host/src/cred/items.rs +++ b/host/src/cred/items.rs @@ -10,12 +10,25 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Rule { pub scheme: String, + #[serde(default)] + pub port: Option, pub hosts: Vec, pub path_prefixes: Vec, pub methods: Option>, } +/// Match a request on the scheme's default port. pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool { - r.scheme.eq_ignore_ascii_case(scheme) + rule_matches_port(r, scheme, host, path, method, None) +} +/// Missing ports mean the scheme's default, not any port. +pub fn rule_matches_port(r: &Rule, scheme: &str, host: &str, path: &str, method: &str, port: Option) -> bool { + let default = match scheme.to_ascii_lowercase().as_str() { + "http" => Some(80), "https" => Some(443), "ssh" => Some(22), _ => None, + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + safe_path(path) + && r.port.or(default) == port.or(default) + && r.scheme.eq_ignore_ascii_case(scheme) && r.hosts.iter().any(|p| { if let Some(s) = p.strip_prefix("*.") { host.to_ascii_lowercase() @@ -30,6 +43,20 @@ pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str .as_ref() .is_none_or(|ms| ms.iter().any(|m| m.eq_ignore_ascii_case(method))) } +pub(crate) fn safe_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + !path.contains('\\') && !lower.contains("%2e") && !lower.contains("%2f") + && !lower.contains("%5c") && !path.split('/').any(|s| s == "." || s == "..") +} +fn local_host(host: &str) -> bool { + let host = host.strip_prefix("*.").unwrap_or(host); + host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".localhost") + || host.parse::().is_ok_and(|ip| match ip { + std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_link_local(), + std::net::IpAddr::V6(ip) => ip.is_loopback() || ip.is_unicast_link_local() + || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback() || v4.is_link_local()), + }) +} #[derive(Clone, Serialize, Deserialize)] pub struct Field { #[serde(default)] @@ -126,7 +153,9 @@ pub(crate) fn validate(item: &Item) -> Result<()> { if !matches!( r.scheme.to_ascii_lowercase().as_str(), "http" | "https" | "ssh" - ) || r.path_prefixes.iter().any(|p| !p.starts_with('/')) + ) || r.port == Some(0) + || (r.port.is_none() && r.hosts.iter().any(|h| local_host(h))) + || r.path_prefixes.iter().any(|p| !p.starts_with('/') || !safe_path(p)) || r.methods.as_ref().is_some_and(|ms| { ms.iter().any(|m| { m.is_empty() || !m.bytes().all(|b| b.is_ascii_alphabetic() || b == b'-') @@ -187,9 +216,14 @@ pub struct ItemVersion { pub created: i64, pub config: Item, } +/// Plain entry is bootstrap-only. Verified entry decrypts the exact bound envelope here. +pub enum FieldValue<'a> { + Plain(&'a str), + Encrypted { key: &'a super::entry::EntryKey, envelope: &'a super::entry::Envelope }, +} /// A secret-entry value with optional replacement metadata. Debug never includes the value. pub struct FieldInput<'a> { - pub value: &'a str, + pub value: FieldValue<'a>, pub field_type: Option, pub key_type: Option, } @@ -202,13 +236,17 @@ impl std::fmt::Debug for FieldInput<'_> { } } impl Cred { - /// Trusted host mutation. The route layer must verify a passkey before this call. - pub fn create_item(&self, item: Item, caller: &Caller, approver: &str) -> Result { + /// Create an item only with an exact proof or a transaction-checked bootstrap value. + pub fn create_item(&self, item: Item, caller: &Caller, proof: impl Into) -> Result { let mut s = self.access()?; let tx = s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; - let item = insert_new(&tx, item, caller, Some(approver))?; + validate(&item)?; + let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::CreateItem, &item)?; + let approver = proof.into().check(&tx, &binding)?; + let item = insert_new(&tx, item, caller, approver.as_deref())?; tx.commit()?; + s.refresh_rules()?; Ok(item) } pub fn item_get(&self, id: &str) -> Result { @@ -242,18 +280,18 @@ impl Cred { field: &str, value: &str, caller: &Caller, - approver: &str, + proof: impl Into, ) -> Result<()> { self.set_field_input( item, field, &FieldInput { - value, + value: FieldValue::Plain(value), field_type: None, key_type: None, }, caller, - approver, + proof, ) } pub fn set_field_input( @@ -262,12 +300,28 @@ impl Cred { field: &str, input: &FieldInput<'_>, caller: &Caller, - approver: &str, + proof: impl Into, ) -> Result<()> { - let value = input.value; let mut s = self.access()?; let key = s.master.clone(); - let tx = s.db.transaction()?; + let tx = s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let proof = proof.into(); + if matches!(&proof, super::webauthn::Proof::Verified(_)) && matches!(&input.value, FieldValue::Plain(_)) { + return Err(Error("binding_mismatch")); + } + let hash = match &input.value { FieldValue::Plain(_) => String::new(), FieldValue::Encrypted { envelope, .. } => envelope.sha256() }; + let approver = proof.check(&tx, &super::webauthn::Binding::SecretEntry { + item: item.into(), field: field.into(), ciphertext_sha256: hash, + field_type: input.field_type.clone(), key_type: input.key_type.clone(), + })?; + let decrypted; + let value = match &input.value { + FieldValue::Plain(value) => *value, + FieldValue::Encrypted { key, envelope } => { + decrypted = zeroize::Zeroizing::new(key.decrypt(envelope).map_err(|_| Error("secret_envelope"))?); + std::str::from_utf8(&decrypted).map_err(|_| Error("field_invalid"))? + }, + }; let mut config = load(&tx, item)?; let index = config .fields @@ -305,8 +359,9 @@ impl Cred { .checked_add(1) .ok_or(Error("version_exhausted"))?; save(&tx, &config)?; - super::audit::record(&tx, "field.set", caller, Some(item), Some(approver))?; + super::audit::record(&tx, "field.set", caller, Some(item), approver.as_deref())?; tx.commit()?; + s.refresh_rules()?; Ok(()) } pub(crate) fn credential(&self, item: &Item, secrets: bool) -> Result { diff --git a/host/src/cred/schema.rs b/host/src/cred/schema.rs index 9dd0a4b..bdd8090 100644 --- a/host/src/cred/schema.rs +++ b/host/src/cred/schema.rs @@ -1,12 +1,15 @@ //! Fresh credentials schema. No v2 migrations or vault lifecycle. pub(crate) const DDL: &str = " PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; +CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value BLOB NOT NULL); +CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL); CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, config TEXT NOT NULL); CREATE TABLE IF NOT EXISTS fields(item TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, id TEXT NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY(item,id)); CREATE TABLE IF NOT EXISTS versions(item TEXT NOT NULL, created INTEGER NOT NULL, config TEXT NOT NULL); -CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT); -CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT); +CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT, revoked_at INTEGER); +CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT, decided_at INTEGER); CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY, time INTEGER NOT NULL, event TEXT NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, conversation TEXT, item TEXT, approver TEXT, payload_sha256 TEXT); +CREATE INDEX IF NOT EXISTS grant_live ON grants(revoked,exp); CREATE INDEX IF NOT EXISTS request_keys ON requests(subject,key); CREATE INDEX IF NOT EXISTS request_expiry ON requests(state,expires); "; diff --git a/host/src/cred/types.rs b/host/src/cred/types.rs index 8cae2af..eaed01b 100644 --- a/host/src/cred/types.rs +++ b/host/src/cred/types.rs @@ -1,7 +1,11 @@ //! Caller identity and mandatory field metadata. Reject mismatched key algorithms. use super::error::{Error, Result}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +/// The host builds identity from the process table, never from a request body. +/// ```compile_fail +/// let _: sausage::cred::Caller = serde_json::from_str("{}").unwrap(); +/// ``` +#[derive(Clone, Debug, Default, Serialize)] pub struct Caller { pub program: String, pub pid: u32, diff --git a/host/src/cred/webauthn.rs b/host/src/cred/webauthn.rs index f40417f..ba01583 100644 --- a/host/src/cred/webauthn.rs +++ b/host/src/cred/webauthn.rs @@ -1,246 +1,176 @@ -//! Passkey enrollment and single-use assertions for host-owned bindings. -//! The caller supplies cred.sqlite and serializes access to this manager. -//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions -//! must bind the exact new label. Neither decision nor secret-entry assertions -//! authorize enrollment. Without passkeys, the host is unlocked and permits -//! first registration without a gate. Restart drops all pending challenges. +//! Passkey assertions produce private, single-use proofs for exact host-owned bindings. +//! Bootstrap proofs remain valid only while the decision transaction finds no passkeys. +//! The caller serializes access to this manager and supplies the credentials database. use anyhow::{Result, bail}; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - time::{Duration, Instant}, -}; +use std::{collections::HashMap, time::{Duration, Instant}}; use webauthn_rs::prelude::*; +use super::{requests::Overrides, types::FieldType}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all="snake_case")] +pub enum Decision { Approve, Deny } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all="snake_case")] +pub enum Mutation { CreateItem, GrantCreate, RevokeSubjects, ResolveStale } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type")] +#[serde(tag="type", deny_unknown_fields)] pub enum Binding { - Decision { - request_id: String, - decision: String, - overrides: serde_json::Value, - }, - SecretEntry { - item: String, - field: String, - ciphertext_sha256: String, - }, - Enrollment { - label: String, - }, + Decision { request_id:String, decision:Decision, overrides:Overrides, payload_sha256:String }, + SecretEntry { item:String, field:String, ciphertext_sha256:String, field_type:Option, key_type:Option }, + Mutation { action:Mutation, parameters_sha256:String }, + Enrollment { label:String }, } - -#[derive(Serialize, Deserialize)] -pub struct Gate { - pub id: String, - pub response: PublicKeyCredential, +impl Binding { + pub fn mutation(action:Mutation, parameters:&impl Serialize) -> super::Result { + Ok(Self::Mutation { action, parameters_sha256:digest(&serde_json::to_vec(parameters)?) }) + } +} +pub(crate) fn digest(bytes:&[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} +/// Only successful WebAuthn verification constructs this value. It cannot be cloned. +pub struct Verified { credential:CredentialID, binding:Binding } +impl Verified { + pub fn credential(&self) -> &CredentialID { &self.credential } + pub fn binding(&self) -> &Binding { &self.binding } } - -#[derive(Serialize, Deserialize)] -pub struct Challenge { - pub id: String, - pub options: T, +/// The constructor checks the registry. Every mutation checks it again in its transaction. +#[derive(Clone, Copy)] +pub struct Unlocked(()); +pub enum Proof { Verified(Verified), Unlocked(Unlocked) } +impl From for Proof { fn from(v:Verified) -> Self { Self::Verified(v) } } +impl From for Proof { fn from(v:Unlocked) -> Self { Self::Unlocked(v) } } +impl Proof { + pub(crate) fn check(self, db:&Connection, expected:&Binding) -> super::Result> { + match self { + Self::Unlocked(_) => { + let unlocked:bool = db.query_row("SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",[],|r|r.get(0))?; + if !unlocked { return Err(super::Error("assertion_required")); } + Ok(None) + }, + Self::Verified(v) => { + if &v.binding != expected { return Err(super::Error("binding_mismatch")); } + let id = serde_json::to_string(&v.credential)?; + let registered:bool = db.query_row("SELECT EXISTS(SELECT 1 FROM passkey_credentials WHERE id=?)",[&id],|r|r.get(0))?; + if !registered { return Err(super::Error("assertion_required")); } + Ok(Some(id)) + }, + } + } } - -struct Pending { - deadline: Instant, - value: T, +impl super::Cred { + pub fn unlocked(&self) -> super::Result { + let s = self.access()?; + let unlocked:bool = s.db.query_row("SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",[],|r|r.get(0))?; + if !unlocked { return Err(super::Error("assertion_required")); } + Ok(Unlocked(())) + } } - +#[derive(Serialize, Deserialize)] +pub struct Gate { pub id:String, pub response:PublicKeyCredential } +#[derive(Serialize, Deserialize)] +pub struct Challenge { pub id:String, pub options:T } +struct Pending { deadline:Instant, value:T } pub struct Passkeys<'a> { - db: &'a Connection, - webauthn: Webauthn, - registrations: HashMap>, - assertions: HashMap>, + db:&'a Connection, webauthn:Webauthn, + registrations:HashMap>, + assertions:HashMap>, } - impl<'a> Passkeys<'a> { - pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result { + pub fn new(db:&'a Connection,rp_id:&str,origin:&str) -> Result { let origin = Url::parse(origin)?; - let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?; - db.execute_batch( - "CREATE TABLE IF NOT EXISTS passkey_credentials ( - id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)", - )?; - Ok(Self { - db, - webauthn, - registrations: HashMap::new(), - assertions: HashMap::new(), - }) + let webauthn = WebauthnBuilder::new(rp_id,&origin)?.build()?; + db.execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?; + Ok(Self { db, webauthn, registrations:HashMap::new(), assertions:HashMap::new() }) } - /// Database errors leave the manager locked. pub fn is_unlocked(&self) -> bool { - self.db - .query_row( - "SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)", - [], - |row| row.get(0), - ) - .unwrap_or(false) + self.db.query_row("SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",[],|r|r.get(0)).unwrap_or(false) } - fn credentials(&self) -> Result> { - let mut stmt = self - .db - .prepare("SELECT credential FROM passkey_credentials")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect() + let mut q = self.db.prepare("SELECT credential FROM passkey_credentials")?; + q.query_map([],|r|r.get::<_,String>(0))?.map(|r|Ok(serde_json::from_str(&r?)?)).collect() } - - pub fn start_registration( - &mut self, - label: String, - gate: Option, - ) -> Result> { + pub fn start_registration(&mut self,label:String,gate:Option) -> Result> { let gated = gate.is_some(); match gate { None if !self.is_unlocked() => bail!("enrollment assertion required"), - None => {} - Some(Gate { id, response }) => { - let (_, binding) = self.finish_assertion(&id, response)?; - if binding - != (Binding::Enrollment { - label: label.clone(), - }) - { - bail!("invalid enrollment gate"); - } - } - } - let exclude = self - .credentials()? - .iter() - .map(|p| p.cred_id().clone()) - .collect(); - let (options, state) = self.webauthn.start_passkey_registration( - Uuid::new_v4(), - &label, - &label, - Some(exclude), - )?; - self.registrations - .retain(|_, p| p.deadline > Instant::now()); - let id = Uuid::new_v4().to_string(); - self.registrations.insert( - id.clone(), - Pending { - deadline: Instant::now() + Duration::from_secs(300), - value: (label, gated, state), + None => {}, + Some(Gate { id,response }) => { + let proof = self.finish_assertion(&id,response)?; + if proof.binding != (Binding::Enrollment { label:label.clone() }) { bail!("invalid enrollment gate"); } }, - ); - Ok(Challenge { id, options }) - } - - pub fn finish_registration( - &mut self, - id: &str, - response: RegisterPublicKeyCredential, - ) -> Result { - let (label, gated, state) = take(&mut self.registrations, id)?; - if !gated && !self.is_unlocked() { - bail!("enrollment assertion required"); } - let credential = self - .webauthn - .finish_passkey_registration(&response, &state)?; - self.db.execute( - "INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)", - params![ - serde_json::to_string(credential.cred_id())?, - label, - serde_json::to_string(&credential)? - ], - )?; + let exclude = self.credentials()?.iter().map(|p|p.cred_id().clone()).collect(); + let (options,state) = self.webauthn.start_passkey_registration(Uuid::new_v4(),&label,&label,Some(exclude))?; + trim(&mut self.registrations); + let id = Uuid::new_v4().to_string(); + self.registrations.insert(id.clone(),Pending { deadline:Instant::now()+Duration::from_secs(300), value:(label,gated,state) }); + Ok(Challenge { id,options }) + } + pub fn finish_registration(&mut self,id:&str,response:RegisterPublicKeyCredential) -> Result { + let unlocked = self.is_unlocked(); + let (label,gated,state) = pending(&mut self.registrations,id)?; + if !*gated && !unlocked { bail!("enrollment assertion required"); } + let credential = self.webauthn.finish_passkey_registration(&response,state)?; + self.db.execute("INSERT INTO passkey_credentials(id,label,credential) VALUES(?1,?2,?3)",params![serde_json::to_string(credential.cred_id())?,label,serde_json::to_string(&credential)?])?; + self.registrations.remove(id); Ok(credential) } - - pub fn start_assertion( - &mut self, - binding: Binding, - ) -> Result> { - let (options, state) = self - .webauthn - .start_passkey_authentication(&self.credentials()?)?; - self.assertions.retain(|_, p| p.deadline > Instant::now()); + pub fn start_assertion(&mut self,binding:Binding) -> Result> { + let (options,state) = self.webauthn.start_passkey_authentication(&self.credentials()?)?; + trim(&mut self.assertions); let id = Uuid::new_v4().to_string(); - self.assertions.insert( - id.clone(), - Pending { - deadline: Instant::now() + Duration::from_secs(300), - value: (binding, state), - }, - ); - Ok(Challenge { id, options }) + self.assertions.insert(id.clone(),Pending { deadline:Instant::now()+Duration::from_secs(300), value:(binding,state) }); + Ok(Challenge { id,options }) } - - pub fn finish_assertion( - &mut self, - id: &str, - response: PublicKeyCredential, - ) -> Result<(CredentialID, Binding)> { - let (binding, state) = take(&mut self.assertions, id)?; - let result = self - .webauthn - .finish_passkey_authentication(&response, &state)?; + pub fn finish_assertion(&mut self,id:&str,response:PublicKeyCredential) -> Result { + let (binding,state) = pending(&mut self.assertions,id)?; + let binding = binding.clone(); + let result = self.webauthn.finish_passkey_authentication(&response,state)?; let mut credentials = self.credentials()?; - let credential = credentials - .iter_mut() - .find(|p| p.cred_id() == result.cred_id()) - .ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?; - // Check the latest stored counter too. Another outstanding assertion may - // contain an older snapshot of this credential. + let credential = credentials.iter_mut().find(|p|p.cred_id()==result.cred_id()).ok_or_else(||anyhow::anyhow!("unregistered passkey"))?; let stored = serde_json::to_value(&*credential)?; - let counter = stored["cred"]["counter"] - .as_u64() - .ok_or_else(|| anyhow::anyhow!("invalid stored passkey counter"))?; - if (counter > 0 || result.counter() > 0) && u64::from(result.counter()) <= counter { - bail!("passkey counter did not increase"); - } + let counter = stored["cred"]["counter"].as_u64().ok_or_else(||anyhow::anyhow!("invalid stored passkey counter"))?; + if (counter>0 || result.counter()>0) && u64::from(result.counter())<=counter { bail!("passkey counter did not increase"); } credential.update_credential(&result); - self.db.execute( - "UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2", - params![ - serde_json::to_string(credential)?, - serde_json::to_string(result.cred_id())? - ], - )?; - Ok((result.cred_id().clone(), binding)) + self.db.execute("UPDATE passkey_credentials SET credential=?1 WHERE id=?2",params![serde_json::to_string(credential)?,serde_json::to_string(result.cred_id())?])?; + self.assertions.remove(id); + Ok(Verified { credential:result.cred_id().clone(),binding }) } } - -fn take(map: &mut HashMap>, id: &str) -> Result { - let pending = map - .remove(id) - .ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?; - if pending.deadline <= Instant::now() { - bail!("expired challenge"); +fn pending<'a,T>(map:&'a mut HashMap>,id:&str) -> Result<&'a T> { + let expired = map.get(id).ok_or_else(||anyhow::anyhow!("unknown or consumed challenge"))?.deadline<=Instant::now(); + if expired { map.remove(id); bail!("expired challenge"); } + Ok(&map.get(id).expect("checked challenge").value) +} +fn trim(map:&mut HashMap>) { + map.retain(|_,p|p.deadline>Instant::now()); + if map.len()>=32 { + let id = map.iter().min_by_key(|(_,p)|p.deadline).map(|(id,_)|id.clone()).expect("full challenge map"); + map.remove(&id); } - Ok(pending.value) } - #[cfg(test)] mod tests { use super::*; - #[test] fn expired_assertion() -> Result<()> { - use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + use webauthn_authenticator_rs::{WebauthnAuthenticator,softpasskey::SoftPasskey}; let db = Connection::open_in_memory()?; - let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000")?; - let start = host.start_registration("test".into(), None)?; + let mut host = Passkeys::new(&db,"localhost","http://localhost:8000")?; + let start = host.start_registration("test".into(),None)?; let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); let origin = Url::parse("http://localhost:8000")?; - let response = auth.do_registration(origin.clone(), start.options)?; - host.finish_registration(&start.id, response)?; - let start = host.start_assertion(Binding::Enrollment { - label: "next".into(), - })?; - let response = auth.do_authentication(origin, start.options)?; - host.assertions.get_mut(&start.id).unwrap().deadline = Instant::now(); - assert!(host.finish_assertion(&start.id, response).is_err()); + let response = auth.do_registration(origin.clone(),start.options)?; + host.finish_registration(&start.id,response)?; + let start = host.start_assertion(Binding::Enrollment { label:"next".into() })?; + let response = auth.do_authentication(origin,start.options)?; + host.assertions.get_mut(&start.id).unwrap().deadline=Instant::now(); + assert!(host.finish_assertion(&start.id,response).is_err()); assert!(!host.assertions.contains_key(&start.id)); Ok(()) } diff --git a/host/tests/cred.rs b/host/tests/cred.rs index 450337f..1a048eb 100644 --- a/host/tests/cred.rs +++ b/host/tests/cred.rs @@ -21,6 +21,7 @@ fn item(free_use: bool) -> Item { version: 0, free_use, rules: vec![Rule { + port: None, scheme: "https".into(), hosts: vec!["api.example".into()], path_prefixes: vec!["/".into()], @@ -74,7 +75,7 @@ fn inject_selection_idempotency_and_subtree() { c.inject(parts("api.example"), &root, &Fake).unwrap(), Outcome::Forward(_) )); - let i = c.create_item(item(false), &root, "passkey").unwrap(); + let i = c.create_item(item(false), &root, c.unlocked().unwrap()).unwrap(); let first = match c.inject(parts("api.example"), &root, &Fake).unwrap() { Outcome::Denied { item, request } => { assert_eq!(item, "token"); @@ -88,9 +89,9 @@ fn inject_selection_idempotency_and_subtree() { }; assert_eq!(first, second); assert_eq!(c.request_list().unwrap().len(), 1); - c.set_field(&i.id, &i.fields[0].field_id, "opaque", &root, "passkey") + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &root, c.unlocked().unwrap()) .unwrap(); - c.approve(&first, "allow", Overrides::default(), Some("passkey")) + c.approve(&first, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); let child = Caller { program: "/bin/tool.wasm".into(), @@ -108,7 +109,7 @@ fn inject_selection_idempotency_and_subtree() { )); let mut other = item(true); other.name = "other".into(); - c.create_item(other, &root, "passkey").unwrap(); + c.create_item(other, &root, c.unlocked().unwrap()).unwrap(); assert_eq!( c.inject(parts("api.example"), &root, &Fake) .err() @@ -132,9 +133,9 @@ fn free_use_validators_transform_recheck_and_redaction() { let mut config = item(true); config.transform_js = source.into(); config.validate_js = validator.map(Into::into); - let i = c.create_item(config, &caller, "passkey").unwrap(); + let i = c.create_item(config, &caller, c.unlocked().unwrap()).unwrap(); let secret = "SECRET_SENTINEL_19"; - c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, "passkey") + c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, c.unlocked().unwrap()) .unwrap(); let result = c.inject(parts("api.example"), &caller, &Fake); if let Some(expected) = expected { @@ -174,8 +175,8 @@ fn atomic_budget_and_decision_notification() { let dir = tempfile::tempdir().unwrap(); let c = std::sync::Arc::new(Cred::open(dir.path()).unwrap()); let caller = caller("root"); - let i = c.create_item(item(false), &caller, "passkey").unwrap(); - c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "passkey") + let i = c.create_item(item(false), &caller, c.unlocked().unwrap()).unwrap(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, c.unlocked().unwrap()) .unwrap(); c.grant_create( Grant { @@ -190,7 +191,7 @@ fn atomic_budget_and_decision_notification() { note: None, }, &caller, - Some("passkey"), + c.unlocked().unwrap(), ) .unwrap(); let handles: Vec<_> = (0..2) @@ -214,10 +215,10 @@ fn atomic_budget_and_decision_notification() { ); let id = c.request_list().unwrap()[0].id.clone(); let mut receiver = c.subscribe(); - c.deny(&id, "no", Some("passkey")).unwrap(); + c.deny(&id, sausage::cred::webauthn::Decision::Deny, c.unlocked().unwrap()).unwrap(); assert_eq!(receiver.try_recv().unwrap(), id); assert_eq!( - c.deny(&id, "again", Some("passkey")).unwrap_err().0, + c.deny(&id, sausage::cred::webauthn::Decision::Deny, c.unlocked().unwrap()).unwrap_err().0, "already_decided" ); } @@ -229,13 +230,13 @@ fn key_and_secret_persist() { let caller = caller("root"); let mut config = item(true); config.transform_js = "inject".into(); - let i = c.create_item(config, &caller, "passkey").unwrap(); + let i = c.create_item(config, &caller, c.unlocked().unwrap()).unwrap(); c.set_field( &i.id, &i.fields[0].field_id, "persistent", &caller, - "passkey", + c.unlocked().unwrap(), ) .unwrap(); drop(c); diff --git a/host/tests/cred_authorization.rs b/host/tests/cred_authorization.rs new file mode 100644 index 0000000..f4f2c4e --- /dev/null +++ b/host/tests/cred_authorization.rs @@ -0,0 +1,36 @@ +//! Bootstrap proofs cannot survive registration. Typed overrides reject misspelled limits. +use sausage::cred::{*, items::{Item,Field}, requests::{GrantRequest,Overrides}, webauthn::{Decision,Unlocked}}; +fn fixture() -> (tempfile::TempDir,Cred,Caller,Item,String,Unlocked) { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let proof = c.unlocked().unwrap(); + let caller = Caller { program:"/bin/test.wasm".into(),pid:1,conversations:vec!["root".into()] }; + let item = c.create_item(Item { id:String::new(),name:"test".into(),version:0,free_use:false,rules:vec![],validate_js:None,transform_js:String::new(),fields:vec![Field { field_id:String::new(),name:"secret".into(),public:false,value:None,needs_value:true,field_type:types::FieldType::Text,key_type:None }] },&caller,proof).unwrap(); + let id = c.file_request(&caller,"request",GrantRequest { items:vec![item.id.clone()],duration_secs:None,max_uses:None,validator:None }).unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("INSERT INTO passkey_credentials VALUES('synthetic','test','{}')",[]).unwrap(); + (dir,c,caller,item,id,proof) +} +#[test] +fn registered_passkey_refuses_unlocked_approval() { + let (_dir,c,_caller,_item,id,proof) = fixture(); + assert!(c.approve(&id,Decision::Approve,Overrides::default(),proof).is_err()); + assert_eq!(c.request_list().unwrap()[0].state,"pending"); + assert!(c.unlocked().is_err()); +} +#[test] +fn registered_passkey_refuses_unlocked_denial() { + let (_dir,c,_caller,_item,id,proof) = fixture(); + assert!(c.deny(&id,Decision::Deny,proof).is_err()); +} +#[test] +fn registered_passkey_refuses_direct_mutations() { + let (_dir,c,caller,item,_id,proof) = fixture(); + assert!(c.set_field(&item.id,&item.fields[0].field_id,"synthetic",&caller,proof).is_err()); + assert!(c.create_item(Item { name:"another".into(),..item },&caller,proof).is_err()); + assert!(c.revoke_subjects(&caller.conversations,&caller,proof).is_err()); +} +#[test] +fn overrides_reject_unknown_fields() { + assert!(serde_json::from_value::(serde_json::json!({"max_use":1})).is_err()); +} diff --git a/host/tests/cred_challenges.rs b/host/tests/cred_challenges.rs new file mode 100644 index 0000000..dca4358 --- /dev/null +++ b/host/tests/cred_challenges.rs @@ -0,0 +1,26 @@ +//! Invalid responses cannot cancel valid challenges. Pending ceremonies have a fixed bound. +use sausage::cred::webauthn::{Binding, Passkeys}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use webauthn_rs::prelude::*; +#[test] +fn failed_response_preserves_challenge_and_oldest_challenge_is_evicted() -> anyhow::Result<()> { + let db = rusqlite::Connection::open_in_memory()?; + let origin = Url::parse("http://localhost:8000")?; + let mut host = Passkeys::new(&db,"localhost",origin.as_str())?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let registration = host.start_registration("test".into(),None)?; + let response = auth.do_registration(origin.clone(),registration.options)?; + host.finish_registration(®istration.id,response)?; + let binding = Binding::Enrollment { label:"next".into() }; + let valid = host.start_assertion(binding.clone())?; + let wrong = host.start_assertion(binding.clone())?; + let invalid_response = auth.do_authentication(origin.clone(),wrong.options)?; + assert!(host.finish_assertion(&valid.id,invalid_response).is_err()); + let response = auth.do_authentication(origin.clone(),valid.options)?; + host.finish_assertion(&valid.id,response)?; + let oldest = host.start_assertion(binding.clone())?; + for _ in 0..32 { host.start_assertion(binding.clone())?; } + let response = auth.do_authentication(origin,oldest.options)?; + assert!(host.finish_assertion(&oldest.id,response).is_err()); + Ok(()) +} diff --git a/host/tests/cred_core.rs b/host/tests/cred_core.rs index a5d09a4..4c3115e 100644 --- a/host/tests/cred_core.rs +++ b/host/tests/cred_core.rs @@ -34,7 +34,7 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { }], }, &caller, - "pk", + c.unlocked().unwrap(), ) .unwrap(); (dir, c, caller, i) @@ -96,7 +96,7 @@ fn rule_matching_table() { #[test] fn revisions_needs_value_and_rename_are_metadata_only() { let (_dir, c, caller, i) = fixture(); - c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk") + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, c.unlocked().unwrap()) .unwrap(); let old = c.item_get(&i.id).unwrap(); assert_eq!(old.version, 2); @@ -117,7 +117,7 @@ fn revisions_needs_value_and_rename_are_metadata_only() { ) .unwrap(); assert!(!c.edit_diff(&id).unwrap().discards_values); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); let current = c.item_get(&i.id).unwrap(); assert_eq!(current.version, 3); @@ -139,10 +139,10 @@ fn stale_edits_leave_history_and_values_untouched() { }, ) .unwrap(); - c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk") + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, c.unlocked().unwrap()) .unwrap(); assert_eq!( - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap_err() .0, "stale" @@ -166,18 +166,20 @@ fn key_conflict_renewal_and_expiry() { c.file_request(&caller, "key", other).unwrap_err().0, "key_conflict" ); - c.deny(&id, "no", Some("pk")).unwrap(); + c.deny(&id, sausage::cred::webauthn::Decision::Deny, c.unlocked().unwrap()).unwrap(); + assert_eq!(c.file_request(&caller, "key", p.clone()).unwrap(), id); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id]).unwrap(); let next = c.file_request(&caller, "key", p).unwrap(); assert_ne!(id, next); assert_eq!(c.request_decisions().unwrap().len(), 1); - let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); db.execute("UPDATE requests SET expires=0 WHERE id=?", [&next]) .unwrap(); let mut notification = c.subscribe(); assert_eq!(c.expire_requests().unwrap(), vec![next.clone()]); assert_eq!(notification.try_recv().unwrap(), next); assert_eq!( - c.approve(&next, "yes", Overrides::default(), Some("pk")) + c.approve(&next, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap_err() .0, "request_expired" @@ -188,7 +190,7 @@ fn key_conflict_renewal_and_expiry() { fn typed_values_and_secret_configuration_fail_without_writes() { let (_dir, c, caller, i) = fixture(); assert_eq!( - c.set_field(&i.id, &i.fields[0].field_id, "line\nbreak", &caller, "pk") + c.set_field(&i.id, &i.fields[0].field_id, "line\nbreak", &caller, c.unlocked().unwrap()) .unwrap_err() .0, "invalid_value" @@ -197,7 +199,7 @@ fn typed_values_and_secret_configuration_fail_without_writes() { let mut config = i.clone(); config.fields[0].value = Some("forbidden".into()); assert_eq!( - c.create_item(config, &caller, "pk").unwrap_err().0, + c.create_item(config, &caller, c.unlocked().unwrap()).unwrap_err().0, "secret_field" ); assert_eq!(c.item_list().unwrap().len(), 1); diff --git a/host/tests/cred_edits.rs b/host/tests/cred_edits.rs index ab8beeb..f9357d0 100644 --- a/host/tests/cred_edits.rs +++ b/host/tests/cred_edits.rs @@ -48,7 +48,7 @@ fn creation_key_generation_and_deletion_keep_secrets_out_of_history() { ) .unwrap(); assert!(c.item_list().unwrap().is_empty()); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); let item = c.item_list().unwrap().remove(0); assert!(!item.fields[0].needs_value); @@ -69,7 +69,7 @@ fn creation_key_generation_and_deletion_keep_secrets_out_of_history() { }, ) .unwrap(); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); assert!(c.item_list().unwrap().is_empty()); assert_eq!(c.item_versions(&item.id).unwrap().len(), 1); @@ -89,8 +89,8 @@ fn operation_order_name_conflicts_and_stale_resolution() { pid: 3, conversations: vec!["root".into()], }; - let item = c.create_item(config("first"), &caller, "pk").unwrap(); - c.create_item(config("taken"), &caller, "pk").unwrap(); + let item = c.create_item(config("first"), &caller, c.unlocked().unwrap()).unwrap(); + c.create_item(config("taken"), &caller, c.unlocked().unwrap()).unwrap(); let request = |change| OperationRequest { item: Some(item.id.clone()), base_version: Some(item.version), @@ -129,18 +129,18 @@ fn operation_order_name_conflicts_and_stale_resolution() { request(vec![Operation::SetFreeUse { enabled: true }]), ) .unwrap(); - c.approve(&other, "yes", Overrides::default(), Some("pk")) + c.approve(&other, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); assert!(c.edit_diff(&id).unwrap().stale); assert_eq!( - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap_err() .0, "stale" ); - c.resolve_stale(&id, 2).unwrap(); + c.resolve_stale(&id, 2, &caller, c.unlocked().unwrap()).unwrap(); assert!(!c.edit_diff(&id).unwrap().stale); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); let current = c.item_get(&item.id).unwrap(); assert_eq!(current.name, "last"); @@ -167,13 +167,13 @@ fn field_removal_revert_and_public_visibility() { field_type: types::FieldType::Text, key_type: None, }); - let item = c.create_item(initial, &caller, "pk").unwrap(); + let item = c.create_item(initial, &caller, c.unlocked().unwrap()).unwrap(); c.set_field( &item.id, &item.fields[0].field_id, "SECRET_REMOVED", &caller, - "pk", + c.unlocked().unwrap(), ) .unwrap(); let current = c.item_get(&item.id).unwrap(); @@ -198,12 +198,12 @@ fn field_removal_revert_and_public_visibility() { ) .unwrap(); assert!(c.edit_diff(&id).unwrap().discards_values); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); let id = c .file_revert_request(&caller, "revert", &item.id, 3, 2) .unwrap(); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); let restored = c.item_get(&item.id).unwrap(); assert!(restored.fields[0].needs_value); @@ -253,14 +253,14 @@ fn decision_failure_rolls_back_every_write() { let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT, 'forced'); END;").unwrap(); assert!( - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .is_err() ); assert!(c.item_list().unwrap().is_empty()); assert!(c.audit_list().unwrap().is_empty()); assert_eq!(c.request_list().unwrap()[0].state, "pending"); db.execute_batch("DROP TRIGGER fail_decision;").unwrap(); - c.approve(&id, "yes", Overrides::default(), Some("pk")) + c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) .unwrap(); assert_eq!(c.item_list().unwrap().len(), 1); } diff --git a/host/tests/cred_grants.rs b/host/tests/cred_grants.rs index b28d18a..b90a321 100644 --- a/host/tests/cred_grants.rs +++ b/host/tests/cred_grants.rs @@ -43,6 +43,7 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { version: 0, free_use: false, rules: vec![Rule { + port: None, scheme: "https".into(), hosts: vec!["api.test".into()], path_prefixes: vec!["/".into()], @@ -53,7 +54,7 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { fields: vec![], }, &caller, - "pk", + c.unlocked().unwrap(), ) .unwrap(); (dir, c, caller, i) @@ -88,9 +89,9 @@ fn program_grant_covers_service_and_agent_but_not_another_program() { ); c.approve( &request, - "allow", + sausage::cred::webauthn::Decision::Approve, sausage::cred::requests::Overrides::default(), - None, + c.unlocked().unwrap(), ) .unwrap(); let agent = Caller { @@ -122,7 +123,7 @@ fn program_grant_covers_service_and_agent_but_not_another_program() { #[test] fn expiry_boundary_revoke_and_exhaustion() { let (dir, c, caller, i) = fixture(); - let g = c.grant_create(grant(&i), &caller, Some("pk")).unwrap(); + let g = c.grant_create(grant(&i), &caller, c.unlocked().unwrap()).unwrap(); assert!(matches!( c.inject(parts(), &caller, &Fake).unwrap(), Outcome::Forward(_) @@ -135,7 +136,7 @@ fn expiry_boundary_revoke_and_exhaustion() { assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); let mut unlimited = grant(&i); unlimited.max_uses = None; - let g = c.grant_create(unlimited, &caller, Some("pk")).unwrap(); + let g = c.grant_create(unlimited, &caller, c.unlocked().unwrap()).unwrap(); let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); db.execute("UPDATE grants SET exp=0 WHERE id=?", [&g.id]) .unwrap(); @@ -144,7 +145,7 @@ fn expiry_boundary_revoke_and_exhaustion() { Outcome::Denied { .. } )); assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); - c.revoke_subjects(&caller.conversations, &caller, Some("pk")) + c.revoke_subjects(&caller.conversations, &caller, c.unlocked().unwrap()) .unwrap(); assert!(c.grant_list().unwrap().iter().all(|g| g.revoked)); } @@ -154,13 +155,13 @@ fn reject_bad_issuance_without_rows() { let mut g = grant(&i); g.max_uses = Some(0); assert_eq!( - c.grant_create(g, &caller, Some("pk")).unwrap_err().0, + c.grant_create(g, &caller, c.unlocked().unwrap()).unwrap_err().0, "invalid_uses" ); let mut g = grant(&i); g.subject = types::Subject::Conversation(String::new()); assert_eq!( - c.grant_create(g, &caller, Some("pk")).unwrap_err().0, + c.grant_create(g, &caller, c.unlocked().unwrap()).unwrap_err().0, "invalid_subject" ); assert!(c.grant_list().unwrap().is_empty()); @@ -171,7 +172,7 @@ fn preflight_spends_nothing_and_validator_denial_spends_one() { let (_dir, c, caller, i) = fixture(); let mut g = grant(&i); g.validator = Some("deny".into()); - let g = c.grant_create(g, &caller, Some("pk")).unwrap(); + let g = c.grant_create(g, &caller, c.unlocked().unwrap()).unwrap(); let mut p = parts(); p.headers .insert("bad", http::HeaderValue::from_bytes(&[0xff]).unwrap()); @@ -194,6 +195,6 @@ fn preflight_spends_nothing_and_validator_denial_spends_one() { audit .iter() .filter(|r| r.event == "grant.create") - .all(|r| r.approver.as_deref() == Some("pk")) + .all(|r| r.approver.is_none()) ); } diff --git a/host/tests/cred_policy.rs b/host/tests/cred_policy.rs new file mode 100644 index 0000000..38ed48c --- /dev/null +++ b/host/tests/cred_policy.rs @@ -0,0 +1,44 @@ +//! Approval policy regressions for explicit grant operations and denied request replay. +use sausage::cred::{*, edits::{Operation, OperationRequest}, grants::Grant, items::Item, requests::{GrantRequest, Overrides}}; +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { program:"/bin/test.wasm".into(), pid:1, conversations:vec!["root".into()] }; + let item = c.create_item(serde_json::from_value(serde_json::json!({"name":"test","rules":[],"validate_js":null,"transform_js":"","fields":[]})).unwrap(), &caller, "test").unwrap(); + (dir,c,caller,item) +} +fn grant(item: &Item) -> Grant { + Grant { id:String::new(), items:vec![item.id.clone()], subject:types::Subject::Conversation("root".into()), exp:None, max_uses:None, uses:0, revoked:false, validator:None, note:None } +} +fn operation(c: &Cred, caller: &Caller, key: &str, op: Operation) -> String { + c.file_operation_request(caller, key, OperationRequest { item:None, base_version:None, change:vec![op] }).unwrap() +} +#[test] +fn grant_operations_apply_overrides_defaults_and_replay_liveness() { + let (_dir,c,caller,item) = fixture(); + let op = Operation::GrantCreate { grant:grant(&item) }; + let id = operation(&c,&caller,"grant",op.clone()); + c.approve(&id,"allow",Overrides { duration_secs:Some(120), max_uses:Some(1), validator:Some("true".into()), unlimited:false },None).unwrap(); + let g = c.grant_list().unwrap().pop().unwrap(); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + assert!(g.exp.is_some_and(|e| (now+115..=now+120).contains(&e))); + assert_eq!(g.max_uses,Some(1)); + assert_eq!(g.validator.as_deref(),Some("true")); + let revoke = operation(&c,&caller,"revoke",Operation::GrantRevoke { target:g.id }); + assert!(c.approve(&revoke,"allow",Overrides { duration_secs:Some(1), ..Default::default() },None).is_err()); + c.approve(&revoke,"allow",Overrides::default(),None).unwrap(); + assert_ne!(operation(&c,&caller,"grant",op),id); + let id = operation(&c,&caller,"default",Operation::GrantCreate { grant:grant(&item) }); + c.approve(&id,"allow",Overrides::default(),None).unwrap(); + assert!(c.grant_list().unwrap().last().unwrap().exp.is_some_and(|e| (now+3595..=now+3605).contains(&e))); +} +#[test] +fn denial_replays_until_expiry_and_requester_duration_is_capped() { + let (_dir,c,caller,item) = fixture(); + let request = GrantRequest { items:vec![item.id], duration_secs:Some(86401), max_uses:None, validator:None }; + assert!(c.file_request(&caller,"too-long",request.clone()).is_err()); + let request = GrantRequest { duration_secs:None, ..request }; + let id = c.file_request(&caller,"denied",request.clone()).unwrap(); + c.deny(&id,"no",None).unwrap(); + assert_eq!(c.file_request(&caller,"denied",request).unwrap(),id); +} diff --git a/host/tests/cred_presentation.rs b/host/tests/cred_presentation.rs new file mode 100644 index 0000000..881d8a5 --- /dev/null +++ b/host/tests/cred_presentation.rs @@ -0,0 +1,16 @@ +//! Approval text exposes public policy and rejects deceptive display controls. +use sausage::cred::{edits::Operation, items::Item, summary::operation_summary}; +#[test] +fn summaries_show_public_values_rules_free_use_and_wildcard_risk() { + let item: Item = serde_json::from_value(serde_json::json!({"name":"test","free_use":true,"rules":[{"scheme":"https","hosts":["*.com","api.example"],"port":8443,"path_prefixes":["/"],"methods":null}],"validate_js":null,"transform_js":"","fields":[]})).unwrap(); + let text = operation_summary(&Operation::CreateItem { config:item }); + for part in ["without a grant","*.com","api.example","8443","wildcard"] { assert!(text.contains(part)); } + let text = operation_summary(&Operation::SetFieldValue { field:"public-id".into(), value:"public-value".into() }); + assert!(text.contains("public-id")); + assert!(text.contains("public-value")); +} +#[test] +fn summaries_remove_bidi_and_zero_width_controls() { + let text = operation_summary(&Operation::SetName { name:"a\u{202e}b\u{2066}c\u{200b}d\u{200d}e\u{feff}".into() }); + for c in ['\u{202e}','\u{2066}','\u{200b}','\u{200d}','\u{feff}'] { assert!(!text.contains(c)); } +} diff --git a/host/tests/cred_regressions.rs b/host/tests/cred_regressions.rs index adc8546..de824ad 100644 --- a/host/tests/cred_regressions.rs +++ b/host/tests/cred_regressions.rs @@ -35,6 +35,7 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { version: 0, free_use: false, rules: vec![Rule { + port: None, scheme: "https".into(), hosts: vec!["api.test".into()], path_prefixes: vec!["/".into()], @@ -64,7 +65,7 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { ], }, &caller, - "pk", + cred.unlocked().unwrap(), ) .unwrap(); (dir, cred, caller, item) @@ -93,7 +94,7 @@ fn grant(item: &Item) -> Grant { fn missing_values_and_wrong_master_spend_nothing() { let (dir, cred, caller, item) = fixture(); let grant = cred - .grant_create(grant(&item), &caller, Some("pk")) + .grant_create(grant(&item), &caller, cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.inject(parts(), &caller, &Fake).err().unwrap().0, @@ -101,23 +102,21 @@ fn missing_values_and_wrong_master_spend_nothing() { ); assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0); for field in &item.fields { - cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + cred.set_field(&item.id, &field.field_id, "opaque", &caller, cred.unlocked().unwrap()) .unwrap(); } drop(cred); std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap(); - let cred = Cred::open(dir.path()).unwrap(); - assert_eq!( - cred.inject(parts(), &caller, &Fake).err().unwrap().0, - "seal" - ); - assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0); + assert!(Cred::open(dir.path()).is_err()); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let uses: i64 = db.query_row("SELECT uses FROM grants WHERE id=?", [&grant.id], |r| r.get(0)).unwrap(); + assert_eq!(uses, 0); } #[test] fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { let (dir, cred, caller, item) = fixture(); for field in &item.fields { - cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + cred.set_field(&item.id, &field.field_id, "opaque", &caller, cred.unlocked().unwrap()) .unwrap(); } let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); @@ -146,7 +145,7 @@ fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { }, ) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert!(cipher() == before); let current = cred.item_get(&item.id).unwrap(); @@ -167,7 +166,7 @@ fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { let audit = cred.audit_list().unwrap().len(); db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap(); assert!( - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .is_err() ); assert!(cipher() == before); @@ -179,7 +178,7 @@ fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { let (_dir, cred, caller, item) = fixture(); for field in &item.fields { - cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + cred.set_field(&item.id, &field.field_id, "opaque", &caller, cred.unlocked().unwrap()) .unwrap(); } let current = cred.item_get(&item.id).unwrap(); @@ -193,7 +192,7 @@ fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { }, ) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); let id = cred .file_revert_request( @@ -204,7 +203,7 @@ fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { current.version, ) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); let restored = cred.item_get(&item.id).unwrap(); assert_eq!(restored.id, item.id); @@ -221,13 +220,13 @@ fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() { let (dir, cred, caller, item) = fixture(); let input = FieldInput { - value: "opaque", + value: sausage::cred::items::FieldValue::Plain("opaque"), field_type: Some(types::FieldType::Oauth2RefreshToken), key_type: None, }; - cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk") + cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, cred.unlocked().unwrap()) .unwrap(); - cred.set_field(&item.id, &item.fields[1].field_id, "opaque", &caller, "pk") + cred.set_field(&item.id, &item.fields[1].field_id, "opaque", &caller, cred.unlocked().unwrap()) .unwrap(); let current = cred.item_get(&item.id).unwrap(); let history = cred.item_versions(&item.id).unwrap().len(); @@ -243,7 +242,7 @@ fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() { }; let before = cipher(); assert_eq!( - cred.set_field_input(&item.id, &item.fields[1].field_id, &input, &caller, "pk") + cred.set_field_input(&item.id, &item.fields[1].field_id, &input, &caller, cred.unlocked().unwrap()) .err() .unwrap() .0, @@ -271,14 +270,14 @@ fn typed_secret_entry_and_bundle_normalization() { }, ) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); let input = FieldInput { - value: "two\nlines", + value: sausage::cred::items::FieldValue::Plain("two\nlines"), field_type: Some(types::FieldType::Note), key_type: None, }; - cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk") + cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, cred.unlocked().unwrap()) .unwrap(); let current = cred.item_get(&item.id).unwrap(); assert_eq!(current.fields[0].field_type, types::FieldType::Note); @@ -304,7 +303,7 @@ fn typed_secret_entry_and_bundle_normalization() { assert_eq!(cred.file_request(&caller, "bundle", p).unwrap(), id); cred.approve( &id, - "yes", + sausage::cred::webauthn::Decision::Approve, Overrides { unlimited: true, ..Overrides::default() diff --git a/host/tests/cred_replay.rs b/host/tests/cred_replay.rs index 7cd9d75..5b380b6 100644 --- a/host/tests/cred_replay.rs +++ b/host/tests/cred_replay.rs @@ -35,7 +35,7 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { }], }, &caller, - "pk", + cred.unlocked().unwrap(), ) .unwrap(); (dir, cred, caller, item) @@ -53,7 +53,7 @@ fn approved_operations_and_deletes_replay() { let id = cred .file_operation_request(&caller, "edit", request.clone()) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.file_operation_request(&caller, "edit", request) @@ -68,7 +68,7 @@ fn approved_operations_and_deletes_replay() { let id = cred .file_delete_request(&caller, "delete", delete.clone()) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.file_delete_request(&caller, "delete", delete).unwrap(), @@ -88,7 +88,7 @@ fn approved_replacements_and_creations_replay() { let id = cred .file_edit_request(&caller, "replace", edit.clone()) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.file_edit_request(&caller, "replace", edit).unwrap(), @@ -103,7 +103,7 @@ fn approved_replacements_and_creations_replay() { let id = cred .file_operation_request(&caller, "create", request.clone()) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.file_operation_request(&caller, "create", request) @@ -143,11 +143,11 @@ fn grant_defaults_normalize_and_pending_requests_replay_after_deletion() { }, ) .unwrap(); - cred.approve(&delete, "yes", Overrides::default(), Some("pk")) + cred.approve(&delete, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!(cred.file_request(&caller, "grant", payload).unwrap(), id); assert!( - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .is_err() ); assert_eq!( @@ -184,7 +184,7 @@ fn values_are_checked_at_each_public_operation() { }, ) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); let current = cred.item_get(&item.id).unwrap(); let field = current @@ -221,7 +221,7 @@ fn values_are_checked_at_each_public_operation() { let id = cred .file_operation_request(&caller, "ordered", request) .unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.item_get(&item.id).unwrap().version, @@ -236,12 +236,12 @@ fn secret_retyping_checks_each_operation_only_on_approval() { &item.id, field, &FieldInput { - value: "VALUE_SENTINEL\nTAIL_SENTINEL", + value: sausage::cred::items::FieldValue::Plain("VALUE_SENTINEL\nTAIL_SENTINEL"), field_type: Some(FieldType::Note), key_type: None, }, &caller, - "pk", + cred.unlocked().unwrap(), ) .unwrap(); let current = cred.item_get(&item.id).unwrap(); @@ -271,7 +271,7 @@ fn secret_retyping_checks_each_operation_only_on_approval() { .contains("VALUE_SENTINEL") ); assert_eq!( - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .err() .unwrap() .0, @@ -280,13 +280,13 @@ fn secret_retyping_checks_each_operation_only_on_approval() { assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); assert_eq!(cred.request_list().unwrap()[0].state, "pending"); let history = cred.item_versions(&item.id).unwrap().len(); - cred.set_field(&item.id, field, "single-line", &caller, "pk") + cred.set_field(&item.id, field, "single-line", &caller, cred.unlocked().unwrap()) .unwrap(); let next = cred.item_get(&item.id).unwrap(); assert_eq!(next.version, current.version + 1); assert_eq!(cred.item_versions(&item.id).unwrap().len(), history + 1); - cred.resolve_stale(&id, next.version).unwrap(); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.resolve_stale(&id, next.version, &caller, cred.unlocked().unwrap()).unwrap(); + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); } #[test] @@ -302,16 +302,16 @@ fn stale_rebases_keep_original_keyed_intent() { let id = cred .file_operation_request(&caller, "rebase", request.clone()) .unwrap(); - cred.set_field(&item.id, &item.fields[0].field_id, "opaque", &caller, "pk") + cred.set_field(&item.id, &item.fields[0].field_id, "opaque", &caller, cred.unlocked().unwrap()) .unwrap(); let current = cred.item_get(&item.id).unwrap(); - cred.resolve_stale(&id, current.version).unwrap(); + cred.resolve_stale(&id, current.version, &caller, cred.unlocked().unwrap()).unwrap(); assert_eq!( cred.file_operation_request(&caller, "rebase", request.clone()) .unwrap(), id ); - cred.approve(&id, "yes", Overrides::default(), Some("pk")) + cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) .unwrap(); assert_eq!( cred.file_operation_request(&caller, "rebase", request) diff --git a/host/tests/cred_security.rs b/host/tests/cred_security.rs new file mode 100644 index 0000000..f924ce7 --- /dev/null +++ b/host/tests/cred_security.rs @@ -0,0 +1,53 @@ +//! Destination matching fails closed before and after a credential transform. +use sausage::cred::{*, inject::Outcome, items::Item, script::{self,Script,Limits}}; +use serde_json::json; +struct Transform(serde_json::Value); +impl Script for Transform { + fn evaluate(&self,_:&str,_:&str,_:Option<&str>,_:Limits) -> std::result::Result { Ok(self.0.to_string()) } +} +fn config(host:&str,port:Option) -> Item { + serde_json::from_value(json!({"name":"destination","free_use":true,"rules":[{"scheme":"https","hosts":[host],"port":port,"path_prefixes":["/allowed/"],"methods":null}],"validate_js":null,"transform_js":"transform","fields":[]})).unwrap() +} +fn parts(uri:&str) -> http::request::Parts { http::Request::get(uri).body(()).unwrap().into_parts().0 } +#[test] +fn ports_before_and_after_transform() { + let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller::default(); + c.create_item(config("api.example",None),&caller,c.unlocked().unwrap()).unwrap(); + let Outcome::Forward(p) = c.inject(parts("https://api.example:9999/allowed/"),&caller,&Transform(json!({"headers":[{"name":"x-injected","value":"synthetic"}]}))).unwrap() else { panic!("denied") }; + assert!(!p.headers.contains_key("x-injected")); + assert!(c.inject(parts("https://api.example/allowed/"),&caller,&Transform(json!({"url":"https://api.example:9999/allowed/"}))).is_err()); +} +#[test] +fn local_rules_require_ports_and_ipv6_matches() { + let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller::default(); + for host in ["localhost","127.0.0.1","::1","169.254.1.2","fe80::1"] { assert!(c.create_item(config(host,None),&caller,c.unlocked().unwrap()).is_err()); } + c.create_item(config("::1",Some(8443)),&caller,c.unlocked().unwrap()).unwrap(); + let Outcome::Forward(p) = c.inject(parts("https://[::1]:8443/allowed/"),&caller,&Transform(json!({"headers":[{"name":"x-injected","value":"synthetic"}]}))).unwrap() else { panic!("denied") }; + assert!(p.headers.contains_key("x-injected")); +} +#[test] +fn unsafe_paths_fail_before_and_after_transform() { + let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller::default(); + c.create_item(config("api.example",None),&caller,c.unlocked().unwrap()).unwrap(); + for tail in ["./x","../x","%2e/x","%2E/x","x%2fy","x%2Fy","x%5cy","x%5Cy","x\\y"] { + let url = format!("https://api.example/allowed/{tail}"); + assert!(c.inject(parts(&url),&caller,&Transform(json!({}))).is_err()); + assert!(c.inject(parts("https://api.example/allowed/"),&caller,&Transform(json!({"url":url}))).is_err()); + } +} +#[test] +fn transformed_authority_and_sensitive_headers() { + let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller::default(); + c.create_item(config("api.example",None),&caller,c.unlocked().unwrap()).unwrap(); + let Outcome::Forward(p) = c.inject(parts("https://api.example/allowed/"),&caller,&Transform(json!({"headers":[{"name":"host","value":"wrong.example"},{"name":"authorization","value":"synthetic"}]}))).unwrap() else { panic!("denied") }; + assert!(p.headers["authorization"].is_sensitive()); assert_eq!(p.headers["host"],"api.example"); + assert!(c.inject(parts("https://api.example/allowed/"),&caller,&Transform(json!({"url":"https://user@api.example/allowed/"}))).is_err()); +} +#[test] +fn oauth2_items_fail_without_exposing_refresh_values() { + let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller::default(); let mut item = config("api.example",None); + item.fields.push(items::Field { field_id:String::new(),name:"refresh".into(),public:false,value:None,needs_value:true,field_type:types::FieldType::Oauth2RefreshToken,key_type:None }); + let item = c.create_item(item,&caller,c.unlocked().unwrap()).unwrap(); + c.set_field(&item.id,&item.fields[0].field_id,"synthetic",&caller,c.unlocked().unwrap()).unwrap(); + assert_eq!(c.inject(parts("https://api.example/allowed/"),&caller,&Transform(json!({}))).err().unwrap().0,"oauth2_item"); +} diff --git a/host/tests/cred_storage.rs b/host/tests/cred_storage.rs new file mode 100644 index 0000000..5603155 --- /dev/null +++ b/host/tests/cred_storage.rs @@ -0,0 +1,30 @@ +//! Master-key startup checks use only temporary host storage and synthetic keys. +use sausage::cred::{Cred, Caller}; +use std::os::unix::fs::PermissionsExt; +#[test] +fn missing_key_in_populated_store_is_not_replaced() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + c.create_item(serde_json::from_value(serde_json::json!({"name":"stored","rules":[],"validate_js":null,"transform_js":"","fields":[]})).unwrap(), &Caller::default(), "test").unwrap(); + drop(c); + std::fs::remove_file(dir.path().join("cred.key")).unwrap(); + assert!(Cred::open(dir.path()).is_err()); + assert!(!dir.path().join("cred.key").exists()); +} +#[test] +fn wrong_master_key_fails_at_open_even_without_secret_fields() { + let dir = tempfile::tempdir().unwrap(); + drop(Cred::open(dir.path()).unwrap()); + std::fs::write(dir.path().join("cred.key"), [7u8;32]).unwrap(); + assert!(Cred::open(dir.path()).is_err()); +} +#[test] +fn permissive_key_is_refused_and_new_host_directory_is_private() { + let dir = tempfile::tempdir().unwrap(); + let host = dir.path().join("host"); + drop(Cred::open(&host).unwrap()); + assert_eq!(std::fs::metadata(&host).unwrap().permissions().mode() & 0o777, 0o700); + let key = host.join("cred.key"); + std::fs::set_permissions(&key,std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(Cred::open(&host).is_err()); +} diff --git a/host/tests/cred_summary.rs b/host/tests/cred_summary.rs index fef420b..69f2b51 100644 --- a/host/tests/cred_summary.rs +++ b/host/tests/cred_summary.rs @@ -21,10 +21,6 @@ fn durations_use_plain_language() { fn summaries_never_include_field_values_or_script_sources() { let value = "SECRET_SUMMARY_SENTINEL"; for operation in [ - Operation::SetFieldValue { - field: "id".into(), - value: value.into(), - }, Operation::SetTransformJs { value: value.into(), }, @@ -43,7 +39,7 @@ fn summaries_never_include_field_values_or_script_sources() { assert!(!operation_summary(&operation).contains(value)); } let input = FieldInput { - value, + value: sausage::cred::items::FieldValue::Plain(value), field_type: None, key_type: None, };