Merge branch 'v3' into v3-t29

# Conflicts:
#	host/Cargo.toml
This commit is contained in:
2026-11-06 22:50:21 +00:00
255 changed files with 159161 additions and 668 deletions
+44 -4
View File
@@ -81,6 +81,8 @@ pub struct Diff {
pub new: Item,
pub discards_values: bool,
#[serde(default)]
pub overlapping_items: Vec<String>,
#[serde(default)]
pub stale: bool,
}
fn check_secret(
@@ -149,6 +151,7 @@ pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> R
.iter()
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
Ok(Diff {
overlapping_items: items::overlaps(db, &new)?,
old,
new,
discards_values,
@@ -245,6 +248,7 @@ pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert
.iter()
.any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id));
Ok(Diff {
overlapping_items: items::overlaps(db, &new)?,
old,
new,
discards_values,
@@ -489,11 +493,17 @@ impl super::Cred {
{
return Err(Error("invalid_params"));
}
items::unique_name(&self.access()?.db, &config.name, "")?;
let overlapping_items = {
let s = self.access()?;
items::unique_name(&s.db, &config.name, "")?;
let mut proposed = config.clone();
proposed.id.clear();
items::overlaps(&s.db, &proposed)?
};
return self.file_payload(
caller,
key,
serde_json::json!({"create":config,"generate_es256":generate,"requested":request}),
serde_json::json!({"create":config,"generate_es256":generate,"requested":request,"overlapping_items":overlapping_items}),
);
}
let edit = Edit {
@@ -564,10 +574,21 @@ impl super::Cred {
self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff}))
}
/// Replace a pending edit's base version after the UI reviews the current item.
pub fn resolve_stale(&self, id: &str, base_version: i64) -> Result<()> {
pub fn resolve_stale(
&self,
id: &str,
base_version: i64,
caller: &Caller,
proof: impl Into<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 approver = proof.into().check(&tx, &binding)?;
let (payload, state): (String, String) =
tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
@@ -595,9 +616,28 @@ impl super::Cred {
payload["edit"] = serde_json::to_value(edit)?;
payload["diff"] = serde_json::to_value(diff)?;
}
let encoded = serde_json::to_string(&payload)?;
tx.execute(
"UPDATE requests SET payload=?2 WHERE id=?1",
params![id, serde_json::to_string(&payload)?],
params![id, encoded],
)?;
audit::record(
&tx,
"request.resolve_stale",
caller,
payload
.get("edit")
.or_else(|| payload.get("revert"))
.and_then(|p| p.get("item"))
.and_then(|v| v.as_str()),
approver.as_deref(),
)?;
tx.execute(
"UPDATE audit SET payload_sha256=?1 WHERE id=?2",
params![
super::webauthn::digest(encoded.as_bytes()),
tx.last_insert_rowid()
],
)?;
tx.commit()?;
Ok(())
+93 -43
View File
@@ -24,25 +24,38 @@ pub struct Grant {
}
pub(crate) fn list(db: &Connection) -> Result<Vec<Grant>> {
let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?;
Ok(q.query_map([], |r| {
Ok(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(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)?,
})
}
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<_>>()?)
}
pub(crate) fn insert(
@@ -53,7 +66,7 @@ pub(crate) fn insert(
) -> Result<()> {
validate(db, grant)?;
db.execute(
"INSERT INTO grants VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)",
"INSERT INTO grants(id,items,subject,exp,max_uses,uses,revoked,validator,note) VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)",
params![
grant.id,
serde_json::to_string(&grant.items)?,
@@ -92,8 +105,10 @@ impl Cred {
&self,
mut grant: Grant,
caller: &Caller,
approver: Option<&str>,
proof: impl Into<super::webauthn::Proof>,
) -> Result<Grant> {
let binding =
super::webauthn::Binding::mutation(super::webauthn::Mutation::GrantCreate, &grant)?;
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
@@ -102,7 +117,8 @@ impl Cred {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
insert(&tx, &grant, caller, approver)?;
let approver = proof.into().check(&tx, &binding)?;
insert(&tx, &grant, caller, approver.as_deref())?;
tx.commit()?;
Ok(grant)
}
@@ -115,23 +131,56 @@ impl Cred {
.find(|g| g.id == id)
.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],
caller: &Caller,
approver: Option<&str>,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
let mut s = self.access()?;
let tx = s.db.transaction()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let binding = super::webauthn::Binding::mutation(
super::webauthn::Mutation::RevokeSubjects,
&subjects,
)?;
let approver = proof.into().check(&tx, &binding)?;
for subject in subjects {
tx.execute(
"UPDATE grants SET revoked=1 WHERE subject=?",
"UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE subject=?",
[serde_json::to_string(&Subject::Conversation(
subject.clone(),
))?],
)?;
}
audit::record(&tx, "grant.revoke", caller, None, approver)?;
audit::record(&tx, "grant.revoke", caller, None, approver.as_deref())?;
tx.commit()?;
Ok(())
}
/// Removing access needs no assertion, including after passkey registration.
pub fn revoke_grant(&self, id: &str, caller: &Caller) -> Result<()> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
if tx.execute(
"UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE id=?",
[id],
)? != 1
{
return Err(Error("grant_missing"));
}
audit::record(&tx, "grant.revoke", caller, None, None)?;
tx.commit()?;
Ok(())
}
pub fn revoke_program(&self, program: &str, caller: &Caller) -> Result<()> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE subject=?", [serde_json::to_string(&Subject::Program(program.into()))?])?;
audit::record(&tx, "grant.revoke", caller, None, None)?;
tx.commit()?;
Ok(())
}
@@ -139,32 +188,33 @@ impl Cred {
&self,
item: &super::items::Item,
caller: &Caller,
) -> Result<Option<(Grant, serde_json::Value)>> {
) -> Result<Option<(Option<Grant>, serde_json::Value, i64)>> {
let mut s = self.access()?;
let master = s.master.clone();
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let grant = list(&tx)?.into_iter().find(|g| {
g.items.iter().any(|i| i == &item.id)
&& g.subject.covers(caller)
&& !g.revoked
&& g.exp.is_none_or(|e| e > now())
&& g.max_uses.is_none_or(|m| g.uses < m)
});
if let Some(mut g) = grant {
if super::items::load(&tx, &item.id)?.version != item.version {
return Err(Error("stale"));
}
let credential = super::items::credential(&tx, &master, item, true)?;
let mut grant = if item.free_use {
None
} else {
active(&tx, &item.id, caller)?.into_iter().next()
};
if !item.free_use && grant.is_none() {
return Ok(None);
}
if super::items::load(&tx, &item.id)?.version != item.version {
return Err(Error("stale"));
}
let credential = super::items::credential(&tx, &master, item, true)?;
if let Some(g) = &mut grant {
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
if changed != 1 {
return Err(Error("grant_exhausted"));
}
g.uses += 1;
tx.commit()?;
Ok(Some((g, credential)))
} else {
Ok(None)
}
audit::record(&tx, "fetch.pending", caller, Some(&item.id), None)?;
let audit_id = tx.last_insert_rowid();
tx.commit()?;
Ok(Some((grant, credential, audit_id)))
}
}
+58 -26
View File
@@ -14,16 +14,27 @@ pub enum Outcome {
Denied { item: String, request: String },
}
fn matches(item: &Item, p: &Parts) -> bool {
item.rules.iter().any(|r| {
super::items::rule_matches(
matches_rules(&item.rules, p)
}
fn matches_rules(rules: &[super::items::Rule], p: &Parts) -> bool {
if !safe_uri(&p.uri) {
return false;
}
rules.iter().any(|r| {
super::items::rule_matches_port(
r,
p.uri.scheme_str().unwrap_or(""),
p.uri.host().unwrap_or(""),
p.uri.path(),
p.method.as_str(),
p.uri.port_u16(),
)
})
}
fn safe_uri(uri: &http::Uri) -> bool {
uri.authority().is_some_and(|a| !a.as_str().contains('@'))
&& super::items::safe_path(uri.path())
}
fn request(p: &Parts) -> Result<Value> {
let mut headers = Vec::new();
for (name, value) in &p.headers {
@@ -45,17 +56,27 @@ impl Cred {
caller: &Caller,
script: &dyn Script,
) -> Result<Outcome> {
let candidates: Vec<_> = self
.item_list()?
.into_iter()
.filter(|i| matches(i, &parts))
.collect();
if !safe_uri(&parts.uri) {
return Err(Error("invalid_url"));
}
let candidates: Vec<_> = {
let s = self.access()?;
s.rules
.iter()
.filter(|(_, (_, rules))| matches_rules(rules, &parts))
.map(|(id, (version, _))| (id.clone(), *version))
.collect()
};
if candidates.len() > 1 {
return Err(Error("ambiguous_item"));
}
let Some(item) = candidates.into_iter().next() else {
let Some((id, version)) = candidates.into_iter().next() else {
return Ok(Outcome::Forward(parts));
};
let item = self.item_get(&id)?;
if item.version != version {
return Err(Error("stale"));
}
let original_request = request(&parts)?;
if item
.fields
@@ -64,13 +85,15 @@ impl Cred {
{
return Err(Error("ssh_item"));
}
let prepared = if item.free_use {
Some((None, self.credential(&item, true)?))
} else {
self.reserve(&item, caller)?
.map(|(grant, credential)| (Some(grant), credential))
};
let Some((grant, secret_credential)) = prepared else {
if item
.fields
.iter()
.any(|f| f.field_type == super::types::FieldType::Oauth2RefreshToken)
{
return Err(Error("oauth2_item"));
}
let prepared = self.reserve(&item, caller)?;
let Some((grant, secret_credential, audit_id)) = prepared else {
let request = self.file_request(
caller,
&format!("inject:{}", item.id),
@@ -142,10 +165,13 @@ impl Cred {
.get("value")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
let mut value =
http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?;
value.set_sensitive(true);
new.append(
http::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| Error("transform"))?,
http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?,
value,
);
}
parts.headers = new;
@@ -154,18 +180,24 @@ impl Cred {
if !matches(&item, &parts) {
return Err(Error("rule_recheck"));
}
let authority = parts.uri.authority().ok_or(Error("rule_recheck"))?;
parts.headers.insert(
http::header::HOST,
http::HeaderValue::from_str(authority.as_str())
.map_err(|_| Error("rule_recheck"))?,
);
Ok(())
})();
super::audit::record(
&self.access()?.db,
if result.is_ok() {
"fetch.use"
} else {
"fetch.denied"
},
caller,
Some(&item.id),
None,
self.access()?.db.execute(
"UPDATE audit SET event=?1 WHERE id=?2",
rusqlite::params![
if result.is_ok() {
"fetch.use"
} else {
"fetch.denied"
},
audit_id
],
)?;
result?;
Ok(Outcome::Forward(parts))
+170 -14
View File
@@ -10,12 +10,35 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Rule {
pub scheme: String,
#[serde(default)]
pub port: Option<u16>,
pub hosts: Vec<String>,
pub path_prefixes: Vec<String>,
pub methods: Option<Vec<String>>,
}
/// Match a request on the scheme's default port.
pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool {
r.scheme.eq_ignore_ascii_case(scheme)
rule_matches_port(r, scheme, host, path, method, None)
}
/// Missing ports mean the scheme's default, not any port.
pub fn rule_matches_port(
r: &Rule,
scheme: &str,
host: &str,
path: &str,
method: &str,
port: Option<u16>,
) -> bool {
let default = match scheme.to_ascii_lowercase().as_str() {
"http" => Some(80),
"https" => Some(443),
"ssh" => Some(22),
_ => None,
};
let host = host.trim_start_matches('[').trim_end_matches(']');
safe_path(path)
&& r.port.or(default) == port.or(default)
&& r.scheme.eq_ignore_ascii_case(scheme)
&& r.hosts.iter().any(|p| {
if let Some(s) = p.strip_prefix("*.") {
host.to_ascii_lowercase()
@@ -30,6 +53,29 @@ pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str
.as_ref()
.is_none_or(|ms| ms.iter().any(|m| m.eq_ignore_ascii_case(method)))
}
pub(crate) fn safe_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
!path.contains('\\')
&& !lower.contains("%2e")
&& !lower.contains("%2f")
&& !lower.contains("%5c")
&& !path.split('/').any(|s| s == "." || s == "..")
}
fn local_host(host: &str) -> bool {
let host = host.strip_prefix("*.").unwrap_or(host);
host.eq_ignore_ascii_case("localhost")
|| host.to_ascii_lowercase().ends_with(".localhost")
|| host.parse::<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())
}
})
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Field {
#[serde(default)]
@@ -126,7 +172,11 @@ pub(crate) fn validate(item: &Item) -> Result<()> {
if !matches!(
r.scheme.to_ascii_lowercase().as_str(),
"http" | "https" | "ssh"
) || r.path_prefixes.iter().any(|p| !p.starts_with('/'))
) || r.port == Some(0)
|| (r.port.is_none() && r.hosts.iter().any(|h| local_host(h)))
|| r.path_prefixes
.iter()
.any(|p| !p.starts_with('/') || !safe_path(p))
|| r.methods.as_ref().is_some_and(|ms| {
ms.iter().any(|m| {
m.is_empty() || !m.bytes().all(|b| b.is_ascii_alphabetic() || b == b'-')
@@ -134,7 +184,8 @@ pub(crate) fn validate(item: &Item) -> Result<()> {
})
|| r.hosts.iter().any(|p| {
let h = p.strip_prefix("*.").unwrap_or(p);
h.is_empty()
p.eq_ignore_ascii_case("host.sausage")
|| h.is_empty()
|| h.starts_with('.')
|| h.ends_with('.')
|| h.contains("..")
@@ -149,6 +200,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)",
@@ -187,9 +293,17 @@ pub struct ItemVersion {
pub created: i64,
pub config: Item,
}
/// Plain entry is bootstrap-only. Verified entry decrypts the exact bound envelope here.
pub enum FieldValue<'a> {
Plain(&'a str),
Encrypted {
key: &'a super::entry::EntryKey,
envelope: &'a super::entry::Envelope,
},
}
/// A secret-entry value with optional replacement metadata. Debug never includes the value.
pub struct FieldInput<'a> {
pub value: &'a str,
pub value: FieldValue<'a>,
pub field_type: Option<FieldType>,
pub key_type: Option<String>,
}
@@ -202,13 +316,23 @@ impl std::fmt::Debug for FieldInput<'_> {
}
}
impl Cred {
/// Trusted host mutation. The route layer must verify a passkey before this call.
pub fn create_item(&self, item: Item, caller: &Caller, approver: &str) -> Result<Item> {
/// 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> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let item = insert_new(&tx, item, caller, Some(approver))?;
validate(&item)?;
let binding =
super::webauthn::Binding::mutation(super::webauthn::Mutation::CreateItem, &item)?;
let approver = proof.into().check(&tx, &binding)?;
let item = insert_new(&tx, item, caller, approver.as_deref())?;
tx.commit()?;
s.refresh_rules()?;
Ok(item)
}
pub fn item_get(&self, id: &str) -> Result<Item> {
@@ -242,18 +366,18 @@ impl Cred {
field: &str,
value: &str,
caller: &Caller,
approver: &str,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
self.set_field_input(
item,
field,
&FieldInput {
value,
value: FieldValue::Plain(value),
field_type: None,
key_type: None,
},
caller,
approver,
proof,
)
}
pub fn set_field_input(
@@ -262,12 +386,43 @@ impl Cred {
field: &str,
input: &FieldInput<'_>,
caller: &Caller,
approver: &str,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
let value = input.value;
let mut s = self.access()?;
let key = s.master.clone();
let tx = s.db.transaction()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let proof = proof.into();
if matches!(&proof, super::webauthn::Proof::Verified(_))
&& matches!(&input.value, FieldValue::Plain(_))
{
return Err(Error("binding_mismatch"));
}
let hash = match &input.value {
FieldValue::Plain(_) => String::new(),
FieldValue::Encrypted { envelope, .. } => envelope.sha256(),
};
let approver = proof.check(
&tx,
&super::webauthn::Binding::SecretEntry {
item: item.into(),
field: field.into(),
ciphertext_sha256: hash,
field_type: input.field_type.clone(),
key_type: input.key_type.clone(),
},
)?;
let decrypted;
let value = match &input.value {
FieldValue::Plain(value) => *value,
FieldValue::Encrypted { key, envelope } => {
decrypted = zeroize::Zeroizing::new(
key.decrypt(envelope)
.map_err(|_| Error("secret_envelope"))?,
);
std::str::from_utf8(&decrypted).map_err(|_| Error("field_invalid"))?
}
};
let mut config = load(&tx, item)?;
let index = config
.fields
@@ -305,8 +460,9 @@ impl Cred {
.checked_add(1)
.ok_or(Error("version_exhausted"))?;
save(&tx, &config)?;
super::audit::record(&tx, "field.set", caller, Some(item), Some(approver))?;
super::audit::record(&tx, "field.set", caller, Some(item), approver.as_deref())?;
tx.commit()?;
s.refresh_rules()?;
Ok(())
}
pub(crate) fn credential(&self, item: &Item, secrets: bool) -> Result<serde_json::Value> {
+91 -34
View File
@@ -8,14 +8,28 @@ use ed25519_dalek::{Signer, SigningKey};
use hmac::{Hmac, Mac};
use rsa::{
pkcs1::DecodeRsaPrivateKey,
pkcs8::{DecodePrivateKey, EncodePublicKey},
pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey},
traits::PublicKeyParts,
};
use serde_json::json;
use sha2::{Digest, Sha256};
use sha2::{Sha384, Sha512};
use sha2::{Sha256, Sha384, Sha512};
use zeroize::Zeroizing;
#[derive(Default, serde::Deserialize)]
#[serde(default)]
struct Jwk {
kty: Zeroizing<String>,
crv: Zeroizing<String>,
k: Option<Zeroizing<String>>,
d: Option<Zeroizing<String>>,
x: Option<Zeroizing<String>>,
y: Option<Zeroizing<String>>,
n: Option<Zeroizing<String>>,
e: Option<Zeroizing<String>>,
p: Option<Zeroizing<String>>,
q: Option<Zeroizing<String>>,
}
pub(super) enum Key {
Ed(SigningKey),
Ec(p256::ecdsa::SigningKey),
@@ -51,7 +65,7 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul
};
match key.key_data() {
ssh_key::private::KeypairData::Ed25519(k) => {
Key::Ed(SigningKey::from_bytes(&k.private.to_bytes()))
Key::Ed(SigningKey::from_bytes(&Zeroizing::new(k.private.to_bytes())))
}
ssh_key::private::KeypairData::Ecdsa(
k @ ssh_key::private::EcdsaKeypair::NistP256 { .. },
@@ -63,11 +77,16 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul
// ssh-key 0.6.7's TryFrom<&RsaKeypair> accidentally passes p twice.
// Convert the parsed components explicitly, preserving p and q.
let int = |n: &ssh_key::Mpint| rsa::BigUint::try_from(n).map_err(|_| invalid_key());
let n = int(&k.public.n)?;
let e = int(&k.public.e)?;
let mut d = Zeroizing::new(int(&k.private.d)?);
let mut p = Zeroizing::new(int(&k.private.p)?);
let mut q = Zeroizing::new(int(&k.private.q)?);
let key = rsa::RsaPrivateKey::from_components(
int(&k.public.n)?,
int(&k.public.e)?,
int(&k.private.d)?,
vec![int(&k.private.p)?, int(&k.private.q)?],
n,
e,
std::mem::take(&mut *d),
vec![std::mem::take(&mut *p), std::mem::take(&mut *q)],
)
.map_err(|_| invalid_key())?;
key.validate().map_err(|_| invalid_key())?;
@@ -94,40 +113,39 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul
_ => return Err(invalid_key()),
}
} else if value.starts_with('{') {
let j: serde_json::Value = serde_json::from_str(value).map_err(|_| invalid_key())?;
let bytes = |name: &str| -> Result<Zeroizing<Vec<u8>>> {
decode(j[name].as_str().ok_or_else(invalid_key)?)
let j: Jwk = serde_json::from_str(value).map_err(|_| invalid_key())?;
let bytes = |value: &Option<Zeroizing<String>>| -> Result<Zeroizing<Vec<u8>>> {
decode(value.as_ref().ok_or_else(invalid_key)?)
};
match j["kty"].as_str() {
Some("oct") => Key::Mac(bytes("k")?),
Some("OKP") if j["crv"] == "Ed25519" => {
let d = bytes("d")?;
match j.kty.as_str() {
"oct" => Key::Mac(bytes(&j.k)?),
"OKP" if j.crv.as_str() == "Ed25519" => {
let d = bytes(&j.d)?;
let k = SigningKey::from_bytes(d.as_slice().try_into().map_err(|_| invalid_key())?);
if bytes("x")?.as_slice() != k.verifying_key().as_bytes() {
if bytes(&j.x)?.as_slice() != k.verifying_key().as_bytes() {
return Err(invalid_key());
}
Key::Ed(k)
}
Some("EC") if j["crv"] == "P-256" => {
"EC" if j.crv.as_str() == "P-256" => {
let k =
p256::ecdsa::SigningKey::from_slice(&bytes("d")?).map_err(|_| invalid_key())?;
p256::ecdsa::SigningKey::from_slice(&bytes(&j.d)?).map_err(|_| invalid_key())?;
let point = k.verifying_key().to_encoded_point(false);
if bytes("x")?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice()
|| bytes("y")?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice()
if bytes(&j.x)?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice()
|| bytes(&j.y)?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice()
{
return Err(invalid_key());
}
Key::Ec(k)
}
Some("RSA") => {
let int = |name| -> Result<rsa::BigUint> {
Ok(rsa::BigUint::from_bytes_be(&bytes(name)?))
};
"RSA" => {
// Decode every component before creating private integers.
let [n, e, d, p, q] = [
bytes(&j.n)?, bytes(&j.e)?, bytes(&j.d)?, bytes(&j.p)?, bytes(&j.q)?,
];
let int = |bytes: &[u8]| rsa::BigUint::from_bytes_be(bytes);
let k = rsa::RsaPrivateKey::from_components(
int("n")?,
int("e")?,
int("d")?,
vec![int("p")?, int("q")?],
int(&n), int(&e), int(&d), vec![int(&p), int(&q)],
)
.map_err(|_| invalid_key())?;
k.validate().map_err(|_| invalid_key())?;
@@ -190,12 +208,7 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
let s: p256::ecdsa::Signature = k.sign(payload);
s.to_bytes().to_vec()
}
Key::Rsa(k) => if format == "rsa-sha2-512" {
k.sign(rsa::Pkcs1v15Sign::new::<Sha512>(), &Sha512::digest(payload))
} else {
k.sign(rsa::Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(payload))
}
.map_err(|_| invalid_key())?,
Key::Rsa(k) => rsa_signature(k, format, payload)?,
Key::Mac(k) => {
macro_rules! mac {
($h:ty) => {{
@@ -212,6 +225,27 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
}
})
}
fn rsa_signature(key: &rsa::RsaPrivateKey, format: &str, payload: &[u8]) -> Result<Vec<u8>> {
// SecretDocument zeroizes the DER after AWS-LC imports the private key.
let der = key.to_pkcs8_der().map_err(|_| invalid_key())?;
let key = aws_lc_rs::signature::RsaKeyPair::from_pkcs8(der.as_bytes())
.map_err(|_| invalid_key())?;
let algorithm = if format == "rsa-sha2-512" {
&aws_lc_rs::signature::RSA_PKCS1_SHA512
} else {
&aws_lc_rs::signature::RSA_PKCS1_SHA256
};
let mut signature = vec![0; key.public_modulus_len()];
key.sign(
algorithm,
&aws_lc_rs::rand::SystemRandom::new(),
payload,
&mut signature,
)
.map_err(|_| invalid_key())?;
Ok(signature)
}
fn string(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
@@ -303,3 +337,26 @@ pub(super) fn public(key: &Key, output: &str) -> Result<String> {
_ => return Err(error("invalid_params", "unknown public key output")),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rsa_backend_rejects_weak_keys() {
let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap();
assert!(rsa_signature(&key, "rsa-sha2-256", b"synthetic").is_err());
}
#[test]
fn jwk_private_fields_have_zeroizing_ownership() {
let jwk: Jwk = serde_json::from_str(r#"{"kty":"oct","k":"SYNTHETIC"}"#).unwrap();
for field in [
&jwk.k, &jwk.d, &jwk.p, &jwk.q, &jwk.x, &jwk.y, &jwk.n, &jwk.e,
] {
let _: &Option<Zeroizing<String>> = field;
}
let _: &Zeroizing<String> = &jwk.kty;
let _: &Zeroizing<String> = &jwk.crv;
}
}
+1 -1
View File
@@ -33,4 +33,4 @@ pub mod script;
pub mod keys;
pub mod sign;
pub mod oauth2;
pub mod v8;
pub mod quickjs;
+140 -29
View File
@@ -1,6 +1,11 @@
//! OAuth2 refresh exchange and a process-local cache keyed by item and version.
//! OAuth2 refresh exchange, endpoint policy, and a revision-aware process-local cache.
use super::{
items::{Item, rule_matches},
types::FieldType,
};
use std::{
collections::BTreeMap,
collections::{BTreeMap, BTreeSet},
sync::Mutex,
time::{Duration, Instant},
};
use zeroize::Zeroizing;
@@ -14,13 +19,18 @@ impl std::fmt::Display for Error {
}
impl std::error::Error for Error {}
/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes.
/// A single HTTP exchange. Adapters must honor the deadline and never follow redirects.
pub trait Http: Sync {
fn send(
fn send_once(
&self,
request: &Request,
deadline: Instant,
) -> impl std::future::Future<Output = Result<Zeroizing<Vec<u8>>, Error>> + Send;
) -> impl std::future::Future<Output = Result<Response, Error>> + Send;
}
/// The status and body of one response, without any redirect exchange.
pub struct Response {
pub status: http::StatusCode,
pub body: Zeroizing<Vec<u8>>,
}
/// Contains credentials. Deliberately does not implement Debug or Serialize.
pub struct Request {
@@ -44,22 +54,39 @@ pub fn request(
{
return Err(Error);
}
let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "refresh_token")
.append_pair("refresh_token", refresh_token)
.append_pair("client_id", client_id)
.append_pair("client_secret", client_secret)
.finish();
let body = Zeroizing::new(
url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "refresh_token")
.append_pair("refresh_token", refresh_token)
.append_pair("client_id", client_id)
.append_pair("client_secret", client_secret)
.finish(),
);
Ok(Request {
url: token_url.into(),
url: target.into(),
method: "POST",
headers: vec![
("Content-Type", "application/x-www-form-urlencoded".into()),
("Content-Length", body.len().to_string()),
],
body: Zeroizing::new(body),
body,
})
}
fn allowed(item: &Item, request: &Request) -> bool {
let Ok(target) = url::Url::parse(&request.url) else {
return false;
};
target.scheme() == "https"
&& target.username().is_empty()
&& target.password().is_none()
&& target.fragment().is_none()
&& request.method == "POST"
&& target.host_str().is_some_and(|host| {
item.rules
.iter()
.any(|rule| rule_matches(rule, target.scheme(), host, target.path(), "POST"))
})
}
#[derive(Clone)]
pub struct Token {
pub value: Zeroizing<String>,
@@ -69,51 +96,135 @@ pub struct Token {
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
#[derive(serde::Deserialize)]
struct Response {
access_token: String,
access_token: Zeroizing<String>,
token_type: String,
expires_in: u64,
scope: Option<String>,
}
let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?;
// Validate header bytes without a second, non-zeroizing token allocation.
if r.access_token.is_empty()
|| !r.token_type.eq_ignore_ascii_case("bearer")
|| r.expires_in == 0
|| http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err()
|| r.access_token
.bytes()
.any(|b| b != b'\t' && (b < 32 || b == 127))
{
return Err(Error);
}
Ok(Token {
value: Zeroizing::new(r.access_token),
value: r.access_token,
scopes: r
.scope
.map(|s| s.split_whitespace().map(str::to_owned).collect()),
expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)),
})
}
/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here.
#[derive(Default)]
struct State {
tokens: BTreeMap<(String, i64), Token>,
busy: BTreeSet<String>,
epoch: u64,
}
/// No mutex guard crosses HTTP await. Clear this cache on vault lock or item changes.
#[derive(Default)]
pub struct Cache {
tokens: BTreeMap<(String, i64), Token>,
state: Mutex<State>,
}
struct Flight<'a> {
cache: &'a Cache,
item: String,
}
impl Drop for Flight<'_> {
fn drop(&mut self) {
if let Ok(mut state) = self.cache.state.lock() {
state.busy.remove(&self.item);
}
}
}
impl Cache {
pub fn clear(&mut self) {
self.tokens.clear();
pub fn clear(&self) -> Result<(), Error> {
let mut state = self.state.lock().map_err(|_| Error)?;
state.tokens.clear();
state.epoch += 1;
Ok(())
}
pub async fn refresh(
&mut self,
item: &str,
version: i64,
&self,
item: &Item,
request: &Request,
http: &impl Http,
deadline: Instant,
) -> Result<Token, Error> {
let key = (item.to_owned(), version);
if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) {
return Ok(t.clone());
if !allowed(item, request) || Instant::now() >= deadline {
return Err(Error);
}
self.tokens.retain(|(id, _), _| id != item);
let token = decode(&http.send(request, deadline).await?)?;
self.tokens.insert(key, token.clone());
let key = (item.id.clone(), item.version);
let epoch;
{
let mut state = self.state.lock().map_err(|_| Error)?;
if let Some(t) = state
.tokens
.get(&key)
.filter(|t| t.expires > Instant::now())
{
return Ok(t.clone());
}
state.tokens.retain(|(id, _), _| id != &item.id);
if !state.busy.insert(item.id.clone()) {
return Err(Error);
}
epoch = state.epoch;
}
let _flight = Flight {
cache: self,
item: item.id.clone(),
};
let response = http.send_once(request, deadline).await?;
if !response.status.is_success() {
return Err(Error);
}
let token = decode(&response.body)?;
let mut state = self.state.lock().map_err(|_| Error)?;
if state.epoch != epoch || Instant::now() >= deadline {
return Err(Error);
}
state.tokens.insert(key, token.clone());
Ok(token)
}
/// Injection entry point. Replace the refresh field before any transform sees these values.
/// The caller must reserve any additional grant use before this exchange.
pub async fn fields_for_injection(
&self,
item: &Item,
mut fields: BTreeMap<String, Zeroizing<String>>,
http: &impl Http,
deadline: Instant,
) -> Result<BTreeMap<String, Zeroizing<String>>, Error> {
let mut refresh = item
.fields
.iter()
.filter(|f| f.field_type == FieldType::Oauth2RefreshToken);
let field = refresh.next().ok_or(Error)?;
if field.public || refresh.next().is_some() {
return Err(Error);
}
let value = |name: &str| {
fields
.get(name)
.map(|s| s.as_str())
.filter(|s| !s.is_empty())
.ok_or(Error)
};
let req = request(
value("token_url")?,
value(&field.name)?,
value("client_id")?,
value("client_secret")?,
)?;
let token = self.refresh(item, &req, http, deadline).await?;
fields.insert(field.name.clone(), token.value);
Ok(fields)
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Credential scripts run in a fresh, memory-limited WASI instance. Only two calls run at once.
use super::script::{Error, ErrorKind, Limits, Script};
use std::{
sync::{Arc, Condvar, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
};
use wasmtime::{
Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDeadline,
component::{Component, Linker, ResourceTable},
};
use wasmtime_wasi::{
WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
clocks::HostWallClock,
p2::{
bindings::sync::CommandPre,
pipe::{MemoryInputPipe, MemoryOutputPipe},
},
random::Deterministic,
};
use zeroize::Zeroizing;
struct State {
wasi: WasiCtx,
table: ResourceTable,
limits: StoreLimits,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
struct FixedClock;
impl HostWallClock for FixedClock {
fn resolution(&self) -> Duration {
Duration::from_millis(1)
}
fn now(&self) -> Duration {
Duration::ZERO
}
}
struct Compiled {
engine: Engine,
pre: CommandPre<State>,
active: Mutex<usize>,
available: Condvar,
}
struct Slot<'a>(&'a Compiled);
impl Drop for Slot<'_> {
fn drop(&mut self) {
*self.0.active.lock().unwrap() -= 1;
self.0.available.notify_one();
}
}
impl Compiled {
fn new() -> Result<Arc<Self>, Error> {
let mut config = Config::new();
config.epoch_interruption(true);
// The cache also keeps warm test runs from compiling the component again.
config.cache(wasmtime::Cache::new(Default::default()).ok());
let engine = Engine::new(&config).map_err(|_| failure(ErrorKind::Engine))?;
let component = Component::new(&engine, include_bytes!("../../cred-script/script.wasm"))
.map_err(|_| failure(ErrorKind::Engine))?;
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
.map_err(|_| failure(ErrorKind::Engine))?;
let pre = CommandPre::new(
linker
.instantiate_pre(&component)
.map_err(|_| failure(ErrorKind::Engine))?,
)
.map_err(|_| failure(ErrorKind::Engine))?;
Ok(Arc::new(Self {
engine,
pre,
active: Mutex::new(0),
available: Condvar::new(),
}))
}
fn acquire(&self, deadline: Instant) -> Result<Slot<'_>, Error> {
let mut active = self.active.lock().map_err(|_| failure(ErrorKind::Engine))?;
while *active >= 2 {
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(|| failure(ErrorKind::Limit))?;
(active, _) = self
.available
.wait_timeout(active, remaining)
.map_err(|_| failure(ErrorKind::Engine))?;
}
if Instant::now() >= deadline {
return Err(failure(ErrorKind::Limit));
}
*active += 1;
Ok(Slot(self))
}
}
static COMPILED: OnceLock<Result<Arc<Compiled>, Error>> = OnceLock::new();
/// Construct once at startup. Compilation is cached; each evaluation gets a fresh Store.
#[derive(Clone)]
pub struct QuickJs {
compiled: Result<Arc<Compiled>, Error>,
}
impl Default for QuickJs {
fn default() -> Self {
Self {
compiled: COMPILED.get_or_init(Compiled::new).clone(),
}
}
}
fn failure(kind: ErrorKind) -> Error {
Error {
kind,
message: match kind {
ErrorKind::Limit => "script time, memory or stack limit exceeded",
ErrorKind::Syntax => "invalid script or JSON",
ErrorKind::Throw => "script threw or rejected",
ErrorKind::NonJson => "result is not JSON",
ErrorKind::Engine => "script engine failure",
}
.into(),
}
}
impl Script for QuickJs {
fn evaluate(
&self,
source: &str,
input: &str,
export: Option<&str>,
limits: Limits,
) -> Result<String, Error> {
let compiled = self.compiled.as_ref().map_err(Clone::clone)?;
let time = limits.max_time_ms.unwrap_or(100).clamp(1, 1000);
let heap = limits
.max_memory_bytes
.unwrap_or(8 * 1024 * 1024)
.clamp(256 * 1024, 32 * 1024 * 1024);
let deadline = Instant::now() + Duration::from_millis(time);
let _slot = compiled.acquire(deadline)?;
if source
.len()
.saturating_add(input.len())
.saturating_add(export.map_or(0, str::len))
> heap
{
return Err(failure(ErrorKind::Limit));
}
// WASI's synchronous adapter must not enter a caller's Tokio runtime.
std::thread::scope(|scope| {
let worker = std::thread::Builder::new()
.name("cred-script".into())
.spawn_scoped(scope, move || {
#[derive(serde::Serialize)]
struct Request<'a> {
source: &'a str,
input: &'a str,
export: Option<&'a str>,
max_time_ms: u64,
max_memory_bytes: usize,
}
let wire = Zeroizing::new(
serde_json::to_vec(&Request {
source,
input,
export,
max_time_ms: time,
max_memory_bytes: heap,
})
.map_err(|_| failure(ErrorKind::Engine))?,
);
let memory = heap + 8 * 1024 * 1024;
let output = MemoryOutputPipe::new(memory);
let mut wasi = WasiCtxBuilder::new();
wasi.stdin(MemoryInputPipe::new(bytes::Bytes::from_owner(wire)))
.stdout(output.clone())
.wall_clock(FixedClock)
.secure_random(Deterministic::new(vec![0]))
.insecure_random(Deterministic::new(vec![0]))
.insecure_random_seed(0)
.allow_tcp(false)
.allow_udp(false)
.allow_ip_name_lookup(false);
let mut store = Store::new(
&compiled.engine,
State {
wasi: wasi.build(),
table: ResourceTable::new(),
limits: StoreLimitsBuilder::new()
.memory_size(memory)
.memories(1)
.trap_on_grow_failure(true)
.build(),
},
);
store.limiter(|state| &mut state.limits);
store.epoch_deadline_callback(move |_| {
if Instant::now() >= deadline {
Err(wasmtime::Trap::Interrupt.into())
} else {
Ok(UpdateDeadline::Continue(1))
}
});
store.set_epoch_deadline(1);
let (done, wait) = mpsc::channel();
let engine = compiled.engine.clone();
let watchdog = std::thread::Builder::new()
.name("cred-script-deadline".into())
.spawn(move || {
if wait
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.is_err()
{
engine.increment_epoch();
}
})
.map_err(|_| failure(ErrorKind::Engine))?;
let result = compiled
.pre
.instantiate(&mut store)
.and_then(|command| command.wasi_cli_run().call_run(&mut store));
let _ = done.send(());
let _ = watchdog.join();
if Instant::now() >= deadline || !matches!(result, Ok(Ok(()))) {
return Err(failure(ErrorKind::Limit));
}
#[derive(serde::Deserialize)]
struct Response {
result: Option<String>,
kind: Option<String>,
}
let response: Response = serde_json::from_slice(&output.contents())
.map_err(|_| failure(ErrorKind::Engine))?;
if Instant::now() >= deadline {
return Err(failure(ErrorKind::Limit));
}
if let Some(result) = response.result {
return Ok(result);
}
Err(failure(match response.kind.as_deref() {
Some("Limit") => ErrorKind::Limit,
Some("Syntax") => ErrorKind::Syntax,
Some("Throw") => ErrorKind::Throw,
Some("NonJson") => ErrorKind::NonJson,
_ => ErrorKind::Engine,
}))
})
.map_err(|_| failure(ErrorKind::Engine))?;
worker.join().map_err(|_| failure(ErrorKind::Engine))?
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn concurrency_is_bounded_and_queue_uses_the_deadline() {
let runtime = QuickJs::default();
let compiled = runtime.compiled.as_ref().unwrap();
let deadline = Instant::now() + Duration::from_secs(1);
let first = compiled.acquire(deadline).unwrap();
let second = compiled.acquire(deadline).unwrap();
assert!(
matches!(compiled.acquire(Instant::now()), Err(error) if error.kind == ErrorKind::Limit)
);
drop(first);
let third = compiled.acquire(deadline).unwrap();
assert_eq!(*compiled.active.lock().unwrap(), 2);
drop((second, third));
}
}
+89 -13
View File
@@ -27,7 +27,8 @@ pub struct Request {
pub decision: Option<String>,
pub approver: Option<String>,
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Overrides {
#[serde(default)]
pub unlimited: bool,
@@ -93,7 +94,8 @@ impl Cred {
}),
};
Ok(
((state == "pending" && expires > now()) || (state == "approved" && usable))
(((state == "pending" || state == "denied") && expires > now())
|| (state == "approved" && usable))
.then_some(id),
)
}
@@ -122,6 +124,9 @@ impl Cred {
payload.items.sort();
payload.items.dedup();
payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600));
if payload.duration_secs.is_some_and(|d| d == 0 || d > 86400) {
return Err(Error("invalid_duration"));
}
if payload.validator.as_deref() == Some("") {
payload.validator = None;
}
@@ -155,7 +160,7 @@ impl Cred {
if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) {
return Err(Error("key_conflict"));
}
if state == "pending" && expires > now() {
if (state == "pending" || state == "denied") && expires > now() {
return Ok(id);
}
if state == "approved"
@@ -231,24 +236,54 @@ impl Cred {
.filter(|r| r.state != "pending")
.collect())
}
/// Build a binding from the exact stored payload, not a preview supplied by the world.
pub fn decision_binding(
&self,
id: &str,
decision: super::webauthn::Decision,
overrides: Overrides,
) -> Result<super::webauthn::Binding> {
let s = self.access()?;
let payload: String =
s.db.query_row("SELECT payload FROM requests WHERE id=?", [id], |r| {
r.get(0)
})?;
Ok(super::webauthn::Binding::Decision {
request_id: id.into(),
decision,
overrides,
payload_sha256: super::webauthn::digest(payload.as_bytes()),
})
}
pub fn approve(
&self,
id: &str,
decision: &str,
decision: super::webauthn::Decision,
overrides: Overrides,
approver: Option<&str>,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
self.decide(id, decision, Some(overrides), approver)
if decision != super::webauthn::Decision::Approve {
return Err(Error("invalid_decision"));
}
self.decide(id, decision, Some(overrides), proof.into())
}
pub fn deny(&self, id: &str, decision: &str, approver: Option<&str>) -> Result<()> {
self.decide(id, decision, None, approver)
pub fn deny(
&self,
id: &str,
decision: super::webauthn::Decision,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
if decision != super::webauthn::Decision::Deny {
return Err(Error("invalid_decision"));
}
self.decide(id, decision, None, proof.into())
}
fn decide(
&self,
id: &str,
decision: &str,
decision: super::webauthn::Decision,
overrides: Option<Overrides>,
approver: Option<&str>,
proof: super::webauthn::Proof,
) -> Result<()> {
let mut s = self.access()?;
let master = s.master.clone();
@@ -275,6 +310,16 @@ impl Cred {
))
},
)?;
let approver = proof.check(
&tx,
&super::webauthn::Binding::Decision {
request_id: id.into(),
decision,
overrides: overrides.clone().unwrap_or_default(),
payload_sha256: super::webauthn::digest(payload.as_bytes()),
},
)?;
let approver = approver.as_deref();
let subject: Subject = serde_json::from_str(&subject)?;
if state == "expired" {
return Err(Error("request_expired"));
@@ -299,6 +344,15 @@ impl Cred {
let mut issued_grant = None;
let state = if let Some(o) = overrides {
let parsed: serde_json::Value = serde_json::from_str(&payload)?;
if parsed.get("items").is_none()
&& parsed.get("grant_create").is_none()
&& o != Overrides::default()
{
return Err(Error("ignored_overrides"));
}
if o.unlimited && o.max_uses.is_some() {
return Err(Error("invalid_uses"));
}
if let Some(edit) = parsed.get("edit") {
let mut edit: super::edits::Edit = serde_json::from_value(edit.clone())?;
if let Some(requested) = parsed.get("requested") {
@@ -358,10 +412,31 @@ impl Cred {
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
if let Some(duration) = o.duration_secs {
if duration == 0 {
return Err(Error("invalid_duration"));
}
grant.exp = Some(
now()
.checked_add(
i64::try_from(duration).map_err(|_| Error("invalid_duration"))?,
)
.ok_or(Error("invalid_duration"))?,
);
} else if grant.exp.is_none() {
grant.exp = Some(now() + 3600);
}
grant.max_uses = if o.unlimited {
None
} else {
o.max_uses.or(grant.max_uses)
};
grant.validator = o.validator.or(grant.validator);
grants::insert(&tx, &grant, &caller, approver)?;
issued_grant = Some(grant.id);
} else if let Some(target) = parsed.get("grant_revoke") {
let target = target.as_str().ok_or(Error("invalid_params"))?;
if tx.execute("UPDATE grants SET revoked=1 WHERE id=?", [target])? != 1 {
if tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE id=?", [target])? != 1 {
return Err(Error("grant_missing"));
}
audit::record(&tx, "grant.revoke", &caller, None, approver)?;
@@ -406,11 +481,12 @@ impl Cred {
"denied"
};
tx.execute(
"UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5 WHERE id=?1",
params![id, state, decision, approver, issued_grant],
"UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5,decided_at=unixepoch() WHERE id=?1",
params![id, state, match decision { super::webauthn::Decision::Approve => "approve", super::webauthn::Decision::Deny => "deny" }, approver, issued_grant],
)?;
audit::record(&tx, state, &caller, None, approver)?;
tx.commit()?;
s.refresh_rules()?;
let _ = self.decided.send(id.to_owned());
Ok(())
}
+5 -2
View File
@@ -1,12 +1,15 @@
//! Fresh credentials schema. No v2 migrations or vault lifecycle.
pub(crate) const DDL: &str = "
PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, config TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS fields(item TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, id TEXT NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY(item,id));
CREATE TABLE IF NOT EXISTS versions(item TEXT NOT NULL, created INTEGER NOT NULL, config TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT);
CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT);
CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT, revoked_at INTEGER);
CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT, decided_at INTEGER);
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY, time INTEGER NOT NULL, event TEXT NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, conversation TEXT, item TEXT, approver TEXT, payload_sha256 TEXT);
CREATE INDEX IF NOT EXISTS grant_live ON grants(revoked,exp);
CREATE INDEX IF NOT EXISTS request_keys ON requests(subject,key);
CREATE INDEX IF NOT EXISTS request_expiry ON requests(state,expires);
";
+107 -23
View File
@@ -3,63 +3,147 @@ use super::{
error::{Error, Result},
schema, seal,
};
use rusqlite::OptionalExtension;
use std::{
io::{Read, Write},
os::unix::fs::OpenOptionsExt,
os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt},
path::Path,
sync::{Mutex, MutexGuard},
};
pub(crate) struct Store {
pub db: rusqlite::Connection,
pub master: zeroize::Zeroizing<[u8; 32]>,
pub rules: std::collections::HashMap<String, (i64, Vec<super::items::Rule>)>,
}
impl Store {
pub fn refresh_rules(&mut self) -> Result<()> {
self.rules = rule_index(&self.db)?;
Ok(())
}
}
fn rule_index(
db: &rusqlite::Connection,
) -> Result<std::collections::HashMap<String, (i64, Vec<super::items::Rule>)>> {
let mut q = db.prepare("SELECT config FROM items")?;
q.query_map([], |r| r.get::<_, String>(0))?
.map(|r| {
let item: super::items::Item = serde_json::from_str(&r?)?;
Ok((item.id, (item.version, item.rules)))
})
.collect()
}
pub struct Cred {
pub(crate) store: Mutex<Store>,
pub(crate) decided: tokio::sync::broadcast::Sender<String>,
pub(crate) filed: tokio::sync::broadcast::Sender<String>,
}
const KEY_CHECK: &[u8] = b"sausage/credentials/master/v1";
impl Cred {
/// Open local host storage. The host resolves systemd credentials, not this library.
pub fn open(host_dir: &Path) -> Result<Self> {
std::fs::create_dir_all(host_dir)?;
let path = std::env::var_os("CREDENTIALS_DIRECTORY")
.map(|p| std::path::PathBuf::from(p).join("sausage-cred-key"))
.unwrap_or_else(|| host_dir.join("cred.key"));
if std::env::var_os("CREDENTIALS_DIRECTORY").is_none() {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
{
Ok(mut file) => {
file.write_all(&seal::random::<32>())?;
file.sync_all()?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
Self::open_with_key(host_dir, &host_dir.join("cred.key"))
}
pub fn open_with_key(host_dir: &Path, key_path: &Path) -> Result<Self> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(host_dir)?;
std::fs::set_permissions(host_dir, std::fs::Permissions::from_mode(0o700))?;
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.execute_batch(schema::DDL)?;
let check: Option<Vec<u8>> = db
.query_row("SELECT value FROM meta WHERE key='master'", [], |r| {
r.get(0)
})
.optional()?;
match std::fs::metadata(key_path) {
Ok(meta) if meta.permissions().mode() & 0o077 != 0 => {
return Err(Error("master_key_permissions"));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let populated: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM items) OR EXISTS(SELECT 1 FROM fields)",
[],
|r| r.get(0),
)?;
if populated || check.is_some() {
return Err(Error("master_key_missing"));
}
let parent = key_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let temp = parent.join(format!(".cred-key-{}", seal::identity()));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp)?;
let key = zeroize::Zeroizing::new(seal::random::<32>());
file.write_all(key.as_ref())?;
file.sync_all()?;
std::fs::rename(&temp, key_path)?;
std::fs::File::open(parent)?.sync_all()?;
}
Err(e) => return Err(e.into()),
}
let mut bytes = zeroize::Zeroizing::new(Vec::new());
std::fs::File::open(path)?.read_to_end(&mut bytes)?;
std::fs::File::open(key_path)?.read_to_end(&mut bytes)?;
let master = zeroize::Zeroizing::new(
bytes
.as_slice()
.try_into()
.map_err(|_| Error("master_key"))?,
);
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
db.busy_timeout(std::time::Duration::from_secs(5))?;
db.execute_batch(schema::DDL)?;
match check {
Some(check) => {
let value =
seal::open(&master, "master-check", &check).map_err(|_| Error("master_key"))?;
if value.as_slice() != KEY_CHECK {
return Err(Error("master_key"));
}
}
None => {
let populated: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM items) OR EXISTS(SELECT 1 FROM fields)",
[],
|r| r.get(0),
)?;
if populated {
return Err(Error("master_key_check_missing"));
}
let check = seal::seal(&master, "master-check", KEY_CHECK)?;
db.execute("INSERT INTO meta(key,value) VALUES('master',?1)", [check])?;
}
}
let (decided, _) = tokio::sync::broadcast::channel(64);
let (filed, _) = tokio::sync::broadcast::channel(64);
let rules = rule_index(&db)?;
let cred = Self {
store: Mutex::new(Store { db, master }),
store: Mutex::new(Store { db, master, rules }),
decided,
filed,
};
cred.expire_requests()?;
Ok(cred)
}
/// D57 maintenance. The harness calls this daily. Item versions remain intact.
pub fn prune(&self, now: i64) -> Result<()> {
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
tx.execute(
"DELETE FROM audit WHERE time<?",
[now.saturating_sub(90 * 86400)],
)?;
let cutoff = now.saturating_sub(30 * 86400);
tx.execute("DELETE FROM requests WHERE state<>'pending' AND (CASE WHEN state='expired' THEN expires ELSE decided_at END)<?", [cutoff])?;
tx.execute("DELETE FROM grants WHERE ((revoked=1 AND revoked_at<?1) OR exp<?1) AND NOT EXISTS(SELECT 1 FROM requests WHERE grant_id=grants.id OR json_extract(payload,'$.grant_revoke')=grants.id)", [cutoff])?;
tx.commit()?;
Ok(())
}
pub(crate) fn access(&self) -> Result<MutexGuard<'_, Store>> {
self.store.lock().map_err(|_| Error("storage"))
}
+55 -7
View File
@@ -1,4 +1,4 @@
//! Public approval summaries. Do not interpolate field values or script sources.
//! Public approval summaries expose policy and public values, never secrets or script sources.
pub fn human_duration(mut seconds: u64) -> String {
let mut parts = Vec::new();
for (size, unit) in [
@@ -26,6 +26,10 @@ fn text(value: &str) -> String {
let mut out = String::new();
for c in value.chars() {
match c {
'\u{200b}'..='\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2060}'..='\u{206f}'
| '\u{feff}' => {}
'\n' | '\r' => out.push(' '),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
@@ -39,15 +43,57 @@ fn text(value: &str) -> String {
}
out
}
fn rules_text(rules: &[super::items::Rule]) -> String {
let mut hosts = Vec::new();
for rule in rules {
let port = rule
.port
.unwrap_or(match rule.scheme.to_ascii_lowercase().as_str() {
"http" => 80,
"https" => 443,
"ssh" => 22,
_ => 0,
});
for host in &rule.hosts {
hosts.push(format!(
"{} host {}, port {}{}",
text(&rule.scheme),
text(host),
port,
if host.starts_with("*.") {
" (wildcard: check the public suffix)"
} else {
""
}
));
}
}
format!("Rules: {}.", hosts.join(", "))
}
fn creation(config: &super::items::Item) -> String {
format!(
"Create item “{}”. {} {} {}",
text(&config.name),
if config.free_use {
"Anyone can use it without a grant."
} else {
"It requires a grant."
},
rules_text(&config.rules),
if config.transform_js.is_empty() {
"It has no transformation script."
} else {
"It runs a transformation script."
}
)
}
pub fn operation_summary(operation: &super::edits::Operation) -> String {
use super::edits::Operation::*;
match operation {
CreateItem { config } => format!("Create item “{}”.", text(&config.name)),
GenerateEs256Item { config } => {
format!("Create item “{}” with a new ES256 key.", text(&config.name))
}
CreateItem { config } => creation(config),
GenerateEs256Item { config } => format!("{} Generate a new ES256 key.", creation(config)),
SetName { name } => format!("Rename the item to “{}”.", text(name)),
SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(),
SetRules { rules } => format!("Replace the access rules. {}", rules_text(rules)),
SetFreeUse { enabled: true } => {
"Allow anyone to use this item without a grant, within its rules.".into()
}
@@ -60,7 +106,9 @@ pub fn operation_summary(operation: &super::edits::Operation) -> String {
if field.public { "public" } else { "secret" },
text(&field.name)
),
SetFieldValue { .. } => "Change a public field value.".into(),
SetFieldValue { field, value } => {
format!("Set public field “{}” to “{}”.", text(field), text(value))
}
SetFieldType { .. } => "Change a field type. Review the type in Details.".into(),
RenameField { name, .. } => format!("Rename a field to “{}”.", text(name)),
RemoveField { .. } => "Remove a field and permanently discard its value.".into(),
+5 -1
View File
@@ -1,7 +1,11 @@
//! Caller identity and mandatory field metadata. Reject mismatched key algorithms.
use super::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
/// The host builds identity from the process table, never from a request body.
/// ```compile_fail
/// let _: sausage::cred::Caller = serde_json::from_str("{}").unwrap();
/// ```
#[derive(Clone, Debug, Default, Serialize)]
pub struct Caller {
pub program: String,
pub pid: u32,
-199
View File
@@ -1,199 +0,0 @@
//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread.
use super::script::{Error, ErrorKind, Limits, Script};
use deno_core::{JsRuntime, RuntimeOptions, v8};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::Duration,
};
#[derive(Default)]
pub struct V8;
fn failure(kind: ErrorKind) -> Error {
Error {
kind,
message: match kind {
ErrorKind::Limit => "script time, memory or stack limit exceeded",
ErrorKind::Syntax => "invalid script or JSON",
ErrorKind::Throw => "script threw or rejected",
ErrorKind::NonJson => "result is not JSON",
ErrorKind::Engine => "script engine failure",
}
.into(),
}
}
impl Script for V8 {
fn evaluate(
&self,
source: &str,
input: &str,
export: Option<&str>,
limits: Limits,
) -> Result<String, Error> {
let source = source.to_owned();
let input = input.to_owned();
let export = export.unwrap_or("default").to_owned();
std::thread::spawn(move || evaluate(source, input, export, limits))
.join()
.map_err(|_| failure(ErrorKind::Engine))?
}
}
fn evaluate(
source: String,
input: String,
export: String,
limits: Limits,
) -> Result<String, Error> {
let heap = limits
.max_memory_bytes
.unwrap_or(8 * 1024 * 1024)
.clamp(256 * 1024, 32 * 1024 * 1024);
// V8 uses Tokio for internal delayed tasks, even without guest extensions.
let executor = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|_| failure(ErrorKind::Engine))?;
let _entered = executor.enter();
let mut runtime = JsRuntime::new(RuntimeOptions {
// Bootstrap before imposing the caller's budget: V8 cannot recover from
// bootstrap OOM because the runtime cannot install its callback yet.
create_params: Some(v8::CreateParams::default().heap_limits(0, 32 * 1024 * 1024)),
..Default::default()
});
if runtime.v8_isolate().get_heap_statistics().total_heap_size() > heap {
return Err(failure(ErrorKind::Limit));
}
// Removing a temporary callback sets the heap limit after bootstrap.
runtime.add_near_heap_limit_callback(|current, _| current);
runtime.remove_near_heap_limit_callback(heap);
let handle = runtime.v8_isolate().thread_safe_handle();
let limited = Arc::new(AtomicBool::new(false));
let flag = limited.clone();
let heap_handle = handle.clone();
runtime.add_near_heap_limit_callback(move |current, _| {
flag.store(true, Ordering::SeqCst);
heap_handle.terminate_execution();
// V8 needs emergency headroom to unwind after termination.
current + 16 * 1024 * 1024
});
let (done, wait) = mpsc::channel();
let flag = limited.clone();
let watchdog = std::thread::spawn(move || {
if wait
.recv_timeout(Duration::from_millis(
limits.max_time_ms.unwrap_or(100).clamp(1, 1000),
))
.is_err()
{
flag.store(true, Ordering::SeqCst);
handle.terminate_execution();
}
});
let task = async {
runtime
.execute_script("sandbox.js", BOOTSTRAP)
.map_err(|_| failure(ErrorKind::Engine))?;
let input = runtime
.execute_script(
"input.js",
format!("JSON.parse({})", serde_json::to_string(&input).unwrap()),
)
.map_err(|_| failure(ErrorKind::Syntax))?;
let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap();
let module = runtime
.load_main_es_module_from_code(&spec, source)
.await
.map_err(|_| failure(ErrorKind::Syntax))?;
let evaluated = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(evaluated, Default::default())
.await
.map_err(|e| {
if e.to_string().contains("call stack") {
failure(ErrorKind::Limit)
} else {
failure(ErrorKind::Throw)
}
})?;
let ns = runtime
.get_module_namespace(module)
.map_err(|_| failure(ErrorKind::Engine))?;
{
deno_core::scope!(scope, &mut runtime);
let ns = v8::Local::new(scope, ns);
let global = scope.get_current_context().global(scope);
let key =
v8::String::new(scope, "__module").ok_or_else(|| failure(ErrorKind::Limit))?;
global.set(scope, key.into(), ns.into());
let input = v8::Local::new(scope, input);
let key = v8::String::new(scope, "__input").ok_or_else(|| failure(ErrorKind::Limit))?;
global.set(scope, key.into(), input);
}
let code = format!(
r#"(async()=>{{
const f=__module[{}];
if(typeof f!=='function') return JSON.stringify({{kind:'Engine'}});
let value;
try {{ value=await f(__input); }} catch(e) {{ const message=String(e?.message); return JSON.stringify({{kind: e instanceof RangeError && message.includes('call stack') ? 'Limit':'Throw'}}); }}
try {{ const text=JSON.stringify(value); return JSON.stringify(text===undefined?{{kind:'NonJson'}}:{{text}}); }} catch(e) {{ return JSON.stringify({{kind:e instanceof RangeError && String(e.message).includes('call stack') ? 'Limit':'NonJson'}}); }}
}})()"#,
serde_json::to_string(&export).unwrap()
);
let value = runtime
.execute_script("call.js", code)
.map_err(|_| failure(ErrorKind::Throw))?;
let resolve = runtime.resolve(value);
let value = runtime
.with_event_loop_promise(resolve, Default::default())
.await
.map_err(|_| failure(ErrorKind::Engine))?;
let text = {
deno_core::scope!(scope, &mut runtime);
v8::Local::new(scope, value).to_rust_string_lossy(scope)
};
let result: serde_json::Value =
serde_json::from_str(&text).map_err(|_| failure(ErrorKind::Engine))?;
match result["text"].as_str() {
Some(s) => Ok(s.to_owned()),
None => Err(failure(match result["kind"].as_str() {
Some("NonJson") => ErrorKind::NonJson,
Some("Throw") => ErrorKind::Throw,
Some("Limit") => ErrorKind::Limit,
_ => ErrorKind::Engine,
})),
}
};
let result = executor.block_on(task);
let _ = done.send(());
let _ = watchdog.join();
if limited.load(Ordering::SeqCst) {
Err(failure(ErrorKind::Limit))
} else {
result
}
}
const BOOTSTRAP: &str = r#"
delete globalThis.Date; delete Math.random;
for (const name of ['Deno','__bootstrap','console','performance']) delete globalThis[name];
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
globalThis.btoa = value => {
const s=String(value); let out='';
for(let i=0;i<s.length;i+=3){
const a=s.charCodeAt(i), b=s.charCodeAt(i+1), c=s.charCodeAt(i+2);
if(a>255||b>255||c>255) throw Error('invalid base64 input');
const n=(a<<16)|((b||0)<<8)|(c||0);
out+=alphabet[(n>>>18)&63]+alphabet[(n>>>12)&63]+(i+1<s.length?alphabet[(n>>>6)&63]:'=')+(i+2<s.length?alphabet[n&63]:'=');
} return out;
};
globalThis.atob = value => {
let s=String(value).replace(/[\t\n\f\r ]/g,'');
if(s.length%4===0) s=s.replace(/==?$/,'');
if(s.length%4===1||/[^A-Za-z0-9+/]/.test(s)) throw Error('invalid base64 input');
let out='', bits=0, n=0;
for(const c of s){n=(n<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;out+=String.fromCharCode((n>>>bits)&255);}}
return out;
};
"#;
+166 -53
View File
@@ -1,9 +1,7 @@
//! Passkey enrollment and single-use assertions for host-owned bindings.
//! The caller supplies cred.sqlite and serializes access to this manager.
//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions
//! must bind the exact new label. Neither decision nor secret-entry assertions
//! authorize enrollment. Without passkeys, the host is unlocked and permits
//! first registration without a gate. Restart drops all pending challenges.
//! Passkey assertions produce private, single-use proofs for exact host-owned bindings.
//! Bootstrap proofs remain valid only while the decision transaction finds no passkeys.
//! The caller serializes access to this manager and supplies the credentials database.
use super::{requests::Overrides, types::FieldType};
use anyhow::{Result, bail};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
@@ -13,56 +11,161 @@ use std::{
};
use webauthn_rs::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
Approve,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mutation {
CreateItem,
GrantCreate,
RevokeSubjects,
ResolveStale,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
#[serde(tag = "type", deny_unknown_fields)]
pub enum Binding {
Decision {
request_id: String,
decision: String,
overrides: serde_json::Value,
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(crate) fn digest(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
/// Only successful WebAuthn verification constructs this value. It cannot be cloned.
pub struct Verified {
credential: CredentialID,
binding: Binding,
}
impl Verified {
pub fn credential(&self) -> &CredentialID {
&self.credential
}
pub fn binding(&self) -> &Binding {
&self.binding
}
}
/// 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)
}
}
impl Proof {
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"));
}
Ok(None)
}
Self::Verified(v) => {
if &v.binding != expected {
return Err(super::Error("binding_mismatch"));
}
let id = serde_json::to_string(&v.credential)?;
let registered: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM passkey_credentials WHERE id=?)",
[&id],
|r| r.get(0),
)?;
if !registered {
return Err(super::Error("assertion_required"));
}
Ok(Some(id))
}
}
}
}
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"));
}
Ok(Unlocked(()))
}
}
#[derive(Serialize, Deserialize)]
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 Passkeys<'a> {
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> {
let origin = Url::parse(origin)?;
let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS passkey_credentials (
id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)",
)?;
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,
@@ -70,26 +173,24 @@ impl<'a> Passkeys<'a> {
assertions: HashMap::new(),
})
}
/// Database errors leave the manager locked.
pub fn is_unlocked(&self) -> bool {
self.db
.query_row(
"SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)",
[],
|row| row.get(0),
|r| r.get(0),
)
.unwrap_or(false)
}
fn credentials(&self) -> Result<Vec<Passkey>> {
let mut stmt = self
let mut q = self
.db
.prepare("SELECT credential FROM passkey_credentials")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect()
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,
@@ -100,8 +201,8 @@ impl<'a> Passkeys<'a> {
None if !self.is_unlocked() => bail!("enrollment assertion required"),
None => {}
Some(Gate { id, response }) => {
let (_, binding) = self.finish_assertion(&id, response)?;
if binding
let proof = self.finish_assertion(&id, response)?;
if proof.binding
!= (Binding::Enrollment {
label: label.clone(),
})
@@ -121,8 +222,7 @@ impl<'a> Passkeys<'a> {
&label,
Some(exclude),
)?;
self.registrations
.retain(|_, p| p.deadline > Instant::now());
trim(&mut self.registrations);
let id = Uuid::new_v4().to_string();
self.registrations.insert(
id.clone(),
@@ -133,30 +233,30 @@ impl<'a> Passkeys<'a> {
);
Ok(Challenge { id, options })
}
pub fn finish_registration(
&mut self,
id: &str,
response: RegisterPublicKeyCredential,
) -> Result<Passkey> {
let (label, gated, state) = take(&mut self.registrations, id)?;
if !gated && !self.is_unlocked() {
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)?;
.finish_passkey_registration(&response, state)?;
self.db.execute(
"INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)",
"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,
@@ -164,7 +264,7 @@ impl<'a> Passkeys<'a> {
let (options, state) = self
.webauthn
.start_passkey_authentication(&self.credentials()?)?;
self.assertions.retain(|_, p| p.deadline > Instant::now());
trim(&mut self.assertions);
let id = Uuid::new_v4().to_string();
self.assertions.insert(
id.clone(),
@@ -175,23 +275,21 @@ impl<'a> Passkeys<'a> {
);
Ok(Challenge { id, options })
}
pub fn finish_assertion(
&mut self,
id: &str,
response: PublicKeyCredential,
) -> Result<(CredentialID, Binding)> {
let (binding, state) = take(&mut self.assertions, id)?;
) -> Result<Verified> {
let (binding, state) = pending(&mut self.assertions, id)?;
let binding = binding.clone();
let result = self
.webauthn
.finish_passkey_authentication(&response, &state)?;
.finish_passkey_authentication(&response, state)?;
let mut credentials = self.credentials()?;
let credential = credentials
.iter_mut()
.find(|p| p.cred_id() == result.cred_id())
.ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?;
// Check the latest stored counter too. Another outstanding assertion may
// contain an older snapshot of this credential.
let stored = serde_json::to_value(&*credential)?;
let counter = stored["cred"]["counter"]
.as_u64()
@@ -201,30 +299,45 @@ impl<'a> Passkeys<'a> {
}
credential.update_credential(&result);
self.db.execute(
"UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2",
"UPDATE passkey_credentials SET credential=?1 WHERE id=?2",
params![
serde_json::to_string(credential)?,
serde_json::to_string(result.cred_id())?
],
)?;
Ok((result.cred_id().clone(), binding))
self.assertions.remove(id);
Ok(Verified {
credential: result.cred_id().clone(),
binding,
})
}
}
fn take<T>(map: &mut HashMap<String, Pending<T>>, id: &str) -> Result<T> {
let pending = map
.remove(id)
.ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?;
if pending.deadline <= Instant::now() {
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(pending.value)
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");
map.remove(&id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expired_assertion() -> Result<()> {
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};