From 1dc09bc418e8b392a90c358061a831154b84faed Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 19:04:42 +0000 Subject: [PATCH 1/6] cred: add passkey challenges and encrypted secret entry Validation remains blocked by rusqlite u64 conversions in cred/grants.rs, which T19 owns. --- host/src/cred/entry.rs | 86 +++++++++++++ host/src/cred/webauthn.rs | 247 ++++++++++++++++++++++++++++++++++++ host/tests/cred_entry.rs | 62 +++++++++ host/tests/cred_webauthn.rs | 124 ++++++++++++++++++ 4 files changed, 519 insertions(+) create mode 100644 host/src/cred/entry.rs create mode 100644 host/src/cred/webauthn.rs create mode 100644 host/tests/cred_entry.rs create mode 100644 host/tests/cred_webauthn.rs diff --git a/host/src/cred/entry.rs b/host/src/cred/entry.rs new file mode 100644 index 0000000..59a24dc --- /dev/null +++ b/host/src/cred/entry.rs @@ -0,0 +1,86 @@ +//! Browser secret entry: ECDH P-256, HKDF-SHA256, AES-256-GCM. +//! JSON envelopes contain byte arrays: `ephemeral_public_key` (65-byte SEC1 +//! uncompressed point), `iv` (12 bytes), and `ciphertext` (includes the GCM tag). +//! HKDF uses an empty salt and info `sausage/secret-entry/v1`. There is no AAD. +//! The binding hash is lowercase hex SHA-256(ephemeral_public_key || iv || ciphertext). +//! The public key uses the same SEC1 format for WebCrypto's `raw` import. +use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; +use anyhow::{Result, bail}; +use hkdf::Hkdf; +use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path}; + +#[derive(Serialize, Deserialize)] +pub struct Envelope { + pub ephemeral_public_key: Vec, + pub iv: [u8; 12], + pub ciphertext: Vec, +} + +impl Envelope { + pub fn sha256(&self) -> String { + let mut hash = Sha256::new(); + hash.update(&self.ephemeral_public_key); + hash.update(self.iv); + hash.update(&self.ciphertext); + format!("{:x}", hash.finalize()) + } +} + +pub struct EntryKey(SecretKey); + +impl EntryKey { + /// The caller supplies a path in the host folder, never in the world. + pub fn open(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let key = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + file.write_all(&key.to_bytes())?; + file.sync_all()?; + return Ok(Self(key)); + } + Err(e) => return Err(e.into()), + }; + Ok(Self( + SecretKey::from_slice(&bytes).map_err(|_| anyhow::anyhow!("invalid entry key"))?, + )) + } + + pub fn public_key(&self) -> Vec { + self.0 + .public_key() + .to_encoded_point(false) + .as_bytes() + .to_vec() + } + + /// The caller must first verify a SecretEntry assertion and compare its + /// item, field, and envelope hash. This method does not authorize a write. + pub fn decrypt(&self, envelope: &Envelope) -> Result> { + if envelope.ephemeral_public_key.len() != 65 || envelope.ephemeral_public_key[0] != 4 { + bail!("invalid secret envelope"); + } + let peer = PublicKey::from_sec1_bytes(&envelope.ephemeral_public_key) + .map_err(|_| anyhow::anyhow!("invalid secret envelope"))?; + let shared = diffie_hellman(self.0.to_nonzero_scalar(), peer.as_affine()); + let mut key = [0u8; 32]; + Hkdf::::new(None, shared.raw_secret_bytes()) + .expand(b"sausage/secret-entry/v1", &mut key) + .map_err(|_| anyhow::anyhow!("invalid secret envelope"))?; + Aes256Gcm::new_from_slice(&key) + .expect("AES key length") + .decrypt( + Nonce::from_slice(&envelope.iv), + envelope.ciphertext.as_ref(), + ) + .map_err(|_| anyhow::anyhow!("invalid secret envelope")) + } +} diff --git a/host/src/cred/webauthn.rs b/host/src/cred/webauthn.rs new file mode 100644 index 0000000..f40417f --- /dev/null +++ b/host/src/cred/webauthn.rs @@ -0,0 +1,247 @@ +//! Passkey enrollment and single-use assertions for host-owned bindings. +//! The caller supplies cred.sqlite and serializes access to this manager. +//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions +//! must bind the exact new label. Neither decision nor secret-entry assertions +//! authorize enrollment. Without passkeys, the host is unlocked and permits +//! first registration without a gate. Restart drops all pending challenges. +use anyhow::{Result, bail}; +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; +use webauthn_rs::prelude::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Binding { + Decision { + request_id: String, + decision: String, + overrides: serde_json::Value, + }, + SecretEntry { + item: String, + field: String, + ciphertext_sha256: String, + }, + Enrollment { + label: String, + }, +} + +#[derive(Serialize, Deserialize)] +pub struct Gate { + pub id: String, + pub response: PublicKeyCredential, +} + +#[derive(Serialize, Deserialize)] +pub struct Challenge { + pub id: String, + pub options: T, +} + +struct Pending { + deadline: Instant, + value: T, +} + +pub struct Passkeys<'a> { + db: &'a Connection, + webauthn: Webauthn, + registrations: HashMap>, + assertions: HashMap>, +} + +impl<'a> Passkeys<'a> { + pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result { + let origin = Url::parse(origin)?; + let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?; + db.execute_batch( + "CREATE TABLE IF NOT EXISTS passkey_credentials ( + id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)", + )?; + Ok(Self { + db, + webauthn, + registrations: HashMap::new(), + assertions: HashMap::new(), + }) + } + + /// Database errors leave the manager locked. + pub fn is_unlocked(&self) -> bool { + self.db + .query_row( + "SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)", + [], + |row| row.get(0), + ) + .unwrap_or(false) + } + + fn credentials(&self) -> Result> { + let mut stmt = self + .db + .prepare("SELECT credential FROM passkey_credentials")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect() + } + + pub fn start_registration( + &mut self, + label: String, + gate: Option, + ) -> Result> { + let gated = gate.is_some(); + match gate { + None if !self.is_unlocked() => bail!("enrollment assertion required"), + None => {} + Some(Gate { id, response }) => { + let (_, binding) = self.finish_assertion(&id, response)?; + if binding + != (Binding::Enrollment { + label: label.clone(), + }) + { + bail!("invalid enrollment gate"); + } + } + } + let exclude = self + .credentials()? + .iter() + .map(|p| p.cred_id().clone()) + .collect(); + let (options, state) = self.webauthn.start_passkey_registration( + Uuid::new_v4(), + &label, + &label, + Some(exclude), + )?; + self.registrations + .retain(|_, p| p.deadline > Instant::now()); + let id = Uuid::new_v4().to_string(); + self.registrations.insert( + id.clone(), + Pending { + deadline: Instant::now() + Duration::from_secs(300), + value: (label, gated, state), + }, + ); + Ok(Challenge { id, options }) + } + + pub fn finish_registration( + &mut self, + id: &str, + response: RegisterPublicKeyCredential, + ) -> Result { + let (label, gated, state) = take(&mut self.registrations, id)?; + if !gated && !self.is_unlocked() { + bail!("enrollment assertion required"); + } + let credential = self + .webauthn + .finish_passkey_registration(&response, &state)?; + self.db.execute( + "INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)", + params![ + serde_json::to_string(credential.cred_id())?, + label, + serde_json::to_string(&credential)? + ], + )?; + Ok(credential) + } + + pub fn start_assertion( + &mut self, + binding: Binding, + ) -> Result> { + let (options, state) = self + .webauthn + .start_passkey_authentication(&self.credentials()?)?; + self.assertions.retain(|_, p| p.deadline > Instant::now()); + let id = Uuid::new_v4().to_string(); + self.assertions.insert( + id.clone(), + Pending { + deadline: Instant::now() + Duration::from_secs(300), + value: (binding, state), + }, + ); + Ok(Challenge { id, options }) + } + + pub fn finish_assertion( + &mut self, + id: &str, + response: PublicKeyCredential, + ) -> Result<(CredentialID, Binding)> { + let (binding, state) = take(&mut self.assertions, id)?; + let result = self + .webauthn + .finish_passkey_authentication(&response, &state)?; + let mut credentials = self.credentials()?; + let credential = credentials + .iter_mut() + .find(|p| p.cred_id() == result.cred_id()) + .ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?; + // Check the latest stored counter too. Another outstanding assertion may + // contain an older snapshot of this credential. + let stored = serde_json::to_value(&*credential)?; + let counter = stored["cred"]["counter"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("invalid stored passkey counter"))?; + if (counter > 0 || result.counter() > 0) && u64::from(result.counter()) <= counter { + bail!("passkey counter did not increase"); + } + credential.update_credential(&result); + self.db.execute( + "UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2", + params![ + serde_json::to_string(credential)?, + serde_json::to_string(result.cred_id())? + ], + )?; + Ok((result.cred_id().clone(), binding)) + } +} + +fn take(map: &mut HashMap>, id: &str) -> Result { + let pending = map + .remove(id) + .ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?; + if pending.deadline <= Instant::now() { + bail!("expired challenge"); + } + Ok(pending.value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expired_assertion() -> Result<()> { + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + let db = Connection::open_in_memory()?; + let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000")?; + let start = host.start_registration("test".into(), None)?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let origin = Url::parse("http://localhost:8000")?; + let response = auth.do_registration(origin.clone(), start.options)?; + host.finish_registration(&start.id, response)?; + let start = host.start_assertion(Binding::Enrollment { + label: "next".into(), + })?; + let response = auth.do_authentication(origin, start.options)?; + host.assertions.get_mut(&start.id).unwrap().deadline = Instant::now(); + assert!(host.finish_assertion(&start.id, response).is_err()); + assert!(!host.assertions.contains_key(&start.id)); + Ok(()) + } +} diff --git a/host/tests/cred_entry.rs b/host/tests/cred_entry.rs new file mode 100644 index 0000000..12bb86c --- /dev/null +++ b/host/tests/cred_entry.rs @@ -0,0 +1,62 @@ +//! WebCrypto interoperability and host key persistence for secret entry. +use deno_core::{JsRuntime, RuntimeOptions}; +use sausage::cred::entry::{EntryKey, Envelope}; +use std::{os::unix::fs::PermissionsExt, sync::Arc}; + +#[tokio::test(flavor = "current_thread")] +async fn webcrypto_entry() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("entry.key"); + let key = EntryKey::open(&path)?; + assert_eq!( + std::fs::metadata(&path)?.permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(key.public_key(), EntryKey::open(&path)?.public_key()); + let mut runtime = JsRuntime::new(RuntimeOptions { + extensions: vec![ + deno_webidl::deno_webidl::init(), + deno_web::deno_web::init( + Arc::new(deno_web::BlobStore::default()), + None, + false, + Default::default(), + ), + deno_crypto::deno_crypto::init(None), + ], + ..Default::default() + }); + runtime.execute_script( + "crypto.js", + "globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto;", + )?; + runtime.execute_script("encrypt.js", format!(r#" + globalThis.done = (async () => {{ + const host = await crypto.subtle.importKey('raw', new Uint8Array({}), {{name:'ECDH', namedCurve:'P-256'}}, false, []); + const pair = await crypto.subtle.generateKey({{name:'ECDH', namedCurve:'P-256'}}, true, ['deriveBits']); + const bits = await crypto.subtle.deriveBits({{name:'ECDH', public:host}}, pair.privateKey, 256); + const material = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']); + const info = new Uint8Array(Array.from('sausage/secret-entry/v1', c => c.charCodeAt(0))); + const aes = await crypto.subtle.deriveKey({{name:'HKDF', hash:'SHA-256', salt:new Uint8Array(), info}}, material, {{name:'AES-GCM', length:256}}, false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({{name:'AES-GCM', iv}}, aes, new Uint8Array(Array.from('browser-only-value', c => c.charCodeAt(0)))); + globalThis.envelope = JSON.stringify({{ephemeral_public_key:Array.from(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), iv:Array.from(iv), ciphertext:Array.from(new Uint8Array(ciphertext))}}); + }})(); + "#, serde_json::to_string(&key.public_key())?))?; + runtime.run_event_loop(Default::default()).await?; + let value = runtime.execute_script("result.js", "envelope")?; + let text = { + deno_core::scope!(scope, &mut runtime); + let value = deno_core::v8::Local::new(scope, value); + value.to_rust_string_lossy(scope) + }; + let mut envelope: Envelope = serde_json::from_str(&text)?; + let expected = b"browser-only-value"; + assert!(key.decrypt(&envelope)? == expected); + let hash = envelope.sha256(); + envelope.ciphertext[0] ^= 1; + assert_ne!(hash, envelope.sha256()); + let error = key.decrypt(&envelope).unwrap_err().to_string(); + assert!(!error.contains(std::str::from_utf8(expected)?)); + Ok(()) +} diff --git a/host/tests/cred_webauthn.rs b/host/tests/cred_webauthn.rs new file mode 100644 index 0000000..da078a0 --- /dev/null +++ b/host/tests/cred_webauthn.rs @@ -0,0 +1,124 @@ +//! Software-authenticator coverage for enrollment gates and bound assertions. +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use sausage::cred::webauthn::{Binding, Gate, Passkeys}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use webauthn_rs::prelude::*; + +fn binding() -> Binding { + Binding::Decision { + request_id: "request-1".into(), + decision: "approve".into(), + overrides: serde_json::json!({}), + } +} + +#[test] +fn enrollment_and_assertions() -> anyhow::Result<()> { + let db = rusqlite::Connection::open_in_memory()?; + let origin = Url::parse("http://localhost:8000")?; + let mut host = Passkeys::new(&db, "localhost", origin.as_str())?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + assert!(host.is_unlocked()); + let start = host.start_registration("first".into(), None)?; + let racing = host.start_registration("racing".into(), None)?; + let response = auth.do_registration(origin.clone(), start.options)?; + host.finish_registration(&start.id, response)?; + assert!(!host.is_unlocked()); + assert!(host.start_registration("second".into(), None).is_err()); + let response = auth.do_registration(origin.clone(), racing.options)?; + assert!(host.finish_registration(&racing.id, response).is_err()); + let gate = host.start_assertion(Binding::Enrollment { + label: "second".into(), + })?; + let response = auth.do_authentication(origin.clone(), gate.options)?; + let start = host.start_registration( + "second".into(), + Some(Gate { + id: gate.id, + response, + }), + )?; + let response = auth.do_registration(origin.clone(), start.options)?; + host.finish_registration(&start.id, response)?; + + let start = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), start.options)?; + let replay = serde_json::to_value(&response)?; + assert_eq!(host.finish_assertion(&start.id, response)?.1, binding()); + assert!( + host.finish_assertion(&start.id, serde_json::from_value(replay)?) + .is_err() + ); + + let gate = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), gate.options)?; + assert!( + host.start_registration( + "unauthorized".into(), + Some(Gate { + id: gate.id, + response + }) + ) + .is_err() + ); + + drop(host); + let mut host = Passkeys::new(&db, "localhost", origin.as_str())?; + let older = host.start_assertion(binding())?; + let newer = host.start_assertion(binding())?; + let old_response = auth.do_authentication(origin.clone(), older.options)?; + let new_response = auth.do_authentication(origin.clone(), newer.options)?; + host.finish_assertion(&newer.id, new_response)?; + assert!(host.finish_assertion(&older.id, old_response).is_err()); + + // Sign a different, valid random challenge with the same credential. + let a = host.start_assertion(binding())?; + let b = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), b.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + let a = host.start_assertion(binding())?; + let response = auth.do_authentication(Url::parse("http://localhost:8001")?, a.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + let mut a = host.start_assertion(binding())?; + a.options.public_key.user_verification = + serde_json::from_value(serde_json::json!("discouraged"))?; + let response = auth.do_authentication(origin.clone(), a.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + for field in ["signature", "authenticatorData"] { + let a = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), a.options)?; + let mut json = serde_json::to_value(response)?; + let mut bytes = URL_SAFE_NO_PAD.decode(json["response"][field].as_str().unwrap())?; + let index = if field == "signature" { + bytes.len() - 1 + } else { + 0 + }; + bytes[index] ^= 1; // Preserve signature encoding, or change rpIdHash. + json["response"][field] = URL_SAFE_NO_PAD.encode(bytes).into(); + assert!( + host.finish_assertion(&a.id, serde_json::from_value(json)?) + .is_err() + ); + } + let foreign_db = rusqlite::Connection::open_in_memory()?; + let mut foreign = Passkeys::new(&foreign_db, "localhost", origin.as_str())?; + let mut foreign_auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let registration = foreign.start_registration("foreign".into(), None)?; + let response = foreign_auth.do_registration(origin.clone(), registration.options)?; + let credential = foreign.finish_registration(®istration.id, response)?; + let mut a = host.start_assertion(binding())?; + a.options.public_key.allow_credentials[0].id = credential.cred_id().to_vec().into(); + let response = foreign_auth.do_authentication(origin.clone(), a.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + let a = host.start_assertion(binding())?; + let response = auth.do_authentication(origin, a.options)?; + db.execute("DELETE FROM passkey_credentials", [])?; + assert!(host.finish_assertion(&a.id, response).is_err()); + Ok(()) +} From 9e7e5d7b2a25a3304ae20e42f4dd07ccce14e2cb Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 19:05:07 +0000 Subject: [PATCH 2/6] cred: add pure scripts, signing, and OAuth refresh; validation blocked --- Cargo.lock | 599 +++++++++++++++++++++++++++++++++++--- host/Cargo.toml | 19 ++ host/src/cred/keys.rs | 305 +++++++++++++++++++ host/src/cred/mod.rs | 33 +++ host/src/cred/oauth2.rs | 119 ++++++++ host/src/cred/sign.rs | 61 ++++ host/src/cred/v8.rs | 196 +++++++++++++ host/tests/cred_oauth2.rs | 100 +++++++ host/tests/cred_sign.rs | 283 ++++++++++++++++++ host/tests/cred_v8.rs | 217 ++++++++++++++ 10 files changed, 1888 insertions(+), 44 deletions(-) create mode 100644 host/src/cred/keys.rs create mode 100644 host/src/cred/oauth2.rs create mode 100644 host/src/cred/sign.rs create mode 100644 host/src/cred/v8.rs create mode 100644 host/tests/cred_oauth2.rs create mode 100644 host/tests/cred_sign.rs create mode 100644 host/tests/cred_v8.rs diff --git a/Cargo.lock b/Cargo.lock index 7297307..7d50be6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,8 +219,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl 0.2.0", "displaydoc", "nom", "num-traits", @@ -241,6 +257,18 @@ dependencies = [ "synstructure 0.12.6", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + [[package]] name = "asn1-rs-impl" version = "0.1.0" @@ -252,6 +280,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ast_node" version = "5.0.0" @@ -275,6 +314,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -346,6 +407,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -368,6 +435,28 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "better_scoped_tls" version = "1.0.1" @@ -383,7 +472,7 @@ version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -403,7 +492,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -451,6 +540,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -505,6 +600,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "borsh" version = "1.8.1" @@ -590,6 +695,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -729,6 +840,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -740,6 +862,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -758,6 +893,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout", + "zeroize", ] [[package]] @@ -1268,6 +1404,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1313,7 +1450,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f7c1384d87fc0a6439a065312fbef8f6ac6128689dbc2831b28b3a1d4f3a4e6" dependencies = [ - "base64", + "base64 0.22.1", "capacity_builder", "deno_error", "deno_media_type", @@ -1356,7 +1493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ad7d5294f640fd99db08c455e2453c9860b1fa728b76fbdacf0eed3e308d48a" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bit-set 0.8.0", "bit-vec 0.8.0", "boxed_error", @@ -1401,7 +1538,7 @@ dependencies = [ "aes-kw", "argon2", "aws-lc-rs", - "base64", + "base64 0.22.1", "cbc", "const-oid 0.9.6", "ctr", @@ -1488,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "752387783c99f75ea6962bb88af3735baba9f4a9e66716eacd39586db0e28d99" dependencies = [ "async-compression", - "base64", + "base64 0.22.1", "bytes", "data-url", "deno_core", @@ -1632,7 +1769,7 @@ dependencies = [ "tokio-vsock", "url", "web-transport-proto", - "x509-parser", + "x509-parser 0.15.1", ] [[package]] @@ -1882,7 +2019,21 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" dependencies = [ - "asn1-rs", + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", "displaydoc", "nom", "num-bigint", @@ -2076,6 +2227,30 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "ed448" version = "0.5.0" @@ -2331,6 +2506,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2483,7 +2673,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags", + "bitflags 2.13.2", "debugid", "rustc-hash 2.1.3", "serde", @@ -2619,6 +2809,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash2curve" version = "0.14.0" @@ -2680,6 +2881,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -3437,7 +3644,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3450,7 +3657,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3500,6 +3707,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -3578,7 +3796,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" dependencies = [ - "asn1-rs", + "asn1-rs 0.5.2", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", ] [[package]] @@ -3603,12 +3830,59 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.32.0" @@ -3657,7 +3931,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry", "opentelemetry_sdk", @@ -3790,12 +4064,27 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3979,6 +4268,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -4080,8 +4380,8 @@ checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bit-set 0.11.1", "bit-vec 0.10.1", - "bitflags", - "chacha20", + "bitflags 2.13.2", + "chacha20 0.10.2", "core_detect", "num-traits", "rand 0.10.3", @@ -4266,7 +4566,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4359,7 +4659,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -4471,6 +4771,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", + "sha2", "signature 2.2.0", "spki 0.7.3", "subtle", @@ -4493,7 +4794,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4544,7 +4845,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4557,7 +4858,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -4690,8 +4991,11 @@ dependencies = [ name = "sausage" version = "0.3.0" dependencies = [ + "aes-gcm", "anyhow", "async-trait", + "base64 0.22.1", + "chacha20poly1305", "clap", "deno_ast", "deno_core", @@ -4703,19 +5007,35 @@ dependencies = [ "deno_telemetry", "deno_web", "deno_webidl", + "ed25519-dalek", + "hex", + "hkdf", + "hmac", + "http", + "openssl", + "p256", + "rsa", "rusqlite", "rustls", "sausage-vfs", "serde", "serde_json", "serde_yaml_ng", + "sha2", + "ssh-key", "sys_traits", "tempfile", + "thiserror 2.0.21", "tokio", + "url", + "uuid", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", "wat", + "webauthn-authenticator-rs", + "webauthn-rs", + "zeroize", ] [[package]] @@ -4793,7 +5113,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -4846,6 +5166,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -5158,6 +5488,57 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20 0.9.1", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1 0.7.3", + "sha2", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5324,7 +5705,7 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a573a0c72850dec8d4d8085f152d5778af35a2520c3093b242d2d1d50776da7c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "is-macro", "num-bigint", "once_cell", @@ -5379,7 +5760,7 @@ version = "26.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e82f7747e052c6ff6e111fa4adeb14e33b46ee6e94fe5ef717601f651db48fc" dependencies = [ - "bitflags", + "bitflags 2.13.2", "either", "num-bigint", "rustc-hash 2.1.3", @@ -5416,7 +5797,7 @@ version = "27.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f1a51af1a92cd4904c073b293e491bbc0918400a45d58227b34c961dd6f52d7" dependencies = [ - "bitflags", + "bitflags 2.13.2", "either", "num-bigint", "phf 0.11.3", @@ -5502,7 +5883,7 @@ version = "33.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03de12e38e47ac1c96ac576f793ad37a9d7b16fbf4f2203881f89152f2498682" dependencies = [ - "base64", + "base64 0.22.1", "bytes-str", "indexmap", "once_cell", @@ -5685,6 +6066,17 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5967,6 +6359,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -6234,9 +6627,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6251,7 +6644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42a978ff11f15b24e5c05a7123cf2b68f41e763546699781a924ef4e2cf43a49" dependencies = [ "bindgen 0.72.1", - "bitflags", + "bitflags 2.13.2", "fslock", "gzip-header", "home", @@ -6268,7 +6661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3701ffb3692a24808503d17f758b0a3f688d2c451779247982566b5941033e63" dependencies = [ "bindgen 0.70.1", - "bitflags", + "bitflags 2.13.2", "cc", "paste", "temporal_capi", @@ -6447,7 +6840,7 @@ version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ - "bitflags", + "bitflags 2.13.2", "hashbrown 0.17.1", "indexmap", "semver", @@ -6460,7 +6853,7 @@ version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ - "bitflags", + "bitflags 2.13.2", "indexmap", "semver", ] @@ -6484,7 +6877,7 @@ checksum = "4fbdd39f5c66f63ba6b5b31391b324b7ade7237d29c866cdb561330118ef436e" dependencies = [ "addr2line", "async-trait", - "bitflags", + "bitflags 2.13.2", "bumpalo", "bytes", "cc", @@ -6566,7 +6959,7 @@ version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "952086551d2041430a159fe4bda3471cfea3d52846b93c825829cea9dd01954d" dependencies = [ - "base64", + "base64 0.22.1", "directories-next", "log", "postcard", @@ -6706,7 +7099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cad5e8cef56790d4741b62f7b6c971af53b2ad4c2d1603f8ffe86872763147aa" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.2", "heck", "indexmap", "wit-component", @@ -6720,7 +7113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "284555904d42017e049fd57c05810b7975075c322481c045f99b07385fb7a5ba" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.13.2", "bytes", "cap-primitives", "futures", @@ -6829,6 +7222,107 @@ dependencies = [ "url", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser 9.0.0", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.5", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser 0.16.0", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "1.0.11" @@ -6868,7 +7362,7 @@ version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad3dcdc2980b91d0e7db6975caee4f553c6e37fd8e1464a38b09baceed3fe568" dependencies = [ - "bitflags", + "bitflags 2.13.2", "thiserror 2.0.21", "tracing", "wasmtime", @@ -7155,7 +7649,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "windows-sys 0.59.0", ] @@ -7165,7 +7659,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -7175,7 +7669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -7251,12 +7745,29 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" dependencies = [ - "asn1-rs", + "asn1-rs 0.5.2", "data-encoding", - "der-parser", + "der-parser 8.2.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.6.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", "rusticata-macros", "thiserror 1.0.69", "time", diff --git a/host/Cargo.toml b/host/Cargo.toml index 1c7244e..88863a7 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -33,7 +33,26 @@ wasmtime-wasi-http = "=49.0.3" serde = { version = "1.0.229", features = ["derive"] } serde_yaml_ng = "0.10.0" rustls = "0.23" +chacha20poly1305 = "0.10" +zeroize = "1" +hex = "0.4" +http = "1" +url = "2" +thiserror = "2" +base64 = "0.22" +hmac = "0.12" +sha2 = "0.10" +p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pem"] } +ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } +rsa = { version = "0.9", features = ["sha2"] } +aes-gcm = "0.10" +hkdf = "0.12" +ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rsa", "p256"] } +uuid = { version = "1.29.0", features = ["v4"] } +webauthn-rs = "0.5" +openssl = { version = "0.10.82", features = ["vendored"] } [dev-dependencies] tempfile = "3" wat = "1" +webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } diff --git a/host/src/cred/keys.rs b/host/src/cred/keys.rs new file mode 100644 index 0000000..b19d814 --- /dev/null +++ b/host/src/cred/keys.rs @@ -0,0 +1,305 @@ +//! Key readers and signature/public-key codecs. No grant or storage policy here. +use super::sign::{Result, SignResult, error, invalid_key}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use ed25519_dalek::{Signer, SigningKey}; +use hmac::{Hmac, Mac}; +use rsa::{ + pkcs1::DecodeRsaPrivateKey, + pkcs8::{DecodePrivateKey, EncodePublicKey}, + traits::PublicKeyParts, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use sha2::{Sha384, Sha512}; +use zeroize::Zeroizing; + +pub(super) enum Key { + Ed(SigningKey), + Ec(p256::ecdsa::SigningKey), + Rsa(rsa::RsaPrivateKey), + Mac(Zeroizing>), +} +fn decode(s: &str) -> Result>> { + Ok(Zeroizing::new( + STANDARD + .decode(s) + .or_else(|_| URL_SAFE_NO_PAD.decode(s)) + .map_err(|_| invalid_key())?, + )) +} +fn family(format: &str) -> &str { + match format { + "ed25519-raw" => "ed25519", + "rs256" | "rsa-sha2-256" | "rsa-sha2-512" => "rsa", + s if s.starts_with("hmac-") => "hmac", + s => s, + } +} +pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Result { + let value = value.trim(); + let family = family(format); + let key = if value.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") { + let key = ssh_key::PrivateKey::from_openssh(value).map_err(|_| invalid_key())?; + let key = if key.is_encrypted() { + key.decrypt(passphrase.ok_or_else(invalid_key)?) + .map_err(|_| invalid_key())? + } else { + key + }; + match key.key_data() { + ssh_key::private::KeypairData::Ed25519(k) => { + Key::Ed(SigningKey::from_bytes(&k.private.to_bytes())) + } + ssh_key::private::KeypairData::Ecdsa( + k @ ssh_key::private::EcdsaKeypair::NistP256 { .. }, + ) => Key::Ec( + p256::ecdsa::SigningKey::from_slice(k.private_key_bytes()) + .map_err(|_| invalid_key())?, + ), + ssh_key::private::KeypairData::Rsa(k) => { + // ssh-key 0.6.7's TryFrom<&RsaKeypair> accidentally passes p twice. + // Convert the parsed components explicitly, preserving p and q. + let int = |n: &ssh_key::Mpint| rsa::BigUint::try_from(n).map_err(|_| invalid_key()); + let key = rsa::RsaPrivateKey::from_components( + int(&k.public.n)?, + int(&k.public.e)?, + int(&k.private.d)?, + vec![int(&k.private.p)?, int(&k.private.q)?], + ) + .map_err(|_| invalid_key())?; + key.validate().map_err(|_| invalid_key())?; + Key::Rsa(key) + } + _ => return Err(invalid_key()), + } + } else if value.starts_with("-----BEGIN ") { + match family { + "ed25519" => Key::Ed(SigningKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?), + "es256" => { + let key = if value.starts_with("-----BEGIN EC PRIVATE KEY-----") { + p256::SecretKey::from_sec1_pem(value).map_err(|_| invalid_key())? + } else { + p256::SecretKey::from_pkcs8_pem(value).map_err(|_| invalid_key())? + }; + Key::Ec(key.into()) + } + "rsa" => Key::Rsa(if value.starts_with("-----BEGIN RSA PRIVATE KEY-----") { + rsa::RsaPrivateKey::from_pkcs1_pem(value).map_err(|_| invalid_key())? + } else { + rsa::RsaPrivateKey::from_pkcs8_pem(value).map_err(|_| invalid_key())? + }), + _ => return Err(invalid_key()), + } + } else if value.starts_with('{') { + let j: serde_json::Value = serde_json::from_str(value).map_err(|_| invalid_key())?; + let bytes = |name: &str| -> Result>> { + decode(j[name].as_str().ok_or_else(invalid_key)?) + }; + match j["kty"].as_str() { + Some("oct") => Key::Mac(bytes("k")?), + Some("OKP") if j["crv"] == "Ed25519" => { + let d = bytes("d")?; + let k = SigningKey::from_bytes(d.as_slice().try_into().map_err(|_| invalid_key())?); + if bytes("x")?.as_slice() != k.verifying_key().as_bytes() { + return Err(invalid_key()); + } + Key::Ed(k) + } + Some("EC") if j["crv"] == "P-256" => { + let k = + p256::ecdsa::SigningKey::from_slice(&bytes("d")?).map_err(|_| invalid_key())?; + let point = k.verifying_key().to_encoded_point(false); + if bytes("x")?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice() + || bytes("y")?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice() + { + return Err(invalid_key()); + } + Key::Ec(k) + } + Some("RSA") => { + let int = |name| -> Result { + Ok(rsa::BigUint::from_bytes_be(&bytes(name)?)) + }; + let k = rsa::RsaPrivateKey::from_components( + int("n")?, + int("e")?, + int("d")?, + vec![int("p")?, int("q")?], + ) + .map_err(|_| invalid_key())?; + k.validate().map_err(|_| invalid_key())?; + Key::Rsa(k) + } + _ => return Err(invalid_key()), + } + } else { + let bytes = decode(value)?; + match family { + "ed25519" => Key::Ed(SigningKey::from_bytes( + bytes.as_slice().try_into().map_err(|_| invalid_key())?, + )), + "es256" => { + Key::Ec(p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|_| invalid_key())?) + } + "hmac" => Key::Mac(bytes), + _ => return Err(invalid_key()), + } + }; + if !matches!( + (&key, family), + (Key::Ed(_), "ed25519") + | (Key::Ec(_), "es256") + | (Key::Rsa(_), "rsa") + | (Key::Mac(_), "hmac") + ) { + return Err(invalid_key()); + } + Ok(key) +} +pub(super) fn check(format: &str, padding: Option<&str>, hash: Option<&str>) -> Result<()> { + match format { + "ed25519" | "ed25519-raw" | "es256" | "hmac-sha256" | "hmac-sha384" | "hmac-sha512" + if padding.is_none() && hash.is_none() => + { + Ok(()) + } + "rs256" | "rsa-sha2-256" | "rsa-sha2-512" + if padding == Some("pkcs1v15") + && hash + == Some(if format == "rsa-sha2-512" { + "sha512" + } else { + "sha256" + }) => + { + Ok(()) + } + _ => Err(error( + "invalid_params", + "unknown algorithm or inconsistent padding/hash; RSA requires padding=pkcs1v15 and explicit sha256/sha512 matching the algorithm", + )), + } +} +pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result> { + Ok(match key { + Key::Ed(k) => k.sign(payload).to_bytes().to_vec(), + Key::Ec(k) => { + let s: p256::ecdsa::Signature = k.sign(payload); + s.to_bytes().to_vec() + } + Key::Rsa(k) => if format == "rsa-sha2-512" { + k.sign(rsa::Pkcs1v15Sign::new::(), &Sha512::digest(payload)) + } else { + k.sign(rsa::Pkcs1v15Sign::new::(), &Sha256::digest(payload)) + } + .map_err(|_| invalid_key())?, + Key::Mac(k) => { + macro_rules! mac { + ($h:ty) => {{ + let mut mac = Hmac::<$h>::new_from_slice(k).map_err(|_| invalid_key())?; + mac.update(payload); + mac.finalize().into_bytes().to_vec() + }}; + } + match format { + "hmac-sha384" => mac!(Sha384), + "hmac-sha512" => mac!(Sha512), + _ => mac!(Sha256), + } + } + }) +} +fn string(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} +fn mpint(out: &mut Vec, bytes: &[u8]) { + let bytes = &bytes[bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len())..]; + let mut positive = Vec::new(); + if bytes.first().is_some_and(|b| b & 128 != 0) { + positive.push(0); + } + positive.extend_from_slice(bytes); + string(out, &positive); +} +fn wire_signature(format: &str, signature: &[u8]) -> Result> { + let (algorithm, signature) = match format { + "ed25519" | "ed25519-raw" => ("ssh-ed25519", signature.to_vec()), + "rs256" | "rsa-sha2-256" => ("rsa-sha2-256", signature.to_vec()), + "rsa-sha2-512" => ("rsa-sha2-512", signature.to_vec()), + "es256" => { + let mut s = Vec::new(); + mpint(&mut s, &signature[..32]); + mpint(&mut s, &signature[32..]); + ("ecdsa-sha2-nistp256", s) + } + _ => { + return Err(error( + "invalid_params", + "SSH wire output requires an asymmetric key", + )); + } + }; + let mut out = Vec::new(); + string(&mut out, algorithm.as_bytes()); + string(&mut out, &signature); + Ok(out) +} +pub(super) fn output(format: &str, output: &str, signature: Vec) -> Result { + let (signature, bytes) = match output { + "raw" => (String::new(), Some(signature)), + "hex" => (hex::encode(signature), None), + "jws" => (URL_SAFE_NO_PAD.encode(signature), None), + "ssh" => (STANDARD.encode(wire_signature(format, &signature)?), None), + "base64" => (STANDARD.encode(signature), None), + _ => return Err(error("invalid_params", "unknown output format")), + }; + Ok(SignResult { signature, bytes }) +} +pub(super) fn public(key: &Key, output: &str) -> Result { + let raw = match key { + Key::Ed(k) => k.verifying_key().to_bytes().to_vec(), + Key::Ec(k) => k + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(), + Key::Rsa(k) => k + .to_public_key() + .to_public_key_der() + .map_err(|_| invalid_key())? + .as_bytes() + .to_vec(), + Key::Mac(_) => return Err(error("invalid_params", "symmetric keys have no public key")), + }; + Ok(match output { + "base64" => STANDARD.encode(&raw), + "hex" => hex::encode(&raw), + "pem" => match key { + Key::Ed(k) => k.verifying_key().to_public_key_pem(Default::default()), + Key::Ec(k) => k.verifying_key().to_public_key_pem(Default::default()), + Key::Rsa(k) => k.to_public_key().to_public_key_pem(Default::default()), + _ => unreachable!(), + }.map_err(|_| invalid_key())?, + "jwk" => match key { + Key::Ed(_) => json!({"kty":"OKP","crv":"Ed25519","x":URL_SAFE_NO_PAD.encode(&raw)}), + Key::Ec(_) => json!({"kty":"EC","crv":"P-256","x":URL_SAFE_NO_PAD.encode(&raw[1..33]),"y":URL_SAFE_NO_PAD.encode(&raw[33..])}), + Key::Rsa(k) => json!({"kty":"RSA","n":URL_SAFE_NO_PAD.encode(k.n().to_bytes_be()),"e":URL_SAFE_NO_PAD.encode(k.e().to_bytes_be())}), + _ => unreachable!(), + }.to_string(), + "ssh" => { + let mut out = Vec::new(); + match key { + Key::Ed(_) => { string(&mut out, b"ssh-ed25519"); string(&mut out, &raw); }, + Key::Ec(_) => { string(&mut out, b"ecdsa-sha2-nistp256"); string(&mut out, b"nistp256"); string(&mut out, &raw); }, + Key::Rsa(k) => { string(&mut out, b"ssh-rsa"); mpint(&mut out, &k.e().to_bytes_be()); mpint(&mut out, &k.n().to_bytes_be()); }, + _ => unreachable!(), + } + STANDARD.encode(out) + } + _ => return Err(error("invalid_params", "unknown public key output")), + }) +} diff --git a/host/src/cred/mod.rs b/host/src/cred/mod.rs index 282e060..5740c6c 100644 --- a/host/src/cred/mod.rs +++ b/host/src/cred/mod.rs @@ -1,3 +1,36 @@ //! The credentials manager (P2, plan §4.4): items, grants, requests and audit in the host, //! outside the world. Programs in the world never see secret values. +//! +//! `Cred::open` loads `cred.sqlite` and the host master key. Read methods return public snapshots. +//! `Caller` carries a binary path and the nearest-first conversation ancestry. +//! `types::Subject` binds grants to a conversation subtree or a binary path. +//! `file_request` normalizes item identities and replays keyed requests. Edit requests carry public diffs. +//! `approve` and `deny` trust the route layer's verified approver identity. +//! That identity can be `None` during bootstrap, before any passkey registration. +//! Each decision commits one SQLite transaction. `subscribe` reports committed decisions across threads. +//! Subscribers must read SQLite after a lag or restart. The durable task layer calls `expire_requests`. +//! `inject` transforms one request's URL and headers. The body stays outside this library. +//! An unmatched request forwards unchanged. An unauthorized match names the item and its filed request. +//! `audit_sign` records signature metadata without the signed payload. +pub mod entry; +pub mod webauthn; +pub mod store; +pub mod schema; +pub mod seal; +pub mod items; +pub mod types; +pub mod grants; +pub mod requests; +pub mod edits; +pub mod summary; +pub mod audit; +pub mod inject; +pub mod error; +pub use store::Cred; +pub use error::{Error, Result}; +pub use types::Caller; pub mod script; +pub mod keys; +pub mod sign; +pub mod oauth2; +pub mod v8; diff --git a/host/src/cred/oauth2.rs b/host/src/cred/oauth2.rs new file mode 100644 index 0000000..6ad2be0 --- /dev/null +++ b/host/src/cred/oauth2.rs @@ -0,0 +1,119 @@ +//! OAuth2 refresh exchange and a process-local cache keyed by item and version. +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Error; +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("OAuth refresh failed or returned an invalid response") + } +} +impl std::error::Error for Error {} + +/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes. +pub trait Http: Sync { + fn send( + &self, + request: &Request, + deadline: Instant, + ) -> impl std::future::Future>, Error>> + Send; +} +/// Contains credentials. Deliberately does not implement Debug or Serialize. +pub struct Request { + pub url: String, + pub method: &'static str, + pub headers: Vec<(&'static str, String)>, + pub body: Zeroizing, +} +pub fn request( + token_url: &str, + refresh_token: &str, + client_id: &str, + client_secret: &str, +) -> Result { + let target = url::Url::parse(token_url).map_err(|_| Error)?; + if target.scheme() != "https" + || !target.username().is_empty() + || target.password().is_some() + || target.fragment().is_some() + || [refresh_token, client_id, client_secret].contains(&"") + { + return Err(Error); + } + let body = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "refresh_token") + .append_pair("refresh_token", refresh_token) + .append_pair("client_id", client_id) + .append_pair("client_secret", client_secret) + .finish(); + Ok(Request { + url: token_url.into(), + method: "POST", + headers: vec![ + ("Content-Type", "application/x-www-form-urlencoded".into()), + ("Content-Length", body.len().to_string()), + ], + body: Zeroizing::new(body), + }) +} +#[derive(Clone)] +pub struct Token { + pub value: Zeroizing, + pub scopes: Option>, + pub expires: Instant, +} +pub fn decode(bytes: &[u8]) -> Result { + #[derive(serde::Deserialize)] + struct Response { + access_token: String, + token_type: String, + expires_in: u64, + scope: Option, + } + let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?; + if r.access_token.is_empty() + || !r.token_type.eq_ignore_ascii_case("bearer") + || r.expires_in == 0 + || http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err() + { + return Err(Error); + } + Ok(Token { + value: Zeroizing::new(r.access_token), + scopes: r + .scope + .map(|s| s.split_whitespace().map(str::to_owned).collect()), + expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)), + }) +} +/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here. +#[derive(Default)] +pub struct Cache { + tokens: BTreeMap<(String, i64), Token>, +} +impl Cache { + pub fn clear(&mut self) { + self.tokens.clear(); + } + pub async fn refresh( + &mut self, + item: &str, + version: i64, + request: &Request, + http: &impl Http, + deadline: Instant, + ) -> Result { + let key = (item.to_owned(), version); + if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) { + return Ok(t.clone()); + } + self.tokens.retain(|(id, _), _| id != item); + let token = decode(&http.send(request, deadline).await?)?; + self.tokens.insert(key, token.clone()); + Ok(token) + } +} diff --git a/host/src/cred/sign.rs b/host/src/cred/sign.rs new file mode 100644 index 0000000..c2c0a12 --- /dev/null +++ b/host/src/cred/sign.rs @@ -0,0 +1,61 @@ +//! Pure signing and public-key export over decrypted field values. No storage or grant policy. +use super::keys; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + pub kind: &'static str, + pub message: &'static str, +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message) + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; +pub(super) fn error(kind: &'static str, message: &'static str) -> Error { + Error { kind, message } +} +pub(super) fn invalid_key() -> Error { + error("invalid_key", "invalid signing key encoding or length") +} + +#[derive(Debug, serde::Serialize)] +pub struct SignResult { + pub signature: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes: Option>, +} + +/// Sign bytes. RSA requires explicit, matching padding and digest parameters. +pub fn sign( + value: &str, + passphrase: Option<&str>, + format: &str, + payload: &[u8], + output: &str, + padding: Option<&str>, + hash: Option<&str>, +) -> Result { + keys::check(format, padding, hash)?; + let key = keys::read(value, format, passphrase)?; + keys::output(format, output, keys::sign_key(&key, format, payload)?) +} + +/// Export only the public part of an asymmetric key. +pub fn public_key( + value: &str, + passphrase: Option<&str>, + format: &str, + output: &str, +) -> Result { + keys::public(&keys::read(value, format, passphrase)?, output) +} + +/// Generate a P-256 private key in PKCS#8 PEM format for ES256. +pub fn generate_es256() -> Result> { + use p256::pkcs8::EncodePrivateKey; + p256::SecretKey::random(&mut rsa::rand_core::OsRng) + .to_pkcs8_pem(Default::default()) + .map_err(|_| invalid_key()) +} diff --git a/host/src/cred/v8.rs b/host/src/cred/v8.rs new file mode 100644 index 0000000..f437791 --- /dev/null +++ b/host/src/cred/v8.rs @@ -0,0 +1,196 @@ +//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread. +use super::script::{Error, ErrorKind, Limits, Script}; +use deno_core::{JsRuntime, RuntimeOptions, v8}; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + time::Duration, +}; + +#[derive(Default)] +pub struct V8; +fn failure(kind: ErrorKind) -> Error { + Error { + kind, + message: match kind { + ErrorKind::Limit => "script time, memory or stack limit exceeded", + ErrorKind::Syntax => "invalid script or JSON", + ErrorKind::Throw => "script threw or rejected", + ErrorKind::NonJson => "result is not JSON", + ErrorKind::Engine => "script engine failure", + } + .into(), + } +} +impl Script for V8 { + fn evaluate( + &self, + source: &str, + input: &str, + export: Option<&str>, + limits: Limits, + ) -> Result { + let source = source.to_owned(); + let input = input.to_owned(); + let export = export.unwrap_or("default").to_owned(); + std::thread::spawn(move || evaluate(source, input, export, limits)) + .join() + .map_err(|_| failure(ErrorKind::Engine))? + } +} +fn evaluate( + source: String, + input: String, + export: String, + limits: Limits, +) -> Result { + let heap = limits + .max_memory_bytes + .unwrap_or(8 * 1024 * 1024) + .clamp(256 * 1024, 32 * 1024 * 1024); + let mut runtime = JsRuntime::new(RuntimeOptions { + // Bootstrap before imposing the caller's budget: V8 cannot recover from + // bootstrap OOM because the runtime cannot install its callback yet. + create_params: Some(v8::CreateParams::default().heap_limits(0, 32 * 1024 * 1024)), + ..Default::default() + }); + if runtime.v8_isolate().get_heap_statistics().total_heap_size() > heap { + return Err(failure(ErrorKind::Limit)); + } + // Removing a temporary callback sets the heap limit after bootstrap. + runtime.add_near_heap_limit_callback(|current, _| current); + runtime.remove_near_heap_limit_callback(heap); + let handle = runtime.v8_isolate().thread_safe_handle(); + let limited = Arc::new(AtomicBool::new(false)); + let flag = limited.clone(); + let heap_handle = handle.clone(); + runtime.add_near_heap_limit_callback(move |current, _| { + flag.store(true, Ordering::SeqCst); + heap_handle.terminate_execution(); + // V8 needs emergency headroom to unwind after termination. + current + 16 * 1024 * 1024 + }); + let (done, wait) = mpsc::channel(); + let flag = limited.clone(); + let watchdog = std::thread::spawn(move || { + if wait + .recv_timeout(Duration::from_millis( + limits.max_time_ms.unwrap_or(100).clamp(1, 1000), + )) + .is_err() + { + flag.store(true, Ordering::SeqCst); + handle.terminate_execution(); + } + }); + let task = async { + runtime + .execute_script("sandbox.js", BOOTSTRAP) + .map_err(|_| failure(ErrorKind::Engine))?; + let input = runtime + .execute_script( + "input.js", + format!("JSON.parse({})", serde_json::to_string(&input).unwrap()), + ) + .map_err(|_| failure(ErrorKind::Syntax))?; + let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap(); + let module = runtime + .load_main_es_module_from_code(&spec, source) + .await + .map_err(|_| failure(ErrorKind::Syntax))?; + let evaluated = runtime.mod_evaluate(module); + runtime + .with_event_loop_promise(evaluated, Default::default()) + .await + .map_err(|e| { + if e.to_string().contains("call stack") { + failure(ErrorKind::Limit) + } else { + failure(ErrorKind::Throw) + } + })?; + let ns = runtime + .get_module_namespace(module) + .map_err(|_| failure(ErrorKind::Engine))?; + { + deno_core::scope!(scope, &mut runtime); + let ns = v8::Local::new(scope, ns); + let global = scope.get_current_context().global(scope); + let key = + v8::String::new(scope, "__module").ok_or_else(|| failure(ErrorKind::Limit))?; + global.set(scope, key.into(), ns.into()); + let input = v8::Local::new(scope, input); + let key = v8::String::new(scope, "__input").ok_or_else(|| failure(ErrorKind::Limit))?; + global.set(scope, key.into(), input); + } + let code = format!( + r#"(async()=>{{ + const f=__module[{}]; + if(typeof f!=='function') return JSON.stringify({{kind:'Engine'}}); + let value; + try {{ value=await f(__input); }} catch(e) {{ const message=String(e?.message); return JSON.stringify({{kind: e instanceof RangeError && message.includes('call stack') ? 'Limit':'Throw'}}); }} + try {{ const text=JSON.stringify(value); return JSON.stringify(text===undefined?{{kind:'NonJson'}}:{{text}}); }} catch(e) {{ return JSON.stringify({{kind:e instanceof RangeError && String(e.message).includes('call stack') ? 'Limit':'NonJson'}}); }} + }})()"#, + serde_json::to_string(&export).unwrap() + ); + let value = runtime + .execute_script("call.js", code) + .map_err(|_| failure(ErrorKind::Throw))?; + let resolve = runtime.resolve(value); + let value = runtime + .with_event_loop_promise(resolve, Default::default()) + .await + .map_err(|_| failure(ErrorKind::Engine))?; + let text = { + deno_core::scope!(scope, &mut runtime); + v8::Local::new(scope, value).to_rust_string_lossy(scope) + }; + let result: serde_json::Value = + serde_json::from_str(&text).map_err(|_| failure(ErrorKind::Engine))?; + match result["text"].as_str() { + Some(s) => Ok(s.to_owned()), + None => Err(failure(match result["kind"].as_str() { + Some("NonJson") => ErrorKind::NonJson, + Some("Throw") => ErrorKind::Throw, + Some("Limit") => ErrorKind::Limit, + _ => ErrorKind::Engine, + })), + } + }; + let result = tokio::runtime::Builder::new_current_thread() + .build() + .map_err(|_| failure(ErrorKind::Engine))? + .block_on(task); + let _ = done.send(()); + let _ = watchdog.join(); + if limited.load(Ordering::SeqCst) { + Err(failure(ErrorKind::Limit)) + } else { + result + } +} +const BOOTSTRAP: &str = r#" +delete globalThis.Date; delete Math.random; +for (const name of ['Deno','__bootstrap','console','performance']) delete globalThis[name]; +const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +globalThis.btoa = value => { + const s=String(value); let out=''; + for(let i=0;i255||b>255||c>255) throw Error('invalid base64 input'); + const n=(a<<16)|((b||0)<<8)|(c||0); + out+=alphabet[(n>>>18)&63]+alphabet[(n>>>12)&63]+(i+1>>6)&63]:'=')+(i+2 { + let s=String(value).replace(/[\t\n\f\r ]/g,''); + if(s.length%4===0) s=s.replace(/==?$/,''); + if(s.length%4===1||/[^A-Za-z0-9+/]/.test(s)) throw Error('invalid base64 input'); + let out='', bits=0, n=0; + for(const c of s){n=(n<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;out+=String.fromCharCode((n>>>bits)&255);}} + return out; +}; +"#; diff --git a/host/tests/cred_oauth2.rs b/host/tests/cred_oauth2.rs new file mode 100644 index 0000000..81f52e8 --- /dev/null +++ b/host/tests/cred_oauth2.rs @@ -0,0 +1,100 @@ +//! Refresh form encoding, response validation, and revision-aware cache behavior. +use sausage::cred::oauth2::*; +use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; +struct Fake(AtomicUsize); +impl Http for Fake { + async fn send(&self, request: &Request, _: Instant) -> Result>, Error> { + assert_eq!(request.method, "POST"); + self.0.fetch_add(1, Ordering::SeqCst); + Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec())) + } +} +#[tokio::test] +async fn encoding_and_cache() { + let req = request( + "https://tokens.example.test/token", + "REFRESH&=+", + "CLIENT", + "SECRET&=+", + ) + .unwrap(); + assert!( + req.body.as_str() + == "grant_type=refresh_token&refresh_token=REFRESH%26%3D%2B&client_id=CLIENT&client_secret=SECRET%26%3D%2B" + ); + assert_eq!(req.headers[1].1, req.body.len().to_string()); + let fake = Fake(AtomicUsize::new(0)); + let mut cache = Cache::default(); + let deadline = Instant::now() + Duration::from_secs(1); + let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap(); + assert_eq!(t.scopes.unwrap(), vec!["one", "two"]); + cache.refresh("id", 1, &req, &fake, deadline).await.unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 1); + cache.refresh("id", 2, &req, &fake, deadline).await.unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 2); + cache.clear(); + cache.refresh("id", 2, &req, &fake, deadline).await.unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 3); + for url in [ + "http://tokens.example.test/token", + "https://user@tokens.example.test/token", + "https://tokens.example.test/token#x", + ] { + assert!(request(url, "r", "c", "s").is_err()); + } +} +#[tokio::test] +async fn expiry_and_failure() { + struct Expired(AtomicUsize); + impl Http for Expired { + async fn send(&self, _: &Request, _: Instant) -> Result>, Error> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(Zeroizing::new( + br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(), + )) + } + } + struct Failed; + impl Http for Failed { + async fn send(&self, _: &Request, _: Instant) -> Result>, Error> { + Err(Error) + } + } + let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + let mut cache = Cache::default(); + let http = Expired(AtomicUsize::new(0)); + let deadline = Instant::now() + Duration::from_secs(1); + cache.refresh("id", 1, &req, &http, deadline).await.unwrap(); + cache.refresh("id", 1, &req, &http, deadline).await.unwrap(); + assert_eq!(http.0.load(Ordering::SeqCst), 2); + assert!( + cache + .refresh("id", 2, &req, &Failed, deadline) + .await + .is_err() + ); + cache.refresh("id", 2, &req, &http, deadline).await.unwrap(); + assert_eq!(http.0.load(Ordering::SeqCst), 3); +} + +#[test] +fn validation() { + for data in [ + br#"{"error":"invalid_grant","secret":"DO NOT ECHO"}"#.as_slice(), + br#"{"access_token":"","token_type":"Bearer","expires_in":3600}"#, + br#"{"access_token":"x","token_type":"MAC","expires_in":3600}"#, + br#"{"access_token":"x","token_type":"Bearer","expires_in":0}"#, + br#"{"access_token":"x\r\ny","token_type":"Bearer","expires_in":3600}"#, + ] { + let e = decode(data).err().unwrap(); + assert!(!e.to_string().contains("DO NOT ECHO")); + } + assert!( + decode(br#"{"access_token":"x","token_type":"Bearer","expires_in":18446744073709551615}"#) + .is_ok() + ); +} diff --git a/host/tests/cred_sign.rs b/host/tests/cred_sign.rs new file mode 100644 index 0000000..debac77 --- /dev/null +++ b/host/tests/cred_sign.rs @@ -0,0 +1,283 @@ +//! RFC vectors and public codecs for pure signing. +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use sausage::cred::sign::*; +fn raw(key: &[u8], alg: &str, payload: &[u8]) -> Vec { + sign(&STANDARD.encode(key), None, alg, payload, "raw", None, None) + .unwrap() + .bytes + .unwrap() +} +#[test] +fn ed25519_readers_and_wire() { + use ed25519_dalek::pkcs8::EncodePrivateKey; + let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + let pem = key.to_pkcs8_pem(Default::default()).unwrap(); + let jwk = serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode(key.to_bytes()),"x":URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())}).to_string(); + let ssh = ssh_key::PrivateKey::new( + ssh_key::private::Ed25519Keypair::from(&key).into(), + "synthetic", + ) + .unwrap() + .to_openssh(Default::default()) + .unwrap(); + let expected = raw(&key.to_bytes(), "ed25519", b"message"); + for value in [pem.as_str(), jwk.as_str(), ssh.as_str()] { + let sig = sign(value, None, "ed25519", b"message", "ssh", None, None).unwrap(); + let wire = STANDARD.decode(sig.signature).unwrap(); + assert_eq!(&wire[..19], b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x40"); + assert_eq!(&wire[19..], expected); + for output in ["pem", "jwk", "ssh", "hex", "base64"] { + assert!(public_key(value, None, "ed25519", output).is_ok()); + } + } + assert!(public_key(&STANDARD.encode([7; 32]), None, "hmac-sha256", "hex").is_err()); +} + +#[test] +fn rfc8032_and_4231() { + let seed = + hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").unwrap(); + assert_eq!( + hex::encode(raw(&seed, "ed25519", b"")), + concat!( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155", + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ) + ); + for (alg, expected) in [ + ( + "hmac-sha256", + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + ), + ( + "hmac-sha384", + "afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6", + ), + ( + "hmac-sha512", + "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854", + ), + ] { + assert_eq!( + hex::encode(raw(&[0x0b; 20], alg, b"Hi There")), + expected.replace(' ', "") + ); + } +} +#[test] +fn ec_readers() { + use p256::pkcs8::EncodePrivateKey; + let secret = p256::SecretKey::from_slice(&[7; 32]).unwrap(); + let public = secret.public_key(); + use p256::elliptic_curve::sec1::ToEncodedPoint; + let point = public.to_encoded_point(false); + let jwk = serde_json::json!({"kty":"EC","crv":"P-256","d":URL_SAFE_NO_PAD.encode(secret.to_bytes()),"x":URL_SAFE_NO_PAD.encode(point.x().unwrap()),"y":URL_SAFE_NO_PAD.encode(point.y().unwrap())}).to_string(); + let pkcs8 = secret.to_pkcs8_pem(Default::default()).unwrap(); + let sec1 = secret.to_sec1_pem(Default::default()).unwrap(); + let expected = raw(&[7; 32], "es256", b"message"); + let ssh = ssh_key::PrivateKey::from(ssh_key::private::EcdsaKeypair::NistP256 { + public: point, + private: ssh_key::private::EcdsaPrivateKey::from(secret.clone()), + }) + .to_openssh(Default::default()) + .unwrap(); + for value in [jwk.as_str(), pkcs8.as_str(), sec1.as_str(), ssh.as_str()] { + assert_eq!( + sign(value, None, "es256", b"message", "raw", None, None) + .unwrap() + .bytes + .unwrap(), + expected + ); + let public = ssh_key::PublicKey::from_bytes( + &STANDARD + .decode(public_key(value, None, "es256", "ssh").unwrap()) + .unwrap(), + ) + .unwrap(); + let wire = STANDARD + .decode( + sign(value, None, "es256", b"message", "ssh", None, None) + .unwrap() + .signature, + ) + .unwrap(); + let signature = ssh_key::Signature::try_from(wire.as_slice()).unwrap(); + ed25519_dalek::Verifier::verify(&public, b"message", &signature).unwrap(); + } + let mut bad: serde_json::Value = serde_json::from_str(&jwk).unwrap(); + bad["x"] = serde_json::json!(URL_SAFE_NO_PAD.encode([0; 32])); + assert!( + sign( + &bad.to_string(), + None, + "es256", + b"message", + "raw", + None, + None + ) + .is_err() + ); +} + +#[test] +fn rfc6979_and_formats() { + let key = + hex::decode("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721").unwrap(); + let expected = concat!( + "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716", + "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8" + ); + assert_eq!(hex::encode(raw(&key, "es256", b"sample")), expected); + let key = STANDARD.encode(key); + for output in ["hex", "base64", "jws", "ssh"] { + let result = sign(&key, None, "es256", b"sample", output, None, None).unwrap(); + assert!(!result.signature.is_empty()); + } + for output in ["hex", "base64", "pem", "jwk", "ssh"] { + assert!(!public_key(&key, None, "es256", output).unwrap().is_empty()); + } + let pem = generate_es256().unwrap(); + assert!(public_key(&pem, None, "es256", "jwk").is_ok()); + for bad in [vec![], vec![1; 31], vec![0; 32], vec![255; 32]] { + assert!(sign(&STANDARD.encode(bad), None, "es256", b"", "raw", None, None).is_err()); + } +} +#[test] +fn rsa_readers_and_ssh_wire() { + use rsa::{ + pkcs1::EncodeRsaPrivateKey, + pkcs8::EncodePrivateKey, + traits::{PrivateKeyParts, PublicKeyParts}, + }; + use sha2::{Digest, Sha256, Sha512}; + let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap(); + let pem = key.to_pkcs8_pem(Default::default()).unwrap(); + let pkcs1 = key.to_pkcs1_pem(Default::default()).unwrap(); + let ssh = ssh_key::PrivateKey::new( + ssh_key::private::RsaKeypair::try_from(&key).unwrap().into(), + "synthetic", + ) + .unwrap(); + let openssh = ssh.to_openssh(Default::default()).unwrap(); + let encrypted = ssh + .encrypt(&mut rsa::rand_core::OsRng, "synthetic") + .unwrap() + .to_openssh(Default::default()) + .unwrap(); + let jwk = serde_json::json!({"kty":"RSA", "n":URL_SAFE_NO_PAD.encode(key.n().to_bytes_be()), "e":URL_SAFE_NO_PAD.encode(key.e().to_bytes_be()), "d":URL_SAFE_NO_PAD.encode(key.d().to_bytes_be()), "p":URL_SAFE_NO_PAD.encode(key.primes()[0].to_bytes_be()), "q":URL_SAFE_NO_PAD.encode(key.primes()[1].to_bytes_be())}).to_string(); + for (value, pass) in [ + (pem.as_str(), None), + (pkcs1.as_str(), None), + (openssh.as_str(), None), + (encrypted.as_str(), Some("synthetic")), + (jwk.as_str(), None), + ] { + for (alg, hash) in [ + ("rs256", "sha256"), + ("rsa-sha2-256", "sha256"), + ("rsa-sha2-512", "sha512"), + ] { + let result = sign( + value, + pass, + alg, + b"message", + "raw", + Some("pkcs1v15"), + Some(hash), + ) + .unwrap(); + let sig = result.bytes.unwrap(); + if hash == "sha512" { + key.to_public_key() + .verify( + rsa::Pkcs1v15Sign::new::(), + &Sha512::digest(b"message"), + &sig, + ) + .unwrap(); + } else { + key.to_public_key() + .verify( + rsa::Pkcs1v15Sign::new::(), + &Sha256::digest(b"message"), + &sig, + ) + .unwrap(); + } + let wire = STANDARD + .decode( + sign( + value, + pass, + alg, + b"message", + "ssh", + Some("pkcs1v15"), + Some(hash), + ) + .unwrap() + .signature, + ) + .unwrap(); + let len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize; + assert_eq!( + &wire[4..4 + len], + if hash == "sha512" { + b"rsa-sha2-512" + } else { + b"rsa-sha2-256" + } + ); + assert_eq!(&wire[8 + len..], sig); + } + for output in ["pem", "jwk", "ssh", "base64", "hex"] { + assert!(public_key(value, pass, "rs256", output).is_ok()); + } + } + assert!(sign(&pem, None, "rs256", b"", "raw", None, None).is_err()); +} + +#[test] +fn malformed_and_mismatched_keys() { + for value in [ + "not a key", + "{broken", + "{\"kty\":\"unknown\"}", + "-----BEGIN UNKNOWN PRIVATE KEY-----\nAAAA\n-----END UNKNOWN PRIVATE KEY-----", + "-----BEGIN OPENSSH PRIVATE KEY-----\ninvalid", + "AAAA", + ] { + assert!(sign(value, None, "ed25519", b"", "raw", None, None).is_err()); + } + let bad=serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode([1;32]),"x":URL_SAFE_NO_PAD.encode([2;32])}).to_string(); + assert!(sign(&bad, None, "ed25519", b"", "raw", None, None).is_err()); + let oct = serde_json::json!({"kty":"oct","k":URL_SAFE_NO_PAD.encode([11;20])}).to_string(); + assert!(sign(&oct, None, "hmac-sha384", b"Hi There", "raw", None, None).is_ok()); + assert!(sign(&oct, None, "ed25519", b"", "raw", None, None).is_err()); + assert!(sign(&oct, None, "hmac-sha384", b"", "ssh", None, None).is_err()); + assert!(sign(&oct, None, "hmac-sha384", b"", "unknown", None, None).is_err()); +} + +#[test] +fn rfc8292() { + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let public=URL_SAFE_NO_PAD.decode("BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs").unwrap(); + let signature=URL_SAFE_NO_PAD.decode("i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA").unwrap(); + let input = concat!( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.", + "eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0" + ); + VerifyingKey::from_sec1_bytes(&public) + .unwrap() + .verify( + input.as_bytes(), + &Signature::from_slice(&signature).unwrap(), + ) + .unwrap(); +} diff --git a/host/tests/cred_v8.rs b/host/tests/cred_v8.rs new file mode 100644 index 0000000..6f7f3f5 --- /dev/null +++ b/host/tests/cred_v8.rs @@ -0,0 +1,217 @@ +//! Behavioral contract for the credential-only isolate. +use sausage::cred::{ + script::{ErrorKind, Limits, Script}, + v8::V8, +}; +use std::time::{Duration, Instant}; +fn run(s: &str) -> Result { + V8.evaluate(s, "null", None, Limits::default()) +} +#[test] +fn modules_promises_and_json() { + assert_eq!( + V8.evaluate( + "JSON.parse=()=>false; export default x=>x", + "42", + None, + Limits::default() + ) + .unwrap(), + "42" + ); + let error = run("export default ()=>{throw Error('DO NOT ECHO')}").unwrap_err(); + assert!(!error.message.contains("DO NOT ECHO")); + assert_eq!( + V8.evaluate( + "export default x=>({value:x}); export const other=x=>x+1", + "42", + None, + Limits::default() + ) + .unwrap(), + "{\"value\":42}" + ); + assert_eq!( + V8.evaluate( + "export const other=x=>x+1", + "42", + Some("other"), + Limits::default() + ) + .unwrap(), + "43" + ); + assert_eq!( + run("await Promise.resolve(); export default async x=>{await Promise.resolve();return 42}") + .unwrap(), + "42" + ); + for (s, k) in [ + ("export default (", ErrorKind::Syntax), + ("export default ()=>{throw Error('oops')}", ErrorKind::Throw), + ("export default ()=>Promise.reject('no')", ErrorKind::Throw), + ("export default ()=>undefined", ErrorKind::NonJson), + ("export default ()=>Symbol()", ErrorKind::NonJson), + ("export default ()=>()=>1", ErrorKind::NonJson), + ( + "export default ()=>{let a={};a.a=a;return a}", + ErrorKind::NonJson, + ), + ("export const x=1", ErrorKind::Engine), + ("export default 1", ErrorKind::Engine), + ("export default ()=>new Promise(()=>{})", ErrorKind::Engine), + ] { + assert_eq!(run(s).unwrap_err().kind, k, "{s}"); + } + assert_eq!( + V8.evaluate("export default x=>x", "invalid", None, Limits::default()) + .unwrap_err() + .kind, + ErrorKind::Syntax + ); +} +#[test] +fn sandbox_and_isolation() { + for name in [ + "Date", + "Math.random", + "fetch", + "setTimeout", + "setInterval", + "WebSocket", + "XMLHttpRequest", + "std", + "os", + "fs", + "Deno", + "__bootstrap", + "console", + "performance", + "require", + "process", + "TextEncoder", + "Buffer", + "Blob", + "File", + "crypto", + ] { + assert_eq!( + run(&format!("export default ()=>typeof {name}")).unwrap(), + "\"undefined\"", + "{name}" + ); + } + assert!(run("import x from 'fs'; export default ()=>x").is_err()); + assert!(run("import {core} from 'ext:core/mod.js'; export default ()=>typeof core").is_err()); + assert_eq!( + run("export default ()=>import('fs')").unwrap_err().kind, + ErrorKind::Throw + ); + run("export default ()=>{globalThis.leak=1;btoa.leak=1;return true}").unwrap(); + assert_eq!( + run("export default ()=>[typeof leak,typeof btoa.leak]").unwrap(), + "[\"undefined\",\"undefined\"]" + ); + for (expr, expected) in [ + ("btoa('user:pass')", "\"dXNlcjpwYXNz\""), + ("atob('dXNlcjpwYXNz')", "\"user:pass\""), + ("btoa('')", "\"\""), + ("atob('AP+A') === '\\x00\\xff\\x80'", "true"), + ] { + assert_eq!( + run(&format!("export default ()=>{expr}")).unwrap(), + expected + ); + } + for expr in ["atob('!')", "btoa('雪')"] { + assert_eq!( + run(&format!("export default ()=>{expr}")).unwrap_err().kind, + ErrorKind::Throw + ); + } +} +#[test] +fn independent_deadlines() { + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let other = barrier.clone(); + let runaway = std::thread::spawn(move || { + other.wait(); + assert_eq!( + V8.evaluate( + "export default ()=>{while(true){}}", + "null", + None, + Limits { + max_time_ms: Some(1000), + ..Limits::default() + } + ) + .unwrap_err() + .kind, + ErrorKind::Limit + ); + }); + barrier.wait(); + let start = Instant::now(); + assert_eq!(run("export default ()=>true").unwrap(), "true"); + assert!(start.elapsed() < Duration::from_millis(500)); + runaway.join().unwrap(); +} + +#[test] +fn clamped_limits() { + for limits in [ + Limits { + max_time_ms: Some(0), + max_memory_bytes: Some(0), + }, + Limits { + max_time_ms: Some(u64::MAX), + max_memory_bytes: Some(usize::MAX), + }, + ] { + match V8.evaluate("export default ()=>true", "null", None, limits) { + Ok(value) => assert_eq!(value, "true"), + Err(error) => assert_eq!(error.kind, ErrorKind::Limit), + } + } + assert_eq!( + V8.evaluate( + "export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}", + "null", + None, + Limits { + max_time_ms: Some(1000), + max_memory_bytes: Some(1024 * 1024) + } + ) + .unwrap_err() + .kind, + ErrorKind::Limit + ); +} + +#[test] +fn limits_and_parallel_calls() { + let mut workers = Vec::new(); + for s in [ + "export default ()=>{while(true){}}", + "while(true){};export default x=>x", + "export default ()=>({toJSON(){while(true){}}})", + "export default ()=>{throw {get message(){while(true){}}}}", + "export default ()=>{function f(){return f()+1}return f()}", + "export default ()=>new Promise(()=>{function f(){Promise.resolve().then(f)}f()})", + "export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}", + ] { + workers.push(std::thread::spawn(move || { + let start = Instant::now(); + assert_eq!(run(s).unwrap_err().kind, ErrorKind::Limit); + assert!(start.elapsed() < Duration::from_secs(2)); + })); + } + let normal = std::thread::spawn(|| assert_eq!(run("export default ()=>true").unwrap(), "true")); + for worker in workers { + worker.join().unwrap(); + } + normal.join().unwrap(); +} From a7e99d52f541ecca1a314d894e65341bbe79ca24 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 19:29:35 +0000 Subject: [PATCH 3/6] cred: reject non-32-byte raw ES256 scalars --- host/src/cred/keys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/src/cred/keys.rs b/host/src/cred/keys.rs index b19d814..78dee7e 100644 --- a/host/src/cred/keys.rs +++ b/host/src/cred/keys.rs @@ -141,7 +141,7 @@ pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Resul "ed25519" => Key::Ed(SigningKey::from_bytes( bytes.as_slice().try_into().map_err(|_| invalid_key())?, )), - "es256" => { + "es256" if bytes.len() == 32 => { Key::Ec(p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|_| invalid_key())?) } "hmac" => Key::Mac(bytes), From 11569119e633ce663696e299437c657e0d085bae Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 19:47:15 +0000 Subject: [PATCH 4/6] cred: initialize V8 inside its Tokio executor and verify concurrent limits --- host/src/cred/v8.rs | 11 +++++++---- host/tests/cred_v8.rs | 9 +++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/host/src/cred/v8.rs b/host/src/cred/v8.rs index f437791..3b2f1ad 100644 --- a/host/src/cred/v8.rs +++ b/host/src/cred/v8.rs @@ -51,6 +51,12 @@ fn evaluate( .max_memory_bytes .unwrap_or(8 * 1024 * 1024) .clamp(256 * 1024, 32 * 1024 * 1024); + // V8 uses Tokio for internal delayed tasks, even without guest extensions. + let executor = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|_| failure(ErrorKind::Engine))?; + let _entered = executor.enter(); let mut runtime = JsRuntime::new(RuntimeOptions { // Bootstrap before imposing the caller's budget: V8 cannot recover from // bootstrap OOM because the runtime cannot install its callback yet. @@ -160,10 +166,7 @@ fn evaluate( })), } }; - let result = tokio::runtime::Builder::new_current_thread() - .build() - .map_err(|_| failure(ErrorKind::Engine))? - .block_on(task); + let result = executor.block_on(task); let _ = done.send(()); let _ = watchdog.join(); if limited.load(Ordering::SeqCst) { diff --git a/host/tests/cred_v8.rs b/host/tests/cred_v8.rs index 6f7f3f5..6eba8fa 100644 --- a/host/tests/cred_v8.rs +++ b/host/tests/cred_v8.rs @@ -132,6 +132,8 @@ fn sandbox_and_isolation() { } #[test] fn independent_deadlines() { + // Exclude the process-wide platform initialization from the overlap check. + assert_eq!(run("export default ()=>true").unwrap(), "true"); let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); let other = barrier.clone(); let runaway = std::thread::spawn(move || { @@ -152,9 +154,12 @@ fn independent_deadlines() { ); }); barrier.wait(); - let start = Instant::now(); + // The quick call keeps its default 100 ms execution deadline. assert_eq!(run("export default ()=>true").unwrap(), "true"); - assert!(start.elapsed() < Duration::from_millis(500)); + assert!( + !runaway.is_finished(), + "the quick call must finish before the runaway call" + ); runaway.join().unwrap(); } From 091f679905e46f9e6e185609d8492e4019635a0a Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 20:05:54 +0000 Subject: [PATCH 5/6] 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"); +} From 30dcd4c2ca08965744aa74e67af27573beef4c59 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 20:16:24 +0000 Subject: [PATCH 6/6] cred: keep typed secret entry configuration valid --- host/src/cred/items.rs | 1 + host/tests/cred_regressions.rs | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/host/src/cred/items.rs b/host/src/cred/items.rs index 1649f44..4ba8579 100644 --- a/host/src/cred/items.rs +++ b/host/src/cred/items.rs @@ -295,6 +295,7 @@ impl Cred { config.fields[index].needs_value = false; config.fields[index].field_type = kind; config.fields[index].key_type = key_type; + validate(&config)?; tx.execute( "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", params![item, previous], diff --git a/host/tests/cred_regressions.rs b/host/tests/cred_regressions.rs index ddaf32d..adc8546 100644 --- a/host/tests/cred_regressions.rs +++ b/host/tests/cred_regressions.rs @@ -218,8 +218,61 @@ fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { assert_eq!(restored.fields[0].field_id, item.fields[0].field_id); } #[test] +fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() { + let (dir, cred, caller, item) = fixture(); + let input = FieldInput { + value: "opaque", + field_type: Some(types::FieldType::Oauth2RefreshToken), + key_type: None, + }; + cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk") + .unwrap(); + cred.set_field(&item.id, &item.fields[1].field_id, "opaque", &caller, "pk") + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + let history = cred.item_versions(&item.id).unwrap().len(); + let audit = cred.audit_list().unwrap().len(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let cipher = || { + db.query_row( + "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", + rusqlite::params![item.id, item.fields[1].field_id], + |r| r.get::<_, Vec>(0), + ) + .unwrap() + }; + let before = cipher(); + assert_eq!( + cred.set_field_input(&item.id, &item.fields[1].field_id, &input, &caller, "pk") + .err() + .unwrap() + .0, + "invalid_params" + ); + 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 typed_secret_entry_and_bundle_normalization() { let (_dir, cred, caller, item) = fixture(); + let mut metadata = item.clone(); + metadata.fields[0].field_type = types::FieldType::SshPrivateKey; + metadata.fields[0].key_type = Some("es256".into()); + let id = cred + .file_edit_request( + &caller, + "metadata", + Edit { + item: item.id.clone(), + base_version: item.version, + config: metadata, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); let input = FieldInput { value: "two\nlines", field_type: Some(types::FieldType::Note), @@ -229,6 +282,7 @@ fn typed_secret_entry_and_bundle_normalization() { .unwrap(); let current = cred.item_get(&item.id).unwrap(); assert_eq!(current.fields[0].field_type, types::FieldType::Note); + assert!(current.fields[0].key_type.is_none()); assert!(current.fields[0].value.is_none()); assert_eq!( cred.item_versions(&item.id).unwrap()[0].config.fields[0].field_type,