260 lines
8.7 KiB
Rust
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);
|
|
}
|