A malicious server could re-serve an old but valid envelope of an item under a new, higher revision: the AAD does not bind the revision and the Merkle root was only checked when head_revision == cursor. The client took it as the latest version, rolled the item back and signed over it in its next manifest. * kv-model Item: `seq: u64` (serde default 0) and `restores: Option<u64>`. * kv-sync replay module: pure decision rules (check_successor, next_version, stamp, same_content). Every pulled live record must authenticate and have seq > the base's seq, unless it carries restores = t with base_revision < t < revision (edit beat a delete this device never saw) or is identical to the base. Otherwise: Integrity alarm. Tombstones and items without a base pass. * engine: check on every pulled record; edit-beats-delete re-seals with restores = tombstone revision; merges write seq = max + 1 and adopt the remote version when only seq/restores differ. Public engine::next_local_version gives the seq/restores for a local save (used by kv-ffi save_item and the tests). * verify_manifest checks the Merkle root whenever no held item is newer than head_revision (previously only at head == cursor). * Tests: replay alarm (clean and pending receivers, plus kv-ffi), harmless replay of the current version, tampered envelope, concurrent equal-seq edits across three devices, edit-beats-delete with a device that skipped the tombstone and a later replay of the restore, legacy items without seq, hidden item with cursor past the manifest head, and the shared item_replay.json decision table. * gen-vectors: envelope vector item payload now includes seq. FFI surface unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
306 lines
12 KiB
Rust
306 lines
12 KiB
Rust
//! 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<u8> {
|
|
(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<Value> = 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<Uuid>, Option<Uuid>, Vec<u8>, [u8; 24]);
|
|
let cases: Vec<Case> = 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<Value> = 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<u8>, 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<Value> = 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<Value> = 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<Value> = [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<Value> = [
|
|
(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<Value> = 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})
|
|
}
|