From 091f679905e46f9e6e185609d8492e4019635a0a Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 20:05:54 +0000 Subject: [PATCH] cred: port native items grants requests and injection core --- host/src/cred/audit.rs | 75 ++++ host/src/cred/edits.rs | 622 +++++++++++++++++++++++++++++++++ host/src/cred/error.rs | 20 ++ host/src/cred/grants.rs | 170 +++++++++ host/src/cred/inject.rs | 173 +++++++++ host/src/cred/items.rs | 351 +++++++++++++++++++ host/src/cred/requests.rs | 436 +++++++++++++++++++++++ host/src/cred/schema.rs | 12 + host/src/cred/seal.rs | 57 +++ host/src/cred/store.rs | 72 ++++ host/src/cred/summary.rs | 111 ++++++ host/src/cred/types.rs | 108 ++++++ host/tests/cred.rs | 255 ++++++++++++++ host/tests/cred_core.rs | 205 +++++++++++ host/tests/cred_edits.rs | 266 ++++++++++++++ host/tests/cred_grants.rs | 199 +++++++++++ host/tests/cred_regressions.rs | 265 ++++++++++++++ host/tests/cred_replay.rs | 346 ++++++++++++++++++ host/tests/cred_summary.rs | 84 +++++ 19 files changed, 3827 insertions(+) create mode 100644 host/src/cred/audit.rs create mode 100644 host/src/cred/edits.rs create mode 100644 host/src/cred/error.rs create mode 100644 host/src/cred/grants.rs create mode 100644 host/src/cred/inject.rs create mode 100644 host/src/cred/items.rs create mode 100644 host/src/cred/requests.rs create mode 100644 host/src/cred/schema.rs create mode 100644 host/src/cred/seal.rs create mode 100644 host/src/cred/store.rs create mode 100644 host/src/cred/summary.rs create mode 100644 host/src/cred/types.rs create mode 100644 host/tests/cred.rs create mode 100644 host/tests/cred_core.rs create mode 100644 host/tests/cred_edits.rs create mode 100644 host/tests/cred_grants.rs create mode 100644 host/tests/cred_regressions.rs create mode 100644 host/tests/cred_replay.rs create mode 100644 host/tests/cred_summary.rs diff --git a/host/src/cred/audit.rs b/host/src/cred/audit.rs new file mode 100644 index 0000000..e4afcb6 --- /dev/null +++ b/host/src/cred/audit.rs @@ -0,0 +1,75 @@ +//! Metadata-only audit ledger. Do not accept arbitrary script data. +use super::{ + error::Result, + store::{Cred, now}, + types::Caller, +}; +use rusqlite::{Connection, params}; +#[derive(Debug, serde::Serialize)] +pub struct Row { + pub id: i64, + pub time: i64, + pub event: String, + pub pid: u32, + pub program: String, + pub conversation: Option, + pub item: Option, + pub approver: Option, + pub payload_sha256: Option, +} +pub(crate) fn record( + db: &Connection, + event: &str, + caller: &Caller, + item: Option<&str>, + approver: Option<&str>, +) -> Result<()> { + db.execute( + "INSERT INTO audit(time,event,pid,conversation,item,approver,program) VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![ + now(), + event, + caller.pid, + caller.conversations.first(), + item, + approver, + caller.program + ], + )?; + Ok(()) +} +impl Cred { + /// Record a completed signature. The signing pipeline supplies the caller and item. + pub fn audit_sign(&self, item: &str, caller: &Caller, payload: &[u8]) -> Result<()> { + use sha2::{Digest, Sha256}; + let mut s = self.access()?; + let tx = s.db.transaction()?; + record(&tx, "sign", caller, Some(item), None)?; + tx.execute( + "UPDATE audit SET payload_sha256=?1 WHERE id=?2", + params![hex::encode(Sha256::digest(payload)), tx.last_insert_rowid()], + )?; + tx.commit()?; + Ok(()) + } + pub fn audit_list(&self) -> Result> { + let s = self.access()?; + let mut q = s.db.prepare( + "SELECT id,time,event,pid,conversation,item,approver,payload_sha256,program FROM audit ORDER BY id", + )?; + Ok(q.query_map([], |r| { + Ok(Row { + id: r.get(0)?, + time: r.get(1)?, + event: r.get(2)?, + pid: r.get(3)?, + program: r.get(8)?, + conversation: r.get(4)?, + item: r.get(5)?, + approver: r.get(6)?, + payload_sha256: r.get(7)?, + }) + })? + .collect::>()?) + } +} diff --git a/host/src/cred/edits.rs b/host/src/cred/edits.rs new file mode 100644 index 0000000..e369474 --- /dev/null +++ b/host/src/cred/edits.rs @@ -0,0 +1,622 @@ +//! Reviewable full-configuration edits. Approval rechecks versions in its transaction. +//! Filing checks public values. Approval checks current secrets and preserves operation order. +use super::{ + audit, + error::{Error, Result}, + items::{self, Item}, + seal, + types::Caller, +}; +use rusqlite::{Connection, OptionalExtension, params}; +/// Explicit public operations. No operation accepts a secret field value. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Operation { + CreateItem { + config: Item, + }, + GenerateEs256Item { + config: Item, + }, + SetName { + name: String, + }, + SetRules { + rules: Vec, + }, + SetFreeUse { + enabled: bool, + }, + SetValidateJs { + value: Option, + }, + SetTransformJs { + value: String, + }, + AddField { + field: items::Field, + }, + SetFieldValue { + field: String, + value: String, + }, + SetFieldType { + field: String, + field_type: super::types::FieldType, + key_type: Option, + }, + RenameField { + field: String, + name: String, + }, + RemoveField { + field: String, + }, + DeleteItem, + ItemRevert { + version: i64, + }, + GrantCreate { + grant: super::grants::Grant, + }, + GrantRevoke { + target: String, + }, +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct OperationRequest { + pub item: Option, + pub base_version: Option, + pub change: Vec, +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Edit { + pub item: String, + pub base_version: i64, + pub config: Item, +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Diff { + pub old: Item, + pub new: Item, + pub discards_values: bool, + #[serde(default)] + pub stale: bool, +} +fn check_secret( + db: &Connection, + master: &[u8; 32], + item: &str, + field: &items::Field, +) -> Result<()> { + let cipher: Option> = db + .query_row( + "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", + params![item, field.field_id], + |r| r.get(0), + ) + .optional()?; + if let Some(cipher) = cipher { + let value = seal::open(master, &format!("{item}|{}", field.field_id), &cipher)?; + super::types::validate( + &field.field_type, + field.key_type.as_deref(), + false, + Some(std::str::from_utf8(&value).map_err(|_| Error("invalid_value"))?), + )?; + } + Ok(()) +} +pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> Result { + let old = items::load(db, &edit.item)?; + if old.version != edit.base_version { + return Err(Error("stale")); + } + let mut new = edit.config.clone(); + new.id = old.id.clone(); + new.version = old + .version + .checked_add(1) + .ok_or(Error("version_exhausted"))?; + items::validate(&new)?; + items::unique_name(db, &new.name, &old.id)?; + for f in &mut new.fields { + if let Some(prior) = old.fields.iter().find(|p| p.field_id == f.field_id) { + if prior.public != f.public { + return Err(Error("immutable_visibility")); + } + if f.public { + f.value = f.value.clone().or_else(|| prior.value.clone()); + f.needs_value = f.value.is_none(); + } else { + f.needs_value = prior.needs_value; + if let Some(master) = master + && (f.field_type != prior.field_type || f.key_type != prior.key_type) + { + check_secret(db, master, &old.id, f)?; + } + } + } else { + if !f.field_id.is_empty() { + return Err(Error("field_missing")); + } + f.needs_value = f.value.is_none(); + } + } + items::validate(&new)?; + let discards_values = old + .fields + .iter() + .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); + Ok(Diff { + old, + new, + discards_values, + stale: false, + }) +} +pub(crate) fn apply( + db: &Connection, + master: &[u8; 32], + edit: &Edit, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + let mut diff = plan(db, Some(master), edit)?; + db.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![edit.item, serde_json::to_string(&diff.old)?], + )?; + for old in &diff.old.fields { + if !diff.new.fields.iter().any(|f| f.field_id == old.field_id) { + db.execute( + "DELETE FROM fields WHERE item=?1 AND id=?2", + params![edit.item, old.field_id], + )?; + } + } + for f in &mut diff.new.fields { + if f.field_id.is_empty() { + f.field_id = seal::identity(); + } + } + items::save(db, &diff.new)?; + audit::record(db, "item.edit", caller, Some(&edit.item), approver) +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Revert { + pub item: String, + pub base_version: i64, + pub version: i64, +} +pub(crate) fn revert_revision(db: &Connection, item: &str) -> Result { + if let Ok(item) = items::load(db, item) { + return Ok(item.version); + } + let latest: Option = db.query_row( + "SELECT max(json_extract(config,'$.version')) FROM versions WHERE item=?", + [item], + |r| r.get(0), + )?; + latest + .ok_or(Error("item_deleted"))? + .checked_add(1) + .ok_or(Error("version_exhausted")) +} +pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert) -> Result { + if revert_revision(db, &r.item)? != r.base_version { + return Err(Error("stale")); + } + let saved:String=db.query_row("SELECT config FROM versions WHERE item=?1 AND json_extract(config,'$.version')=?2 ORDER BY rowid DESC LIMIT 1",params![r.item,r.version],|row|row.get(0))?; + let mut new: Item = serde_json::from_str(&saved)?; + let current = items::load(db, &r.item).ok(); + for f in &mut new.fields { + let prior = current + .as_ref() + .and_then(|i| i.fields.iter().find(|p| p.field_id == f.field_id)); + if let Some(prior) = prior { + if prior.public != f.public { + return Err(Error("immutable_visibility")); + } + f.needs_value = prior.needs_value; + if f.public { + f.value = prior.value.clone(); + } else if let Some(master) = master { + check_secret(db, master, &r.item, f)?; + } + } else { + f.needs_value = f.value.is_none(); + } + } + items::validate(&new)?; + items::unique_name(db, &new.name, &new.id)?; + new.version = r + .base_version + .checked_add(1) + .ok_or(Error("version_exhausted"))?; + let old = current.unwrap_or_else(|| { + let mut old = new.clone(); + old.version = r.base_version; + old.fields.clear(); + old + }); + let discards_values = old + .fields + .iter() + .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); + Ok(Diff { + old, + new, + discards_values, + stale: false, + }) +} +pub(crate) fn apply_revert( + db: &Connection, + master: &[u8; 32], + r: &Revert, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + let diff = plan_revert(db, Some(master), r)?; + if items::load(db, &r.item).is_ok() { + db.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![r.item, serde_json::to_string(&diff.old)?], + )?; + items::save(db, &diff.new)?; + } else { + db.execute( + "INSERT INTO items VALUES(?1,?2,?3)", + params![r.item, diff.new.name, serde_json::to_string(&diff.new)?], + )?; + } + for f in &diff.old.fields { + if !diff.new.fields.iter().any(|n| n.field_id == f.field_id) { + db.execute( + "DELETE FROM fields WHERE item=?1 AND id=?2", + params![r.item, f.field_id], + )?; + } + } + audit::record(db, "item.revert", caller, Some(&r.item), approver) +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Delete { + pub item: String, + pub base_version: i64, +} +pub(crate) fn apply_delete( + db: &Connection, + delete: &Delete, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + let item = items::load(db, &delete.item)?; + if item.version != delete.base_version { + return Err(Error("stale")); + } + db.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![item.id, serde_json::to_string(&item)?], + )?; + db.execute("DELETE FROM items WHERE id=?", [&item.id])?; + db.execute("UPDATE grants SET revoked=1 WHERE EXISTS(SELECT 1 FROM json_each(grants.items) WHERE value=?)", [&item.id])?; + audit::record(db, "item.delete", caller, Some(&item.id), approver) +} +pub(crate) fn apply_changes( + mut config: Item, + change: &[Operation], + start: usize, + secrets: Option<(&Connection, &[u8; 32])>, +) -> Result { + for operation in &change[start..] { + let field = |id: &str| { + if id.is_empty() { + return Err(Error("field_missing")); + } + config + .fields + .iter() + .position(|f| f.field_id == id) + .ok_or(Error("field_missing")) + }; + match operation { + Operation::SetName { name } => config.name = name.clone(), + Operation::SetRules { rules } => config.rules = rules.clone(), + Operation::SetFreeUse { enabled } => config.free_use = *enabled, + Operation::SetValidateJs { value } => config.validate_js = value.clone(), + Operation::SetTransformJs { value } => config.transform_js = value.clone(), + Operation::AddField { field } => { + let mut field = field.clone(); + if !field.field_id.is_empty() { + return Err(Error("invalid_params")); + } + if !field.public && field.value.is_some() { + return Err(Error("secret_field")); + } + super::types::validate( + &field.field_type, + field.key_type.as_deref(), + field.public, + field.value.as_deref(), + )?; + field.needs_value = field.value.is_none(); + config.fields.push(field); + } + Operation::SetFieldValue { field: id, value } => { + let n = field(id)?; + if !config.fields[n].public { + return Err(Error("secret_field")); + } + super::types::validate( + &config.fields[n].field_type, + config.fields[n].key_type.as_deref(), + true, + Some(value), + )?; + config.fields[n].value = Some(value.clone()); + config.fields[n].needs_value = false; + } + Operation::SetFieldType { + field: id, + field_type, + key_type, + } => { + let n = field(id)?; + config.fields[n].field_type = field_type.clone(); + config.fields[n].key_type = key_type.clone(); + let f = &config.fields[n]; + super::types::validate( + &f.field_type, + f.key_type.as_deref(), + f.public, + f.value.as_deref(), + )?; + if !f.public + && let Some((db, master)) = secrets + { + check_secret(db, master, &config.id, f)?; + } + } + Operation::RenameField { field: id, name } => { + let n = field(id)?; + config.fields[n].name = name.clone(); + } + Operation::RemoveField { field: id } => { + let n = field(id)?; + config.fields.remove(n); + } + _ => return Err(Error("invalid_params")), + } + } + Ok(config) +} +impl super::Cred { + pub fn file_operation_request( + &self, + caller: &Caller, + key: &str, + request: OperationRequest, + ) -> Result { + if request.change.is_empty() { + return Err(Error("invalid_params")); + } + if request.change.len() == 1 { + match &request.change[0] { + Operation::DeleteItem => { + return self.file_delete_request( + caller, + key, + Delete { + item: request.item.clone().ok_or(Error("invalid_params"))?, + base_version: request.base_version.ok_or(Error("invalid_params"))?, + }, + ); + } + Operation::ItemRevert { version } => { + return self.file_revert_request( + caller, + key, + request.item.as_deref().ok_or(Error("invalid_params"))?, + request.base_version.ok_or(Error("invalid_params"))?, + *version, + ); + } + Operation::GrantCreate { grant } => { + let mut grant = grant.clone(); + grant.id.clear(); + grant.uses = 0; + grant.revoked = false; + grant.items.sort(); + grant.items.dedup(); + let payload = serde_json::json!({"grant_create":grant}); + if let Some(id) = self.replay(caller, key, &payload)? { + return Ok(id); + } + super::grants::validate(&self.access()?.db, &grant)?; + return self.file_payload( + caller, + key, + serde_json::json!({"grant_create":grant}), + ); + } + Operation::GrantRevoke { target } => { + self.grant_status(target)?; + return self.file_payload( + caller, + key, + serde_json::json!({"grant_revoke":target}), + ); + } + _ => {} + } + } + let intent = serde_json::json!({"requested":request}); + if let Some(id) = self.replay(caller, key, &intent)? { + return Ok(id); + } + let mut create = false; + let mut generate = false; + let config = if let Some(id) = &request.item { + self.item_get(id)? + } else { + create = true; + match &request.change[0] { + Operation::CreateItem { config } => config.clone(), + Operation::GenerateEs256Item { config } => { + generate = true; + config.clone() + } + _ => return Err(Error("invalid_params")), + } + }; + if create { + items::validate(&config)?; + } + let config = apply_changes(config, &request.change, usize::from(create), None)?; + if create { + if request.base_version.is_some() { + return Err(Error("invalid_params")); + } + items::validate(&config)?; + if generate + && !config.fields.iter().any(|f| { + !f.public + && f.field_type == super::types::FieldType::SshPrivateKey + && f.key_type.as_deref() == Some("es256") + }) + { + return Err(Error("invalid_params")); + } + items::unique_name(&self.access()?.db, &config.name, "")?; + return self.file_payload( + caller, + key, + serde_json::json!({"create":config,"generate_es256":generate,"requested":request}), + ); + } + let edit = Edit { + item: request.item.clone().ok_or(Error("invalid_params"))?, + base_version: request.base_version.ok_or(Error("invalid_params"))?, + config, + }; + let diff = { + let s = self.access()?; + plan(&s.db, None, &edit)? + }; + self.file_payload( + caller, + key, + serde_json::json!({"edit":edit,"diff":diff,"requested":request}), + ) + } + pub fn file_delete_request( + &self, + caller: &Caller, + key: &str, + delete: Delete, + ) -> Result { + if let Some(id) = self.replay(caller, key, &serde_json::json!({"delete":delete}))? { + return Ok(id); + } + let item = self.item_get(&delete.item)?; + if item.version != delete.base_version { + return Err(Error("stale")); + } + self.file_payload(caller, key, serde_json::json!({"delete":delete,"old":item})) + } + /// Revert public configuration. Removed secrets remain absent. + pub fn file_revert_request( + &self, + caller: &Caller, + key: &str, + item: &str, + base_version: i64, + version: i64, + ) -> Result { + let revert = Revert { + item: item.into(), + base_version, + version, + }; + if let Some(id) = self.replay(caller, key, &serde_json::json!({"revert":revert}))? { + return Ok(id); + } + let diff = { + let s = self.access()?; + plan_revert(&s.db, None, &revert)? + }; + self.file_payload( + caller, + key, + serde_json::json!({"revert":revert,"diff":diff}), + ) + } + pub fn file_edit_request(&self, caller: &Caller, key: &str, edit: Edit) -> Result { + if let Some(id) = self.replay(caller, key, &serde_json::json!({"edit":edit}))? { + return Ok(id); + } + let diff = { + let s = self.access()?; + plan(&s.db, None, &edit)? + }; + 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<()> { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let (payload, state): (String, String) = + tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| { + Ok((r.get(0)?, r.get(1)?)) + })?; + if state != "pending" { + return Err(Error("already_decided")); + } + let mut payload: serde_json::Value = serde_json::from_str(&payload)?; + if let Some(revert) = payload.get("revert") { + let mut revert: Revert = serde_json::from_value(revert.clone())?; + revert.base_version = base_version; + let diff = plan_revert(&tx, None, &revert)?; + payload["revert"] = serde_json::to_value(revert)?; + payload["diff"] = serde_json::to_value(diff)?; + } else { + let mut edit: Edit = + serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?; + edit.base_version = base_version; + if let Some(requested) = payload.get("requested") { + let requested: OperationRequest = serde_json::from_value(requested.clone())?; + edit.config = + apply_changes(items::load(&tx, &edit.item)?, &requested.change, 0, None)?; + } + let diff = plan(&tx, None, &edit)?; + payload["edit"] = serde_json::to_value(edit)?; + payload["diff"] = serde_json::to_value(diff)?; + } + tx.execute( + "UPDATE requests SET payload=?2 WHERE id=?1", + params![id, serde_json::to_string(&payload)?], + )?; + tx.commit()?; + Ok(()) + } + pub fn edit_diff(&self, id: &str) -> Result { + let payload = self.request_diff(id)?; + let mut diff: Diff = + serde_json::from_value(payload.get("diff").ok_or(Error("not_edit"))?.clone())?; + if let Some(revert) = payload.get("revert") { + let revert: Revert = serde_json::from_value(revert.clone())?; + diff.stale = revert_revision(&self.access()?.db, &revert.item) + .map_or(true, |v| v != revert.base_version); + } else { + let edit: Edit = + serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?; + diff.stale = self + .item_get(&edit.item) + .map_or(true, |i| i.version != edit.base_version); + } + Ok(diff) + } +} diff --git a/host/src/cred/error.rs b/host/src/cred/error.rs new file mode 100644 index 0000000..b98b89c --- /dev/null +++ b/host/src/cred/error.rs @@ -0,0 +1,20 @@ +//! Stable failures. Never include input values or script exception messages. +#[derive(Debug, thiserror::Error)] +#[error("credential operation failed: {0}")] +pub struct Error(pub &'static str); +pub type Result = std::result::Result; +impl From for Error { + fn from(_: rusqlite::Error) -> Self { + Self("storage") + } +} +impl From for Error { + fn from(_: std::io::Error) -> Self { + Self("io") + } +} +impl From for Error { + fn from(_: serde_json::Error) -> Self { + Self("json") + } +} diff --git a/host/src/cred/grants.rs b/host/src/cred/grants.rs new file mode 100644 index 0000000..3d04654 --- /dev/null +++ b/host/src/cred/grants.rs @@ -0,0 +1,170 @@ +//! Conversation-subtree grant ledger. Reservations use an immediate transaction. +use super::{ + audit, + error::{Error, Result}, + seal, + store::{Cred, now}, + types::{Caller, Subject}, +}; +use rusqlite::{Connection, TransactionBehavior, params}; +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Grant { + #[serde(default)] + pub id: String, + pub items: Vec, + pub subject: Subject, + pub exp: Option, + pub max_uses: Option, + #[serde(default)] + pub uses: u64, + #[serde(default)] + pub revoked: bool, + pub validator: Option, + pub note: Option, +} +pub(crate) fn list(db: &Connection) -> Result> { + let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?; + Ok(q.query_map([], |r| { + Ok(Grant { + id: r.get(0)?, + items: serde_json::from_str(&r.get::<_, String>(1)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + subject: serde_json::from_str(&r.get::<_, String>(2)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + exp: r.get(3)?, + max_uses: r + .get::<_, Option>(4)? + .map(u64::try_from) + .transpose() + .map_err(|_| rusqlite::Error::InvalidQuery)?, + uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?, + revoked: r.get(6)?, + validator: r.get(7)?, + note: r.get(8)?, + }) + })? + .collect::>()?) +} +pub(crate) fn insert( + db: &Connection, + grant: &Grant, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + validate(db, grant)?; + db.execute( + "INSERT INTO grants VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)", + params![ + grant.id, + serde_json::to_string(&grant.items)?, + serde_json::to_string(&grant.subject)?, + grant.exp, + grant.max_uses.map(|m| m as i64), + grant.validator, + grant.note + ], + )?; + audit::record( + db, + "grant.create", + caller, + grant.items.first().map(String::as_str), + approver, + ) +} +pub(crate) fn validate(db: &Connection, grant: &Grant) -> Result<()> { + if grant.max_uses == Some(0) || grant.max_uses.is_some_and(|m| m > i64::MAX as u64) { + return Err(Error("invalid_uses")); + } + if !grant.subject.valid() { + return Err(Error("invalid_subject")); + } + if grant.items.is_empty() { + return Err(Error("invalid_items")); + } + for item in &grant.items { + super::items::load(db, item)?; + } + Ok(()) +} +impl Cred { + pub fn grant_create( + &self, + mut grant: Grant, + caller: &Caller, + approver: Option<&str>, + ) -> Result { + grant.id = seal::identity(); + grant.uses = 0; + grant.revoked = false; + grant.items.sort(); + grant.items.dedup(); + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + insert(&tx, &grant, caller, approver)?; + tx.commit()?; + Ok(grant) + } + pub fn grant_list(&self) -> Result> { + list(&self.access()?.db) + } + pub fn grant_status(&self, id: &str) -> Result { + self.grant_list()? + .into_iter() + .find(|g| g.id == id) + .ok_or(Error("grant_missing")) + } + pub fn revoke_subjects( + &self, + subjects: &[String], + caller: &Caller, + approver: Option<&str>, + ) -> Result<()> { + let mut s = self.access()?; + let tx = s.db.transaction()?; + for subject in subjects { + tx.execute( + "UPDATE grants SET revoked=1 WHERE subject=?", + [serde_json::to_string(&Subject::Conversation( + subject.clone(), + ))?], + )?; + } + audit::record(&tx, "grant.revoke", caller, None, approver)?; + tx.commit()?; + Ok(()) + } + pub(crate) fn reserve( + &self, + item: &super::items::Item, + caller: &Caller, + ) -> Result> { + 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 changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses bool { + item.rules.iter().any(|r| { + super::items::rule_matches( + r, + p.uri.scheme_str().unwrap_or(""), + p.uri.host().unwrap_or(""), + p.uri.path(), + p.method.as_str(), + ) + }) +} +fn request(p: &Parts) -> Result { + let mut headers = Vec::new(); + for (name, value) in &p.headers { + headers.push(json!({"name":name.as_str(),"value":value.to_str().map_err(|_|Error("invalid_headers"))?})); + } + Ok(json!({"method":p.method.as_str(),"url":p.uri.to_string(),"headers":headers})) +} +fn evaluate(script: &dyn Script, source: &str, context: &Value) -> Result { + let input = serde_json::to_string(context)?; + let result = script + .evaluate(source, &input, None, Limits::default()) + .map_err(|_| Error("script"))?; + serde_json::from_str(&result).map_err(|_| Error("script")) +} +impl Cred { + pub fn inject( + &self, + mut parts: Parts, + caller: &Caller, + script: &dyn Script, + ) -> Result { + let candidates: Vec<_> = self + .item_list()? + .into_iter() + .filter(|i| matches(i, &parts)) + .collect(); + if candidates.len() > 1 { + return Err(Error("ambiguous_item")); + } + let Some(item) = candidates.into_iter().next() else { + return Ok(Outcome::Forward(parts)); + }; + let original_request = request(&parts)?; + if item + .fields + .iter() + .any(|f| f.field_type == super::types::FieldType::SshPrivateKey) + { + 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 { + let request = self.file_request( + caller, + &format!("inject:{}", item.id), + GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + return Ok(Outcome::Denied { + item: item.name, + request, + }); + }; + let result = (|| { + let public = self.credential(&item, false)?; + let ctx = json!({"request":original_request,"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {super::types::Subject::Conversation(id)=>Some(id),super::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))}); + for source in [ + item.validate_js.as_deref(), + grant.as_ref().and_then(|g| g.validator.as_deref()), + ] + .into_iter() + .flatten() + { + let value = evaluate(script, source, &ctx)?; + let allow = value + .as_bool() + .or_else(|| value.get("allow").and_then(Value::as_bool)) + .ok_or(Error("script"))?; + if !allow { + return Err(Error("validator_denied")); + } + } + if !item.transform_js.is_empty() { + let mut ctx = json!({"request":original_request,"credential":secret_credential}); + let url = + url::Url::parse(&parts.uri.to_string()).map_err(|_| Error("invalid_url"))?; + let mut base = url.clone(); + base.set_query(None); + base.set_fragment(None); + ctx["url"] = json!({"base":base.as_str(),"query":url.query_pairs().collect::>(),"fragment":url.fragment().map(|f|format!("#{f}")).unwrap_or_default()}); + let output = evaluate(script, &item.transform_js, &ctx)?; + let object = output.as_object().ok_or(Error("transform"))?; + if object + .keys() + .any(|k| k != "url" && k != "headers" && k != "method") + || output + .get("method") + .is_some_and(|m| m.as_str() != Some(parts.method.as_str())) + { + return Err(Error("transform")); + } + if let Some(url) = output.get("url") { + parts.uri = url + .as_str() + .ok_or(Error("transform"))? + .parse() + .map_err(|_| Error("transform"))?; + } + if let Some(headers) = output.get("headers") { + let mut new = http::HeaderMap::new(); + for header in headers.as_array().ok_or(Error("transform"))? { + let name = header + .get("name") + .and_then(Value::as_str) + .ok_or(Error("transform"))?; + let value = header + .get("value") + .and_then(Value::as_str) + .ok_or(Error("transform"))?; + new.append( + http::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Error("transform"))?, + http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?, + ); + } + parts.headers = new; + } + } + if !matches(&item, &parts) { + return 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, + )?; + result?; + Ok(Outcome::Forward(parts)) + } +} diff --git a/host/src/cred/items.rs b/host/src/cred/items.rs new file mode 100644 index 0000000..1649f44 --- /dev/null +++ b/host/src/cred/items.rs @@ -0,0 +1,351 @@ +//! Public item snapshots and host-only field entry. Snapshots never contain secret values. +use super::{ + error::{Error, Result}, + seal, + store::Cred, + types::{Caller, FieldType}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Rule { + pub scheme: String, + pub hosts: Vec, + pub path_prefixes: Vec, + pub methods: Option>, +} +pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool { + r.scheme.eq_ignore_ascii_case(scheme) + && r.hosts.iter().any(|p| { + if let Some(s) = p.strip_prefix("*.") { + host.to_ascii_lowercase() + .strip_suffix(&format!(".{}", s.to_ascii_lowercase())) + .is_some_and(|p| !p.is_empty()) + } else { + p.eq_ignore_ascii_case(host) + } + }) + && r.path_prefixes.iter().any(|p| path.starts_with(p)) + && r.methods + .as_ref() + .is_none_or(|ms| ms.iter().any(|m| m.eq_ignore_ascii_case(method))) +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Field { + #[serde(default)] + pub field_id: String, + pub name: String, + #[serde(default)] + pub public: bool, + pub value: Option, + #[serde(default)] + pub needs_value: bool, + pub field_type: FieldType, + pub key_type: Option, +} +impl std::fmt::Debug for Field { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Field") + .field("field_id", &self.field_id) + .field("name", &self.name) + .field("public", &self.public) + .field("needs_value", &self.needs_value) + .field("field_type", &self.field_type) + .field("key_type", &self.key_type) + .field( + "value", + &if self.public { + self.value.as_deref() + } else { + None + }, + ) + .finish() + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Item { + #[serde(default)] + pub id: String, + pub name: String, + #[serde(default)] + pub version: i64, + #[serde(default)] + pub free_use: bool, + pub rules: Vec, + pub validate_js: Option, + pub transform_js: String, + pub fields: Vec, +} +pub(crate) fn load(db: &Connection, id: &str) -> Result { + let json: Option = db + .query_row("SELECT config FROM items WHERE id=?", [id], |r| r.get(0)) + .optional()?; + Ok(serde_json::from_str(&json.ok_or(Error("item_deleted"))?)?) +} +pub(crate) fn save(db: &Connection, item: &Item) -> Result<()> { + db.execute( + "UPDATE items SET name=?2,config=?3 WHERE id=?1", + params![item.id, item.name, serde_json::to_string(item)?], + )?; + Ok(()) +} +pub(crate) fn validate(item: &Item) -> Result<()> { + if item.name.trim().is_empty() { + return Err(Error("invalid_params")); + } + let mut names = std::collections::HashSet::new(); + let mut ids = std::collections::HashSet::new(); + for f in &item.fields { + if !f.public && f.value.is_some() { + return Err(Error("secret_field")); + } + super::types::validate( + &f.field_type, + f.key_type.as_deref(), + f.public, + f.value.as_deref(), + )?; + if f.name.trim().is_empty() + || !names.insert(&f.name) + || (!f.field_id.is_empty() && !ids.insert(&f.field_id)) + { + return Err(Error("invalid_params")); + } + } + if item + .fields + .iter() + .filter(|f| f.field_type == FieldType::Oauth2RefreshToken) + .count() + > 1 + { + return Err(Error("invalid_params")); + } + for r in &item.rules { + if !matches!( + r.scheme.to_ascii_lowercase().as_str(), + "http" | "https" | "ssh" + ) || r.path_prefixes.iter().any(|p| !p.starts_with('/')) + || 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'-') + }) + }) + || r.hosts.iter().any(|p| { + let h = p.strip_prefix("*.").unwrap_or(p); + h.is_empty() + || h.starts_with('.') + || h.ends_with('.') + || h.contains("..") + || h.bytes().any(|b| { + b.is_ascii_whitespace() || matches!(b, b'*' | b'/' | b'?' | b'#' | b'@') + }) + || (h.contains(':') && h.parse::().is_err()) + }) + { + return Err(Error("invalid_params")); + } + } + Ok(()) +} +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)", + params![name, except], + |r| r.get(0), + )?; + if exists { + Err(Error("name_conflict")) + } else { + Ok(()) + } +} +pub(crate) fn insert_new( + db: &Connection, + mut item: Item, + caller: &Caller, + approver: Option<&str>, +) -> Result { + validate(&item)?; + item.id = seal::identity(); + item.version = 1; + for field in &mut item.fields { + field.field_id = seal::identity(); + field.needs_value = field.value.is_none(); + } + unique_name(db, &item.name, "")?; + db.execute( + "INSERT INTO items VALUES(?1,?2,?3)", + params![item.id, item.name, serde_json::to_string(&item)?], + )?; + super::audit::record(db, "item.create", caller, Some(&item.id), approver)?; + Ok(item) +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ItemVersion { + pub created: i64, + pub config: Item, +} +/// A secret-entry value with optional replacement metadata. Debug never includes the value. +pub struct FieldInput<'a> { + pub value: &'a str, + pub field_type: Option, + pub key_type: Option, +} +impl std::fmt::Debug for FieldInput<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FieldInput") + .field("field_type", &self.field_type) + .field("key_type", &self.key_type) + .finish_non_exhaustive() + } +} +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 { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let item = insert_new(&tx, item, caller, Some(approver))?; + tx.commit()?; + Ok(item) + } + pub fn item_get(&self, id: &str) -> Result { + load(&self.access()?.db, id) + } + pub fn item_list(&self) -> Result> { + let s = self.access()?; + let mut q = s.db.prepare("SELECT config FROM items ORDER BY name")?; + q.query_map([], |r| r.get::<_, String>(0))? + .map(|r| Ok(serde_json::from_str(&r?)?)) + .collect() + } + pub fn item_versions(&self, id: &str) -> Result> { + let s = self.access()?; + let mut q = + s.db.prepare("SELECT created,config FROM versions WHERE item=? ORDER BY rowid")?; + q.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? + .map(|r| { + let (created, config) = r?; + Ok(ItemVersion { + created, + config: serde_json::from_str(&config)?, + }) + }) + .collect() + } + /// Trusted secret entry core. Never put the value into a public snapshot. + pub fn set_field( + &self, + item: &str, + field: &str, + value: &str, + caller: &Caller, + approver: &str, + ) -> Result<()> { + self.set_field_input( + item, + field, + &FieldInput { + value, + field_type: None, + key_type: None, + }, + caller, + approver, + ) + } + pub fn set_field_input( + &self, + item: &str, + field: &str, + input: &FieldInput<'_>, + caller: &Caller, + approver: &str, + ) -> Result<()> { + let value = input.value; + let mut s = self.access()?; + let key = s.master.clone(); + let tx = s.db.transaction()?; + let mut config = load(&tx, item)?; + let index = config + .fields + .iter() + .position(|f| f.field_id == field) + .ok_or(Error("field_missing"))?; + let f = &config.fields[index]; + let kind = input.field_type.as_ref().unwrap_or(&f.field_type).clone(); + let key_type = if input.field_type.is_some() { + input.key_type.clone() + } else { + input.key_type.clone().or_else(|| f.key_type.clone()) + }; + super::types::validate(&kind, key_type.as_deref(), f.public, Some(value))?; + let previous = serde_json::to_string(&config)?; + if config.fields[index].public { + config.fields[index].value = Some(value.to_owned()); + } else { + let encrypted = seal::seal(&key, &format!("{item}|{field}"), value.as_bytes())?; + tx.execute( + "INSERT OR REPLACE INTO fields VALUES(?1,?2,?3)", + params![item, field, encrypted], + )?; + } + config.fields[index].needs_value = false; + config.fields[index].field_type = kind; + config.fields[index].key_type = key_type; + tx.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![item, previous], + )?; + config.version = config + .version + .checked_add(1) + .ok_or(Error("version_exhausted"))?; + save(&tx, &config)?; + super::audit::record(&tx, "field.set", caller, Some(item), Some(approver))?; + tx.commit()?; + Ok(()) + } + pub(crate) fn credential(&self, item: &Item, secrets: bool) -> Result { + let s = self.access()?; + if secrets && load(&s.db, &item.id)?.version != item.version { + return Err(Error("stale")); + } + credential(&s.db, &s.master, item, secrets) + } +} +pub(crate) fn credential( + db: &Connection, + master: &[u8; 32], + item: &Item, + secrets: bool, +) -> Result { + if secrets && item.fields.iter().any(|f| f.needs_value) { + return Err(Error("needs_value")); + } + let mut values = serde_json::Map::new(); + for f in &item.fields { + if f.public { + if let Some(v) = &f.value { + values.insert(f.name.clone(), v.clone().into()); + } + } else if secrets { + let cipher: Option> = db + .query_row( + "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", + params![item.id, f.field_id], + |r| r.get(0), + ) + .optional()?; + let cipher = cipher.ok_or(Error("needs_value"))?; + { + let bytes = seal::open(master, &format!("{}|{}", item.id, f.field_id), &cipher)?; + let value = std::str::from_utf8(&bytes).map_err(|_| Error("field_invalid"))?; + values.insert(f.name.clone(), value.into()); + } + } + } + Ok(values.into()) +} diff --git a/host/src/cred/requests.rs b/host/src/cred/requests.rs new file mode 100644 index 0000000..7c207e6 --- /dev/null +++ b/host/src/cred/requests.rs @@ -0,0 +1,436 @@ +//! Keyed grant requests, transactional decisions, explicit expiry and decision notifications. +use super::{ + audit, + error::{Error, Result}, + grants::{self, Grant}, + seal, + store::{Cred, now}, + types::{Caller, Subject}, +}; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct GrantRequest { + pub items: Vec, + pub duration_secs: Option, + pub max_uses: Option, + pub validator: Option, +} +#[derive(Clone, Debug, serde::Serialize)] +pub struct Request { + pub id: String, + pub subject: Subject, + pub payload: serde_json::Value, + pub state: String, + pub expires: i64, + pub pid: u32, + pub program: String, + pub decision: Option, + pub approver: Option, +} +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct Overrides { + #[serde(default)] + pub unlimited: bool, + pub duration_secs: Option, + pub max_uses: Option, + pub validator: Option, +} +fn normalised(payload: &serde_json::Value) -> serde_json::Value { + let mut value = if let Some(requested) = payload.get("requested") { + serde_json::json!({"requested":requested}) + } else { + payload.clone() + }; + if let Some(object) = value.as_object_mut() { + object.remove("diff"); + object.remove("old"); + } + fn sets(value: &mut serde_json::Value, ordered: bool) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + sets(value, key == "change" || key == "fields"); + } + } + serde_json::Value::Array(values) => { + for value in values.iter_mut() { + sets(value, false); + } + if !ordered { + values.sort_by_cached_key(|v| v.to_string()); + values.dedup(); + } + } + _ => {} + } + } + sets(&mut value, false); + value +} +impl Cred { + pub(crate) fn replay( + &self, + caller: &Caller, + key: &str, + payload: &serde_json::Value, + ) -> Result> { + let s = self.access()?; + let subject = serde_json::to_string(&Subject::requester(caller))?; + let old:Option<(String,String,String,i64,Option)>=s.db.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject=?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; + let Some((id, prior, state, expires, grant_id)) = old else { + return Ok(None); + }; + if normalised(&serde_json::from_str(&prior)?) != normalised(payload) { + return Err(Error("key_conflict")); + } + let usable = match grant_id { + None => true, + Some(id) => grants::list(&s.db)?.iter().any(|g| { + g.id == id + && !g.revoked + && g.exp.is_none_or(|e| e > now()) + && g.max_uses.is_none_or(|m| g.uses < m) + }), + }; + Ok( + ((state == "pending" && expires > now()) || (state == "approved" && usable)) + .then_some(id), + ) + } + pub fn file_request( + &self, + caller: &Caller, + key: &str, + mut payload: GrantRequest, + ) -> Result { + if payload.items.is_empty() { + return Err(Error("invalid_items")); + } + { + let s = self.access()?; + for item in &mut payload.items { + *item = + s.db.query_row( + "SELECT id FROM items WHERE id=?1 OR name=?1 ORDER BY id=?1 DESC LIMIT 1", + [&*item], + |r| r.get(0), + ) + .optional()? + .unwrap_or_else(|| item.clone()); + } + } + payload.items.sort(); + payload.items.dedup(); + payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600)); + if payload.validator.as_deref() == Some("") { + payload.validator = None; + } + let value = serde_json::to_value(&payload)?; + if let Some(id) = self.replay(caller, key, &value)? { + return Ok(id); + } + { + let s = self.access()?; + for item in &payload.items { + super::items::load(&s.db, item)?; + } + } + self.file_payload(caller, key, value) + } + pub(crate) fn file_payload( + &self, + caller: &Caller, + key: &str, + payload: serde_json::Value, + ) -> Result { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let mut expired_id = None; + let subject = Subject::requester(caller); + let subject_json = serde_json::to_string(&subject)?; + let encoded = serde_json::to_string(&payload)?; + let old:Option<(String,String,String,i64,Option)>=tx.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject IS ?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject_json,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; + if let Some((id, prior, state, expires, grant_id)) = old { + if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) { + return Err(Error("key_conflict")); + } + if state == "pending" && expires > now() { + return Ok(id); + } + if state == "approved" + && match grant_id { + None => true, + Some(grant_id) => grants::list(&tx)?.iter().any(|g| { + g.id == grant_id + && !g.revoked + && g.exp.is_none_or(|e| e > now()) + && g.max_uses.is_none_or(|m| g.uses < m) + }), + } + { + return Ok(id); + } + if state == "pending" { + tx.execute("UPDATE requests SET state='expired' WHERE id=?", [&id])?; + expired_id = Some(id); + } + } + let id = seal::identity(); + tx.execute("INSERT INTO requests(id,subject,key,payload,state,expires,pid,program) VALUES(?1,?2,?3,?4,'pending',?5,?6,?7)",params![id,subject_json,key,encoded,now()+86400,caller.pid,caller.program])?; + tx.commit()?; + if let Some(expired) = expired_id { + let _ = self.decided.send(expired); + } + Ok(id) + } + pub fn request_list(&self) -> Result> { + let s = self.access()?; + let mut q=s.db.prepare("SELECT id,subject,payload,state,expires,pid,decision,approver,program FROM requests ORDER BY rowid")?; + let rows = q.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + )) + })?; + rows.map(|r| { + let (id, subject, payload, state, expires, pid, decision, approver, program) = r?; + Ok(Request { + id, + subject: serde_json::from_str(&subject)?, + payload: serde_json::from_str(&payload)?, + state, + expires, + pid, + program, + decision, + approver, + }) + }) + .collect() + } + pub fn request_diff(&self, id: &str) -> Result { + self.request_list()? + .into_iter() + .find(|r| r.id == id) + .map(|r| r.payload) + .ok_or(Error("request_missing")) + } + pub fn request_decisions(&self) -> Result> { + Ok(self + .request_list()? + .into_iter() + .filter(|r| r.state != "pending") + .collect()) + } + pub fn approve( + &self, + id: &str, + decision: &str, + overrides: Overrides, + approver: Option<&str>, + ) -> Result<()> { + self.decide(id, decision, Some(overrides), approver) + } + pub fn deny(&self, id: &str, decision: &str, approver: Option<&str>) -> Result<()> { + self.decide(id, decision, None, approver) + } + fn decide( + &self, + id: &str, + decision: &str, + overrides: Option, + approver: Option<&str>, + ) -> Result<()> { + let mut s = self.access()?; + let master = s.master.clone(); + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (subject, payload, state, expires, pid, program): ( + String, + String, + String, + i64, + u32, + String, + ) = tx.query_row( + "SELECT subject,payload,state,expires,pid,program FROM requests WHERE id=?", + [id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + }, + )?; + let subject: Subject = serde_json::from_str(&subject)?; + if state == "expired" { + return Err(Error("request_expired")); + } + if state != "pending" { + return Err(Error("already_decided")); + } + if expires <= now() { + tx.execute("UPDATE requests SET state='expired' WHERE id=?", [id])?; + tx.commit()?; + let _ = self.decided.send(id.to_owned()); + return Err(Error("request_expired")); + } + let caller = Caller { + pid, + program, + conversations: match &subject { + Subject::Conversation(id) => vec![id.clone()], + Subject::Program(_) => vec![], + }, + }; + let mut issued_grant = None; + let state = if let Some(o) = overrides { + let parsed: serde_json::Value = serde_json::from_str(&payload)?; + 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") { + let requested: super::edits::OperationRequest = + serde_json::from_value(requested.clone())?; + let current = super::items::load(&tx, &edit.item)?; + if current.version != edit.base_version { + return Err(Error("stale")); + } + edit.config = super::edits::apply_changes( + current, + &requested.change, + 0, + Some((&tx, &master)), + )?; + } + super::edits::apply(&tx, &master, &edit, &caller, approver)?; + } else if let Some(revert) = parsed.get("revert") { + super::edits::apply_revert( + &tx, + &master, + &serde_json::from_value(revert.clone())?, + &caller, + approver, + )?; + } else if let Some(create) = parsed.get("create") { + let mut item = super::items::insert_new( + &tx, + serde_json::from_value(create.clone())?, + &caller, + approver, + )?; + if parsed.get("generate_es256").and_then(|v| v.as_bool()) == Some(true) { + let private = + super::sign::generate_es256().map_err(|_| Error("key_generation"))?; + let index = item + .fields + .iter() + .position(|f| { + !f.public + && f.field_type == super::types::FieldType::SshPrivateKey + && f.key_type.as_deref() == Some("es256") + }) + .ok_or(Error("invalid_params"))?; + let field = &item.fields[index].field_id; + let cipher = + seal::seal(&master, &format!("{}|{field}", item.id), private.as_bytes())?; + tx.execute( + "INSERT INTO fields VALUES(?1,?2,?3)", + params![item.id, field, cipher], + )?; + item.fields[index].needs_value = false; + super::items::save(&tx, &item)?; + } + } else if let Some(grant) = parsed.get("grant_create") { + let mut grant: Grant = serde_json::from_value(grant.clone())?; + grant.id = seal::identity(); + grant.uses = 0; + grant.revoked = false; + grants::insert(&tx, &grant, &caller, approver)?; + } 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 { + return Err(Error("grant_missing")); + } + audit::record(&tx, "grant.revoke", &caller, None, approver)?; + } else if let Some(delete) = parsed.get("delete") { + super::edits::apply_delete( + &tx, + &serde_json::from_value(delete.clone())?, + &caller, + approver, + )?; + } else { + let p: GrantRequest = serde_json::from_value(parsed)?; + let duration = o.duration_secs.or(p.duration_secs).unwrap_or(3600); + if duration == 0 { + return Err(Error("invalid_duration")); + } + let duration = i64::try_from(duration).map_err(|_| Error("invalid_duration"))?; + let g = Grant { + id: seal::identity(), + items: p.items, + subject, + exp: Some( + now() + .checked_add(duration) + .ok_or(Error("invalid_duration"))?, + ), + max_uses: if o.unlimited { + None + } else { + o.max_uses.or(p.max_uses) + }, + uses: 0, + revoked: false, + validator: o.validator.or(p.validator), + note: None, + }; + grants::insert(&tx, &g, &caller, approver)?; + issued_grant = Some(g.id); + } + "approved" + } else { + "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], + )?; + audit::record(&tx, state, &caller, None, approver)?; + tx.commit()?; + let _ = self.decided.send(id.to_owned()); + Ok(()) + } + pub fn expire_requests(&self) -> Result> { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let ids = { + let mut q = + tx.prepare("SELECT id FROM requests WHERE state='pending' AND expires<=?")?; + q.query_map([now()], |r| r.get::<_, String>(0))? + .collect::>>()? + }; + tx.execute( + "UPDATE requests SET state='expired' WHERE state='pending' AND expires<=?", + [now()], + )?; + tx.commit()?; + for id in &ids { + let _ = self.decided.send(id.clone()); + } + Ok(ids) + } +} diff --git a/host/src/cred/schema.rs b/host/src/cred/schema.rs new file mode 100644 index 0000000..9dd0a4b --- /dev/null +++ b/host/src/cred/schema.rs @@ -0,0 +1,12 @@ +//! 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 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 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 request_keys ON requests(subject,key); +CREATE INDEX IF NOT EXISTS request_expiry ON requests(state,expires); +"; diff --git a/host/src/cred/seal.rs b/host/src/cred/seal.rs new file mode 100644 index 0000000..a916637 --- /dev/null +++ b/host/src/cred/seal.rs @@ -0,0 +1,57 @@ +//! Authenticated field encryption. The field identity binds each ciphertext. +use super::error::{Error, Result}; +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, OsRng, Payload, rand_core::RngCore}, +}; +pub(crate) fn random() -> [u8; N] { + let mut bytes = [0; N]; + OsRng.fill_bytes(&mut bytes); + bytes +} +pub(crate) fn identity() -> String { + hex::encode(random::<16>()) +} +pub(crate) fn seal(key: &[u8; 32], aad: &str, value: &[u8]) -> Result> { + let nonce = random::<24>(); + let cipher = XChaCha20Poly1305::new(key.into()); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: value, + aad: aad.as_bytes(), + }, + ) + .map_err(|_| Error("seal"))?; + Ok([nonce.to_vec(), ciphertext].concat()) +} +pub(crate) fn open(key: &[u8; 32], aad: &str, value: &[u8]) -> Result>> { + if value.len() < 24 { + return Err(Error("seal")); + } + XChaCha20Poly1305::new(key.into()) + .decrypt( + XNonce::from_slice(&value[..24]), + Payload { + msg: &value[24..], + aad: aad.as_bytes(), + }, + ) + .map(zeroize::Zeroizing::new) + .map_err(|_| Error("seal")) +} +#[cfg(test)] +mod tests { + #[test] + fn field_seal_rejects_tamper_and_wrong_identity() { + let key = super::random::<32>(); + let mut cipher = super::seal(&key, "item|field", b"value").unwrap(); + assert!(super::open(&key, "item|field", &cipher).unwrap().as_slice() == b"value"); + assert!(super::open(&key, "other|field", &cipher).is_err()); + assert!(super::open(&super::random::<32>(), "item|field", &cipher).is_err()); + let end = cipher.len() - 1; + cipher[end] ^= 1; + assert!(super::open(&key, "item|field", &cipher).is_err()); + } +} diff --git a/host/src/cred/store.rs b/host/src/cred/store.rs new file mode 100644 index 0000000..fbebed3 --- /dev/null +++ b/host/src/cred/store.rs @@ -0,0 +1,72 @@ +//! Host-only database and master key. One mutex protects synchronous transactions. +use super::{ + error::{Error, Result}, + schema, seal, +}; +use std::{ + io::{Read, Write}, + os::unix::fs::OpenOptionsExt, + path::Path, + sync::{Mutex, MutexGuard}, +}; +pub(crate) struct Store { + pub db: rusqlite::Connection, + pub master: zeroize::Zeroizing<[u8; 32]>, +} +pub struct Cred { + pub(crate) store: Mutex, + pub(crate) decided: tokio::sync::broadcast::Sender, +} +impl Cred { + pub fn open(host_dir: &Path) -> Result { + std::fs::create_dir_all(host_dir)?; + let path = std::env::var_os("CREDENTIALS_DIRECTORY") + .map(|p| std::path::PathBuf::from(p).join("sausage-cred-key")) + .unwrap_or_else(|| host_dir.join("cred.key")); + if std::env::var_os("CREDENTIALS_DIRECTORY").is_none() { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + file.write_all(&seal::random::<32>())?; + file.sync_all()?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + let mut bytes = zeroize::Zeroizing::new(Vec::new()); + std::fs::File::open(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)?; + let (decided, _) = tokio::sync::broadcast::channel(64); + let cred = Self { + store: Mutex::new(Store { db, master }), + decided, + }; + cred.expire_requests()?; + Ok(cred) + } + pub(crate) fn access(&self) -> Result> { + self.store.lock().map_err(|_| Error("storage")) + } + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.decided.subscribe() + } +} +pub(crate) fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock before epoch") + .as_secs() as i64 +} diff --git a/host/src/cred/summary.rs b/host/src/cred/summary.rs new file mode 100644 index 0000000..7316f94 --- /dev/null +++ b/host/src/cred/summary.rs @@ -0,0 +1,111 @@ +//! Public approval summaries. Do not interpolate field values or script sources. +pub fn human_duration(mut seconds: u64) -> String { + let mut parts = Vec::new(); + for (size, unit) in [ + (86400, "day"), + (3600, "hour"), + (60, "minute"), + (1, "second"), + ] { + let count = seconds / size; + seconds %= size; + if count != 0 { + parts.push(format!( + "{count} {unit}{}", + if count == 1 { "" } else { "s" } + )); + } + } + if parts.is_empty() { + "0 seconds".into() + } else { + parts.join(" ") + } +} +fn text(value: &str) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '\n' | '\r' => out.push(' '), + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '\\' | '`' | '*' | '_' | '[' | ']' | '#' | '|' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + out +} +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)) + } + SetName { name } => format!("Rename the item to “{}”.", text(name)), + SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(), + SetFreeUse { enabled: true } => { + "Allow anyone to use this item without a grant, within its rules.".into() + } + SetFreeUse { enabled: false } => "Require a grant to use this item.".into(), + SetValidateJs { value: None } => "Remove the validation script.".into(), + SetValidateJs { .. } => "Replace the validation script.".into(), + SetTransformJs { .. } => "Replace the transformation script.".into(), + AddField { field } => format!( + "Add {} field “{}”.", + if field.public { "public" } else { "secret" }, + text(&field.name) + ), + SetFieldValue { .. } => "Change a public field value.".into(), + 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(), + DeleteItem => "Delete the item and permanently discard its field values.".into(), + ItemRevert { version } => format!( + "Restore public configuration from revision {version}. Discarded secret values cannot be recovered." + ), + GrantCreate { grant } => format!( + "Give access to {} credential items. Review the subject and limits in Details.", + grant.items.len() + ), + GrantRevoke { .. } => "Revoke the grant. All access through this grant will stop.".into(), + } +} +/// UI presets. The runtime never interprets a preset name. +pub fn script(preset: &str) -> super::Result { + let field = "const get = name => { if (typeof credential[name] !== 'string') throw Error('credential field is missing'); return credential[name]; };"; + if let Some(name) = preset.strip_prefix("query:") { + let name = serde_json::to_string(name)?; + return Ok(format!( + r#"export default ({{request,credential,url}}) => {{ + {field} + const name = {name}; + const encode = value => encodeURIComponent(value).replace(/[!'()~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/%20/g, '+'); + const pairs = url.query.filter(([key]) => key !== name); + pairs.push([name,get('token')]); + return {{...request,url:url.base+'?'+pairs.map(([k,v])=>encode(k)+'='+encode(v)).join('&')+url.fragment}}; +}}"# + )); + } + let (name, value) = match preset { + "bearer" => ("Authorization", "'Bearer ' + get('token')"), + "basic" => ( + "Authorization", + "'Basic ' + btoa(unescape(encodeURIComponent(get('username') + ':' + get('password'))))", + ), + p if p.starts_with("header:") => (&p[7..], "get('token')"), + _ => return Err(super::Error("invalid_preset")), + }; + let name = serde_json::to_string(name)?; + Ok(format!( + r#"export default ({{request,credential}}) => {{ + {field} + const name = {name}; + return {{...request,headers:[...request.headers.filter(h=>h.name.toLowerCase()!==name.toLowerCase()),{{name,value:{value}}}]}}; +}}"# + )) +} diff --git a/host/src/cred/types.rs b/host/src/cred/types.rs new file mode 100644 index 0000000..8cae2af --- /dev/null +++ b/host/src/cred/types.rs @@ -0,0 +1,108 @@ +//! 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)] +pub struct Caller { + pub program: String, + pub pid: u32, + pub conversations: Vec, +} +/// Conversation subjects cover descendants. Program subjects cover the same binary path. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum Subject { + Conversation(String), + Program(String), +} +impl Subject { + pub fn covers(&self, caller: &Caller) -> bool { + match self { + Self::Conversation(id) => caller.conversations.contains(id), + Self::Program(path) => caller.program == *path, + } + } + pub(crate) fn requester(caller: &Caller) -> Self { + caller.conversations.first().map_or_else( + || Self::Program(caller.program.clone()), + |id| Self::Conversation(id.clone()), + ) + } + pub(crate) fn valid(&self) -> bool { + match self { + Self::Conversation(id) => !id.is_empty(), + Self::Program(path) => path.starts_with('/'), + } + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldType { + Text, + Note, + SshPublicKey, + SshPrivateKey, + Oauth2RefreshToken, +} +fn matches_algorithm(algorithm: ssh_key::Algorithm, format: &str) -> bool { + match algorithm { + ssh_key::Algorithm::Ed25519 => matches!(format, "ed25519" | "ed25519-raw"), + ssh_key::Algorithm::Ecdsa { + curve: ssh_key::EcdsaCurve::NistP256, + } => format == "es256", + ssh_key::Algorithm::Rsa { .. } => { + matches!(format, "rs256" | "rsa-sha2-256" | "rsa-sha2-512") + } + _ => false, + } +} +pub(crate) fn validate( + kind: &FieldType, + key_type: Option<&str>, + public: bool, + value: Option<&str>, +) -> Result<()> { + let key_format = key_type.is_some_and(|k| { + matches!( + k, + "ed25519" | "ed25519-raw" | "es256" | "rs256" | "rsa-sha2-256" | "rsa-sha2-512" + ) + }); + match kind { + FieldType::Text | FieldType::Note if key_type.is_none() => {} + FieldType::Oauth2RefreshToken if !public && key_type.is_none() => {} + FieldType::SshPublicKey if public && key_format => {} + FieldType::SshPrivateKey if !public && key_format => {} + _ => return Err(Error("invalid_type")), + } + if let Some(value) = value { + match kind { + FieldType::Text | FieldType::Oauth2RefreshToken if value.contains(['\r', '\n']) => { + return Err(Error("invalid_value")); + } + FieldType::SshPublicKey => { + let key = + ssh_key::PublicKey::from_openssh(value).map_err(|_| Error("invalid_value"))?; + if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) { + return Err(Error("invalid_value")); + } + } + FieldType::SshPrivateKey => { + if value + .trim() + .starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") + { + let key = ssh_key::PrivateKey::from_openssh(value) + .map_err(|_| Error("invalid_value"))?; + if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) { + return Err(Error("invalid_value")); + } + } else { + super::keys::read(value, key_type.unwrap_or(""), None) + .map_err(|_| Error("invalid_value"))?; + } + } + _ => {} + } + } + Ok(()) +} diff --git a/host/tests/cred.rs b/host/tests/cred.rs new file mode 100644 index 0000000..450337f --- /dev/null +++ b/host/tests/cred.rs @@ -0,0 +1,255 @@ +//! Credential core acceptance tests with a script fake and host-only temporary storage. +use sausage::cred::{ + grants::Grant, + inject::Outcome, + items::*, + requests::{GrantRequest, Overrides}, + script::{self, Limits, Script}, + *, +}; +fn caller(id: &str) -> Caller { + Caller { + program: "/bin/tool.wasm".into(), + pid: 42, + conversations: vec![id.into()], + } +} +fn item(free_use: bool) -> Item { + Item { + id: String::new(), + name: "token".into(), + version: 0, + free_use, + rules: vec![Rule { + scheme: "https".into(), + hosts: vec!["api.example".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }], + } +} +fn parts(host: &str) -> http::request::Parts { + http::Request::get(format!("https://{host}/path")) + .body(()) + .unwrap() + .into_parts() + .0 +} +struct Fake; +impl Script for Fake { + fn evaluate( + &self, + source: &str, + input: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + let ctx: serde_json::Value = serde_json::from_str(input).unwrap(); + match source { + "deny"=>Ok("false".into()), + "validate"=>{assert!(ctx["credential"].get("token").is_none());Ok("true".into())}, + "throw"=>Err(script::Error{kind:script::ErrorKind::Throw,message:ctx["credential"]["token"].as_str().unwrap().into()}), + "escape"=>Ok(r#"{"url":"https://evil.example/"}"#.into()), + _=>Ok(serde_json::json!({"headers":[{"name":"authorization","value":ctx["credential"]["token"]}]}).to_string()) + } + } +} +#[test] +fn inject_selection_idempotency_and_subtree() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let root = caller("root"); + assert!(matches!( + c.inject(parts("api.example"), &root, &Fake).unwrap(), + Outcome::Forward(_) + )); + let i = c.create_item(item(false), &root, "passkey").unwrap(); + let first = match c.inject(parts("api.example"), &root, &Fake).unwrap() { + Outcome::Denied { item, request } => { + assert_eq!(item, "token"); + request + } + _ => panic!("expected denial"), + }; + let second = match c.inject(parts("api.example"), &root, &Fake).unwrap() { + Outcome::Denied { request, .. } => request, + _ => panic!("expected denial"), + }; + assert_eq!(first, second); + assert_eq!(c.request_list().unwrap().len(), 1); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &root, "passkey") + .unwrap(); + c.approve(&first, "allow", Overrides::default(), Some("passkey")) + .unwrap(); + let child = Caller { + program: "/bin/tool.wasm".into(), + pid: 43, + conversations: vec!["child".into(), "root".into()], + }; + assert!(matches!( + c.inject(parts("api.example"), &child, &Fake).unwrap(), + Outcome::Forward(_) + )); + assert!(matches!( + c.inject(parts("api.example"), &caller("sibling"), &Fake) + .unwrap(), + Outcome::Denied { .. } + )); + let mut other = item(true); + other.name = "other".into(); + c.create_item(other, &root, "passkey").unwrap(); + assert_eq!( + c.inject(parts("api.example"), &root, &Fake) + .err() + .unwrap() + .0, + "ambiguous_item" + ); + assert_eq!(c.grant_list().unwrap()[0].items, vec![i.id]); +} +#[test] +fn free_use_validators_transform_recheck_and_redaction() { + for (source, validator, expected) in [ + ("inject", Some("validate"), None), + ("inject", Some("deny"), Some("validator_denied")), + ("escape", None, Some("rule_recheck")), + ("throw", None, Some("script")), + ] { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = caller("root"); + let mut config = item(true); + config.transform_js = source.into(); + config.validate_js = validator.map(Into::into); + let i = c.create_item(config, &caller, "passkey").unwrap(); + let secret = "SECRET_SENTINEL_19"; + c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, "passkey") + .unwrap(); + let result = c.inject(parts("api.example"), &caller, &Fake); + if let Some(expected) = expected { + let error = result.err().unwrap(); + assert_eq!(error.0, expected); + assert!(!format!("{error:?}").contains(secret)); + } else { + let Outcome::Forward(p) = result.unwrap() else { + panic!("denied") + }; + assert!(p.headers["authorization"] == secret); + } + c.audit_sign(&i.id, &caller, secret.as_bytes()).unwrap(); + assert!(!format!("{:?}", c.item_get(&i.id).unwrap()).contains(secret)); + assert!( + !serde_json::to_string(&c.audit_list().unwrap()) + .unwrap() + .contains(secret) + ); + let id = c + .file_request( + &caller, + "review", + GrantRequest { + items: vec![i.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }, + ) + .unwrap(); + assert!(!format!("{:?}", c.request_diff(&id).unwrap()).contains(secret)); + } +} +#[test] +fn atomic_budget_and_decision_notification() { + let dir = tempfile::tempdir().unwrap(); + let c = std::sync::Arc::new(Cred::open(dir.path()).unwrap()); + let caller = caller("root"); + let i = c.create_item(item(false), &caller, "passkey").unwrap(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "passkey") + .unwrap(); + c.grant_create( + Grant { + id: String::new(), + items: vec![i.id], + subject: types::Subject::Conversation("root".into()), + exp: None, + max_uses: Some(1), + uses: 0, + revoked: false, + validator: None, + note: None, + }, + &caller, + Some("passkey"), + ) + .unwrap(); + let handles: Vec<_> = (0..2) + .map(|_| { + let c = c.clone(); + let caller = caller.clone(); + std::thread::spawn(move || { + matches!( + c.inject(parts("api.example"), &caller, &Fake).unwrap(), + Outcome::Forward(_) + ) + }) + }) + .collect(); + assert_eq!( + handles + .into_iter() + .map(|h| usize::from(h.join().unwrap())) + .sum::(), + 1 + ); + let id = c.request_list().unwrap()[0].id.clone(); + let mut receiver = c.subscribe(); + c.deny(&id, "no", Some("passkey")).unwrap(); + assert_eq!(receiver.try_recv().unwrap(), id); + assert_eq!( + c.deny(&id, "again", Some("passkey")).unwrap_err().0, + "already_decided" + ); +} +#[test] +fn key_and_secret_persist() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = caller("root"); + let mut config = item(true); + config.transform_js = "inject".into(); + let i = c.create_item(config, &caller, "passkey").unwrap(); + c.set_field( + &i.id, + &i.fields[0].field_id, + "persistent", + &caller, + "passkey", + ) + .unwrap(); + drop(c); + assert_eq!( + std::fs::metadata(dir.path().join("cred.key")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let c = Cred::open(dir.path()).unwrap(); + let Outcome::Forward(p) = c.inject(parts("api.example"), &caller, &Fake).unwrap() else { + panic!("denied") + }; + assert!(p.headers["authorization"] == "persistent"); +} diff --git a/host/tests/cred_core.rs b/host/tests/cred_core.rs new file mode 100644 index 0000000..a5d09a4 --- /dev/null +++ b/host/tests/cred_core.rs @@ -0,0 +1,205 @@ +//! Ported rule boundaries and request/edit ledger regressions. +use sausage::cred::{ + edits::Edit, + items::*, + requests::{GrantRequest, Overrides}, + *, +}; +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 7, + conversations: vec!["root".into()], + }; + let i = c + .create_item( + Item { + id: String::new(), + name: "item".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, + "pk", + ) + .unwrap(); + (dir, c, caller, i) +} +#[test] +fn rule_matching_table() { + let mut rule:Rule=serde_json::from_value(serde_json::json!({"scheme":"https","hosts":["api.example.test","*.service.test"],"path_prefixes":["/v1/","/health"],"methods":["GET","POST"]})).unwrap(); + for (scheme, host, path, method, allowed) in [ + ("https", "api.example.test", "/v1/a", "GET", true), + ("HTTPS", "API.EXAMPLE.TEST", "/v1/a", "get", true), + ("https", "a.service.test", "/health", "POST", true), + ("https", "a.b.service.test", "/healthz", "POST", true), + ("https", "service.test", "/health", "GET", false), + ("https", "evilservice.test", "/health", "GET", false), + ("https", "a.service.test.evil", "/health", "GET", false), + ("https", "api.example.test.evil", "/v1/a", "GET", false), + ("http", "api.example.test", "/v1/a", "GET", false), + ("https", "api.example.test", "/V1/a", "GET", false), + ("https", "api.example.test", "/v1", "GET", false), + ("https", "api.example.test", "/v1/a", "DELETE", false), + ] { + assert_eq!(rule_matches(&rule, scheme, host, path, method), allowed); + } + rule.methods = None; + assert!(rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "DELETE" + )); + rule.methods = Some(vec![]); + assert!(!rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "GET" + )); + rule.methods = None; + rule.path_prefixes.clear(); + assert!(!rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "GET" + )); + rule.path_prefixes.push("/".into()); + rule.hosts.clear(); + assert!(!rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "GET" + )); +} +#[test] +fn revisions_needs_value_and_rename_are_metadata_only() { + let (_dir, c, caller, i) = fixture(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk") + .unwrap(); + let old = c.item_get(&i.id).unwrap(); + assert_eq!(old.version, 2); + assert!(!old.fields[0].needs_value); + assert!(old.fields[0].value.is_none()); + let mut new = old.clone(); + new.name = "renamed".into(); + new.fields[0].name = "renamed_field".into(); + let id = c + .file_edit_request( + &caller, + "rename", + Edit { + item: i.id.clone(), + base_version: 2, + config: new, + }, + ) + .unwrap(); + assert!(!c.edit_diff(&id).unwrap().discards_values); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .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); + assert_eq!(current.name, "renamed"); + assert_eq!(c.item_versions(&i.id).unwrap().len(), 2); +} +#[test] +fn stale_edits_leave_history_and_values_untouched() { + let (_dir, c, caller, i) = fixture(); + let id = c + .file_edit_request( + &caller, + "edit", + Edit { + item: i.id.clone(), + base_version: 1, + config: i.clone(), + }, + ) + .unwrap(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk") + .unwrap(); + assert_eq!( + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap_err() + .0, + "stale" + ); + assert_eq!(c.request_list().unwrap()[0].state, "pending"); + assert_eq!(c.item_versions(&i.id).unwrap().len(), 1); +} +#[test] +fn key_conflict_renewal_and_expiry() { + let (dir, c, caller, i) = fixture(); + let p = GrantRequest { + items: vec![i.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }; + let id = c.file_request(&caller, "key", p.clone()).unwrap(); + let mut other = p.clone(); + other.max_uses = Some(1); + assert_eq!( + c.file_request(&caller, "key", other).unwrap_err().0, + "key_conflict" + ); + c.deny(&id, "no", Some("pk")).unwrap(); + let next = c.file_request(&caller, "key", p).unwrap(); + assert_ne!(id, next); + assert_eq!(c.request_decisions().unwrap().len(), 1); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("UPDATE requests SET expires=0 WHERE id=?", [&next]) + .unwrap(); + let mut notification = c.subscribe(); + assert_eq!(c.expire_requests().unwrap(), vec![next.clone()]); + assert_eq!(notification.try_recv().unwrap(), next); + assert_eq!( + c.approve(&next, "yes", Overrides::default(), Some("pk")) + .unwrap_err() + .0, + "request_expired" + ); + assert!(c.grant_list().unwrap().is_empty()); +} +#[test] +fn typed_values_and_secret_configuration_fail_without_writes() { + let (_dir, c, caller, i) = fixture(); + assert_eq!( + c.set_field(&i.id, &i.fields[0].field_id, "line\nbreak", &caller, "pk") + .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, "pk").unwrap_err().0, + "secret_field" + ); + assert_eq!(c.item_list().unwrap().len(), 1); + assert!(c.item_versions(&i.id).unwrap().is_empty()); +} diff --git a/host/tests/cred_edits.rs b/host/tests/cred_edits.rs new file mode 100644 index 0000000..ab8beeb --- /dev/null +++ b/host/tests/cred_edits.rs @@ -0,0 +1,266 @@ +//! Explicit edit operations and atomic approval regression tests. +use sausage::cred::{ + edits::{Operation, OperationRequest}, + items::*, + requests::Overrides, + *, +}; +fn config(name: &str) -> Item { + Item { + id: String::new(), + name: name.into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "private".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::SshPrivateKey, + key_type: Some("es256".into()), + }], + } +} +#[test] +fn creation_key_generation_and_deletion_keep_secrets_out_of_history() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let id = c + .file_operation_request( + &caller, + "generate", + OperationRequest { + item: None, + base_version: None, + change: vec![Operation::GenerateEs256Item { + config: config("generated"), + }], + }, + ) + .unwrap(); + assert!(c.item_list().unwrap().is_empty()); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let item = c.item_list().unwrap().remove(0); + assert!(!item.fields[0].needs_value); + assert!(item.fields[0].value.is_none()); + assert!( + !serde_json::to_string(&c.request_diff(&id).unwrap()) + .unwrap() + .contains("PRIVATE KEY") + ); + let id = c + .file_operation_request( + &caller, + "delete", + OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change: vec![Operation::DeleteItem], + }, + ) + .unwrap(); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .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(); + assert_eq!( + db.query_row("SELECT COUNT(*) FROM fields", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); +} +#[test] +fn operation_order_name_conflicts_and_stale_resolution() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let item = c.create_item(config("first"), &caller, "pk").unwrap(); + c.create_item(config("taken"), &caller, "pk").unwrap(); + let request = |change| OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change, + }; + assert_eq!( + c.file_operation_request( + &caller, + "conflict", + request(vec![Operation::SetName { + name: "taken".into() + }]) + ) + .unwrap_err() + .0, + "name_conflict" + ); + let id = c + .file_operation_request( + &caller, + "rename", + request(vec![ + Operation::SetName { + name: "intermediate".into(), + }, + Operation::SetName { + name: "last".into(), + }, + ]), + ) + .unwrap(); + let other = c + .file_operation_request( + &caller, + "other", + request(vec![Operation::SetFreeUse { enabled: true }]), + ) + .unwrap(); + c.approve(&other, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert!(c.edit_diff(&id).unwrap().stale); + assert_eq!( + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap_err() + .0, + "stale" + ); + c.resolve_stale(&id, 2).unwrap(); + assert!(!c.edit_diff(&id).unwrap().stale); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let current = c.item_get(&item.id).unwrap(); + assert_eq!(current.name, "last"); + assert!(current.free_use); +} +#[test] +fn field_removal_revert_and_public_visibility() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let mut initial = config("item"); + initial.fields[0].field_type = types::FieldType::Text; + initial.fields[0].key_type = None; + initial.fields.push(Field { + field_id: String::new(), + name: "public".into(), + public: true, + value: Some("first".into()), + needs_value: false, + field_type: types::FieldType::Text, + key_type: None, + }); + let item = c.create_item(initial, &caller, "pk").unwrap(); + c.set_field( + &item.id, + &item.fields[0].field_id, + "SECRET_REMOVED", + &caller, + "pk", + ) + .unwrap(); + let current = c.item_get(&item.id).unwrap(); + let request = |change| OperationRequest { + item: Some(item.id.clone()), + base_version: Some(current.version), + change, + }; + let id = c + .file_operation_request( + &caller, + "remove", + request(vec![ + Operation::RemoveField { + field: item.fields[0].field_id.clone(), + }, + Operation::SetFieldValue { + field: item.fields[1].field_id.clone(), + value: "second".into(), + }, + ]), + ) + .unwrap(); + assert!(c.edit_diff(&id).unwrap().discards_values); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let id = c + .file_revert_request(&caller, "revert", &item.id, 3, 2) + .unwrap(); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .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); + assert_eq!(restored.fields[1].value.as_deref(), Some("second")); + let mut changed = restored.clone(); + changed.fields[1].public = false; + changed.fields[1].value = None; + assert_eq!( + c.file_edit_request( + &caller, + "visibility", + sausage::cred::edits::Edit { + item: item.id, + base_version: restored.version, + config: changed + } + ) + .unwrap_err() + .0, + "immutable_visibility" + ); + assert!(!format!("{:?}", c.item_list().unwrap()).contains("SECRET_REMOVED")); +} +#[test] +fn decision_failure_rolls_back_every_write() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let id = c + .file_operation_request( + &caller, + "create", + OperationRequest { + item: None, + base_version: None, + change: vec![Operation::CreateItem { + config: config("first"), + }], + }, + ) + .unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT, 'forced'); END;").unwrap(); + assert!( + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .is_err() + ); + assert!(c.item_list().unwrap().is_empty()); + assert!(c.audit_list().unwrap().is_empty()); + assert_eq!(c.request_list().unwrap()[0].state, "pending"); + db.execute_batch("DROP TRIGGER fail_decision;").unwrap(); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!(c.item_list().unwrap().len(), 1); +} diff --git a/host/tests/cred_grants.rs b/host/tests/cred_grants.rs new file mode 100644 index 0000000..b28d18a --- /dev/null +++ b/host/tests/cred_grants.rs @@ -0,0 +1,199 @@ +//! Grant expiry, revocation, validators and issuance boundaries from v2. +use sausage::cred::{ + grants::Grant, + inject::Outcome, + items::*, + script::{Limits, Script}, + *, +}; +struct Fake; +impl Script for Fake { + fn evaluate( + &self, + source: &str, + input: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + let ctx: serde_json::Value = serde_json::from_str(input).unwrap(); + assert_eq!(ctx["grant"]["conversation"], "root"); + Ok(if source == "deny" { "false" } else { "true" }.into()) + } +} +fn parts() -> http::request::Parts { + http::Request::get("https://api.test/path") + .body(()) + .unwrap() + .into_parts() + .0 +} +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 1, + conversations: vec!["root".into()], + }; + let i = c + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![Rule { + scheme: "https".into(), + hosts: vec!["api.test".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, c, caller, i) +} +fn grant(item: &Item) -> Grant { + 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: Some("work".into()), + } +} +#[test] +fn program_grant_covers_service_and_agent_but_not_another_program() { + let (_dir, c, _caller, _item) = fixture(); + let service = Caller { + pid: 10, + program: "/bin/service.wasm".into(), + conversations: vec![], + }; + let Outcome::Denied { request, .. } = c.inject(parts(), &service, &Fake).unwrap() else { + panic!("expected request") + }; + assert_eq!( + c.request_list().unwrap()[0].subject, + types::Subject::Program(service.program.clone()) + ); + c.approve( + &request, + "allow", + sausage::cred::requests::Overrides::default(), + None, + ) + .unwrap(); + let agent = Caller { + pid: 11, + program: service.program.clone(), + conversations: vec!["agent".into()], + }; + for caller in [&service, &agent] { + assert!(matches!( + c.inject(parts(), caller, &Fake).unwrap(), + Outcome::Forward(_) + )); + } + let other = Caller { + pid: 12, + program: "/bin/other.wasm".into(), + conversations: vec![], + }; + assert!(matches!( + c.inject(parts(), &other, &Fake).unwrap(), + Outcome::Denied { .. } + )); + let decisions = c.audit_list().unwrap(); + let row = decisions.iter().find(|r| r.event == "approved").unwrap(); + assert!(row.approver.is_none()); + assert_eq!(row.program, service.program); + assert!(row.conversation.is_none()); +} +#[test] +fn expiry_boundary_revoke_and_exhaustion() { + let (dir, c, caller, i) = fixture(); + let g = c.grant_create(grant(&i), &caller, Some("pk")).unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Fake).unwrap(), + Outcome::Forward(_) + )); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); + assert!(matches!( + c.inject(parts(), &caller, &Fake).unwrap(), + Outcome::Denied { .. } + )); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); + let mut unlimited = grant(&i); + unlimited.max_uses = None; + let g = c.grant_create(unlimited, &caller, Some("pk")).unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("UPDATE grants SET exp=0 WHERE id=?", [&g.id]) + .unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Fake).unwrap(), + Outcome::Denied { .. } + )); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); + c.revoke_subjects(&caller.conversations, &caller, Some("pk")) + .unwrap(); + assert!(c.grant_list().unwrap().iter().all(|g| g.revoked)); +} +#[test] +fn reject_bad_issuance_without_rows() { + let (_dir, c, caller, i) = fixture(); + let mut g = grant(&i); + g.max_uses = Some(0); + assert_eq!( + c.grant_create(g, &caller, Some("pk")).unwrap_err().0, + "invalid_uses" + ); + let mut g = grant(&i); + g.subject = types::Subject::Conversation(String::new()); + assert_eq!( + c.grant_create(g, &caller, Some("pk")).unwrap_err().0, + "invalid_subject" + ); + assert!(c.grant_list().unwrap().is_empty()); + assert_eq!(c.audit_list().unwrap().len(), 1); +} +#[test] +fn preflight_spends_nothing_and_validator_denial_spends_one() { + let (_dir, c, caller, i) = fixture(); + let mut g = grant(&i); + g.validator = Some("deny".into()); + let g = c.grant_create(g, &caller, Some("pk")).unwrap(); + let mut p = parts(); + p.headers + .insert("bad", http::HeaderValue::from_bytes(&[0xff]).unwrap()); + assert_eq!( + c.inject(p, &caller, &Fake).err().unwrap().0, + "invalid_headers" + ); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); + assert_eq!( + c.inject(parts(), &caller, &Fake).err().unwrap().0, + "validator_denied" + ); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); + let audit = c.audit_list().unwrap(); + let use_row = audit.last().unwrap(); + assert_eq!(use_row.event, "fetch.denied"); + assert_eq!(use_row.pid, caller.pid); + assert_eq!(use_row.conversation.as_deref(), Some("root")); + assert!( + audit + .iter() + .filter(|r| r.event == "grant.create") + .all(|r| r.approver.as_deref() == Some("pk")) + ); +} diff --git a/host/tests/cred_regressions.rs b/host/tests/cred_regressions.rs new file mode 100644 index 0000000..ddaf32d --- /dev/null +++ b/host/tests/cred_regressions.rs @@ -0,0 +1,265 @@ +//! Retained v2 item, grant, and transaction regressions over native SQLite. +use sausage::cred::{ + edits::{Delete, Edit}, + grants::Grant, + items::*, + requests::{GrantRequest, Overrides}, + script::{Limits, Script}, + *, +}; +struct Fake; +impl Script for Fake { + fn evaluate( + &self, + _: &str, + _: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + Ok("true".into()) + } +} +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let cred = Cred::open(dir.path()).unwrap(); + let caller = Caller { + pid: 3, + program: "/bin/tool.wasm".into(), + conversations: vec!["root".into()], + }; + let item = cred + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![Rule { + scheme: "https".into(), + hosts: vec!["api.test".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![ + Field { + field_id: String::new(), + name: "first".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }, + Field { + field_id: String::new(), + name: "second".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }, + ], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, cred, caller, item) +} +fn parts() -> http::request::Parts { + http::Request::get("https://api.test/") + .body(()) + .unwrap() + .into_parts() + .0 +} +fn grant(item: &Item) -> Grant { + 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, + } +} +#[test] +fn missing_values_and_wrong_master_spend_nothing() { + let (dir, cred, caller, item) = fixture(); + let grant = cred + .grant_create(grant(&item), &caller, Some("pk")) + .unwrap(); + assert_eq!( + cred.inject(parts(), &caller, &Fake).err().unwrap().0, + "needs_value" + ); + assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0); + for field in &item.fields { + cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + .unwrap(); + } + drop(cred); + std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap(); + let cred = Cred::open(dir.path()).unwrap(); + assert_eq!( + cred.inject(parts(), &caller, &Fake).err().unwrap().0, + "seal" + ); + assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0); +} +#[test] +fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { + let (dir, cred, caller, item) = fixture(); + for field in &item.fields { + cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + .unwrap(); + } + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let cipher = || { + let mut q = db + .prepare("SELECT ciphertext FROM fields ORDER BY id") + .unwrap(); + q.query_map([], |r| r.get::<_, Vec>(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + let before = cipher(); + let old = cred.item_get(&item.id).unwrap(); + let mut config = old.clone(); + config.fields[0].name = "second".into(); + config.fields[1].name = "first".into(); + let id = cred + .file_edit_request( + &caller, + "swap", + Edit { + item: item.id.clone(), + base_version: old.version, + config, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert!(cipher() == before); + let current = cred.item_get(&item.id).unwrap(); + let mut config = current.clone(); + config.fields.clear(); + let id = cred + .file_edit_request( + &caller, + "remove", + Edit { + item: item.id.clone(), + base_version: current.version, + config, + }, + ) + .unwrap(); + let history = cred.item_versions(&item.id).unwrap().len(); + let audit = cred.audit_list().unwrap().len(); + db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap(); + assert!( + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .is_err() + ); + assert!(cipher() == before); + assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); + assert_eq!(cred.item_versions(&item.id).unwrap().len(), history); + assert_eq!(cred.audit_list().unwrap().len(), audit); +} +#[test] +fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { + let (_dir, cred, caller, item) = fixture(); + for field in &item.fields { + cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + .unwrap(); + } + let current = cred.item_get(&item.id).unwrap(); + let id = cred + .file_delete_request( + &caller, + "delete", + Delete { + item: item.id.clone(), + base_version: current.version, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let id = cred + .file_revert_request( + &caller, + "restore", + &item.id, + current.version + 1, + current.version, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let restored = cred.item_get(&item.id).unwrap(); + assert_eq!(restored.id, item.id); + assert_eq!(restored.version, current.version + 2); + assert!( + restored + .fields + .iter() + .all(|f| f.needs_value && f.value.is_none()) + ); + assert_eq!(restored.fields[0].field_id, item.fields[0].field_id); +} +#[test] +fn typed_secret_entry_and_bundle_normalization() { + let (_dir, cred, caller, item) = fixture(); + let input = FieldInput { + value: "two\nlines", + field_type: Some(types::FieldType::Note), + key_type: None, + }; + cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk") + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + assert_eq!(current.fields[0].field_type, types::FieldType::Note); + assert!(current.fields[0].value.is_none()); + assert_eq!( + cred.item_versions(&item.id).unwrap()[0].config.fields[0].field_type, + types::FieldType::Text + ); + let p = GrantRequest { + items: vec![item.name.clone(), item.id.clone()], + duration_secs: None, + max_uses: Some(1), + validator: None, + }; + let id = cred.file_request(&caller, "bundle", p).unwrap(); + let p = GrantRequest { + items: vec![item.id], + duration_secs: None, + max_uses: Some(1), + validator: None, + }; + assert_eq!(cred.file_request(&caller, "bundle", p).unwrap(), id); + cred.approve( + &id, + "yes", + Overrides { + unlimited: true, + ..Overrides::default() + }, + None, + ) + .unwrap(); + let g = cred.grant_list().unwrap().remove(0); + assert_eq!(g.items.len(), 1); + assert!(g.max_uses.is_none()); + assert!(g.exp.is_some()); +} diff --git a/host/tests/cred_replay.rs b/host/tests/cred_replay.rs new file mode 100644 index 0000000..7cd9d75 --- /dev/null +++ b/host/tests/cred_replay.rs @@ -0,0 +1,346 @@ +//! v2 keyed replay compares intent before current item validation or edit planning. +use sausage::cred::{ + edits::*, + items::*, + requests::{GrantRequest, Overrides}, + types::FieldType, + *, +}; +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let cred = Cred::open(dir.path()).unwrap(); + let caller = Caller { + pid: 1, + program: "/bin/tool".into(), + conversations: vec!["root".into()], + }; + let item = cred + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: None, + needs_value: true, + field_type: FieldType::Text, + key_type: None, + }], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, cred, caller, item) +} +#[test] +fn approved_operations_and_deletes_replay() { + let (_dir, cred, caller, item) = fixture(); + let request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change: vec![Operation::SetName { + name: "renamed".into(), + }], + }; + let id = cred + .file_operation_request(&caller, "edit", request.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "edit", request) + .unwrap(), + id + ); + let current = cred.item_get(&item.id).unwrap(); + let delete = Delete { + item: item.id, + base_version: current.version, + }; + let id = cred + .file_delete_request(&caller, "delete", delete.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_delete_request(&caller, "delete", delete).unwrap(), + id + ); +} +#[test] +fn approved_replacements_and_creations_replay() { + let (_dir, cred, caller, item) = fixture(); + let mut config = item.clone(); + config.name = "renamed".into(); + let edit = Edit { + item: item.id, + base_version: item.version, + config: config.clone(), + }; + let id = cred + .file_edit_request(&caller, "replace", edit.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_edit_request(&caller, "replace", edit).unwrap(), + id + ); + config.name = "new".into(); + let request = OperationRequest { + item: None, + base_version: None, + change: vec![Operation::CreateItem { config }], + }; + let id = cred + .file_operation_request(&caller, "create", request.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "create", request) + .unwrap(), + id + ); + assert_eq!(cred.item_list().unwrap().len(), 2); +} +#[test] +fn grant_defaults_normalize_and_pending_requests_replay_after_deletion() { + let (_dir, cred, caller, item) = fixture(); + let payload = GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: Some(String::new()), + }; + let id = cred.file_request(&caller, "grant", payload).unwrap(); + let payload = GrantRequest { + items: vec![item.id.clone()], + duration_secs: Some(3600), + max_uses: None, + validator: None, + }; + assert_eq!( + cred.file_request(&caller, "grant", payload.clone()) + .unwrap(), + id + ); + let delete = cred + .file_delete_request( + &caller, + "delete", + Delete { + item: item.id, + base_version: item.version, + }, + ) + .unwrap(); + cred.approve(&delete, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!(cred.file_request(&caller, "grant", payload).unwrap(), id); + assert!( + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .is_err() + ); + assert_eq!( + cred.request_list() + .unwrap() + .iter() + .find(|r| r.id == id) + .unwrap() + .state, + "pending" + ); +} +#[test] +fn values_are_checked_at_each_public_operation() { + let (_dir, cred, caller, item) = fixture(); + let mut config = item.clone(); + config.fields.push(Field { + field_id: String::new(), + name: "public".into(), + public: true, + value: Some("old".into()), + needs_value: false, + field_type: FieldType::Text, + key_type: None, + }); + let id = cred + .file_edit_request( + &caller, + "setup", + Edit { + item: item.id.clone(), + base_version: item.version, + config, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + let field = current + .fields + .iter() + .find(|f| f.name == "public") + .unwrap() + .field_id + .clone(); + let mut request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(current.version), + change: vec![ + Operation::SetFieldValue { + field: field.clone(), + value: "bad\nvalue".into(), + }, + Operation::SetFieldType { + field, + field_type: FieldType::Note, + key_type: None, + }, + ], + }; + assert_eq!( + cred.file_operation_request(&caller, "invalid", request.clone()) + .err() + .unwrap() + .0, + "invalid_value" + ); + assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); + request.change.reverse(); + let id = cred + .file_operation_request(&caller, "ordered", request) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.item_get(&item.id).unwrap().version, + current.version + 1 + ); +} +#[test] +fn secret_retyping_checks_each_operation_only_on_approval() { + let (_dir, cred, caller, item) = fixture(); + let field = &item.fields[0].field_id; + cred.set_field_input( + &item.id, + field, + &FieldInput { + value: "VALUE_SENTINEL\nTAIL_SENTINEL", + field_type: Some(FieldType::Note), + key_type: None, + }, + &caller, + "pk", + ) + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + let request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(current.version), + change: vec![ + Operation::SetFieldType { + field: field.clone(), + field_type: FieldType::Text, + key_type: None, + }, + Operation::SetFieldType { + field: field.clone(), + field_type: FieldType::Note, + key_type: None, + }, + ], + }; + let id = cred + .file_operation_request(&caller, "types", request) + .unwrap(); + assert!( + !cred.request_list().unwrap()[0] + .payload + .to_string() + .contains("VALUE_SENTINEL") + ); + assert_eq!( + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .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, "pk") + .unwrap(); + let next = cred.item_get(&item.id).unwrap(); + assert_eq!(next.version, current.version + 1); + assert_eq!(cred.item_versions(&item.id).unwrap().len(), history + 1); + cred.resolve_stale(&id, next.version).unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); +} +#[test] +fn stale_rebases_keep_original_keyed_intent() { + let (_dir, cred, caller, item) = fixture(); + let request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change: vec![Operation::SetName { + name: "renamed".into(), + }], + }; + let id = cred + .file_operation_request(&caller, "rebase", request.clone()) + .unwrap(); + cred.set_field(&item.id, &item.fields[0].field_id, "opaque", &caller, "pk") + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + cred.resolve_stale(&id, current.version).unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "rebase", request.clone()) + .unwrap(), + id + ); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "rebase", request) + .unwrap(), + id + ); + assert!(!cred.item_get(&item.id).unwrap().fields[0].needs_value); +} +#[test] +fn operation_order_is_part_of_key_identity() { + let (_dir, cred, caller, item) = fixture(); + let mut request = OperationRequest { + item: Some(item.id), + base_version: Some(item.version), + change: vec![ + Operation::SetName { + name: "renamed".into(), + }, + Operation::SetFreeUse { enabled: true }, + ], + }; + cred.file_operation_request(&caller, "ordered", request.clone()) + .unwrap(); + request.change.reverse(); + assert_eq!( + cred.file_operation_request(&caller, "ordered", request) + .err() + .unwrap() + .0, + "key_conflict" + ); +} diff --git a/host/tests/cred_summary.rs b/host/tests/cred_summary.rs new file mode 100644 index 0000000..fef420b --- /dev/null +++ b/host/tests/cred_summary.rs @@ -0,0 +1,84 @@ +//! Public approval text and template presets from v2, without vault migrations. +use sausage::cred::{ + edits::Operation, + items::{Field, FieldInput}, + summary::*, + types::FieldType, +}; +#[test] +fn durations_use_plain_language() { + for (seconds, expected) in [ + (0, "0 seconds"), + (1, "1 second"), + (60, "1 minute"), + (3600, "1 hour"), + (93604, "1 day 2 hours 4 seconds"), + ] { + assert_eq!(human_duration(seconds), expected); + } +} +#[test] +fn summaries_never_include_field_values_or_script_sources() { + let value = "SECRET_SUMMARY_SENTINEL"; + for operation in [ + Operation::SetFieldValue { + field: "id".into(), + value: value.into(), + }, + Operation::SetTransformJs { + value: value.into(), + }, + Operation::AddField { + field: Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: Some(value.into()), + needs_value: true, + field_type: FieldType::Text, + key_type: None, + }, + }, + ] { + assert!(!operation_summary(&operation).contains(value)); + } + let input = FieldInput { + value, + field_type: None, + key_type: None, + }; + assert!(!format!("{input:?}").contains(value)); +} +#[test] +fn summary_names_are_inert_and_stay_on_one_line() { + let text = operation_summary(&Operation::SetName { + name: "&*\nnext".into(), + }); + assert!(!text.contains('<')); + assert!(!text.contains('\n')); + assert!(text.contains("<x>&\\* next")); +} +#[test] +fn presets_preserve_headers_and_utf8_basic_encoding() { + assert!( + script("bearer") + .unwrap() + .contains("'Bearer ' + get('token')") + ); + assert!( + script("basic") + .unwrap() + .contains("unescape(encodeURIComponent") + ); + assert!( + script("header:x-api-key") + .unwrap() + .contains("const name = \"x-api-key\"") + ); + assert!( + script("query:access_token") + .unwrap() + .contains("url.query.filter") + ); + assert_eq!(script("unknown").unwrap_err().0, "invalid_preset"); +}