Merge v3 with T15 into v3-t22

This commit is contained in:
2026-11-06 22:20:05 +00:00
171 changed files with 33754 additions and 481 deletions
+255
View File
@@ -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<String, script::Error> {
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::<usize>(),
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");
}
+205
View File
@@ -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());
}
+266
View File
@@ -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);
}
+62
View File
@@ -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(())
}
+199
View File
@@ -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<String, script::Error> {
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"))
);
}
+100
View File
@@ -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<Zeroizing<Vec<u8>>, 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<Zeroizing<Vec<u8>>, 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<Zeroizing<Vec<u8>>, 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()
);
}
+319
View File
@@ -0,0 +1,319 @@
//! 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<String, script::Error> {
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<u8>>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.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 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<u8>>(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),
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].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,
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());
}
+346
View File
@@ -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"
);
}
+283
View File
@@ -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<u8> {
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>(),
&Sha512::digest(b"message"),
&sig,
)
.unwrap();
} else {
key.to_public_key()
.verify(
rsa::Pkcs1v15Sign::new::<Sha256>(),
&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();
}
+84
View File
@@ -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: "<x>&*\nnext".into(),
});
assert!(!text.contains('<'));
assert!(!text.contains('\n'));
assert!(text.contains("&lt;x&gt;&amp;\\* 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");
}
+222
View File
@@ -0,0 +1,222 @@
//! 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<String, sausage::cred::script::Error> {
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() {
// 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 || {
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();
// The quick call keeps its default 100 ms execution deadline.
assert_eq!(run("export default ()=>true").unwrap(), "true");
assert!(
!runaway.is_finished(),
"the quick call must finish before the runaway call"
);
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();
}
+124
View File
@@ -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(&registration.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(())
}
+325
View File
@@ -0,0 +1,325 @@
//! Credential decisions wake an idle faux-model conversation through durable tasks.
use sausage::{
cred::{
items::Item,
requests::{GrantRequest, Overrides},
store::Cred,
types::Caller,
},
harness::{run_module, run_module_with_credentials},
world::World,
};
use std::sync::Arc;
#[tokio::test(flavor = "current_thread")]
async fn restart_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("DURABLE_RESTART_DIR") else {
return Ok(());
};
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.expectedState = 'approved';\n{}",
include_str!("../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
&source,
)
.await
}
#[test]
fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
use std::{
process::Command,
time::{Duration, Instant},
};
let dir = std::env::temp_dir().join(format!("sausage-durable-restart-{}", std::process::id()));
let world = World::open(&dir)?;
let cred = Cred::open(&world.host_dir)?;
let admin = Caller {
pid: 0,
program: "test".into(),
conversations: vec![],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "github".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![],
},
&admin,
"test",
)?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
let conversation = loop {
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
&& !id.is_empty()
{
break id;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Conversation did not start");
}
std::thread::yield_now();
};
let caller = Caller {
conversations: vec![conversation],
..admin
};
let id = cred.file_request(
&caller,
"restart",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
loop {
let count: i64 = db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
if count == 1 {
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Durable wait did not start");
}
std::thread::yield_now();
}
child.kill()?;
child.wait()?;
cred.approve(&id, "yes", Overrides::default(), Some("test"))?;
for _ in 0..2 {
let mut resumed = Command::new(std::env::current_exe()?)
.args(["--exact", "restart_child", "--nocapture"])
.env("DURABLE_RESTART_DIR", &dir)
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = resumed.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
resumed.kill()?;
resumed.wait()?;
anyhow::bail!("Restart did not finish");
}
std::thread::yield_now();
}
}
drop(db);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn proxy_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("DURABLE_PROXY_DIR") else {
return Ok(());
};
let dir = std::path::Path::new(&dir);
let source = format!(
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
include_str!("../js/durable-test-bundle.js")
);
run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
&source,
)
.await?;
let error = run_module(
World::open(dir)?,
sausage::process::exec(World::open(dir)?)?,
"throw new Error(Deno.core.ops.op_model_proxy().api_key)",
)
.await
.unwrap_err();
assert!(!error.to_string().contains("faux-private-token"));
assert!(error.to_string().contains("[redacted]"));
Ok(())
}
#[test]
fn proxy_bootstrap_once() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
for start in 0..2 {
let status = std::process::Command::new(std::env::current_exe()?)
.args(["--exact", "proxy_child", "--nocapture"])
.env("DURABLE_PROXY_DIR", &dir)
.env(
"PI_PROXY_BASE_URL",
if start == 0 {
"https://proxy.invalid"
} else {
"https://ignored.invalid"
},
)
.env(
"PI_PROXY_API_KEY",
if start == 0 {
"faux-private-token"
} else {
"ignored-token"
},
)
.status()?;
assert!(status.success());
}
let cred = Cred::open(&dir.join("host"))?;
assert_eq!(
cred.item_list()?
.iter()
.filter(|i| i.name == "llm-proxy")
.count(),
1
);
for file in [
"host/cred.sqlite",
"host/cred.sqlite-wal",
"host/harness.sqlite",
"host/harness.sqlite-wal",
] {
if let Ok(bytes) = std::fs::read(dir.join(file)) {
assert!(
!bytes
.windows(b"faux-private-token".len())
.any(|w| w == b"faux-private-token")
);
}
}
assert!(!dir.join("world/conversation").exists());
drop(cred);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn decisions_wake_idle_conversation() -> anyhow::Result<()> {
for state in ["approved", "denied", "expired"] {
let dir =
std::env::temp_dir().join(format!("sausage-durable-{}-{state}", std::process::id()));
let world = World::open(&dir)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let admin = Caller {
pid: 0,
program: "test".into(),
conversations: vec![],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "github".into(),
version: 0,
free_use: false,
rules: vec![],
validate_js: None,
transform_js: "export default (ctx) => ctx.request".into(),
fields: vec![],
},
&admin,
"test",
)?;
cred.file_request(
&admin,
"program",
GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let source = format!(
"globalThis.expectedState = {state:?};\n{}",
include_str!("../js/durable-test-bundle.js")
);
let decide = async {
let conversation = loop {
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
&& !id.is_empty()
{
break id;
}
tokio::task::yield_now().await;
};
let caller = Caller {
conversations: vec![conversation],
..admin
};
let id = cred.file_request(
&caller,
"test",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let harness_db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
loop {
let count: i64 = harness_db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
if count == 1 {
break;
}
tokio::task::yield_now().await;
}
match state {
"approved" => cred.approve(&id, "yes", Overrides::default(), Some("test"))?,
"denied" => cred.deny(&id, "no", Some("test"))?,
_ => {
let db = rusqlite::Connection::open(dir.join("host/cred.sqlite"))?;
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id])?;
}
}
Ok::<_, anyhow::Error>(())
};
let exec = sausage::process::exec(World::open(&dir)?)?;
tokio::time::timeout(std::time::Duration::from_secs(20), async {
let (a, b) = tokio::join!(
run_module_with_credentials(world, Arc::clone(&exec), cred.clone(), &source),
decide
);
a?;
b?;
Ok::<_, anyhow::Error>(())
})
.await??;
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
let tasks: i64 = db.query_row(
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
[],
|r| r.get(0),
)?;
assert_eq!(tasks, 1, "Program requests must create no durable task");
drop(db);
std::fs::remove_dir_all(dir)?;
}
Ok(())
}
+114 -8
View File
@@ -1,13 +1,13 @@
//! P1 integration: a read-only bundle and real shell calls from the durable harness.
//! P1 integration: independent bundle copies and real shell calls from the durable harness.
use sausage::{harness::run_module, world::World};
use sausage_vfs::Open;
use std::path::PathBuf;
#[tokio::test(flavor = "current_thread")]
async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
let bundle = repo.join("target/userland/bin");
if !bundle.join("sh").exists() {
let bundle = repo.join("target/userland/bundle");
if !bundle.join("bin/sh.wasm").exists() {
assert!(
std::process::Command::new("nu")
.env_remove("CARGO_TARGET_DIR")
@@ -18,9 +18,16 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
}
let dir = tempfile::tempdir()?;
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
assert!(world.root.stat("/bin/sh", true).await?.size > 0);
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
use std::os::unix::fs::MetadataExt;
for entry in std::fs::read_dir(dir.path().join("world/bin"))? {
let entry = entry?;
assert_eq!(entry.path().extension().unwrap(), "wasm");
assert_eq!(entry.metadata()?.nlink(), 1);
assert!(!entry.file_type()?.is_symlink());
}
assert!(world.root.stat("/usr/local/bin", true).await?.directory);
let denied = world
let writable = world
.root
.open(
"/bin/forbidden",
@@ -31,17 +38,72 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
},
)
.await;
assert!(matches!(denied, Err(e) if e.kind() == std::io::ErrorKind::ReadOnlyFilesystem));
assert!(writable.is_ok());
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
use sausage::exec::{Command, Options, Stream};
for (script, expected) in [
("command -v ls", "/bin/ls.wasm\n"),
("ls", "bin"),
("git --version", "git version"),
("printf 'a\\n' | xargs echo", "a\n"),
("env FOO=1 printenv FOO", "1\n"),
] {
let mut out = Vec::new();
let code = exec
.exec(
Command::Shell(script.into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
out.extend_from_slice(bytes);
}
},
)
.await?;
assert_eq!(code, 0, "{script}");
assert!(String::from_utf8(out)?.contains(expected), "{script}");
}
let mut history = Vec::new();
assert_eq!(
exec.exec(
Command::Shell("cd /src/sausage && git log --oneline".into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
history.extend_from_slice(bytes);
}
},
)
.await?,
0,
);
let history = String::from_utf8(history)?;
assert_eq!(history.lines().count(), 1);
assert!(history.contains("Bundle source "));
let mut status = Vec::new();
assert_eq!(
exec.exec(
Command::Shell("cd /src/sausage && git status --short".into()),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
status.extend_from_slice(bytes);
}
},
)
.await?,
0,
);
assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status));
let source = format!(
"globalThis.testP1 = true;\n{}",
include_str!("../js/test-bundle.js")
);
run_module(world, exec, &source).await?;
run_module(world, exec.clone(), &source).await?;
assert_eq!(
std::fs::read_to_string(dir.path().join("world/test.txt"))?,
"after\n"
@@ -59,6 +121,50 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
let actual = std::fs::read_to_string(spills[0].path())?;
let expected: String = (1..=10000).map(|n| format!("{n}\n")).collect();
assert_eq!(actual, expected);
let source_cat = std::fs::read(bundle.join("bin/cat.wasm"))?;
let head = std::fs::read(dir.path().join("world/bin/head.wasm"))?;
let mut out = Vec::new();
assert_eq!(
exec.exec(
Command::Shell(
"cp /bin/true.wasm /bin/cat.wasm; cat /test.txt; head -n 1 /test.txt".into()
),
Options::default(),
&mut |stream, bytes| {
if matches!(stream, Stream::Stdout) {
out.extend_from_slice(bytes);
}
}
)
.await?,
0
);
assert_eq!(out, b"after\n");
assert_eq!(std::fs::read(bundle.join("bin/cat.wasm"))?, source_cat);
assert_eq!(std::fs::read(dir.path().join("world/bin/head.wasm"))?, head);
assert_eq!(
exec.exec(
Command::Shell("cp /bin/true.wasm /usr/local/bin/head.wasm; head /test.txt".into()),
Options::default(),
&mut |_, bytes| assert!(bytes.is_empty())
)
.await?,
0
);
let changed = std::fs::read(dir.path().join("world/bin/cat.wasm"))?;
let reopened = World::open_with_userland(
dir.path(),
Some(std::path::Path::new("/nonexistent-bundle")),
)?;
assert_eq!(
reopened
.root
.open("/bin/cat.wasm", Open::default())
.await?
.read(0, changed.len())
.await?,
changed
);
// An unbundled world remains usable by process tests and file-only callers.
let unbundled = tempfile::tempdir()?;
let world = World::open(unbundled.path())?;
+56 -11
View File
@@ -9,6 +9,39 @@ use std::{
sync::{Arc, Once},
time::Duration,
};
#[test]
fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let bundle = temp.path().join("bundle");
std::fs::create_dir_all(bundle.join("bin"))?;
std::fs::create_dir_all(bundle.join("src/sausage/.git"))?;
std::fs::write(bundle.join("bin/probe.wasm"), b"program")?;
std::fs::write(
bundle.join("src/sausage/.git/HEAD"),
b"ref: refs/heads/main\n",
)?;
std::fs::write(bundle.join("src/sausage/README.md"), b"source")?;
let path = temp.path().join("world");
let _world = World::open_with_userland(&path, Some(&bundle))?;
assert_eq!(
std::fs::read(path.join("world/bin/probe.wasm"))?,
b"program"
);
assert_eq!(
std::fs::read(path.join("world/src/sausage/README.md"))?,
b"source"
);
assert!(path.join("world/src/sausage/.git/HEAD").is_file());
std::fs::write(path.join("world/src/sausage/README.md"), b"agent patch")?;
let _world = World::open_with_userland(&path, Some(Path::new("/missing-bundle")))?;
assert_eq!(
std::fs::read(path.join("world/src/sausage/README.md"))?,
b"agent patch"
);
assert!(!path.join("host/compile-cache").exists());
Ok(())
}
mod features {
//! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases.
use super::*;
@@ -253,28 +286,32 @@ mod features {
empty(&processes).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn relative_aliases_share_compilation() {
async fn independent_copies_share_compilation() {
let _serial = SERIAL.lock().await;
let (temp, runtime, _) = world();
let bin = temp.path().join("world/bin");
for name in ["true", "echo"] {
std::os::unix::fs::symlink("cat", bin.join(name)).unwrap();
for name in ["read-a.wasm", "read-b.wasm"] {
std::fs::copy(bin.join("cat.wasm"), bin.join(name)).unwrap();
}
assert_eq!(
run(&runtime, Command::Argv(vec!["/bin/true".into()]), "/")
.await
.0,
run(
&runtime,
Command::Argv(vec!["/bin/read-a".into(), "/f".into()]),
"/"
)
.await
.0,
0
);
assert_eq!(runtime.compiled_programs(), 1);
let (code, out, _) = run(
&runtime,
Command::Argv(vec!["/bin/echo".into(), "shared".into()]),
Command::Argv(vec!["/bin/read-b".into(), "/f".into()]),
"/",
)
.await;
assert_eq!(code, 0);
assert_eq!(out, b"shared\n");
assert_eq!(out, b"root\n");
assert_eq!(runtime.compiled_programs(), 1);
}
#[tokio::test(flavor = "multi_thread")]
@@ -402,13 +439,17 @@ fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
let world = World::open(temp.path()).unwrap();
let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("process-host/compile-cache");
std::fs::create_dir_all(&cache).unwrap();
std::os::unix::fs::symlink(cache, world.host_dir.join("compile-cache")).unwrap();
let processes = world.processes.clone();
fixture(&temp.path().join("world"));
let artifacts = repo().join("userland/target/wasm32-wasip2/release");
let bin = temp.path().join("world/bin");
for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] {
std::fs::copy(artifacts.join("sausage-coreutils.wasm"), bin.join(tool)).unwrap();
std::fs::copy(
artifacts.join(format!("{tool}.wasm")),
bin.join(format!("{tool}.wasm")),
)
.unwrap();
}
for tool in ["ps", "kill"] {
std::fs::copy(artifacts.join("sausage-procps.wasm"), bin.join(tool)).unwrap();
@@ -429,7 +470,11 @@ fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
bin.join("git"),
)
.unwrap();
(temp, Runtime::new(world).unwrap(), processes)
(
temp,
Runtime::new_with_cache(world, &cache).unwrap(),
processes,
)
}
async fn run(runtime: &Arc<Runtime>, command: Command, cwd: &str) -> (i32, Vec<u8>, Vec<u8>) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());