cred: complete core proof, policy, storage and injection fixes
This commit is contained in:
+41
-8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Public approval summaries. Do not interpolate field values or script sources.
|
||||
//! Public approval summaries expose policy and public values, never secrets or script sources.
|
||||
pub fn human_duration(mut seconds: u64) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for (size, unit) in [
|
||||
@@ -26,6 +26,10 @@ fn text(value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'\u{200b}'..='\u{200f}'
|
||||
| '\u{202a}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{206f}'
|
||||
| '\u{feff}' => {}
|
||||
'\n' | '\r' => out.push(' '),
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
@@ -39,15 +43,57 @@ fn text(value: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn rules_text(rules: &[super::items::Rule]) -> String {
|
||||
let mut hosts = Vec::new();
|
||||
for rule in rules {
|
||||
let port = rule
|
||||
.port
|
||||
.unwrap_or(match rule.scheme.to_ascii_lowercase().as_str() {
|
||||
"http" => 80,
|
||||
"https" => 443,
|
||||
"ssh" => 22,
|
||||
_ => 0,
|
||||
});
|
||||
for host in &rule.hosts {
|
||||
hosts.push(format!(
|
||||
"{} host {}, port {}{}",
|
||||
text(&rule.scheme),
|
||||
text(host),
|
||||
port,
|
||||
if host.starts_with("*.") {
|
||||
" (wildcard: check the public suffix)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
format!("Rules: {}.", hosts.join(", "))
|
||||
}
|
||||
fn creation(config: &super::items::Item) -> String {
|
||||
format!(
|
||||
"Create item “{}”. {} {} {}",
|
||||
text(&config.name),
|
||||
if config.free_use {
|
||||
"Anyone can use it without a grant."
|
||||
} else {
|
||||
"It requires a grant."
|
||||
},
|
||||
rules_text(&config.rules),
|
||||
if config.transform_js.is_empty() {
|
||||
"It has no transformation script."
|
||||
} else {
|
||||
"It runs a transformation script."
|
||||
}
|
||||
)
|
||||
}
|
||||
pub fn operation_summary(operation: &super::edits::Operation) -> String {
|
||||
use super::edits::Operation::*;
|
||||
match operation {
|
||||
CreateItem { config } => format!("Create item “{}”.", text(&config.name)),
|
||||
GenerateEs256Item { config } => {
|
||||
format!("Create item “{}” with a new ES256 key.", text(&config.name))
|
||||
}
|
||||
CreateItem { config } => creation(config),
|
||||
GenerateEs256Item { config } => format!("{} Generate a new ES256 key.", creation(config)),
|
||||
SetName { name } => format!("Rename the item to “{}”.", text(name)),
|
||||
SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(),
|
||||
SetRules { rules } => format!("Replace the access rules. {}", rules_text(rules)),
|
||||
SetFreeUse { enabled: true } => {
|
||||
"Allow anyone to use this item without a grant, within its rules.".into()
|
||||
}
|
||||
@@ -60,7 +106,9 @@ pub fn operation_summary(operation: &super::edits::Operation) -> String {
|
||||
if field.public { "public" } else { "secret" },
|
||||
text(&field.name)
|
||||
),
|
||||
SetFieldValue { .. } => "Change a public field value.".into(),
|
||||
SetFieldValue { field, value } => {
|
||||
format!("Set public field “{}” to “{}”.", text(field), text(value))
|
||||
}
|
||||
SetFieldType { .. } => "Change a field type. Review the type in Details.".into(),
|
||||
RenameField { name, .. } => format!("Rename a field to “{}”.", text(name)),
|
||||
RemoveField { .. } => "Remove a field and permanently discard its value.".into(),
|
||||
|
||||
+268
-85
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -6,21 +6,25 @@ use webauthn_rs::prelude::*;
|
||||
fn failed_response_preserves_challenge_and_oldest_challenge_is_evicted() -> anyhow::Result<()> {
|
||||
let db = rusqlite::Connection::open_in_memory()?;
|
||||
let origin = Url::parse("http://localhost:8000")?;
|
||||
let mut host = Passkeys::new(&db,"localhost",origin.as_str())?;
|
||||
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
|
||||
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let registration = host.start_registration("test".into(),None)?;
|
||||
let response = auth.do_registration(origin.clone(),registration.options)?;
|
||||
host.finish_registration(®istration.id,response)?;
|
||||
let binding = Binding::Enrollment { label:"next".into() };
|
||||
let registration = host.start_registration("test".into(), None)?;
|
||||
let response = auth.do_registration(origin.clone(), registration.options)?;
|
||||
host.finish_registration(®istration.id, response)?;
|
||||
let binding = Binding::Enrollment {
|
||||
label: "next".into(),
|
||||
};
|
||||
let valid = host.start_assertion(binding.clone())?;
|
||||
let wrong = host.start_assertion(binding.clone())?;
|
||||
let invalid_response = auth.do_authentication(origin.clone(),wrong.options)?;
|
||||
assert!(host.finish_assertion(&valid.id,invalid_response).is_err());
|
||||
let response = auth.do_authentication(origin.clone(),valid.options)?;
|
||||
host.finish_assertion(&valid.id,response)?;
|
||||
let invalid_response = auth.do_authentication(origin.clone(), wrong.options)?;
|
||||
assert!(host.finish_assertion(&valid.id, invalid_response).is_err());
|
||||
let response = auth.do_authentication(origin.clone(), valid.options)?;
|
||||
host.finish_assertion(&valid.id, response)?;
|
||||
let oldest = host.start_assertion(binding.clone())?;
|
||||
for _ in 0..32 { host.start_assertion(binding.clone())?; }
|
||||
let response = auth.do_authentication(origin,oldest.options)?;
|
||||
assert!(host.finish_assertion(&oldest.id,response).is_err());
|
||||
for _ in 0..32 {
|
||||
host.start_assertion(binding.clone())?;
|
||||
}
|
||||
let response = auth.do_authentication(origin, oldest.options)?;
|
||||
assert!(host.finish_assertion(&oldest.id, response).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+59
-18
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user