#[cfg(target_arch = "wasm32")]
use wasm_bindgen_test::wasm_bindgen_test as test;
use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{Level, Members, MembersError};
use gunnar_crypt::userkey::{self, recovery_code, security_key, UserKey, UserKeyError};
use gunnar_crypt::wrap::{self, UnwrapError};
#[cfg(not(target_arch = "wasm32"))]
const TEST_KEY: &str = include_str!("vectors/test_ed25519");
const TEST_KEY_PUB: &str = include_str!("vectors/test_ed25519.pub");
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn prf(fill: u8) -> [u8; 32] {
[fill; 32]
}
#[test]
fn a_security_key_gives_the_vector_recipient_on_this_target() {
let p: [u8; 32] = std::array::from_fn(|i| i as u8);
let cred: Vec<u8> = (1..=16).collect();
assert_eq!(security_key::recipient(&p, &cred), "age163l93dqcd6xl9yc689yhcmmhdjva74a5haahfj30khnlc7j9qy9str6pg0");
let cred: Vec<u8> = (0..64).collect();
assert_eq!(security_key::recipient(&prf(0xA5), &cred), "age1zc0xvt6zgy283sz0dpjfculu3stwkc4u4hvmd8gde0h7pmh4g5wqkweyay");
assert_eq!(security_key::recipient(&prf(0xA5), &cred), security_key::recipient(&prf(0xA5), &cred), "fixed per credential");
assert_ne!(security_key::recipient(&prf(0xA5), &cred), security_key::recipient(&prf(0xA5), &cred[..63]), "the credential id is context");
assert_eq!(
security_key::hmac_secret_salt().to_vec(),
unhex("fdac5f81e153833ee47bdb6ddbd5e912c26d2e3ad614d73295f23efc611d5e3b"),
"the CTAP2 salt is WebAuthn's PRF salt, so the CLI and the browser get one secret"
);
}
#[test]
fn a_user_key_opens_with_its_security_key_and_not_with_another() {
let user = UserKey::generate(Level::Classic).unwrap();
let cred = b"credential-1";
let wrap = user.wrap_to_security_key(&security_key::recipient(&prf(7), cred)).unwrap();
let opened = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(7), cred), &user.public()).unwrap();
assert_eq!(opened.public(), user.public());
let other = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(8), cred), &user.public());
assert_eq!(other.unwrap_err(), UserKeyError::WrongKey);
let other_cred = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(7), b"credential-2"), &user.public());
assert_eq!(other_cred.unwrap_err(), UserKeyError::WrongKey);
assert!(UserKeyError::WrongKey.to_string().contains("not one of your unlock methods"));
}
#[test]
fn a_substituted_wrap_is_refused() {
let user = UserKey::generate(Level::Classic).unwrap();
let planted = UserKey::generate(Level::Classic).unwrap();
let wrap = planted.wrap_to_security_key(&security_key::recipient(&prf(1), b"c")).unwrap();
let r = userkey::open(wrap.as_bytes(), &security_key::identity(&prf(1), b"c"), &user.public());
assert_eq!(r.unwrap_err(), UserKeyError::NotThisAccountsKey);
}
#[test]
fn any_method_opens_every_ring_and_adding_one_touches_no_repository() {
let user = UserKey::generate(Level::Classic).unwrap();
let ring = Keyring::generate();
let mut members = Members::new();
members.add("alice", &user.public()).unwrap();
let keys = wrap::wrap(&ring, &members).unwrap();
let keys_before = keys.clone();
let by_key_one = user.wrap_to_security_key(&security_key::recipient(&prf(1), b"one")).unwrap();
let open_one = userkey::open(by_key_one.as_bytes(), &security_key::identity(&prf(1), b"one"), &user.public()).unwrap();
let by_key_two = open_one.wrap_to_security_key(&security_key::recipient(&prf(2), b"two")).unwrap();
for (w, id) in [(&by_key_one, security_key::identity(&prf(1), b"one")), (&by_key_two, security_key::identity(&prf(2), b"two"))] {
let k = userkey::open(w.as_bytes(), &id, &user.public()).unwrap();
let got = wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap();
assert_eq!(got, ring);
}
assert_eq!(keys, keys_before, "no repository was touched");
}
#[test]
fn a_member_is_added_without_being_present() {
let alice = UserKey::generate(Level::Classic).unwrap();
let bob = UserKey::generate(Level::Classic).unwrap();
let bob_public = bob.public();
let ring = Keyring::generate();
let mut members = Members::new();
members.add("alice", &alice.public()).unwrap();
members.add("bob", &bob_public).unwrap();
let keys = wrap::wrap(&ring, &members).unwrap();
let bob_wrap = bob.wrap_to_recovery_code(&recovery_code::from_bytes(&[3; 20])).unwrap();
drop(bob);
let bob = userkey::open_with_recovery_code(bob_wrap.as_bytes(), &recovery_code::from_bytes(&[3; 20]), &bob_public).unwrap();
assert_eq!(wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[bob.identity()]).unwrap(), ring);
let stranger = UserKey::generate(Level::Classic).unwrap();
assert!(matches!(
wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[stranger.identity()]),
Err(UnwrapError::NotARecipient)
));
}
#[test]
fn an_ssh_key_is_refused_as_a_member_by_name() {
let mut m = Members::new();
let e = m.add("alice", TEST_KEY_PUB.trim()).unwrap_err();
assert_eq!(e, MembersError::SshKeyIsAMethod { name: "alice".into() });
assert!(e.to_string().contains("encryption key"), "{e}");
let e = m.add("alice", "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t").unwrap_err();
assert_eq!(e, MembersError::SshKeyIsAMethod { name: "alice".into() });
}
#[test]
fn a_recovery_code_opens_and_a_wrong_one_is_named() {
let code = recovery_code::from_bytes(&[0xFF; 20]);
assert_eq!(code.len(), 32 + 7);
assert_eq!(&*recovery_code::normalize(&code.to_lowercase().replace('-', " ")).unwrap(), &code.replace('-', ""));
let user = UserKey::generate(Level::Classic).unwrap();
let wrap = user.wrap_to_recovery_code(&code).unwrap();
assert!(userkey::open_with_recovery_code(wrap.as_bytes(), &code, &user.public()).is_ok());
let wrong = recovery_code::from_bytes(&[0xFE; 20]);
assert_eq!(userkey::open_with_recovery_code(wrap.as_bytes(), &wrong, &user.public()).unwrap_err(), UserKeyError::WrongKey);
assert_eq!(userkey::open_with_recovery_code(wrap.as_bytes(), "1234", &user.public()).unwrap_err(), UserKeyError::BadRecoveryCode);
}
#[test]
fn a_wrap_that_holds_no_user_key_is_refused() {
let id = security_key::identity(&prf(9), b"c");
let mut out = Vec::new();
{
use std::io::Write as _;
let r = id.to_public();
let enc = gunnar_crypt::wrap::age::Encryptor::with_recipients(std::iter::once(&r as &dyn gunnar_crypt::wrap::age::Recipient)).unwrap();
let mut w = enc.wrap_output(&mut out).unwrap();
w.write_all(b"gunnar-crypt keyring v1\n").unwrap();
w.finish().unwrap();
}
assert_eq!(userkey::open(&out, &id, "age1x").unwrap_err(), UserKeyError::NotAUserKey);
assert!(matches!(userkey::open(b"not age", &id, "age1x"), Err(UserKeyError::Malformed(_))));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_ssh_key_file_opens_the_user_key_and_every_ring() {
let user = UserKey::generate(Level::Classic).unwrap();
let w = user.wrap_to_ssh(TEST_KEY_PUB).unwrap();
assert!(gunnar_crypt::policy::age_stanzas(w.as_bytes()).unwrap().iter().any(|s| s.tag == "ssh-ed25519"));
let ssh = wrap::ssh_identity(TEST_KEY.as_bytes(), None, wrap::NoPassphrase).unwrap();
let k = userkey::open(w.as_bytes(), ssh.as_ref(), &user.public()).unwrap();
let ring = Keyring::generate();
let mut members = Members::new();
members.add("alice", &user.public()).unwrap();
let keys = wrap::wrap(&ring, &members).unwrap();
assert_eq!(wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(), ring);
let stanzas = gunnar_crypt::policy::age_stanzas(keys.as_bytes()).unwrap();
assert!(stanzas.iter().all(|s| s.tag == "X25519" || s.tag.ends_with("-grease")), "the ring names no SSH key: {stanzas:?}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_quantum_safe_user_key_opens_a_quantum_safe_ring() {
let user = UserKey::generate(Level::QuantumSafe).unwrap();
assert!(user.public().starts_with("age1pq1"));
let w = user.wrap_to_ssh(TEST_KEY_PUB).unwrap();
let ssh = wrap::ssh_identity(TEST_KEY.as_bytes(), None, wrap::NoPassphrase).unwrap();
let k = userkey::open(w.as_bytes(), ssh.as_ref(), &user.public()).unwrap();
let mut members = Members::new();
members.set_level(Level::QuantumSafe).unwrap();
members.add("alice", &k.public()).unwrap();
let ring = Keyring::generate();
let keys = wrap::wrap(&ring, &members).unwrap();
assert_eq!(wrap::unwrap_checked(keys.as_bytes(), &members.to_text(), &[k.identity()]).unwrap(), ring);
}