M1: persistent vault, local unlock key, sync client, test vectors
kv-store: SQLite (bundled) and in-memory Store holding only ciphertext envelopes, with base copies for three-way merges and a 10-version history. kv-ffi: VaultCore::open(path); persisted account record; local unlock key (enable/unlock_with_local_key/disable); async sync API (configure_server, set_device_signer, register_account, login, sync_now, logout, devices); DeviceSigner foreign trait; IntegrityAlarm/PinMismatch/... error variants. kv-sync: reqwest + rustls (TLS 1.3 only, ring) with SPKI pinning; OPAQUE client on kv-proto's OpaqueSuite; RFC 9421 signing per kv_proto::httpsig; pull/push with If-Match, field-level three-way merge, edit-beats-delete; signed manifest (canonical CBOR, Merkle root, prev chain, Ed25519) with rollback/fork/Merkle checks as hard integrity alarms. Tested against a wiremock fake server, plus an #[ignore] test against kestrove-server. kv-crypto: gen-vectors example and tests reproducing kestrove-spec/vectors. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
//! 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}"#;
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user