//! Deterministic test-vector generation (kestrove-spec/vectors). Shared by `examples/gen-vectors.rs` //! and `tests/vectors.rs`. Every input is fixed, so regenerating must reproduce the files byte for byte. //! //! Byte strings are lowercase hex unless noted. Envelopes are given in their JSON wire form (base64url). #![allow(dead_code)] // each includer uses a different subset use kv_crypto::{ aad::{Aad, ObjectType}, aead::{pad, SymKey}, kdf, seal::{seal_to_with_rng, EncKeypair}, secret_key::SecretKey, }; use kv_model::{matching, totp::Totp}; use kv_proto::KdfParams; use serde_json::{json, Value}; use uuid::Uuid; pub const FILES: [&str; 6] = ["kdf", "envelope", "hpke", "secret_key_format", "totp", "matching"]; pub fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } pub fn unhex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() } fn id(n: u8) -> Uuid { // Valid v4 layout so every implementation's UUID parser accepts it. let mut b = [n; 16]; b[6] = 0x40 | (n & 0x0f); b[8] = 0x80 | (n & 0x3f); Uuid::from_bytes(b) } /// Returns `(file stem, JSON document)` for every vector file. pub fn generate() -> Vec<(&'static str, Value)> { vec![ ("kdf", kdf_vectors()), ("envelope", envelope_vectors()), ("hpke", hpke_vectors()), ("secret_key_format", secret_key_vectors()), ("totp", totp_vectors()), ("matching", matching_vectors()), ] } pub fn render(doc: &Value) -> String { let mut s = serde_json::to_string_pretty(doc).unwrap(); s.push('\n'); s } fn kdf_vectors() -> Value { let cases = [ ("correct horse battery staple", [0x01u8; 16], [0xa5u8; 16], id(0x11)), // Leading/trailing whitespace is trimmed and the password NFKD-normalized ("é" precomposed, "fi" ligature). (" caf\u{e9} \u{fb01}sh ", [0xffu8; 16], [0x00u8; 16], id(0x22)), ]; let vectors: Vec = cases .iter() .map(|(pw, sk, salt, account)| { let params = KdfParams { alg: "argon2id".into(), m_kib: 64 * 1024, t: 3, p: 4, salt: salt.to_vec() }; let secret_key = SecretKey::from_bytes(*sk); let d = kdf::derive_keys(pw, &secret_key, *account, ¶ms).unwrap(); json!({ "password": pw, "secret_key": hex(sk), "secret_key_display": secret_key.to_display().as_str(), "account_id": account.to_string(), "params": {"alg": "argon2id", "m_kib": params.m_kib, "t": params.t, "p": params.p, "salt": hex(salt)}, "root": hex(d.root.as_ref()), "auk": hex(d.auk.as_ref()), "auth_input": hex(d.auth_input.as_ref()), }) }) .collect(); json!({ "description": "2SKD (docs/02 'Derivation'): Argon2id(NFKD(trim(password))) + HKDF(secret_key) -> root -> AUK, AUTH_INPUT", "vectors": vectors, }) } fn envelope_vectors() -> Value { let account = id(0x01); let vault = id(0x02); let item = id(0x03); let key = SymKey::from_bytes(id(0x04), [0x42; 32]); let item_json = br#"{"id":"03030303-0303-4303-8303-030303030303","kind":"login","title":"Example","created_at":1,"modified_at":1,"seq":1}"#; type Case = (&'static str, ObjectType, Option, Option, Vec, [u8; 24]); let cases: Vec = vec![ ("item payload (padded)", ObjectType::Item, Some(vault), Some(item), pad(item_json).to_vec(), [0x01; 24]), ( "item key: kid || key", ObjectType::ItemKey, Some(vault), Some(item), [id(0x05).as_bytes().as_slice(), &[0x55; 32]].concat(), [0x02; 24], ), ("vault metadata", ObjectType::VaultMeta, Some(vault), None, br#"{"name":"Personal"}"#.to_vec(), [0x03; 24]), ("keyset (80 bytes)", ObjectType::Keyset, None, None, (0u8..80).collect(), [0x04; 24]), ]; let vectors: Vec = cases .into_iter() .map(|(name, ty, v, i, pt, nonce)| { let aad = Aad::build(ty, account, v, i); let env = key.seal_with_nonce(nonce, &pt, &aad).unwrap(); json!({ "name": name, "key": hex(key.expose()), "kid": key.id.to_string(), "nonce": hex(&nonce), "aad": hex(&aad), "plaintext": hex(&pt), "ciphertext": hex(&env.ct), "envelope": env, }) }) .collect(); json!({ "description": "XChaCha20-Poly1305 envelopes with location-binding AAD and 256-byte padding (docs/02 'Envelope format')", "aad_layout": "\"kestrove/v1/\" || type || 0x00 || account || 0x00 || vault || 0x00 || item (absent ids = nil UUID)", "account_id": account.to_string(), "vault_id": vault.to_string(), "item_id": item.to_string(), "vectors": vectors, }) } /// Yields fixed bytes: the HPKE ephemeral `ikmE`. struct FixedRng(Vec, usize); impl rand_core::RngCore for FixedRng { fn next_u32(&mut self) -> u32 { let mut b = [0u8; 4]; self.fill_bytes(&mut b); u32::from_le_bytes(b) } fn next_u64(&mut self) -> u64 { let mut b = [0u8; 8]; self.fill_bytes(&mut b); u64::from_le_bytes(b) } fn fill_bytes(&mut self, dest: &mut [u8]) { for d in dest { *d = self.0[self.1 % self.0.len()]; self.1 += 1; } } fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { self.fill_bytes(dest); Ok(()) } } impl rand_core::CryptoRng for FixedRng {} fn hpke_vectors() -> Value { let account = id(0x01); let cases = [([0x11u8; 32], [0x77u8; 32], id(0x02)), ([0x2au8; 32], [0x03u8; 32], id(0x09))]; let vectors: Vec = cases .iter() .map(|(sk, ikm_e, vault)| { let kp = EncKeypair::from_secret_bytes(id(0x0e), sk).unwrap(); let aad = Aad::build(ObjectType::VaultKey, account, Some(*vault), None); let pt = [id(0x0a).as_bytes().as_slice(), &[0xab; 32]].concat(); let env = seal_to_with_rng(kp.id, &kp.public_bytes(), &pt, &aad, &mut FixedRng(ikm_e.to_vec(), 0)).unwrap(); json!({ "recipient_sk": hex(sk), "recipient_pk": hex(&kp.public_bytes()), "recipient_kid": kp.id.to_string(), "ikm_e": hex(ikm_e), "info": hex(b"kestrove/v1/hpke"), "vault_id": vault.to_string(), "aad": hex(&aad), "plaintext": hex(&pt), "enc": hex(&env.enc), "ciphertext": hex(&env.ct), "envelope": env, }) }) .collect(); json!({ "description": "HPKE base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305 sealing a vault key (vault key id || key) to the account. ikm_e is the RFC 9180 DeriveKeyPair input for the ephemeral key.", "account_id": account.to_string(), "vectors": vectors, }) } fn secret_key_vectors() -> Value { let raws: [[u8; 16]; 5] = [ [0x00; 16], [0xff; 16], [0x80; 16], [0xab; 16], *b"\x01\x23\x45\x67\x89\xab\xcd\xef\xfe\xdc\xba\x98\x76\x54\x32\x10", ]; let valid: Vec = raws .iter() .map(|r| json!({"raw": hex(r), "display": SecretKey::from_bytes(*r).to_display().as_str()})) .collect(); let base = SecretKey::from_bytes(raws[4]).to_display().to_string(); let lower = base.to_lowercase(); let spaced = base.replace('-', " "); let compact = base.replace('-', ""); // Crockford confusables: 0 may be typed as O, 1 as I or L. let confusable = format!("K1{}", base[2..].replace('0', "O").replace('1', "I")); let alternate: Vec = [lower, spaced, compact, confusable] .into_iter() .map(|s| { assert_eq!(SecretKey::parse(&s).unwrap().expose(), &raws[4], "{s}"); json!({"input": s, "raw": hex(&raws[4])}) }) .collect(); let check = base.chars().last().unwrap(); let other_check = if check == '0' { '1' } else { '0' }; let mut bad_checksum = base.clone(); bad_checksum.pop(); bad_checksum.push(other_check); let max = SecretKey::from_bytes([0xff; 16]).to_display().to_string(); let invalid: Vec = [ (bad_checksum, "checksum"), (base.replacen("K1", "K2", 1), "prefix"), (base[..base.len() - 2].to_string(), "length"), (max.replacen("K1-7", "K1-8", 1), "overflow: first symbol carries only 3 bits"), (base.replacen('-', "-U", 1)[..base.len()].to_string(), "invalid symbol U"), ] .into_iter() .map(|(s, why)| { assert!(SecretKey::parse(&s).is_err(), "{s} should be invalid ({why})"); json!({"input": s, "reason": why}) }) .collect(); json!({ "description": "Secret Key display format K1-XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX-C: 26 Crockford base32 symbols of the 128-bit key (MSB first, 2 zero pad bits at the top), check symbol = SHA-256(raw)[0] mod 32", "alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ", "valid": valid, "alternate_inputs": alternate, "invalid": invalid, }) } fn totp_vectors() -> Value { let b32 = |s: &[u8]| data_encoding::BASE32_NOPAD.encode(s); let seeds: [(&str, &[u8]); 3] = [ ("SHA1", b"12345678901234567890"), ("SHA256", b"12345678901234567890123456789012"), ("SHA512", b"1234567890123456789012345678901234567890123456789012345678901234"), ]; let mut vectors = Vec::new(); for t in [59u64, 1_111_111_109, 1_111_111_111, 1_234_567_890, 2_000_000_000, 20_000_000_000] { for (alg, seed) in seeds { let uri = format!("otpauth://totp/RFC6238?secret={}&algorithm={alg}&digits=8&period=30", b32(seed)); let code = Totp::parse(&uri).unwrap().code_at(t); vectors.push(json!({"source": "RFC 6238 Appendix B", "uri": uri, "time": t, "code": code})); } } let defaults = b32(b"12345678901234567890"); for t in [59u64, 1_111_111_109] { vectors.push(json!({"source": "bare base32 secret, defaults (SHA1, 6 digits, 30 s)", "uri": defaults, "time": t, "code": Totp::parse(&defaults).unwrap().code_at(t)})); } let custom = format!( "otpauth://totp/x?secret={}&digits=6&period=60&algorithm=SHA256", b32(b"12345678901234567890123456789012") ); vectors.push(json!({"source": "60 s period", "uri": custom, "time": 1_111_111_109u64, "code": Totp::parse(&custom).unwrap().code_at(1_111_111_109)})); json!({"description": "RFC 6238 TOTP (HMAC-SHA1/256/512). `uri` is what Totp::parse accepts.", "vectors": vectors}) } fn matching_vectors() -> Value { let cases = [ ("https://example.com", "https://example.com/login"), ("example.com", "https://example.com/"), ("https://example.com:8443", "https://example.com"), ("https://example.com", "https://login.example.com"), ("https://login.example.com", "https://example.com"), ("https://a.github.io", "https://b.github.io"), ("https://example.com", "https://example.com.evil.net"), ("https://example.com", "http://example.com"), ("http://example.com", "https://example.com"), ("https://example.co.uk", "https://other.co.uk"), ("https://shop.example.co.uk", "https://www.example.co.uk/cart"), ("https://EXAMPLE.com", "https://example.COM/"), ("https://192.168.1.10:8080", "https://192.168.1.10:8080/admin"), ("not a url", "https://example.com"), ("ftp://example.com", "https://example.com"), ]; let vectors: Vec = cases .iter() .map(|(item, page)| { let level = match matching::match_url(item, page) { matching::MatchLevel::None => "none", matching::MatchLevel::Domain => "domain", matching::MatchLevel::Host => "host", matching::MatchLevel::Exact => "exact", }; json!({"item_url": item, "page_url": page, "level": level}) }) .collect(); json!({"description": "Autofill URL matching (docs/05): exact > host > domain (eTLD+1 via the PSL) > none. http items may match https pages, never the reverse.", "vectors": vectors}) }