Files
sausage/host/tests/cred_proofs.rs
T

260 lines
8.7 KiB
Rust

//! 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<'_>, auth: &mut WebauthnAuthenticator<SoftPasskey>) {
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<'_>,
auth: &mut WebauthnAuthenticator<SoftPasskey>,
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::<sha2::Sha256>::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);
}