Files
kestrove-core/crates/kv-crypto/vectors/gen.rs
T
samiandClaude Opus 5.5 abcb758b67 kv-sync: refuse per-item replays (seq/restores in the item plaintext)
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]>
2026-10-16 11:08:42 +09:00

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, &params).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})
}