From 5b3e1393f5787c1773076b71bd1f9b19f4def729 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 22:19:35 +0000 Subject: [PATCH] cred: complete core proof, policy, storage and injection fixes --- host/src/cred/edits.rs | 49 ++++- host/src/cred/grants.rs | 87 +++++--- host/src/cred/inject.rs | 48 ++++- host/src/cred/items.rs | 140 ++++++++++-- host/src/cred/requests.rs | 102 +++++++-- host/src/cred/store.rs | 130 ++++++++++-- host/src/cred/summary.rs | 62 +++++- host/src/cred/webauthn.rs | 353 +++++++++++++++++++++++-------- host/tests/cred.rs | 68 ++++-- host/tests/cred_accounting.rs | 111 ++++++++++ host/tests/cred_authorization.rs | 104 +++++++-- host/tests/cred_challenges.rs | 28 +-- host/tests/cred_core.rs | 77 +++++-- host/tests/cred_edits.rs | 97 +++++++-- host/tests/cred_grants.rs | 16 +- host/tests/cred_maintenance.rs | 216 +++++++++++++++++++ host/tests/cred_policy.rs | 164 +++++++++++--- host/tests/cred_presentation.rs | 84 +++++++- host/tests/cred_proofs.rs | 259 +++++++++++++++++++++++ host/tests/cred_regressions.rs | 127 ++++++++--- host/tests/cred_replay.rs | 127 ++++++++--- host/tests/cred_security.rs | 157 +++++++++++--- host/tests/cred_storage.rs | 13 +- host/tests/cred_webauthn.rs | 12 +- 24 files changed, 2223 insertions(+), 408 deletions(-) create mode 100644 host/tests/cred_accounting.rs create mode 100644 host/tests/cred_maintenance.rs create mode 100644 host/tests/cred_proofs.rs diff --git a/host/src/cred/edits.rs b/host/src/cred/edits.rs index 21d7e7d..e32c2d7 100644 --- a/host/src/cred/edits.rs +++ b/host/src/cred/edits.rs @@ -81,6 +81,8 @@ pub struct Diff { pub new: Item, pub discards_values: bool, #[serde(default)] + pub overlapping_items: Vec, + #[serde(default)] pub stale: bool, } fn check_secret( @@ -149,6 +151,7 @@ pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> R .iter() .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); Ok(Diff { + overlapping_items: items::overlaps(db, &new)?, old, new, discards_values, @@ -245,6 +248,7 @@ pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert .iter() .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); Ok(Diff { + overlapping_items: items::overlaps(db, &new)?, old, new, discards_values, @@ -489,11 +493,17 @@ impl super::Cred { { return Err(Error("invalid_params")); } - items::unique_name(&self.access()?.db, &config.name, "")?; + let overlapping_items = { + let s = self.access()?; + items::unique_name(&s.db, &config.name, "")?; + let mut proposed = config.clone(); + proposed.id.clear(); + items::overlaps(&s.db, &proposed)? + }; return self.file_payload( caller, key, - serde_json::json!({"create":config,"generate_es256":generate,"requested":request}), + serde_json::json!({"create":config,"generate_es256":generate,"requested":request,"overlapping_items":overlapping_items}), ); } let edit = Edit { @@ -564,12 +574,20 @@ 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, caller: &Caller, proof: impl Into) -> 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 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| { @@ -603,9 +621,24 @@ impl super::Cred { "UPDATE requests SET payload=?2 WHERE id=?1", 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()])?; + 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 27da8c2..b652e99 100644 --- a/host/src/cred/grants.rs +++ b/host/src/cred/grants.rs @@ -27,27 +27,36 @@ pub(crate) fn list(db: &Connection) -> Result> { 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)?) - .map_err(|_| rusqlite::Error::InvalidQuery)?, - subject: serde_json::from_str(&r.get::<_, String>(2)?) - .map_err(|_| rusqlite::Error::InvalidQuery)?, - exp: r.get(3)?, - max_uses: r - .get::<_, Option>(4)? - .map(u64::try_from) - .transpose() - .map_err(|_| rusqlite::Error::InvalidQuery)?, - uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?, - revoked: r.get(6)?, - validator: r.get(7)?, - note: r.get(8)?, - }) + Ok(Grant { + id: r.get(0)?, + items: serde_json::from_str(&r.get::<_, String>(1)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + subject: serde_json::from_str(&r.get::<_, String>(2)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + exp: r.get(3)?, + max_uses: r + .get::<_, Option>(4)? + .map(u64::try_from) + .transpose() + .map_err(|_| rusqlite::Error::InvalidQuery)?, + uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?, + revoked: r.get(6)?, + validator: r.get(7)?, + note: r.get(8)?, + }) } 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>()?) + Ok(q.query_map( + params![ + now(), + item, + caller.program, + serde_json::to_string(&caller.conversations)? + ], + read, + )? + .collect::>()?) } pub(crate) fn insert( db: &Connection, @@ -98,7 +107,8 @@ impl Cred { caller: &Caller, proof: impl Into, ) -> Result { - let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::GrantCreate, &grant)?; + let binding = + super::webauthn::Binding::mutation(super::webauthn::Mutation::GrantCreate, &grant)?; grant.id = seal::identity(); grant.uses = 0; grant.revoked = false; @@ -122,6 +132,7 @@ impl Cred { .ok_or(Error("grant_missing")) } /// The harness supplies every conversation in the subtree, not only its root. + /// The harness supplies every conversation id in the terminated subtree. pub fn revoke_subjects( &self, subjects: &[String], @@ -129,8 +140,12 @@ impl Cred { proof: impl Into, ) -> Result<()> { let mut s = self.access()?; - let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; - let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::RevokeSubjects, &subjects)?; + 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( @@ -147,8 +162,13 @@ impl Cred { /// 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 { + 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)?; @@ -157,7 +177,8 @@ impl Cred { } pub fn revoke_program(&self, program: &str, caller: &Caller) -> Result<()> { let mut s = self.access()?; - let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + 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()?; @@ -172,13 +193,23 @@ impl Cred { let master = s.master.clone(); let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; - 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 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 { matches_rules(&item.rules, p) } fn matches_rules(rules: &[super::items::Rule], p: &Parts) -> bool { - if !safe_uri(&p.uri) { return false; } + if !safe_uri(&p.uri) { + return false; + } rules.iter().any(|r| { super::items::rule_matches_port( r, @@ -54,11 +56,16 @@ impl Cred { caller: &Caller, script: &dyn Script, ) -> Result { - if !safe_uri(&parts.uri) { return Err(Error("invalid_url")); } + 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() + 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")); @@ -67,7 +74,9 @@ impl Cred { return Ok(Outcome::Forward(parts)); }; let item = self.item_get(&id)?; - if item.version != version { return Err(Error("stale")); } + if item.version != version { + return Err(Error("stale")); + } let original_request = request(&parts)?; if item .fields @@ -76,7 +85,11 @@ impl Cred { { return Err(Error("ssh_item")); } - if item.fields.iter().any(|f| f.field_type == super::types::FieldType::Oauth2RefreshToken) { + if item + .fields + .iter() + .any(|f| f.field_type == super::types::FieldType::Oauth2RefreshToken) + { return Err(Error("oauth2_item")); } let prepared = self.reserve(&item, caller)?; @@ -152,7 +165,8 @@ 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"))?; + 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()) @@ -167,12 +181,24 @@ impl Cred { 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"))?); + parts.headers.insert( + http::header::HOST, + http::HeaderValue::from_str(authority.as_str()) + .map_err(|_| Error("rule_recheck"))?, + ); Ok(()) })(); - 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])?; + 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 2e5ef07..2bff013 100644 --- a/host/src/cred/items.rs +++ b/host/src/cred/items.rs @@ -21,9 +21,19 @@ pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str 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 { +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, + "http" => Some(80), + "https" => Some(443), + "ssh" => Some(22), + _ => None, }; let host = host.trim_start_matches('[').trim_end_matches(']'); safe_path(path) @@ -45,16 +55,25 @@ pub fn rule_matches_port(r: &Rule, scheme: &str, host: &str, path: &str, 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 == "..") + !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.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()), + 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)] @@ -155,7 +174,9 @@ pub(crate) fn validate(item: &Item) -> Result<()> { "http" | "https" | "ssh" ) || 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.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'-') @@ -178,6 +199,61 @@ pub(crate) fn validate(item: &Item) -> Result<()> { } Ok(()) } +/// Report every item whose rules can match the same outgoing request. +pub(crate) fn overlaps(db: &Connection, item: &Item) -> Result> { + let mut q = db.prepare("SELECT config FROM items WHERE id<>? ORDER BY id")?; + let mut ids = Vec::new(); + for row in q.query_map([&item.id], |r| r.get::<_, String>(0))? { + let other: Item = serde_json::from_str(&row?)?; + if item + .rules + .iter() + .any(|a| other.rules.iter().any(|b| rules_overlap(a, b))) + { + ids.push(other.id); + } + } + Ok(ids) +} +fn rules_overlap(a: &Rule, b: &Rule) -> bool { + let default = match a.scheme.to_ascii_lowercase().as_str() { + "http" => Some(80), + "https" => Some(443), + "ssh" => Some(22), + _ => None, + }; + a.scheme.eq_ignore_ascii_case(&b.scheme) + && a.port.or(default) == b.port.or(default) + && a.path_prefixes.iter().any(|p| { + b.path_prefixes + .iter() + .any(|q| p.starts_with(q) || q.starts_with(p)) + }) + && match (&a.methods, &b.methods) { + (Some(a), Some(b)) => a + .iter() + .any(|m| b.iter().any(|n| m.eq_ignore_ascii_case(n))), + (Some(m), None) | (None, Some(m)) => !m.is_empty(), + (None, None) => true, + } + && a.hosts.iter().any(|p| { + b.hosts.iter().any(|q| { + let p = p.to_ascii_lowercase(); + let q = q.to_ascii_lowercase(); + if p == q { + return true; + } + match (p.strip_prefix("*."), q.strip_prefix("*.")) { + (Some(p), Some(q)) => { + p.ends_with(&format!(".{q}")) || q.ends_with(&format!(".{p}")) + } + (Some(p), None) => q.ends_with(&format!(".{p}")), + (None, Some(q)) => p.ends_with(&format!(".{q}")), + (None, None) => false, + } + }) + }) +} pub(crate) fn unique_name(db: &Connection, name: &str, except: &str) -> Result<()> { let exists: bool = db.query_row( "SELECT EXISTS(SELECT 1 FROM items WHERE name=?1 AND id<>?2)", @@ -219,7 +295,10 @@ pub struct ItemVersion { /// 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 }, + 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> { @@ -237,12 +316,18 @@ impl std::fmt::Debug for FieldInput<'_> { } impl Cred { /// 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 { + 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)?; validate(&item)?; - let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::CreateItem, &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()?; @@ -304,23 +389,38 @@ impl Cred { ) -> Result<()> { let mut s = self.access()?; let key = s.master.clone(); - let tx = s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + 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(_)) { + 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 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"))?); + 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 diff --git a/host/src/cred/requests.rs b/host/src/cred/requests.rs index 90b0bef..89e81dd 100644 --- a/host/src/cred/requests.rs +++ b/host/src/cred/requests.rs @@ -27,7 +27,8 @@ pub struct Request { pub decision: Option, pub approver: Option, } -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct Overrides { #[serde(default)] pub unlimited: bool, @@ -93,7 +94,8 @@ impl Cred { }), }; Ok( - ((state == "pending" && expires > now()) || (state == "approved" && usable)) + (((state == "pending" || state == "denied") && expires > now()) + || (state == "approved" && usable)) .then_some(id), ) } @@ -122,6 +124,9 @@ impl Cred { payload.items.sort(); payload.items.dedup(); payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600)); + if payload.duration_secs.is_some_and(|d| d == 0 || d > 86400) { + return Err(Error("invalid_duration")); + } if payload.validator.as_deref() == Some("") { payload.validator = None; } @@ -155,7 +160,7 @@ impl Cred { if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) { return Err(Error("key_conflict")); } - if state == "pending" && expires > now() { + if (state == "pending" || state == "denied") && expires > now() { return Ok(id); } if state == "approved" @@ -231,24 +236,54 @@ impl Cred { .filter(|r| r.state != "pending") .collect()) } + /// Build a binding from the exact stored payload, not a preview supplied by the world. + pub fn decision_binding( + &self, + id: &str, + decision: super::webauthn::Decision, + overrides: Overrides, + ) -> Result { + let s = self.access()?; + let payload: String = + s.db.query_row("SELECT payload FROM requests WHERE id=?", [id], |r| { + r.get(0) + })?; + Ok(super::webauthn::Binding::Decision { + request_id: id.into(), + decision, + overrides, + payload_sha256: super::webauthn::digest(payload.as_bytes()), + }) + } pub fn approve( &self, id: &str, - decision: &str, + decision: super::webauthn::Decision, overrides: Overrides, - approver: Option<&str>, + proof: impl Into, ) -> Result<()> { - self.decide(id, decision, Some(overrides), approver) + if decision != super::webauthn::Decision::Approve { + return Err(Error("invalid_decision")); + } + self.decide(id, decision, Some(overrides), proof.into()) } - pub fn deny(&self, id: &str, decision: &str, approver: Option<&str>) -> Result<()> { - self.decide(id, decision, None, approver) + pub fn deny( + &self, + id: &str, + decision: super::webauthn::Decision, + proof: impl Into, + ) -> Result<()> { + if decision != super::webauthn::Decision::Deny { + return Err(Error("invalid_decision")); + } + self.decide(id, decision, None, proof.into()) } fn decide( &self, id: &str, - decision: &str, + decision: super::webauthn::Decision, overrides: Option, - approver: Option<&str>, + proof: super::webauthn::Proof, ) -> Result<()> { let mut s = self.access()?; let master = s.master.clone(); @@ -275,6 +310,16 @@ impl Cred { )) }, )?; + let approver = proof.check( + &tx, + &super::webauthn::Binding::Decision { + request_id: id.into(), + decision, + overrides: overrides.clone().unwrap_or_default(), + payload_sha256: super::webauthn::digest(payload.as_bytes()), + }, + )?; + let approver = approver.as_deref(); let subject: Subject = serde_json::from_str(&subject)?; if state == "expired" { return Err(Error("request_expired")); @@ -299,6 +344,15 @@ impl Cred { let mut issued_grant = None; let state = if let Some(o) = overrides { let parsed: serde_json::Value = serde_json::from_str(&payload)?; + if parsed.get("items").is_none() + && parsed.get("grant_create").is_none() + && o != Overrides::default() + { + return Err(Error("ignored_overrides")); + } + if o.unlimited && o.max_uses.is_some() { + return Err(Error("invalid_uses")); + } if let Some(edit) = parsed.get("edit") { let mut edit: super::edits::Edit = serde_json::from_value(edit.clone())?; if let Some(requested) = parsed.get("requested") { @@ -358,10 +412,31 @@ impl Cred { grant.id = seal::identity(); grant.uses = 0; grant.revoked = false; + if let Some(duration) = o.duration_secs { + if duration == 0 { + return Err(Error("invalid_duration")); + } + grant.exp = Some( + now() + .checked_add( + i64::try_from(duration).map_err(|_| Error("invalid_duration"))?, + ) + .ok_or(Error("invalid_duration"))?, + ); + } else if grant.exp.is_none() { + grant.exp = Some(now() + 3600); + } + grant.max_uses = if o.unlimited { + None + } else { + o.max_uses.or(grant.max_uses) + }; + grant.validator = o.validator.or(grant.validator); grants::insert(&tx, &grant, &caller, approver)?; + issued_grant = Some(grant.id); } else if let Some(target) = parsed.get("grant_revoke") { let target = target.as_str().ok_or(Error("invalid_params"))?; - if tx.execute("UPDATE grants SET revoked=1 WHERE id=?", [target])? != 1 { + if tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE id=?", [target])? != 1 { return Err(Error("grant_missing")); } audit::record(&tx, "grant.revoke", &caller, None, approver)?; @@ -406,11 +481,12 @@ impl Cred { "denied" }; tx.execute( - "UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5 WHERE id=?1", - params![id, state, decision, approver, issued_grant], + "UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5,decided_at=unixepoch() WHERE id=?1", + params![id, state, match decision { super::webauthn::Decision::Approve => "approve", super::webauthn::Decision::Deny => "deny" }, approver, issued_grant], )?; audit::record(&tx, state, &caller, None, approver)?; tx.commit()?; + s.refresh_rules()?; let _ = self.decided.send(id.to_owned()); Ok(()) } diff --git a/host/src/cred/store.rs b/host/src/cred/store.rs index 48118b8..5da46d3 100644 --- a/host/src/cred/store.rs +++ b/host/src/cred/store.rs @@ -3,63 +3,147 @@ use super::{ error::{Error, Result}, schema, seal, }; +use rusqlite::OptionalExtension; use std::{ io::{Read, Write}, - os::unix::fs::OpenOptionsExt, + os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}, path::Path, sync::{Mutex, MutexGuard}, }; pub(crate) struct Store { pub db: rusqlite::Connection, pub master: zeroize::Zeroizing<[u8; 32]>, + pub rules: std::collections::HashMap)>, +} +impl Store { + pub fn refresh_rules(&mut self) -> Result<()> { + self.rules = rule_index(&self.db)?; + Ok(()) + } +} +fn rule_index( + db: &rusqlite::Connection, +) -> Result)>> { + let mut q = db.prepare("SELECT config FROM items")?; + q.query_map([], |r| r.get::<_, String>(0))? + .map(|r| { + let item: super::items::Item = serde_json::from_str(&r?)?; + Ok((item.id, (item.version, item.rules))) + }) + .collect() } pub struct Cred { pub(crate) store: Mutex, pub(crate) decided: tokio::sync::broadcast::Sender, pub(crate) filed: tokio::sync::broadcast::Sender, } +const KEY_CHECK: &[u8] = b"sausage/credentials/master/v1"; impl Cred { + /// Open local host storage. The host resolves systemd credentials, not this library. pub fn open(host_dir: &Path) -> Result { - std::fs::create_dir_all(host_dir)?; - let path = std::env::var_os("CREDENTIALS_DIRECTORY") - .map(|p| std::path::PathBuf::from(p).join("sausage-cred-key")) - .unwrap_or_else(|| host_dir.join("cred.key")); - if std::env::var_os("CREDENTIALS_DIRECTORY").is_none() { - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - { - Ok(mut file) => { - file.write_all(&seal::random::<32>())?; - file.sync_all()?; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(e.into()), + Self::open_with_key(host_dir, &host_dir.join("cred.key")) + } + pub fn open_with_key(host_dir: &Path, key_path: &Path) -> Result { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(host_dir)?; + std::fs::set_permissions(host_dir, std::fs::Permissions::from_mode(0o700))?; + let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?; + db.busy_timeout(std::time::Duration::from_secs(5))?; + db.execute_batch(schema::DDL)?; + let check: Option> = db + .query_row("SELECT value FROM meta WHERE key='master'", [], |r| { + r.get(0) + }) + .optional()?; + match std::fs::metadata(key_path) { + Ok(meta) if meta.permissions().mode() & 0o077 != 0 => { + return Err(Error("master_key_permissions")); } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let populated: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM items) OR EXISTS(SELECT 1 FROM fields)", + [], + |r| r.get(0), + )?; + if populated || check.is_some() { + return Err(Error("master_key_missing")); + } + let parent = key_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temp = parent.join(format!(".cred-key-{}", seal::identity())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp)?; + let key = zeroize::Zeroizing::new(seal::random::<32>()); + file.write_all(key.as_ref())?; + file.sync_all()?; + std::fs::rename(&temp, key_path)?; + std::fs::File::open(parent)?.sync_all()?; + } + Err(e) => return Err(e.into()), } let mut bytes = zeroize::Zeroizing::new(Vec::new()); - std::fs::File::open(path)?.read_to_end(&mut bytes)?; + std::fs::File::open(key_path)?.read_to_end(&mut bytes)?; let master = zeroize::Zeroizing::new( bytes .as_slice() .try_into() .map_err(|_| Error("master_key"))?, ); - let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?; - db.busy_timeout(std::time::Duration::from_secs(5))?; - db.execute_batch(schema::DDL)?; + match check { + Some(check) => { + let value = + seal::open(&master, "master-check", &check).map_err(|_| Error("master_key"))?; + if value.as_slice() != KEY_CHECK { + return Err(Error("master_key")); + } + } + None => { + let populated: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM items) OR EXISTS(SELECT 1 FROM fields)", + [], + |r| r.get(0), + )?; + if populated { + return Err(Error("master_key_check_missing")); + } + let check = seal::seal(&master, "master-check", KEY_CHECK)?; + db.execute("INSERT INTO meta(key,value) VALUES('master',?1)", [check])?; + } + } let (decided, _) = tokio::sync::broadcast::channel(64); let (filed, _) = tokio::sync::broadcast::channel(64); + let rules = rule_index(&db)?; let cred = Self { - store: Mutex::new(Store { db, master }), + store: Mutex::new(Store { db, master, rules }), decided, filed, }; cred.expire_requests()?; Ok(cred) } + /// D57 maintenance. The harness calls this daily. Item versions remain intact. + pub fn prune(&self, now: i64) -> Result<()> { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + tx.execute( + "DELETE FROM audit WHERE time'pending' AND (CASE WHEN state='expired' THEN expires ELSE decided_at END) Result> { self.store.lock().map_err(|_| Error("storage")) } diff --git a/host/src/cred/summary.rs b/host/src/cred/summary.rs index 7316f94..2dec79d 100644 --- a/host/src/cred/summary.rs +++ b/host/src/cred/summary.rs @@ -1,4 +1,4 @@ -//! Public approval summaries. Do not interpolate field values or script sources. +//! Public approval summaries expose policy and public values, never secrets or script sources. pub fn human_duration(mut seconds: u64) -> String { let mut parts = Vec::new(); for (size, unit) in [ @@ -26,6 +26,10 @@ fn text(value: &str) -> String { let mut out = String::new(); for c in value.chars() { match c { + '\u{200b}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{206f}' + | '\u{feff}' => {} '\n' | '\r' => out.push(' '), '&' => out.push_str("&"), '<' => out.push_str("<"), @@ -39,15 +43,57 @@ fn text(value: &str) -> String { } out } +fn rules_text(rules: &[super::items::Rule]) -> String { + let mut hosts = Vec::new(); + for rule in rules { + let port = rule + .port + .unwrap_or(match rule.scheme.to_ascii_lowercase().as_str() { + "http" => 80, + "https" => 443, + "ssh" => 22, + _ => 0, + }); + for host in &rule.hosts { + hosts.push(format!( + "{} host {}, port {}{}", + text(&rule.scheme), + text(host), + port, + if host.starts_with("*.") { + " (wildcard: check the public suffix)" + } else { + "" + } + )); + } + } + format!("Rules: {}.", hosts.join(", ")) +} +fn creation(config: &super::items::Item) -> String { + format!( + "Create item “{}”. {} {} {}", + text(&config.name), + if config.free_use { + "Anyone can use it without a grant." + } else { + "It requires a grant." + }, + rules_text(&config.rules), + if config.transform_js.is_empty() { + "It has no transformation script." + } else { + "It runs a transformation script." + } + ) +} pub fn operation_summary(operation: &super::edits::Operation) -> String { use super::edits::Operation::*; match operation { - CreateItem { config } => format!("Create item “{}”.", text(&config.name)), - GenerateEs256Item { config } => { - format!("Create item “{}” with a new ES256 key.", text(&config.name)) - } + CreateItem { config } => creation(config), + GenerateEs256Item { config } => format!("{} Generate a new ES256 key.", creation(config)), SetName { name } => format!("Rename the item to “{}”.", text(name)), - SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(), + SetRules { rules } => format!("Replace the access rules. {}", rules_text(rules)), SetFreeUse { enabled: true } => { "Allow anyone to use this item without a grant, within its rules.".into() } @@ -60,7 +106,9 @@ pub fn operation_summary(operation: &super::edits::Operation) -> String { if field.public { "public" } else { "secret" }, text(&field.name) ), - SetFieldValue { .. } => "Change a public field value.".into(), + SetFieldValue { field, value } => { + format!("Set public field “{}” to “{}”.", text(field), text(value)) + } SetFieldType { .. } => "Change a field type. Review the type in Details.".into(), RenameField { name, .. } => format!("Rename a field to “{}”.", text(name)), RemoveField { .. } => "Remove a field and permanently discard its value.".into(), diff --git a/host/src/cred/webauthn.rs b/host/src/cred/webauthn.rs index ba01583..b02cab5 100644 --- a/host/src/cred/webauthn.rs +++ b/host/src/cred/webauthn.rs @@ -1,156 +1,337 @@ //! 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 super::{requests::Overrides, types::FieldType}; 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 } +#[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 } +#[serde(rename_all = "snake_case")] +pub enum Mutation { + CreateItem, + GrantCreate, + RevokeSubjects, + ResolveStale, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag="type", deny_unknown_fields)] +#[serde(tag = "type", deny_unknown_fields)] pub enum Binding { - 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 }, + 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, + }, } 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 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 { +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 } +pub struct Verified { + credential: CredentialID, + binding: Binding, +} impl Verified { - pub fn credential(&self) -> &CredentialID { &self.credential } - pub fn binding(&self) -> &Binding { &self.binding } + pub fn credential(&self) -> &CredentialID { + &self.credential + } + pub fn binding(&self) -> &Binding { + &self.binding + } } /// 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) } } +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> { + 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")); } + 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")); } + 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")); } + 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)) - }, + } } } } 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")); } + 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 } +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 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()?; + 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() }) + 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)",[],|r|r.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 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() + 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 proof = self.finish_assertion(&id,response)?; - if proof.binding != (Binding::Enrollment { label:label.clone() }) { bail!("invalid enrollment gate"); } - }, + None => {} + Some(Gate { id, response }) => { + let proof = self.finish_assertion(&id, response)?; + if proof.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))?; + 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 }) + 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 { + 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)?])?; + 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()?)?; + 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 { - let (binding,state) = pending(&mut self.assertions,id)?; + 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 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"))?; + 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())?])?; + 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 }) + Ok(Verified { + credential: result.cred_id().clone(), + binding, + }) } } -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"); } +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"); +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); } } @@ -159,18 +340,20 @@ 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 1a048eb..9b058e3 100644 --- a/host/tests/cred.rs +++ b/host/tests/cred.rs @@ -75,7 +75,9 @@ fn inject_selection_idempotency_and_subtree() { c.inject(parts("api.example"), &root, &Fake).unwrap(), Outcome::Forward(_) )); - let i = c.create_item(item(false), &root, c.unlocked().unwrap()).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"); @@ -89,10 +91,21 @@ 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, c.unlocked().unwrap()) - .unwrap(); - c.approve(&first, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + c.set_field( + &i.id, + &i.fields[0].field_id, + "opaque", + &root, + c.unlocked().unwrap(), + ) + .unwrap(); + c.approve( + &first, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + c.unlocked().unwrap(), + ) + .unwrap(); let child = Caller { program: "/bin/tool.wasm".into(), pid: 43, @@ -133,10 +146,18 @@ 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, c.unlocked().unwrap()).unwrap(); - let secret = "SECRET_SENTINEL_19"; - c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, c.unlocked().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, + c.unlocked().unwrap(), + ) + .unwrap(); let result = c.inject(parts("api.example"), &caller, &Fake); if let Some(expected) = expected { let error = result.err().unwrap(); @@ -175,9 +196,17 @@ 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, c.unlocked().unwrap()).unwrap(); - c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, c.unlocked().unwrap()) + 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 { id: String::new(), @@ -215,10 +244,21 @@ fn atomic_budget_and_decision_notification() { ); let id = c.request_list().unwrap()[0].id.clone(); let mut receiver = c.subscribe(); - c.deny(&id, sausage::cred::webauthn::Decision::Deny, c.unlocked().unwrap()).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, sausage::cred::webauthn::Decision::Deny, c.unlocked().unwrap()).unwrap_err().0, + c.deny( + &id, + sausage::cred::webauthn::Decision::Deny, + c.unlocked().unwrap() + ) + .unwrap_err() + .0, "already_decided" ); } @@ -230,7 +270,9 @@ 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, c.unlocked().unwrap()).unwrap(); + let i = c + .create_item(config, &caller, c.unlocked().unwrap()) + .unwrap(); c.set_field( &i.id, &i.fields[0].field_id, diff --git a/host/tests/cred_accounting.rs b/host/tests/cred_accounting.rs new file mode 100644 index 0000000..3ee10cb --- /dev/null +++ b/host/tests/cred_accounting.rs @@ -0,0 +1,111 @@ +//! Reservations commit their audit row atomically. Unrelated rows do not enter the hot path. +use sausage::cred::{ + grants::Grant, + inject::Outcome, + items::Item, + script::{self, Limits, Script}, + *, +}; +struct Noop; +impl Script for Noop { + fn evaluate( + &self, + _: &str, + _: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + Ok("{}".into()) + } +} +fn config(name: &str, host: &str) -> Item { + serde_json::from_value(serde_json::json!({"name":name,"rules":[{"scheme":"https","hosts":[host],"path_prefixes":["/"],"methods":null}],"validate_js":null,"transform_js":"","fields":[]})).unwrap() +} +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item, Grant) { + 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( + config("test", "api.example"), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let grant = c + .grant_create( + Grant { + id: String::new(), + items: vec![item.id.clone()], + subject: types::Subject::Conversation("root".into()), + exp: None, + max_uses: Some(1), + uses: 0, + revoked: false, + validator: None, + note: None, + }, + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + (dir, c, caller, item, grant) +} +fn parts() -> http::request::Parts { + http::Request::get("https://api.example/") + .body(()) + .unwrap() + .into_parts() + .0 +} +#[test] +fn audit_failure_rolls_back_reservation() { + let (dir, c, caller, _item, grant) = fixture(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute_batch("CREATE TRIGGER fail_fetch_audit BEFORE INSERT ON audit WHEN NEW.event LIKE 'fetch.%' BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap(); + assert!(c.inject(parts(), &caller, &Noop).is_err()); + assert_eq!(c.grant_status(&grant.id).unwrap().uses, 0); + db.execute_batch("DROP TRIGGER fail_fetch_audit").unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Noop).unwrap(), + Outcome::Forward(_) + )); + let rows = c.audit_list().unwrap(); + assert_eq!(rows.iter().filter(|r| r.event == "fetch.use").count(), 1); +} +#[test] +fn revoked_rows_are_filtered_before_deserialization() { + let (dir, c, caller, _item, _grant) = fixture(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("INSERT INTO grants(id,items,subject,uses,revoked) VALUES('inactive','invalid-json','invalid-json',0,1)",[]).unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Noop).unwrap(), + Outcome::Forward(_) + )); +} +#[test] +fn unmatched_configs_are_not_read_during_injection() { + let (dir, c, caller, _item, _grant) = fixture(); + let other = c + .create_item( + config("other", "other.example"), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + // An invalid JSON marker detects an unwanted read of an unrelated config. + db.execute( + "UPDATE items SET config='invalid-json' WHERE id=?", + [other.id], + ) + .unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Noop).unwrap(), + Outcome::Forward(_) + )); +} diff --git a/host/tests/cred_authorization.rs b/host/tests/cred_authorization.rs index f4f2c4e..2783cc6 100644 --- a/host/tests/cred_authorization.rs +++ b/host/tests/cred_authorization.rs @@ -1,34 +1,106 @@ //! 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) { +use sausage::cred::{ + items::{Field, Item}, + 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 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) + 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"); + 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()); + 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()); + 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() { diff --git a/host/tests/cred_challenges.rs b/host/tests/cred_challenges.rs index dca4358..c8098c3 100644 --- a/host/tests/cred_challenges.rs +++ b/host/tests/cred_challenges.rs @@ -6,21 +6,25 @@ use webauthn_rs::prelude::*; 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 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 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 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()); + 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 4c3115e..20061ee 100644 --- a/host/tests/cred_core.rs +++ b/host/tests/cred_core.rs @@ -96,8 +96,14 @@ 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, c.unlocked().unwrap()) - .unwrap(); + 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); assert!(!old.fields[0].needs_value); @@ -117,8 +123,13 @@ fn revisions_needs_value_and_rename_are_metadata_only() { ) .unwrap(); assert!(!c.edit_diff(&id).unwrap().discards_values); - c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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); assert_eq!(current.fields[0].field_id, old.fields[0].field_id); @@ -139,12 +150,23 @@ fn stale_edits_leave_history_and_values_untouched() { }, ) .unwrap(); - c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, c.unlocked().unwrap()) - .unwrap(); + c.set_field( + &i.id, + &i.fields[0].field_id, + "opaque", + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( - c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap_err() - .0, + c.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + c.unlocked().unwrap() + ) + .unwrap_err() + .0, "stale" ); assert_eq!(c.request_list().unwrap()[0].state, "pending"); @@ -166,10 +188,16 @@ fn key_conflict_renewal_and_expiry() { c.file_request(&caller, "key", other).unwrap_err().0, "key_conflict" ); - c.deny(&id, sausage::cred::webauthn::Decision::Deny, c.unlocked().unwrap()).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(); + 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); @@ -179,9 +207,14 @@ fn key_conflict_renewal_and_expiry() { assert_eq!(c.expire_requests().unwrap(), vec![next.clone()]); assert_eq!(notification.try_recv().unwrap(), next); assert_eq!( - c.approve(&next, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap_err() - .0, + c.approve( + &next, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + c.unlocked().unwrap() + ) + .unwrap_err() + .0, "request_expired" ); assert!(c.grant_list().unwrap().is_empty()); @@ -190,16 +223,24 @@ 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, c.unlocked().unwrap()) - .unwrap_err() - .0, + c.set_field( + &i.id, + &i.fields[0].field_id, + "line\nbreak", + &caller, + c.unlocked().unwrap() + ) + .unwrap_err() + .0, "invalid_value" ); assert_eq!(c.item_get(&i.id).unwrap().version, 1); let mut config = i.clone(); config.fields[0].value = Some("forbidden".into()); assert_eq!( - c.create_item(config, &caller, c.unlocked().unwrap()).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 f9357d0..86d87b9 100644 --- a/host/tests/cred_edits.rs +++ b/host/tests/cred_edits.rs @@ -48,8 +48,13 @@ fn creation_key_generation_and_deletion_keep_secrets_out_of_history() { ) .unwrap(); assert!(c.item_list().unwrap().is_empty()); - c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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); assert!(item.fields[0].value.is_none()); @@ -69,8 +74,13 @@ fn creation_key_generation_and_deletion_keep_secrets_out_of_history() { }, ) .unwrap(); - c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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); let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); @@ -89,8 +99,11 @@ fn operation_order_name_conflicts_and_stale_resolution() { pid: 3, conversations: vec!["root".into()], }; - let item = c.create_item(config("first"), &caller, c.unlocked().unwrap()).unwrap(); - c.create_item(config("taken"), &caller, c.unlocked().unwrap()).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,19 +142,35 @@ fn operation_order_name_conflicts_and_stale_resolution() { request(vec![Operation::SetFreeUse { enabled: true }]), ) .unwrap(); - c.approve(&other, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap_err() - .0, + c.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + c.unlocked().unwrap() + ) + .unwrap_err() + .0, "stale" ); - c.resolve_stale(&id, 2, &caller, c.unlocked().unwrap()).unwrap(); - assert!(!c.edit_diff(&id).unwrap().stale); - c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) + c.resolve_stale(&id, 2, &caller, c.unlocked().unwrap()) .unwrap(); + assert!(!c.edit_diff(&id).unwrap().stale); + 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"); assert!(current.free_use); @@ -167,7 +196,9 @@ fn field_removal_revert_and_public_visibility() { field_type: types::FieldType::Text, key_type: None, }); - let item = c.create_item(initial, &caller, c.unlocked().unwrap()).unwrap(); + let item = c + .create_item(initial, &caller, c.unlocked().unwrap()) + .unwrap(); c.set_field( &item.id, &item.fields[0].field_id, @@ -198,13 +229,23 @@ fn field_removal_revert_and_public_visibility() { ) .unwrap(); assert!(c.edit_diff(&id).unwrap().discards_values); - c.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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); assert_eq!(restored.fields[0].field_id, item.fields[0].field_id); @@ -253,14 +294,24 @@ 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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .is_err() + 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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), c.unlocked().unwrap()) - .unwrap(); + 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 b90a321..c78c8f0 100644 --- a/host/tests/cred_grants.rs +++ b/host/tests/cred_grants.rs @@ -123,7 +123,9 @@ 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, c.unlocked().unwrap()).unwrap(); + let g = c + .grant_create(grant(&i), &caller, c.unlocked().unwrap()) + .unwrap(); assert!(matches!( c.inject(parts(), &caller, &Fake).unwrap(), Outcome::Forward(_) @@ -136,7 +138,9 @@ 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, c.unlocked().unwrap()).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(); @@ -155,13 +159,17 @@ fn reject_bad_issuance_without_rows() { let mut g = grant(&i); g.max_uses = Some(0); assert_eq!( - c.grant_create(g, &caller, c.unlocked().unwrap()).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, c.unlocked().unwrap()).unwrap_err().0, + c.grant_create(g, &caller, c.unlocked().unwrap()) + .unwrap_err() + .0, "invalid_subject" ); assert!(c.grant_list().unwrap().is_empty()); diff --git a/host/tests/cred_maintenance.rs b/host/tests/cred_maintenance.rs new file mode 100644 index 0000000..be16bb3 --- /dev/null +++ b/host/tests/cred_maintenance.rs @@ -0,0 +1,216 @@ +//! D57 retention preserves pending work and all versions. Revocation only removes access. +use sausage::cred::{ + edits::{Edit, Operation, OperationRequest}, + grants::Grant, + items::Item, + requests::{GrantRequest, Overrides}, + webauthn::Decision, + *, +}; +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,c.unlocked().unwrap()).unwrap(); + (dir, c, caller, item) +} +fn grant(item: &Item, subject: types::Subject) -> Grant { + Grant { + id: String::new(), + items: vec![item.id.clone()], + subject, + exp: None, + max_uses: None, + uses: 0, + revoked: false, + validator: None, + note: None, + } +} +fn request(item: &Item) -> GrantRequest { + GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + } +} +#[test] +fn prune_obeys_cutoffs_and_preserves_pending_live_and_versions() { + let (dir, c, caller, item) = fixture(); + let now = 200 * 86400; + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let old = c.file_request(&caller, "old", request(&item)).unwrap(); + c.approve( + &old, + Decision::Approve, + Overrides::default(), + c.unlocked().unwrap(), + ) + .unwrap(); + let expired = c.grant_list().unwrap()[0].id.clone(); + db.execute( + "UPDATE requests SET decided_at=? WHERE id=?", + rusqlite::params![now - 31 * 86400, old], + ) + .unwrap(); + db.execute( + "UPDATE grants SET exp=? WHERE id=?", + rusqlite::params![now - 31 * 86400, expired], + ) + .unwrap(); + let pending = c.file_request(&caller, "pending", request(&item)).unwrap(); + db.execute("UPDATE requests SET expires=0 WHERE id=?", [&pending]) + .unwrap(); + let edge = c.file_request(&caller, "edge", request(&item)).unwrap(); + c.deny(&edge, Decision::Deny, c.unlocked().unwrap()) + .unwrap(); + db.execute( + "UPDATE requests SET decided_at=? WHERE id=?", + rusqlite::params![now - 30 * 86400, edge], + ) + .unwrap(); + let live = c + .grant_create( + grant(&item, types::Subject::Conversation("root".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let revoked = c + .grant_create( + grant(&item, types::Subject::Conversation("root".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + c.revoke_grant(&revoked.id, &caller).unwrap(); + db.execute( + "UPDATE grants SET revoked_at=? WHERE id=?", + rusqlite::params![now - 31 * 86400, revoked.id], + ) + .unwrap(); + let protected = c + .grant_create( + grant(&item, types::Subject::Conversation("root".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + db.execute( + "UPDATE grants SET exp=? WHERE id=?", + rusqlite::params![now - 31 * 86400, protected.id], + ) + .unwrap(); + c.file_operation_request( + &caller, + "protected", + OperationRequest { + item: None, + base_version: None, + change: vec![Operation::GrantRevoke { + target: protected.id.clone(), + }], + }, + ) + .unwrap(); + let mut config = item.clone(); + config.name = "renamed".into(); + let edit = c + .file_edit_request( + &caller, + "edit", + Edit { + item: item.id.clone(), + base_version: item.version, + config, + }, + ) + .unwrap(); + c.approve( + &edit, + Decision::Approve, + Overrides::default(), + c.unlocked().unwrap(), + ) + .unwrap(); + db.execute("UPDATE versions SET created=0", []).unwrap(); + db.execute( + "INSERT INTO audit(time,event,pid,program) VALUES(?,'old-marker',1,'test')", + [now - 91 * 86400], + ) + .unwrap(); + db.execute( + "INSERT INTO audit(time,event,pid,program) VALUES(?,'edge-marker',1,'test')", + [now - 90 * 86400], + ) + .unwrap(); + c.prune(now).unwrap(); + let requests = c.request_list().unwrap(); + assert!(!requests.iter().any(|r| r.id == old)); + assert!(requests.iter().any(|r| r.id == pending)); + assert!(requests.iter().any(|r| r.id == edge)); + let grants = c.grant_list().unwrap(); + assert!(!grants.iter().any(|g| g.id == expired || g.id == revoked.id)); + assert!(grants.iter().any(|g| g.id == live.id)); + assert!(grants.iter().any(|g| g.id == protected.id)); + let audit = c.audit_list().unwrap(); + assert!(!audit.iter().any(|r| r.event == "old-marker")); + assert!(audit.iter().any(|r| r.event == "edge-marker")); + assert_eq!(c.item_versions(&item.id).unwrap().len(), 1); +} +#[test] +fn revoke_ids_programs_and_full_subtrees_without_widening_access() { + let (dir, c, caller, item) = fixture(); + let root = c + .grant_create( + grant(&item, types::Subject::Conversation("root".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let child = c + .grant_create( + grant(&item, types::Subject::Conversation("child".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + c.revoke_subjects( + &["root".into(), "child".into()], + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + assert!(c.grant_status(&root.id).unwrap().revoked); + assert!(c.grant_status(&child.id).unwrap().revoked); + let program = c + .grant_create( + grant(&item, types::Subject::Program("/bin/service.wasm".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let other = c + .grant_create( + grant(&item, types::Subject::Program("/bin/other.wasm".into())), + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute( + "INSERT INTO passkey_credentials VALUES('synthetic','test','{}')", + [], + ) + .unwrap(); + c.revoke_program("/bin/service.wasm", &caller).unwrap(); + assert!(c.grant_status(&program.id).unwrap().revoked); + assert!(!c.grant_status(&other.id).unwrap().revoked); + c.revoke_grant(&other.id, &caller).unwrap(); + assert!(c.grant_status(&other.id).unwrap().revoked); +} diff --git a/host/tests/cred_policy.rs b/host/tests/cred_policy.rs index 38ed48c..195e232 100644 --- a/host/tests/cred_policy.rs +++ b/host/tests/cred_policy.rs @@ -1,44 +1,150 @@ -//! Approval policy regressions for explicit grant operations and denied request replay. -use sausage::cred::{*, edits::{Operation, OperationRequest}, grants::Grant, items::Item, requests::{GrantRequest, Overrides}}; +//! Explicit grant approvals honor their limits. Denials suppress duplicate approval prompts. +use sausage::cred::{ + edits::{Operation, OperationRequest}, + grants::Grant, + items::Item, + requests::{GrantRequest, Overrides}, + webauthn::Decision, + *, +}; +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} 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) + 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,c.unlocked().unwrap()).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 } + 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() + 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 (_dir, c, caller, item) = fixture(); + let op = Operation::GrantCreate { + grant: grant(&item), + }; + let id = operation(&c, &caller, "grant", op.clone()); + let before = now(); + c.approve( + &id, + Decision::Approve, + Overrides { + duration_secs: Some(120), + max_uses: Some(1), + validator: Some("true".into()), + unlimited: false, + }, + c.unlocked().unwrap(), + ) + .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))); + assert!( + g.exp + .is_some_and(|e| (before + 120..=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, + Decision::Approve, + Overrides { + duration_secs: Some(1), + ..Default::default() + }, + c.unlocked().unwrap() + ) + .is_err() + ); + c.approve( + &revoke, + Decision::Approve, + Overrides::default(), + c.unlocked().unwrap(), + ) + .unwrap(); + assert_ne!(operation(&c, &caller, "grant", op), id); + let id = operation( + &c, + &caller, + "default", + Operation::GrantCreate { + grant: grant(&item), + }, + ); + let before = now(); + c.approve( + &id, + Decision::Approve, + Overrides::default(), + c.unlocked().unwrap(), + ) + .unwrap(); + assert!( + c.grant_list() + .unwrap() + .last() + .unwrap() + .exp + .is_some_and(|e| (before + 3600..=now() + 3600).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); + 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, Decision::Deny, c.unlocked().unwrap()).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 index 881d8a5..8ef670e 100644 --- a/host/tests/cred_presentation.rs +++ b/host/tests/cred_presentation.rs @@ -1,16 +1,88 @@ //! Approval text exposes public policy and rejects deceptive display controls. -use sausage::cred::{edits::Operation, items::Item, summary::operation_summary}; +use sausage::cred::{ + Caller, Cred, + edits::{Edit, Operation, OperationRequest}, + 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() }); + 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 diffs_report_rule_overlaps_for_edits_and_creation() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller::default(); + let mut item:Item=serde_json::from_value(serde_json::json!({"name":"first","rules":[{"scheme":"https","hosts":["*.example"],"path_prefixes":["/"],"methods":null}],"validate_js":null,"transform_js":"","fields":[]})).unwrap(); + let first = c + .create_item(item.clone(), &caller, c.unlocked().unwrap()) + .unwrap(); + item.name = "second".into(); + item.rules[0].hosts = vec!["api.example".into()]; + let create = c + .file_operation_request( + &caller, + "create", + OperationRequest { + item: None, + base_version: None, + change: vec![Operation::CreateItem { + config: item.clone(), + }], + }, + ) + .unwrap(); + assert!( + c.request_diff(&create).unwrap()["overlapping_items"] + .as_array() + .unwrap() + .iter() + .any(|v| v == &first.id) + ); + let second = c.create_item(item, &caller, c.unlocked().unwrap()).unwrap(); + let mut config = second.clone(); + config.name = "edited".into(); + let edit = c + .file_edit_request( + &caller, + "edit", + Edit { + item: second.id, + base_version: second.version, + config, + }, + ) + .unwrap(); + assert!( + c.edit_diff(&edit) + .unwrap() + .overlapping_items + .contains(&first.id) + ); +} +#[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)); } + 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_proofs.rs b/host/tests/cred_proofs.rs new file mode 100644 index 0000000..26bedc9 --- /dev/null +++ b/host/tests/cred_proofs.rs @@ -0,0 +1,259 @@ +//! Real assertions bind decisions, current payloads, and the exact encrypted field input. +use sausage::cred::{ + edits::Edit, + items::{Field, FieldInput, FieldValue, Item}, + requests::{GrantRequest, Overrides}, + webauthn::{Binding, Decision, Mutation, Passkeys}, + *, +}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use webauthn_rs::prelude::Url; +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( + 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, + c.unlocked().unwrap(), + ) + .unwrap(); + (dir, c, caller, item) +} +fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator) { + let start = host.start_registration("test".into(), None).unwrap(); + let response = auth + .do_registration(Url::parse("http://localhost:8000").unwrap(), start.options) + .unwrap(); + host.finish_registration(&start.id, response).unwrap(); +} +fn proof( + host: &mut Passkeys<'_>, + auth: &mut WebauthnAuthenticator, + binding: Binding, +) -> webauthn::Verified { + let start = host.start_assertion(binding).unwrap(); + let response = auth + .do_authentication(Url::parse("http://localhost:8000").unwrap(), start.options) + .unwrap(); + host.finish_assertion(&start.id, response).unwrap() +} +#[test] +fn verified_decisions_compare_choice_overrides_and_request() { + let (dir, c, caller, item) = fixture(); + let request = GrantRequest { + items: vec![item.id], + duration_secs: None, + max_uses: None, + validator: None, + }; + let a = c.file_request(&caller, "a", request.clone()).unwrap(); + let b = c.file_request(&caller, "b", request).unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000").unwrap(); + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + register(&mut host, &mut auth); + for binding in [ + c.decision_binding(&a, Decision::Deny, Overrides::default()) + .unwrap(), + c.decision_binding( + &a, + Decision::Approve, + Overrides { + max_uses: Some(1), + ..Default::default() + }, + ) + .unwrap(), + c.decision_binding(&b, Decision::Approve, Overrides::default()) + .unwrap(), + ] { + let p = proof(&mut host, &mut auth, binding); + assert_eq!( + c.approve(&a, Decision::Approve, Overrides::default(), p) + .unwrap_err() + .0, + "binding_mismatch" + ); + } + assert_eq!(c.request_list().unwrap()[0].state, "pending"); + let p = proof( + &mut host, + &mut auth, + c.decision_binding(&a, Decision::Approve, Overrides::default()) + .unwrap(), + ); + c.approve(&a, Decision::Approve, Overrides::default(), p) + .unwrap(); + assert!( + c.audit_list() + .unwrap() + .iter() + .any(|r| r.event == "approved" && r.approver.is_some()) + ); +} +#[test] +fn stale_resolution_invalidates_review_proof_and_audits_caller() { + let (dir, c, caller, item) = fixture(); + let mut config = item.clone(); + config.name = "edited".into(); + let id = c + .file_edit_request( + &caller, + "edit", + Edit { + item: item.id.clone(), + base_version: item.version, + config, + }, + ) + .unwrap(); + let binding = c + .decision_binding(&id, Decision::Approve, Overrides::default()) + .unwrap(); + c.set_field( + &item.id, + &item.fields[0].field_id, + "synthetic", + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); + let version = c.item_get(&item.id).unwrap().version; + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000").unwrap(); + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + register(&mut host, &mut auth); + let reviewed = proof(&mut host, &mut auth, binding); + let resolve = proof( + &mut host, + &mut auth, + Binding::mutation( + Mutation::ResolveStale, + &serde_json::json!({"request_id":id,"base_version":version}), + ) + .unwrap(), + ); + let reviewer = Caller { + pid: 77, + program: "/bin/review.wasm".into(), + conversations: vec!["review".into()], + }; + c.resolve_stale(&id, version, &reviewer, resolve).unwrap(); + assert_eq!( + c.approve(&id, Decision::Approve, Overrides::default(), reviewed) + .unwrap_err() + .0, + "binding_mismatch" + ); + assert!( + c.audit_list() + .unwrap() + .iter() + .any(|r| r.event == "request.resolve_stale" + && r.pid == 77 + && r.payload_sha256.is_some()) + ); +} +fn envelope(key: &entry::EntryKey) -> entry::Envelope { + use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; + use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint}; + let secret = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let peer = PublicKey::from_sec1_bytes(&key.public_key()).unwrap(); + let shared = diffie_hellman(secret.to_nonzero_scalar(), peer.as_affine()); + let mut aes = [0u8; 32]; + hkdf::Hkdf::::new(None, shared.raw_secret_bytes()) + .expand(b"sausage/secret-entry/v1", &mut aes) + .unwrap(); + let iv = [1u8; 12]; + let ciphertext = Aes256Gcm::new_from_slice(&aes) + .unwrap() + .encrypt(Nonce::from_slice(&iv), b"synthetic".as_slice()) + .unwrap(); + entry::Envelope { + ephemeral_public_key: secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(), + iv, + ciphertext, + } +} +#[test] +fn secret_entry_binds_actual_envelope_target_and_metadata() { + let (dir, c, caller, item) = fixture(); + let key = entry::EntryKey::open(&dir.path().join("entry.key")).unwrap(); + let a = envelope(&key); + let b = envelope(&key); + let field = &item.fields[0].field_id; + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000").unwrap(); + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + register(&mut host, &mut auth); + let binding = Binding::SecretEntry { + item: item.id.clone(), + field: field.clone(), + ciphertext_sha256: a.sha256(), + field_type: Some(types::FieldType::Note), + key_type: None, + }; + for (target, env, kind) in [ + ("wrong-field", &a, types::FieldType::Note), + (field.as_str(), &b, types::FieldType::Note), + (field.as_str(), &a, types::FieldType::Text), + ] { + let p = proof(&mut host, &mut auth, binding.clone()); + let input = FieldInput { + value: FieldValue::Encrypted { + key: &key, + envelope: env, + }, + field_type: Some(kind), + key_type: None, + }; + assert_eq!( + c.set_field_input(&item.id, target, &input, &caller, p) + .unwrap_err() + .0, + "binding_mismatch" + ); + } + assert!(c.item_get(&item.id).unwrap().fields[0].needs_value); + let p = proof(&mut host, &mut auth, binding); + let input = FieldInput { + value: FieldValue::Encrypted { + key: &key, + envelope: &a, + }, + field_type: Some(types::FieldType::Note), + key_type: None, + }; + c.set_field_input(&item.id, field, &input, &caller, p) + .unwrap(); + let current = c.item_get(&item.id).unwrap(); + assert!(!current.fields[0].needs_value); + assert_eq!(current.fields[0].field_type, types::FieldType::Note); +} diff --git a/host/tests/cred_regressions.rs b/host/tests/cred_regressions.rs index de824ad..52e7d6a 100644 --- a/host/tests/cred_regressions.rs +++ b/host/tests/cred_regressions.rs @@ -102,22 +102,38 @@ 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, cred.unlocked().unwrap()) - .unwrap(); + 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(); 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(); + 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, cred.unlocked().unwrap()) - .unwrap(); + 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(); let cipher = || { @@ -145,8 +161,13 @@ fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { }, ) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + 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(); let mut config = current.clone(); @@ -166,8 +187,13 @@ 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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .is_err() + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap() + ) + .is_err() ); assert!(cipher() == before); assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); @@ -178,8 +204,14 @@ 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, cred.unlocked().unwrap()) - .unwrap(); + cred.set_field( + &item.id, + &field.field_id, + "opaque", + &caller, + cred.unlocked().unwrap(), + ) + .unwrap(); } let current = cred.item_get(&item.id).unwrap(); let id = cred @@ -192,8 +224,13 @@ fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { }, ) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); let id = cred .file_revert_request( &caller, @@ -203,8 +240,13 @@ fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { current.version, ) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + 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); assert_eq!(restored.version, current.version + 2); @@ -224,10 +266,22 @@ fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() { field_type: Some(types::FieldType::Oauth2RefreshToken), key_type: None, }; - 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, cred.unlocked().unwrap()) - .unwrap(); + 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, + cred.unlocked().unwrap(), + ) + .unwrap(); let current = cred.item_get(&item.id).unwrap(); let history = cred.item_versions(&item.id).unwrap().len(); let audit = cred.audit_list().unwrap().len(); @@ -242,10 +296,16 @@ 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, cred.unlocked().unwrap()) - .err() - .unwrap() - .0, + cred.set_field_input( + &item.id, + &item.fields[1].field_id, + &input, + &caller, + cred.unlocked().unwrap() + ) + .err() + .unwrap() + .0, "invalid_params" ); assert!(cipher() == before); @@ -270,15 +330,26 @@ fn typed_secret_entry_and_bundle_normalization() { }, ) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); let input = FieldInput { 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, cred.unlocked().unwrap()) - .unwrap(); + 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); assert!(current.fields[0].key_type.is_none()); @@ -308,7 +379,7 @@ fn typed_secret_entry_and_bundle_normalization() { unlimited: true, ..Overrides::default() }, - None, + cred.unlocked().unwrap(), ) .unwrap(); let g = cred.grant_list().unwrap().remove(0); diff --git a/host/tests/cred_replay.rs b/host/tests/cred_replay.rs index 5b380b6..78025f0 100644 --- a/host/tests/cred_replay.rs +++ b/host/tests/cred_replay.rs @@ -53,8 +53,13 @@ fn approved_operations_and_deletes_replay() { let id = cred .file_operation_request(&caller, "edit", request.clone()) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( cred.file_operation_request(&caller, "edit", request) .unwrap(), @@ -68,8 +73,13 @@ fn approved_operations_and_deletes_replay() { let id = cred .file_delete_request(&caller, "delete", delete.clone()) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( cred.file_delete_request(&caller, "delete", delete).unwrap(), id @@ -88,8 +98,13 @@ fn approved_replacements_and_creations_replay() { let id = cred .file_edit_request(&caller, "replace", edit.clone()) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( cred.file_edit_request(&caller, "replace", edit).unwrap(), id @@ -103,8 +118,13 @@ fn approved_replacements_and_creations_replay() { let id = cred .file_operation_request(&caller, "create", request.clone()) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( cred.file_operation_request(&caller, "create", request) .unwrap(), @@ -143,12 +163,22 @@ fn grant_defaults_normalize_and_pending_requests_replay_after_deletion() { }, ) .unwrap(); - cred.approve(&delete, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + 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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .is_err() + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap() + ) + .is_err() ); assert_eq!( cred.request_list() @@ -184,8 +214,13 @@ fn values_are_checked_at_each_public_operation() { }, ) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + 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 .fields @@ -221,8 +256,13 @@ fn values_are_checked_at_each_public_operation() { let id = cred .file_operation_request(&caller, "ordered", request) .unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( cred.item_get(&item.id).unwrap().version, current.version + 1 @@ -271,23 +311,40 @@ fn secret_retyping_checks_each_operation_only_on_approval() { .contains("VALUE_SENTINEL") ); assert_eq!( - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .err() - .unwrap() - .0, + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap() + ) + .err() + .unwrap() + .0, "invalid_value" ); 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, cred.unlocked().unwrap()) - .unwrap(); + 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, &caller, cred.unlocked().unwrap()).unwrap(); - cred.approve(&id, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) + 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] fn stale_rebases_keep_original_keyed_intent() { @@ -302,17 +359,29 @@ 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, cred.unlocked().unwrap()) - .unwrap(); + 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, &caller, cred.unlocked().unwrap()).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, sausage::cred::webauthn::Decision::Approve, Overrides::default(), cred.unlocked().unwrap()) - .unwrap(); + cred.approve( + &id, + sausage::cred::webauthn::Decision::Approve, + Overrides::default(), + cred.unlocked().unwrap(), + ) + .unwrap(); assert_eq!( cred.file_operation_request(&caller, "rebase", request) .unwrap(), diff --git a/host/tests/cred_security.rs b/host/tests/cred_security.rs index f924ce7..ab63353 100644 --- a/host/tests/cred_security.rs +++ b/host/tests/cred_security.rs @@ -1,53 +1,158 @@ //! Destination matching fails closed before and after a credential transform. -use sausage::cred::{*, inject::Outcome, items::Item, script::{self,Script,Limits}}; +use sausage::cred::{ + inject::Outcome, + items::Item, + script::{self, Limits, Script}, + *, +}; 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 evaluate( + &self, + _: &str, + _: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + Ok(self.0.to_string()) + } } -fn config(host:&str,port:Option) -> Item { +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 } +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") }; + 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()); + 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") }; + 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 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()); + 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 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()); + 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"); + 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 index 5603155..668fa7f 100644 --- a/host/tests/cred_storage.rs +++ b/host/tests/cred_storage.rs @@ -1,11 +1,11 @@ //! Master-key startup checks use only temporary host storage and synthetic keys. -use sausage::cred::{Cred, Caller}; +use sausage::cred::{Caller, Cred}; 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(); + c.create_item(serde_json::from_value(serde_json::json!({"name":"stored","rules":[],"validate_js":null,"transform_js":"","fields":[]})).unwrap(), &Caller::default(), c.unlocked().unwrap()).unwrap(); drop(c); std::fs::remove_file(dir.path().join("cred.key")).unwrap(); assert!(Cred::open(dir.path()).is_err()); @@ -15,7 +15,7 @@ fn missing_key_in_populated_store_is_not_replaced() { 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(); + std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap(); assert!(Cred::open(dir.path()).is_err()); } #[test] @@ -23,8 +23,11 @@ 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); + 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(); + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap(); assert!(Cred::open(&host).is_err()); } diff --git a/host/tests/cred_webauthn.rs b/host/tests/cred_webauthn.rs index da078a0..925440d 100644 --- a/host/tests/cred_webauthn.rs +++ b/host/tests/cred_webauthn.rs @@ -1,14 +1,15 @@ //! Software-authenticator coverage for enrollment gates and bound assertions. use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use sausage::cred::webauthn::{Binding, Gate, Passkeys}; +use sausage::cred::webauthn::{Binding, Decision, Gate, Passkeys}; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; use webauthn_rs::prelude::*; fn binding() -> Binding { Binding::Decision { request_id: "request-1".into(), - decision: "approve".into(), - overrides: serde_json::json!({}), + decision: Decision::Approve, + overrides: sausage::cred::requests::Overrides::default(), + payload_sha256: "synthetic-payload".into(), } } @@ -44,7 +45,10 @@ fn enrollment_and_assertions() -> anyhow::Result<()> { let start = host.start_assertion(binding())?; let response = auth.do_authentication(origin.clone(), start.options)?; let replay = serde_json::to_value(&response)?; - assert_eq!(host.finish_assertion(&start.id, response)?.1, binding()); + assert_eq!( + host.finish_assertion(&start.id, response)?.binding(), + &binding() + ); assert!( host.finish_assertion(&start.id, serde_json::from_value(replay)?) .is_err()