//! Real assertions bind decisions, current payloads, and the exact encrypted field input. use sausage::cred::{ edits::Edit, items::{Field, FieldInput, FieldValue, Item}, requests::{GrantRequest, Overrides}, webauthn::{Binding, Decision, Mutation, Passkeys}, *, }; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; use webauthn_rs::prelude::Url; fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller { program: "/bin/test.wasm".into(), pid: 1, conversations: vec!["root".into()], }; let item = c .create_item( Item { id: String::new(), name: "test".into(), version: 0, free_use: false, rules: vec![], validate_js: None, transform_js: String::new(), fields: vec![Field { field_id: String::new(), name: "secret".into(), public: false, value: None, needs_value: true, field_type: types::FieldType::Text, key_type: None, }], }, &caller, c.unlocked().unwrap(), ) .unwrap(); (dir, c, caller, item) } fn register( host: &mut Passkeys<&rusqlite::Connection>, auth: &mut WebauthnAuthenticator, ) { let start = host.start_registration("test".into(), None).unwrap(); let response = auth .do_registration(Url::parse("http://localhost:8000").unwrap(), start.options) .unwrap(); host.finish_registration(&start.id, response).unwrap(); } fn proof( host: &mut Passkeys<&rusqlite::Connection>, auth: &mut WebauthnAuthenticator, binding: Binding, ) -> webauthn::Verified { let start = host.start_assertion(binding).unwrap(); let response = auth .do_authentication(Url::parse("http://localhost:8000").unwrap(), start.options) .unwrap(); host.finish_assertion(&start.id, response).unwrap() } #[test] fn verified_decisions_compare_choice_overrides_and_request() { let (dir, c, caller, item) = fixture(); let request = GrantRequest { items: vec![item.id], duration_secs: None, max_uses: None, validator: None, }; let a = c.file_request(&caller, "a", request.clone()).unwrap(); let b = c.file_request(&caller, "b", request).unwrap(); let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000").unwrap(); let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); register(&mut host, &mut auth); for binding in [ c.decision_binding(&a, Decision::Deny, Overrides::default()) .unwrap(), c.decision_binding( &a, Decision::Approve, Overrides { max_uses: Some(1), ..Default::default() }, ) .unwrap(), c.decision_binding(&b, Decision::Approve, Overrides::default()) .unwrap(), ] { let p = proof(&mut host, &mut auth, binding); assert_eq!( c.approve(&a, Decision::Approve, Overrides::default(), p) .unwrap_err() .0, "binding_mismatch" ); } assert_eq!(c.request_list().unwrap()[0].state, "pending"); let p = proof( &mut host, &mut auth, c.decision_binding(&a, Decision::Approve, Overrides::default()) .unwrap(), ); c.approve(&a, Decision::Approve, Overrides::default(), p) .unwrap(); assert!( c.audit_list() .unwrap() .iter() .any(|r| r.event == "approved" && r.approver.is_some()) ); } #[test] fn stale_resolution_invalidates_review_proof_and_audits_caller() { let (dir, c, caller, item) = fixture(); let mut config = item.clone(); config.name = "edited".into(); let id = c .file_edit_request( &caller, "edit", Edit { item: item.id.clone(), base_version: item.version, config, }, ) .unwrap(); let binding = c .decision_binding(&id, Decision::Approve, Overrides::default()) .unwrap(); c.set_field( &item.id, &item.fields[0].field_id, "synthetic", &caller, c.unlocked().unwrap(), ) .unwrap(); let version = c.item_get(&item.id).unwrap().version; let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000").unwrap(); let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); register(&mut host, &mut auth); let reviewed = proof(&mut host, &mut auth, binding); let resolve = proof( &mut host, &mut auth, Binding::mutation( Mutation::ResolveStale, &serde_json::json!({"request_id":id,"base_version":version}), ) .unwrap(), ); let reviewer = Caller { pid: 77, program: "/bin/review.wasm".into(), conversations: vec!["review".into()], }; c.resolve_stale(&id, version, &reviewer, resolve).unwrap(); assert_eq!( c.approve(&id, Decision::Approve, Overrides::default(), reviewed) .unwrap_err() .0, "binding_mismatch" ); assert!( c.audit_list() .unwrap() .iter() .any(|r| r.event == "request.resolve_stale" && r.pid == 77 && r.payload_sha256.is_some()) ); } fn envelope(key: &entry::EntryKey) -> entry::Envelope { use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint}; let secret = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); let peer = PublicKey::from_sec1_bytes(&key.public_key()).unwrap(); let shared = diffie_hellman(secret.to_nonzero_scalar(), peer.as_affine()); let mut aes = [0u8; 32]; hkdf::Hkdf::::new(None, shared.raw_secret_bytes()) .expand(b"sausage/secret-entry/v1", &mut aes) .unwrap(); let iv = [1u8; 12]; let ciphertext = Aes256Gcm::new_from_slice(&aes) .unwrap() .encrypt(Nonce::from_slice(&iv), b"synthetic".as_slice()) .unwrap(); entry::Envelope { ephemeral_public_key: secret .public_key() .to_encoded_point(false) .as_bytes() .to_vec(), iv, ciphertext, } } #[test] fn secret_entry_binds_actual_envelope_target_and_metadata() { let (dir, c, caller, item) = fixture(); let key = entry::EntryKey::open(&dir.path().join("entry.key")).unwrap(); let a = envelope(&key); let b = envelope(&key); let field = &item.fields[0].field_id; let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000").unwrap(); let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); register(&mut host, &mut auth); let binding = Binding::SecretEntry { item: item.id.clone(), field: field.clone(), ciphertext_sha256: a.sha256(), field_type: Some(types::FieldType::Note), key_type: None, }; for (target, env, kind) in [ ("wrong-field", &a, types::FieldType::Note), (field.as_str(), &b, types::FieldType::Note), (field.as_str(), &a, types::FieldType::Text), ] { let p = proof(&mut host, &mut auth, binding.clone()); let input = FieldInput { value: FieldValue::Encrypted { key: &key, envelope: env, }, field_type: Some(kind), key_type: None, }; assert_eq!( c.set_field_input(&item.id, target, &input, &caller, p) .unwrap_err() .0, "binding_mismatch" ); } assert!(c.item_get(&item.id).unwrap().fields[0].needs_value); let p = proof(&mut host, &mut auth, binding); let input = FieldInput { value: FieldValue::Encrypted { key: &key, envelope: &a, }, field_type: Some(types::FieldType::Note), key_type: None, }; c.set_field_input(&item.id, field, &input, &caller, p) .unwrap(); let current = c.item_get(&item.id).unwrap(); assert!(!current.fields[0].needs_value); assert_eq!(current.fields[0].field_type, types::FieldType::Note); }