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:
2026-10-15 21:52:14 +09:00
co-authored by Claude Opus 5.5
parent cbef689903
commit 2be9a3baad
35 changed files with 6832 additions and 183 deletions
+9
View File
@@ -19,3 +19,12 @@ chacha20poly1305.workspace = true
hpke.workspace = true
ed25519-dalek.workspace = true
unicode-normalization.workspace = true
[dev-dependencies]
kv-model.workspace = true
serde_json.workspace = true
data-encoding.workspace = true
[[example]]
name = "gen-vectors"
path = "examples/gen-vectors.rs"
+32
View File
@@ -0,0 +1,32 @@
//! Writes the cross-implementation test vectors.
//!
//! ```sh
//! cargo run -p kv-crypto --example gen-vectors # in-repo copy + ../kestrove-spec/vectors if present
//! cargo run -p kv-crypto --example gen-vectors -- <dir> # explicit output directory
//! ```
use std::path::PathBuf;
#[path = "../vectors/gen.rs"]
mod gen;
fn main() {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut dirs: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if dirs.is_empty() {
dirs.push(manifest.join("tests/vectors"));
let spec = manifest.join("../../../kestrove-spec/vectors");
if spec.is_dir() {
dirs.push(spec);
}
}
let docs = gen::generate();
for dir in &dirs {
std::fs::create_dir_all(dir).expect("create output dir");
for (name, doc) in &docs {
let path = dir.join(format!("{name}.json"));
std::fs::write(&path, gen::render(doc)).expect("write vector file");
println!("wrote {}", path.display());
}
}
}
+6 -1
View File
@@ -30,8 +30,13 @@ impl SymKey {
}
pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
self.seal_with_nonce(random_bytes(), plaintext, aad)
}
/// Deterministic variant for test vectors. Never reuse a nonce with the same key.
#[doc(hidden)]
pub fn seal_with_nonce(&self, nonce: [u8; 24], plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.bytes[..]));
let nonce: [u8; 24] = random_bytes();
let ct = cipher
.encrypt(XNonce::from_slice(&nonce), Payload { msg: plaintext, aad })
.map_err(|_| CryptoError::Encrypt)?;
+3 -1
View File
@@ -12,6 +12,8 @@ use crate::{random_bytes, secret_key::SecretKey, CryptoError, Result};
/// Keys derived from master password + Secret Key.
pub struct DerivedKeys {
/// Root key both outputs are expanded from. Exposed for test vectors only.
pub root: Zeroizing<[u8; 32]>,
/// Account Unlock Key: wraps the account keyset.
pub auk: Zeroizing<[u8; 32]>,
/// Fed to OPAQUE as the "password". Independent of `auk`.
@@ -90,7 +92,7 @@ pub(crate) fn derive_keys_unchecked(
hk.expand(b"kestrove/v1/auk", auk.as_mut()).map_err(|_| CryptoError::Kdf)?;
hk.expand(b"kestrove/v1/opaque-pwd", auth_input.as_mut()).map_err(|_| CryptoError::Kdf)?;
Ok(DerivedKeys { auk, auth_input })
Ok(DerivedKeys { root, auk, auth_input })
}
#[cfg(test)]
+16 -9
View File
@@ -60,16 +60,23 @@ impl EncKeypair {
/// Seals `plaintext` to a recipient public key (raw 32-byte X25519).
pub fn seal_to(recipient_id: Uuid, recipient_pk: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
seal_to_with_rng(recipient_id, recipient_pk, plaintext, aad, &mut OsRng)
}
/// Same as [`seal_to`] with a caller-supplied RNG. The first 32 bytes it yields are the
/// ephemeral `ikmE` of RFC 9180 `DeriveKeyPair`. Only for test vectors.
#[doc(hidden)]
pub fn seal_to_with_rng<R: rand_core::CryptoRng + rand_core::RngCore>(
recipient_id: Uuid,
recipient_pk: &[u8],
plaintext: &[u8],
aad: &[u8],
rng: &mut R,
) -> Result<Envelope> {
let pk = <Kem as KemTrait>::PublicKey::from_bytes(recipient_pk).map_err(|_| CryptoError::Malformed)?;
let (encapped, ct) = hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
&OpModeS::Base,
&pk,
INFO,
plaintext,
aad,
&mut OsRng,
)
.map_err(|_| CryptoError::Encrypt)?;
let (encapped, ct) =
hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(&OpModeS::Base, &pk, INFO, plaintext, aad, rng)
.map_err(|_| CryptoError::Encrypt)?;
Ok(Envelope {
v: FORMAT_VERSION,
alg: Alg::HpkeX25519ChaCha,
+100
View File
@@ -0,0 +1,100 @@
//! The code must reproduce the published test vectors byte for byte, and the vectors must decode.
use std::path::PathBuf;
use kv_crypto::{aead::SymKey, seal::EncKeypair, secret_key::SecretKey};
use kv_proto::Envelope;
use serde_json::Value;
use uuid::Uuid;
#[path = "../vectors/gen.rs"]
mod gen;
fn dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors")
}
fn load(name: &str) -> Value {
let text = std::fs::read_to_string(dir().join(format!("{name}.json"))).expect("vector file (run gen-vectors)");
serde_json::from_str(&text).unwrap()
}
#[test]
fn code_reproduces_vector_files() {
let spec = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../kestrove-spec/vectors");
for (name, doc) in gen::generate() {
let rendered = gen::render(&doc);
let on_disk = std::fs::read_to_string(dir().join(format!("{name}.json"))).unwrap();
assert_eq!(rendered, on_disk, "{name}.json differs from what the code produces");
// When the spec repo is checked out next to this one, it must carry the same files.
if let Ok(published) = std::fs::read_to_string(spec.join(format!("{name}.json"))) {
assert_eq!(published, on_disk, "kestrove-spec/vectors/{name}.json is stale");
}
}
assert_eq!(gen::FILES.len(), gen::generate().len());
}
#[test]
fn envelopes_open() {
for v in load("envelope")["vectors"].as_array().unwrap() {
let key: [u8; 32] = gen::unhex(v["key"].as_str().unwrap()).try_into().unwrap();
let key = SymKey::from_bytes(Uuid::parse_str(v["kid"].as_str().unwrap()).unwrap(), key);
let env: Envelope = serde_json::from_value(v["envelope"].clone()).unwrap();
let pt = key.open(&env, &gen::unhex(v["aad"].as_str().unwrap())).unwrap();
assert_eq!(gen::hex(&pt), v["plaintext"].as_str().unwrap());
assert_eq!(gen::hex(&env.ct), v["ciphertext"].as_str().unwrap());
}
}
#[test]
fn hpke_opens() {
for v in load("hpke")["vectors"].as_array().unwrap() {
let kp = EncKeypair::from_secret_bytes(
Uuid::parse_str(v["recipient_kid"].as_str().unwrap()).unwrap(),
&gen::unhex(v["recipient_sk"].as_str().unwrap()),
)
.unwrap();
assert_eq!(gen::hex(&kp.public_bytes()), v["recipient_pk"].as_str().unwrap());
let env: Envelope = serde_json::from_value(v["envelope"].clone()).unwrap();
let pt = kp.open(&env, &gen::unhex(v["aad"].as_str().unwrap())).unwrap();
assert_eq!(gen::hex(&pt), v["plaintext"].as_str().unwrap());
}
}
#[test]
fn secret_keys_parse() {
let doc = load("secret_key_format");
for v in doc["valid"].as_array().unwrap() {
let k = SecretKey::parse(v["display"].as_str().unwrap()).unwrap();
assert_eq!(gen::hex(k.expose()), v["raw"].as_str().unwrap());
}
for v in doc["alternate_inputs"].as_array().unwrap() {
let k = SecretKey::parse(v["input"].as_str().unwrap()).unwrap();
assert_eq!(gen::hex(k.expose()), v["raw"].as_str().unwrap());
}
for v in doc["invalid"].as_array().unwrap() {
assert!(SecretKey::parse(v["input"].as_str().unwrap()).is_err(), "{v}");
}
}
#[test]
fn rfc6238_codes_are_the_published_ones() {
// Spot-check against the RFC 6238 Appendix B table itself, not only our own output.
let doc = load("totp");
let find = |t: u64, alg: &str| {
doc["vectors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["time"] == t && v["uri"].as_str().unwrap().contains(&format!("algorithm={alg}&")))
.unwrap()["code"]
.as_str()
.unwrap()
.to_string()
};
assert_eq!(find(59, "SHA1"), "94287082");
assert_eq!(find(59, "SHA256"), "46119246");
assert_eq!(find(59, "SHA512"), "90693936");
assert_eq!(find(20_000_000_000, "SHA1"), "65353130");
assert_eq!(find(20_000_000_000, "SHA512"), "47863826");
}
@@ -0,0 +1,73 @@
{
"aad_layout": "\"kestrove/v1/\" || type || 0x00 || account || 0x00 || vault || 0x00 || item (absent ids = nil UUID)",
"account_id": "01010101-0101-4101-8101-010101010101",
"description": "XChaCha20-Poly1305 envelopes with location-binding AAD and 256-byte padding (docs/02 'Envelope format')",
"item_id": "03030303-0303-4303-8303-030303030303",
"vault_id": "02020202-0202-4202-8202-020202020202",
"vectors": [
{
"aad": "6b657374726f76652f76312f6974656d000101010101014101810101010101010100020202020202420282020202020202020003030303030343038303030303030303",
"ciphertext": "0ae824b61b2253f40015d22f52ee7412e5be95cf17185bd8490e0e77200d50b5f629c6dfd2e2bccc1d86a06c6453150a96e1c8fc81f1a82b4830f511e831aa66438d35ed9c7d6d11d904b3d53641f05175066f63c0d985ea91738efcd7afb6a205b4a8471fc827a11c51cb598e429227ef2c3020ccdd749f254538ca5429e95f70b7258d87a6b52018976695a15b6e77e6c47a49609fa691f602dbeb4a15ea73c87909c7267d7bf84de31762480feab0b96553c2d4046b5ed00c899e30175fbd7333e9da01329d89a3158a715a85a83d81b8a0105e3c2cdd0ffb2f52e1971d0c7ac1c57504fea274b883fd4b65dae757d758cb3ecd7828e7edd44b35cbba452261039e436beeb3aeba178d379a54abf9",
"envelope": {
"alg": "xc20p",
"ct": "CugkthsiU_QAFdIvUu50EuW-lc8XGFvYSQ4OdyANULX2Kcbf0uK8zB2GoGxkUxUKluHI_IHxqCtIMPUR6DGqZkONNe2cfW0R2QSz1TZB8FF1Bm9jwNmF6pFzjvzXr7aiBbSoRx_IJ6EcUctZjkKSJ-8sMCDM3XSfJUU4ylQp6V9wtyWNh6a1IBiXZpWhW2535sR6SWCfppH2AtvrShXqc8h5CccmfXv4TeMXYkgP6rC5ZVPC1ARrXtAMiZ4wF1-9czPp2gEynYmjFYpxWoWoPYG4oBBePCzdD_svUuGXHQx6wcV1BP6idLiD_Utl2udX11jLPs14KOft1Es1y7pFImEDnkNr7rOuuheNN5pUq_k",
"kid": "04040404-0404-4404-8404-040404040404",
"n": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
"v": 1
},
"key": "4242424242424242424242424242424242424242424242424242424242424242",
"kid": "04040404-0404-4404-8404-040404040404",
"name": "item payload (padded)",
"nonce": "010101010101010101010101010101010101010101010101",
"plaintext": "6d0000007b226964223a2230333033303330332d303330332d343330332d383330332d303330333033303330333033222c226b696e64223a226c6f67696e222c227469746c65223a224578616d706c65222c22637265617465645f6174223a312c226d6f6469666965645f6174223a317d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"aad": "6b657374726f76652f76312f6974656d2d6b6579000101010101014101810101010101010100020202020202420282020202020202020003030303030343038303030303030303",
"ciphertext": "89b9440109da7c2a9fa4c5d03a25c7a047b11f9133e7e8ddcff986029675c5106f9240f5ca69911f73dc1106e3f3d32bcc8470e999d6818689daee36907279ec",
"envelope": {
"alg": "xc20p",
"ct": "iblEAQnafCqfpMXQOiXHoEexH5Ez5-jdz_mGApZ1xRBvkkD1ymmRH3PcEQbj89MrzIRw6ZnWgYaJ2u42kHJ57A",
"kid": "04040404-0404-4404-8404-040404040404",
"n": "AgICAgICAgICAgICAgICAgICAgICAgIC",
"v": 1
},
"key": "4242424242424242424242424242424242424242424242424242424242424242",
"kid": "04040404-0404-4404-8404-040404040404",
"name": "item key: kid || key",
"nonce": "020202020202020202020202020202020202020202020202",
"plaintext": "050505050505450585050505050505055555555555555555555555555555555555555555555555555555555555555555"
},
{
"aad": "6b657374726f76652f76312f7661756c742d6d657461000101010101014101810101010101010100020202020202420282020202020202020000000000000000000000000000000000",
"ciphertext": "0ee485ea37c4e9b6f4ea55e7381f12dbd4390a34b4d1342f9043da961bdcd3b47d1986",
"envelope": {
"alg": "xc20p",
"ct": "DuSF6jfE6bb06lXnOB8S29Q5CjS00TQvkEPalhvc07R9GYY",
"kid": "04040404-0404-4404-8404-040404040404",
"n": "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD",
"v": 1
},
"key": "4242424242424242424242424242424242424242424242424242424242424242",
"kid": "04040404-0404-4404-8404-040404040404",
"name": "vault metadata",
"nonce": "030303030303030303030303030303030303030303030303",
"plaintext": "7b226e616d65223a22506572736f6e616c227d"
},
{
"aad": "6b657374726f76652f76312f6b6579736574000101010101014101810101010101010100000000000000000000000000000000000000000000000000000000000000000000",
"ciphertext": "e7b3287dd61ee4062f51d81ed4bc13841733671cfd05429e42f7c820906a73e8d8ca3f083b0713fa560a597c65c18cceccbf90ae2a17e711f8d3a9b7c94ba4561d2e0f5d63bbd4bb4f6d5c81b0d903cad7f19e5a89abb43973269f37f8aad8e5",
"envelope": {
"alg": "xc20p",
"ct": "57MofdYe5AYvUdge1LwThBczZxz9BUKeQvfIIJBqc-jYyj8IOwcT-lYKWXxlwYzOzL-QrioX5xH406m3yUukVh0uD11ju9S7T21cgbDZA8rX8Z5aiau0OXMmnzf4qtjl",
"kid": "04040404-0404-4404-8404-040404040404",
"n": "BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
"v": 1
},
"key": "4242424242424242424242424242424242424242424242424242424242424242",
"kid": "04040404-0404-4404-8404-040404040404",
"name": "keyset (80 bytes)",
"nonce": "040404040404040404040404040404040404040404040404",
"plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f"
}
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"account_id": "01010101-0101-4101-8101-010101010101",
"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.",
"vectors": [
{
"aad": "6b657374726f76652f76312f7661756c742d6b6579000101010101014101810101010101010100020202020202420282020202020202020000000000000000000000000000000000",
"ciphertext": "41e270319f576cf6e10bd2981b866b9a296b42b810e5b10f617f2b03fabe54e1dbbc434382b79b007dff7734de75e03f2a99cb54ec0d4be64c0972211fa65442",
"enc": "e62492d40ab10d0f43fdaa89e1f2107e1e2d059d23a1f00c487a7e62b5c6e67d",
"envelope": {
"alg": "hpke-x25519-chacha",
"ct": "QeJwMZ9XbPbhC9KYG4ZrmilrQrgQ5bEPYX8rA_q-VOHbvENDgrebAH3_dzTedeA_KpnLVOwNS-ZMCXIhH6ZUQg",
"enc": "5iSS1AqxDQ9D_aqJ4fIQfh4tBZ0jofAMSHp-YrXG5n0",
"kid": "0e0e0e0e-0e0e-4e0e-8e0e-0e0e0e0e0e0e",
"v": 1
},
"ikm_e": "7777777777777777777777777777777777777777777777777777777777777777",
"info": "6b657374726f76652f76312f68706b65",
"plaintext": "0a0a0a0a0a0a4a0a8a0a0a0a0a0a0a0aabababababababababababababababababababababababababababababababab",
"recipient_kid": "0e0e0e0e-0e0e-4e0e-8e0e-0e0e0e0e0e0e",
"recipient_pk": "7b4e909bbe7ffe44c465a220037d608ee35897d31ef972f07f74892cb0f73f13",
"recipient_sk": "1111111111111111111111111111111111111111111111111111111111111111",
"vault_id": "02020202-0202-4202-8202-020202020202"
},
{
"aad": "6b657374726f76652f76312f7661756c742d6b6579000101010101014101810101010101010100090909090909490989090909090909090000000000000000000000000000000000",
"ciphertext": "da381938bfd0c81106206bca02b17c5bade147d88e21e6627aeac640c232e489c5a54cf659e27c6b272bbce1df895f998a3ed250b4b299a3d2485a0f05519a4b",
"enc": "90ab790ecbf0704232ffe9436faeccc913b66a60e99c688a40bba4d5e2e61450",
"envelope": {
"alg": "hpke-x25519-chacha",
"ct": "2jgZOL_QyBEGIGvKArF8W63hR9iOIeZieurGQMIy5InFpUz2WeJ8aycrvOHfiV-Zij7SULSymaPSSFoPBVGaSw",
"enc": "kKt5DsvwcEIy_-lDb67MyRO2amDpnGiKQLuk1eLmFFA",
"kid": "0e0e0e0e-0e0e-4e0e-8e0e-0e0e0e0e0e0e",
"v": 1
},
"ikm_e": "0303030303030303030303030303030303030303030303030303030303030303",
"info": "6b657374726f76652f76312f68706b65",
"plaintext": "0a0a0a0a0a0a4a0a8a0a0a0a0a0a0a0aabababababababababababababababababababababababababababababababab",
"recipient_kid": "0e0e0e0e-0e0e-4e0e-8e0e-0e0e0e0e0e0e",
"recipient_pk": "07aaff3e9fc167275544f4c3a6a17cd837f2ec6e78cd8a57b1e3dfb3cc035a76",
"recipient_sk": "2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a",
"vault_id": "09090909-0909-4909-8909-090909090909"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"description": "2SKD (docs/02 'Derivation'): Argon2id(NFKD(trim(password))) + HKDF(secret_key) -> root -> AUK, AUTH_INPUT",
"vectors": [
{
"account_id": "11111111-1111-4111-9111-111111111111",
"auk": "33e9bfb720e3cb5092677bf71d58781f7c032840299d5cc0f10c59b6d0b868d6",
"auth_input": "0eeae51fc4c439082b91211477c5e6fd792c98f1781a7b9808dbb39b54483096",
"params": {
"alg": "argon2id",
"m_kib": 65536,
"p": 4,
"salt": "a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5",
"t": 3
},
"password": "correct horse battery staple",
"root": "c6b0513bc1244cdabe9b2ec388b5cd441b8d25a3ba0ab8834f9fb231ad89a5e7",
"secret_key": "01010101010101010101010101010101",
"secret_key_display": "K1-01040G-20810-40G20-81040-G2081-C"
},
{
"account_id": "22222222-2222-4222-a222-222222222222",
"auk": "fc443a82cb371b0a33672509e61b64c08cd26bdc4ce0cdc726e98adc25906068",
"auth_input": "77a83f16adf68bae8fe9ee448e7b2ab60ff2ea5234fba69816d5b560700c18a1",
"params": {
"alg": "argon2id",
"m_kib": 65536,
"p": 4,
"salt": "00000000000000000000000000000000",
"t": 3
},
"password": " café fish ",
"root": "da0438d0cb15483dff9f4f569741911fdde9dee37b116c11402480c8f4d9b406",
"secret_key": "ffffffffffffffffffffffffffffffff",
"secret_key_display": "K1-7ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-T"
}
]
}
@@ -0,0 +1,80 @@
{
"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": [
{
"item_url": "https://example.com",
"level": "exact",
"page_url": "https://example.com/login"
},
{
"item_url": "example.com",
"level": "exact",
"page_url": "https://example.com/"
},
{
"item_url": "https://example.com:8443",
"level": "host",
"page_url": "https://example.com"
},
{
"item_url": "https://example.com",
"level": "domain",
"page_url": "https://login.example.com"
},
{
"item_url": "https://login.example.com",
"level": "domain",
"page_url": "https://example.com"
},
{
"item_url": "https://a.github.io",
"level": "none",
"page_url": "https://b.github.io"
},
{
"item_url": "https://example.com",
"level": "none",
"page_url": "https://example.com.evil.net"
},
{
"item_url": "https://example.com",
"level": "none",
"page_url": "http://example.com"
},
{
"item_url": "http://example.com",
"level": "host",
"page_url": "https://example.com"
},
{
"item_url": "https://example.co.uk",
"level": "none",
"page_url": "https://other.co.uk"
},
{
"item_url": "https://shop.example.co.uk",
"level": "domain",
"page_url": "https://www.example.co.uk/cart"
},
{
"item_url": "https://EXAMPLE.com",
"level": "exact",
"page_url": "https://example.COM/"
},
{
"item_url": "https://192.168.1.10:8080",
"level": "exact",
"page_url": "https://192.168.1.10:8080/admin"
},
{
"item_url": "not a url",
"level": "none",
"page_url": "https://example.com"
},
{
"item_url": "ftp://example.com",
"level": "none",
"page_url": "https://example.com"
}
]
}
@@ -0,0 +1,66 @@
{
"alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
"alternate_inputs": [
{
"input": "k1-014d2p-f2dbs-qqzxq-5tk1v-58cgg-1",
"raw": "0123456789abcdeffedcba9876543210"
},
{
"input": "K1 014D2P F2DBS QQZXQ 5TK1V 58CGG 1",
"raw": "0123456789abcdeffedcba9876543210"
},
{
"input": "K1014D2PF2DBSQQZXQ5TK1V58CGG1",
"raw": "0123456789abcdeffedcba9876543210"
},
{
"input": "K1-OI4D2P-F2DBS-QQZXQ-5TKIV-58CGG-I",
"raw": "0123456789abcdeffedcba9876543210"
}
],
"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",
"invalid": [
{
"input": "K1-014D2P-F2DBS-QQZXQ-5TK1V-58CGG-0",
"reason": "checksum"
},
{
"input": "K2-014D2P-F2DBS-QQZXQ-5TK1V-58CGG-1",
"reason": "prefix"
},
{
"input": "K1-014D2P-F2DBS-QQZXQ-5TK1V-58CGG",
"reason": "length"
},
{
"input": "K1-8ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-T",
"reason": "overflow: first symbol carries only 3 bits"
},
{
"input": "K1-U014D2P-F2DBS-QQZXQ-5TK1V-58CGG-",
"reason": "invalid symbol U"
}
],
"valid": [
{
"display": "K1-000000-00000-00000-00000-00000-Q",
"raw": "00000000000000000000000000000000"
},
{
"display": "K1-7ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-T",
"raw": "ffffffffffffffffffffffffffffffff"
},
{
"display": "K1-40G208-1040G-20810-40G20-81040-E",
"raw": "80808080808080808080808080808080"
},
{
"display": "K1-5BNENT-QAXBN-ENTQA-XBNEN-TQAXB-T",
"raw": "abababababababababababababababab"
},
{
"display": "K1-014D2P-F2DBS-QQZXQ-5TK1V-58CGG-1",
"raw": "0123456789abcdeffedcba9876543210"
}
]
}
+131
View File
@@ -0,0 +1,131 @@
{
"description": "RFC 6238 TOTP (HMAC-SHA1/256/512). `uri` is what Totp::parse accepts.",
"vectors": [
{
"code": "94287082",
"source": "RFC 6238 Appendix B",
"time": 59,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30"
},
{
"code": "46119246",
"source": "RFC 6238 Appendix B",
"time": 59,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&algorithm=SHA256&digits=8&period=30"
},
{
"code": "90693936",
"source": "RFC 6238 Appendix B",
"time": 59,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA&algorithm=SHA512&digits=8&period=30"
},
{
"code": "07081804",
"source": "RFC 6238 Appendix B",
"time": 1111111109,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30"
},
{
"code": "68084774",
"source": "RFC 6238 Appendix B",
"time": 1111111109,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&algorithm=SHA256&digits=8&period=30"
},
{
"code": "25091201",
"source": "RFC 6238 Appendix B",
"time": 1111111109,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA&algorithm=SHA512&digits=8&period=30"
},
{
"code": "14050471",
"source": "RFC 6238 Appendix B",
"time": 1111111111,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30"
},
{
"code": "67062674",
"source": "RFC 6238 Appendix B",
"time": 1111111111,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&algorithm=SHA256&digits=8&period=30"
},
{
"code": "99943326",
"source": "RFC 6238 Appendix B",
"time": 1111111111,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA&algorithm=SHA512&digits=8&period=30"
},
{
"code": "89005924",
"source": "RFC 6238 Appendix B",
"time": 1234567890,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30"
},
{
"code": "91819424",
"source": "RFC 6238 Appendix B",
"time": 1234567890,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&algorithm=SHA256&digits=8&period=30"
},
{
"code": "93441116",
"source": "RFC 6238 Appendix B",
"time": 1234567890,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA&algorithm=SHA512&digits=8&period=30"
},
{
"code": "69279037",
"source": "RFC 6238 Appendix B",
"time": 2000000000,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30"
},
{
"code": "90698825",
"source": "RFC 6238 Appendix B",
"time": 2000000000,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&algorithm=SHA256&digits=8&period=30"
},
{
"code": "38618901",
"source": "RFC 6238 Appendix B",
"time": 2000000000,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA&algorithm=SHA512&digits=8&period=30"
},
{
"code": "65353130",
"source": "RFC 6238 Appendix B",
"time": 20000000000,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30"
},
{
"code": "77737706",
"source": "RFC 6238 Appendix B",
"time": 20000000000,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&algorithm=SHA256&digits=8&period=30"
},
{
"code": "47863826",
"source": "RFC 6238 Appendix B",
"time": 20000000000,
"uri": "otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA&algorithm=SHA512&digits=8&period=30"
},
{
"code": "287082",
"source": "bare base32 secret, defaults (SHA1, 6 digits, 30 s)",
"time": 59,
"uri": "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
},
{
"code": "081804",
"source": "bare base32 secret, defaults (SHA1, 6 digits, 30 s)",
"time": 1111111109,
"uri": "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
},
{
"code": "857319",
"source": "60 s period",
"time": 1111111109,
"uri": "otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA&digits=6&period=60&algorithm=SHA256"
}
]
}
+305
View File
@@ -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, &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}"#;
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})
}