cred: complete core proof, policy, storage and injection fixes

This commit is contained in:
2026-11-06 22:19:35 +00:00
parent a75094054e
commit 5b3e1393f5
24 changed files with 2223 additions and 408 deletions
+41 -8
View File
@@ -81,6 +81,8 @@ pub struct Diff {
pub new: Item,
pub discards_values: bool,
#[serde(default)]
pub overlapping_items: Vec<String>,
#[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<super::webauthn::Proof>) -> Result<()> {
pub fn resolve_stale(
&self,
id: &str,
base_version: i64,
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> 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(())
}
+59 -28
View File
@@ -27,27 +27,36 @@ pub(crate) fn list(db: &Connection) -> Result<Vec<Grant>> {
Ok(q.query_map([], read)?.collect::<rusqlite::Result<_>>()?)
}
fn read(r: &rusqlite::Row<'_>) -> rusqlite::Result<Grant> {
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<i64>>(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<i64>>(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<Vec<Grant>> {
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<max_uses) AND EXISTS(SELECT 1 FROM json_each(items) WHERE value=?2) AND ((json_extract(subject,'$.kind')='program' AND json_extract(subject,'$.id')=?3) OR (json_extract(subject,'$.kind')='conversation' AND EXISTS(SELECT 1 FROM json_each(?4) WHERE value=json_extract(grants.subject,'$.id')))) ORDER BY rowid LIMIT 1")?;
Ok(q.query_map(params![now(), item, caller.program, serde_json::to_string(&caller.conversations)?], read)?.collect::<rusqlite::Result<_>>()?)
Ok(q.query_map(
params![
now(),
item,
caller.program,
serde_json::to_string(&caller.conversations)?
],
read,
)?
.collect::<rusqlite::Result<_>>()?)
}
pub(crate) fn insert(
db: &Connection,
@@ -98,7 +107,8 @@ impl Cred {
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> Result<Grant> {
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<super::webauthn::Proof>,
) -> 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<max_uses)",params![g.id,now()])?;
if changed != 1 { return Err(Error("grant_exhausted")); }
if changed != 1 {
return Err(Error("grant_exhausted"));
}
g.uses += 1;
}
audit::record(&tx, "fetch.pending", caller, Some(&item.id), None)?;
+37 -11
View File
@@ -17,7 +17,9 @@ fn matches(item: &Item, p: &Parts) -> 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<Outcome> {
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))
}
+120 -20
View File
@@ -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<u16>) -> bool {
pub fn rule_matches_port(
r: &Rule,
scheme: &str,
host: &str,
path: &str,
method: &str,
port: Option<u16>,
) -> 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::<std::net::IpAddr>().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<Vec<String>> {
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<super::webauthn::Proof>) -> Result<Item> {
pub fn create_item(
&self,
item: Item,
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> Result<Item> {
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
+89 -13
View File
@@ -27,7 +27,8 @@ pub struct Request {
pub decision: Option<String>,
pub approver: Option<String>,
}
#[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<super::webauthn::Binding> {
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<super::webauthn::Proof>,
) -> 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<super::webauthn::Proof>,
) -> 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<Overrides>,
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(())
}
+107 -23
View File
@@ -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<String, (i64, Vec<super::items::Rule>)>,
}
impl Store {
pub fn refresh_rules(&mut self) -> Result<()> {
self.rules = rule_index(&self.db)?;
Ok(())
}
}
fn rule_index(
db: &rusqlite::Connection,
) -> Result<std::collections::HashMap<String, (i64, Vec<super::items::Rule>)>> {
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<Store>,
pub(crate) decided: tokio::sync::broadcast::Sender<String>,
pub(crate) filed: tokio::sync::broadcast::Sender<String>,
}
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<Self> {
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<Self> {
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<Vec<u8>> = 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<?",
[now.saturating_sub(90 * 86400)],
)?;
let cutoff = now.saturating_sub(30 * 86400);
tx.execute("DELETE FROM requests WHERE state<>'pending' AND (CASE WHEN state='expired' THEN expires ELSE decided_at END)<?", [cutoff])?;
tx.execute("DELETE FROM grants WHERE ((revoked=1 AND revoked_at<?1) OR exp<?1) AND NOT EXISTS(SELECT 1 FROM requests WHERE grant_id=grants.id OR json_extract(payload,'$.grant_revoke')=grants.id)", [cutoff])?;
tx.commit()?;
Ok(())
}
pub(crate) fn access(&self) -> Result<MutexGuard<'_, Store>> {
self.store.lock().map_err(|_| Error("storage"))
}
+55 -7
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
@@ -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(),
+268 -85
View File
@@ -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<FieldType>, key_type:Option<String> },
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<FieldType>,
key_type: Option<String>,
},
Mutation {
action: Mutation,
parameters_sha256: String,
},
Enrollment {
label: String,
},
}
impl Binding {
pub fn mutation(action:Mutation, parameters:&impl Serialize) -> super::Result<Self> {
Ok(Self::Mutation { action, parameters_sha256:digest(&serde_json::to_vec(parameters)?) })
pub fn mutation(action: Mutation, parameters: &impl Serialize) -> super::Result<Self> {
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<Verified> for Proof { fn from(v:Verified) -> Self { Self::Verified(v) } }
impl From<Unlocked> for Proof { fn from(v:Unlocked) -> Self { Self::Unlocked(v) } }
pub enum Proof {
Verified(Verified),
Unlocked(Unlocked),
}
impl From<Verified> for Proof {
fn from(v: Verified) -> Self {
Self::Verified(v)
}
}
impl From<Unlocked> for Proof {
fn from(v: Unlocked) -> Self {
Self::Unlocked(v)
}
}
impl Proof {
pub(crate) fn check(self, db:&Connection, expected:&Binding) -> super::Result<Option<String>> {
pub(crate) fn check(
self,
db: &Connection,
expected: &Binding,
) -> super::Result<Option<String>> {
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<Unlocked> {
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<T> { pub id:String, pub options:T }
struct Pending<T> { deadline:Instant, value:T }
pub struct Challenge<T> {
pub id: String,
pub options: T,
}
struct Pending<T> {
deadline: Instant,
value: T,
}
pub struct Passkeys<'a> {
db:&'a Connection, webauthn:Webauthn,
registrations:HashMap<String,Pending<(String,bool,PasskeyRegistration)>>,
assertions:HashMap<String,Pending<(Binding,PasskeyAuthentication)>>,
db: &'a Connection,
webauthn: Webauthn,
registrations: HashMap<String, Pending<(String, bool, PasskeyRegistration)>>,
assertions: HashMap<String, Pending<(Binding, PasskeyAuthentication)>>,
}
impl<'a> Passkeys<'a> {
pub fn new(db:&'a Connection,rp_id:&str,origin:&str) -> Result<Self> {
pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result<Self> {
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<Vec<Passkey>> {
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<Gate>) -> Result<Challenge<CreationChallengeResponse>> {
pub fn start_registration(
&mut self,
label: String,
gate: Option<Gate>,
) -> Result<Challenge<CreationChallengeResponse>> {
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<Passkey> {
pub fn finish_registration(
&mut self,
id: &str,
response: RegisterPublicKeyCredential,
) -> Result<Passkey> {
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<Challenge<RequestChallengeResponse>> {
let (options,state) = self.webauthn.start_passkey_authentication(&self.credentials()?)?;
pub fn start_assertion(
&mut self,
binding: Binding,
) -> Result<Challenge<RequestChallengeResponse>> {
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<Verified> {
let (binding,state) = pending(&mut self.assertions,id)?;
pub fn finish_assertion(
&mut self,
id: &str,
response: PublicKeyCredential,
) -> Result<Verified> {
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<String,Pending<T>>,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<String, Pending<T>>, 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<T>(map:&mut HashMap<String,Pending<T>>) {
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<T>(map: &mut HashMap<String, Pending<T>>) {
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(())
}
+55 -13
View File
@@ -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,
+111
View File
@@ -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<String, script::Error> {
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(_)
));
}
+88 -16
View File
@@ -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() {
+16 -12
View File
@@ -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(&registration.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(&registration.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(())
}
+59 -18
View File
@@ -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);
+74 -23
View File
@@ -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);
}
+12 -4
View File
@@ -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());
+216
View File
@@ -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);
}
+135 -29
View File
@@ -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);
}
+78 -6
View File
@@ -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));
}
}
+259
View File
@@ -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<SoftPasskey>) {
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<SoftPasskey>,
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::<sha2::Sha256>::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);
}
+99 -28
View File
@@ -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);
+98 -29
View File
@@ -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(),
+131 -26
View File
@@ -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<String,script::Error> { Ok(self.0.to_string()) }
fn evaluate(
&self,
_: &str,
_: &str,
_: Option<&str>,
_: Limits,
) -> std::result::Result<String, script::Error> {
Ok(self.0.to_string())
}
}
fn config(host:&str,port:Option<u16>) -> Item {
fn config(host: &str, port: Option<u16>) -> 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"
);
}
+8 -5
View File
@@ -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());
}
+8 -4
View File
@@ -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()