#[cfg(target_arch = "wasm32")]
use wasm_bindgen_test::wasm_bindgen_test as test;
use gunnar_crypt::envelope::{self, OpenError};
use gunnar_crypt::keyring::{ContinuityError, DataKey, IdKey, Keyring, KeyringError};
use gunnar_crypt::members::{classify, KeyKind, KeyRefusal, Members, MembersError};
use gunnar_crypt::policy::{self, Refusal};
const ED: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl alice@laptop";
const SK: &str = "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEX/dQ0v4127bEo8eeG1EV0ApO2lWbSnN6RWusn/NjqIAAAABHNzaDo= bob@yubikey";
fn ring2() -> Keyring {
let mut r = Keyring::new(
IdKey::from_bytes([3u8; 32]),
DataKey::from_bytes(1, [7u8; 64]),
);
r.rotate();
r
}
#[test]
fn an_envelope_round_trips_and_is_deterministic() {
let ring = Keyring::generate();
let a = envelope::seal(ring.newest(), b"the secret recipe\n");
let b = envelope::seal(ring.newest(), b"the secret recipe\n");
assert_eq!(
a, b,
"same key + same content = same blob, or git status never settles"
);
assert!(envelope::is_envelope(&a));
assert_eq!(envelope::epoch_of(&a), Some(1));
assert!(
!a.windows(6).any(|w| w == b"secret"),
"plaintext must not show through"
);
assert_eq!(envelope::open(&ring, &a).unwrap(), b"the secret recipe\n");
}
#[test]
fn empty_content_seals_too() {
let ring = Keyring::generate();
let e = envelope::seal(ring.newest(), b"");
assert_eq!(e.len(), envelope::V2_HEADER_LEN + envelope::TAG_LEN);
assert_eq!(envelope::open(&ring, &e).unwrap(), b"");
}
#[test]
fn a_different_key_gives_a_different_blob_so_ids_are_keyed() {
let a = envelope::seal(Keyring::generate().newest(), b"README\n");
let b = envelope::seal(Keyring::generate().newest(), b"README\n");
assert_ne!(a, b);
}
#[test]
fn a_tampered_envelope_or_a_wrong_key_does_not_open() {
let ring = Keyring::generate();
let mut e = envelope::seal(ring.newest(), b"payload");
let other = Keyring::generate();
assert_eq!(envelope::open(&other, &e), Err(OpenError::Corrupt));
let last = e.len() - 1;
e[last] ^= 1;
assert_eq!(envelope::open(&ring, &e), Err(OpenError::Corrupt));
}
#[test]
fn the_epoch_is_authenticated() {
let ring = ring2();
let mut e = envelope::seal(ring.get(1).unwrap(), b"old content");
e[12] = 2;
assert_eq!(envelope::open(&ring, &e), Err(OpenError::Corrupt));
}
#[test]
fn rotation_seals_new_content_under_the_new_epoch_and_old_content_still_opens() {
let mut ring = Keyring::generate();
let old = envelope::seal(ring.newest(), b"before");
assert_eq!(ring.rotate(), 2);
let new = envelope::seal(ring.newest(), b"after");
assert_eq!(envelope::epoch_of(&new), Some(2));
assert_eq!(envelope::open(&ring, &old).unwrap(), b"before");
let kept = Keyring::parse(&ring.truncated(1).to_text()).unwrap();
assert_eq!(envelope::open(&kept, &old).unwrap(), b"before");
assert_eq!(envelope::open(&kept, &new), Err(OpenError::UnknownEpoch(2)));
}
#[test]
fn not_an_envelope_and_a_future_version_are_named() {
let ring = Keyring::generate();
assert_eq!(
envelope::open(&ring, b"plain text"),
Err(OpenError::NotEnvelope)
);
let mut e = envelope::seal(ring.newest(), b"x");
e[8] = 9;
assert!(!envelope::is_envelope(&e));
assert_eq!(
envelope::open(&ring, &e),
Err(OpenError::UnsupportedVersion(9))
);
}
#[test]
fn the_key_ring_text_round_trips_and_never_debug_prints_a_key() {
let ring = ring2();
let text = ring.to_text();
assert!(text.starts_with("gunnar-crypt keyring v1\n"));
assert_eq!(Keyring::parse(&text).unwrap(), ring);
let dbg = format!("{ring:?}");
assert!(text.lines().nth(1).unwrap().starts_with("id-key "));
let b64 = text.lines().nth(2).unwrap().split(' ').nth(2).unwrap();
assert!(
!dbg.contains(b64),
"Debug must not print key material: {dbg}"
);
assert_eq!(ring.get(1).unwrap().id().len(), 16);
}
#[test]
fn a_payload_that_is_not_a_key_ring_is_refused() {
assert_eq!(
Keyring::parse("AWS_SECRET=hunter2\n"),
Err(KeyringError::NotAKeyring)
);
assert_eq!(
Keyring::parse("gunnar-crypt keyring v1\n"),
Err(KeyringError::Empty)
);
let gap = "gunnar-crypt keyring v1\nepoch 2 AAAA\n";
assert!(Keyring::parse(gap).is_err());
let mut no_id = String::from("gunnar-crypt keyring v1\n");
no_id.push_str(Keyring::generate().to_text().lines().nth(2).unwrap());
assert_eq!(Keyring::parse(&no_id), Err(KeyringError::BadIdKey));
}
#[test]
fn continuity_refuses_a_ring_that_drops_or_replaces_a_known_epoch() {
let ring = ring2();
let mut grown = ring.clone();
grown.rotate();
assert_eq!(ring.check_continuity(&grown), Ok(()));
let substituted = Keyring::generate();
assert_eq!(
ring.check_continuity(&substituted),
Err(ContinuityError::IdKeyChanged)
);
let mut forged = Keyring::new(ring.id_key().clone(), DataKey::from_bytes(1, [9u8; 64]));
forged.rotate();
assert_eq!(
ring.check_continuity(&forged),
Err(ContinuityError::Replaced(1))
);
let shorter = ring.truncated(1);
assert_eq!(
ring.check_continuity(&shorter),
Err(ContinuityError::Dropped(2))
);
}
#[test]
fn key_kinds_and_the_security_key_refusal() {
assert_eq!(classify(ED), Ok(KeyKind::SshEd25519));
assert_eq!(
classify("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ x"),
Ok(KeyKind::SshRsa)
);
assert_eq!(
classify("age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq"),
Ok(KeyKind::Age)
);
let sk = classify(SK).unwrap_err();
assert!(matches!(sk, KeyRefusal::SecurityKeyOnly(_)));
assert!(sk.to_string().contains("can only sign"), "{sk}");
assert!(matches!(
classify("ecdsa-sha2-nistp256 AAAAE2VjZHNh x"),
Err(KeyRefusal::Unsupported(_))
));
assert_eq!(classify("hello"), Err(KeyRefusal::Malformed));
}
#[test]
fn the_members_file_round_trips_adds_and_removes() {
let mut m = Members::new();
let alice = gunnar_crypt::wrap::age::x25519::Identity::generate().to_public().to_string();
m.add("alice", &alice).unwrap();
m.add(
"recovery",
"age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq",
)
.unwrap();
for ssh in [ED, SK] {
assert!(matches!(m.add("bob", ssh), Err(MembersError::SshKeyIsAMethod { .. })), "{ssh}");
}
assert!(matches!(m.add("alice", &alice), Err(MembersError::Duplicate(_))));
assert!(matches!(m.add("al ice", &alice), Err(MembersError::BadName(_))));
let text = m.to_text();
assert!(text.starts_with("# gunnar-crypt members v1"));
let back = Members::parse(&text).unwrap();
assert_eq!(back, m);
assert_eq!(back.names(), vec!["alice", "recovery"]);
let mut back = back;
assert_eq!(back.remove("alice"), 1);
assert!(!back.contains("alice"));
}
#[test]
fn the_server_check_wants_envelopes_except_the_schemes_own_files() {
let ring = Keyring::generate();
let env = envelope::seal(ring.newest(), b"fn main() {}\n");
assert_eq!(policy::check_blob("src/main.rs", false, &env), Ok(()));
assert_eq!(
policy::check_blob("src/main.rs", false, b"fn main() {}\n"),
Err(Refusal::Plaintext {
path: "src/main.rs".into()
})
);
assert_eq!(
policy::check_blob(".gitattributes", false, b"* filter=gunnar-crypt\n"),
Ok(())
);
assert_eq!(
policy::check_blob(".gunnar-crypt/members", false, ED.as_bytes()),
Ok(())
);
assert_eq!(
policy::check_blob(
".gunnar-crypt/keys",
false,
b"-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n"
),
Ok(())
);
assert_eq!(
policy::check_blob(".gunnar-crypt/keys", false, b"epoch 1 AAAA"),
Err(Refusal::KeysNotAge)
);
assert_eq!(policy::check_blob("sub/.gitattributes", false, b"x"), Ok(()));
assert!(policy::check_blob("sub/.gitattributes.bak", false, b"x").is_err());
assert!(policy::check_blob("sub/notes.txt", false, b"x").is_err());
assert!(matches!(
policy::check_blob("link", true, &env),
Err(Refusal::Symlink { .. })
));
let msg = Refusal::Plaintext { path: "a".into() }.to_string();
assert!(msg.contains("gunnar crypt unlock"), "{msg}");
}