diff --git a/Cargo.lock b/Cargo.lock index 85eecc3..6b7ac8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -37,6 +37,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -73,7 +94,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -84,7 +105,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -101,10 +122,16 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "askama" version = "0.12.1" @@ -146,6 +173,35 @@ dependencies = [ "nom", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-compat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -164,6 +220,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -188,6 +250,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "blake2" version = "0.10.6" @@ -197,6 +265,19 @@ dependencies = [ "digest", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -250,6 +331,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -264,7 +355,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -280,6 +371,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -343,6 +461,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -352,6 +476,21 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -391,7 +530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -418,6 +557,24 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -425,9 +582,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive-where" version = "1.7.0" @@ -446,6 +610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -461,6 +626,20 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -499,12 +678,48 @@ dependencies = [ "generic-array", "group", "hkdf", + "pem-rfc7468", + "pkcs8", "rand_core", "sec1", "subtle", "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" @@ -521,6 +736,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -539,12 +766,71 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -557,8 +843,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -569,6 +860,7 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", "zeroize", @@ -636,12 +928,72 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hkdf" version = "0.12.4" @@ -681,6 +1033,112 @@ dependencies = [ "zeroize", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -785,6 +1243,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inout" version = "0.1.4" @@ -794,6 +1262,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -823,11 +1297,14 @@ version = "0.1.0" dependencies = [ "argon2", "chacha20poly1305", + "data-encoding", "ed25519-dalek", "hkdf", "hpke", + "kv-model", "kv-proto", "rand_core", + "serde_json", "sha2", "thiserror 2.0.21", "unicode-normalization", @@ -839,11 +1316,18 @@ dependencies = [ name = "kv-ffi" version = "0.1.0" dependencies = [ + "hkdf", "kv-crypto", "kv-model", "kv-proto", + "kv-store", + "kv-sync", + "serde", "serde_json", + "sha2", + "tempfile", "thiserror 2.0.21", + "tokio", "uniffi", "uuid", "zeroize", @@ -871,7 +1355,7 @@ dependencies = [ name = "kv-proto" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "opaque-ke", "rand_core", "serde", @@ -885,6 +1369,9 @@ name = "kv-store" version = "0.1.0" dependencies = [ "kv-proto", + "rusqlite", + "serde_json", + "tempfile", "thiserror 2.0.21", "uuid", ] @@ -893,17 +1380,65 @@ dependencies = [ name = "kv-sync" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "blake3", + "ciborium", + "kv-crypto", + "kv-model", "kv-proto", + "kv-store", + "kv-sync", + "opaque-ke", + "p256", + "rand_core", + "rcgen", + "reqwest", + "rustls", + "rustls-webpki", + "serde", + "serde_json", + "sha2", + "tempfile", "thiserror 2.0.21", + "time", + "tokio", + "tokio-rustls", + "url", "uuid", + "webpki-roots 0.26.11", + "wiremock", + "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -944,6 +1479,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nom" version = "7.1.3" @@ -954,6 +1500,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -988,6 +1550,7 @@ dependencies = [ "hkdf", "hmac", "rand", + "serde", "subtle", "voprf", "zeroize", @@ -999,8 +1562,10 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2", ] [[package]] @@ -1020,6 +1585,25 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1042,6 +1626,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plain" version = "0.2.3" @@ -1054,7 +1644,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -1066,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -1080,6 +1670,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1167,6 +1763,123 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1176,12 +1889,66 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scroll" version = "0.12.0" @@ -1211,6 +1978,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -1268,6 +2036,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -1275,7 +2055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1286,16 +2066,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core", ] @@ -1323,6 +2110,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spki" version = "0.7.3" @@ -1379,6 +2176,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -1390,6 +2196,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "textwrap" version = "0.16.4" @@ -1440,6 +2259,36 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1456,6 +2305,56 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.11" @@ -1465,6 +2364,76 @@ dependencies = [ "serde", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1553,6 +2522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" dependencies = [ "anyhow", + "async-compat", "bytes", "log", "once_cell", @@ -1626,6 +2596,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1662,6 +2638,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1681,11 +2663,21 @@ dependencies = [ "elliptic-curve", "generic-array", "rand_core", + "serde", "sha2", "subtle", "zeroize", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1705,6 +2697,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -1737,6 +2740,34 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -1752,6 +2783,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1761,6 +2801,93 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "writeable" version = "0.6.4" @@ -1777,6 +2904,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 8465b8e..2912ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,21 @@ data-encoding = "2" url = "2" psl = "2" uniffi = "0.28" +rusqlite = { version = "0.32", features = ["bundled"] } +opaque-ke = { version = "3", features = ["std"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-manual-roots-no-provider", "http2"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +webpki-roots = "0.26" +rustls-webpki = { version = "0.103", default-features = false, features = ["alloc", "ring"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util"] } +ciborium = "0.2" +blake3 = "1" +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } +time = { version = "0.3", features = ["formatting"] } +wiremock = "0.6" +rcgen = "0.13" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +tempfile = "3" [profile.release] lto = true diff --git a/README.md b/README.md index ae65363..efffcd6 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Android and desktop apps call it through UniFFI. The server uses only `kv-proto` | `kv-proto` | ✅ M0 | Wire types (Envelope, KDF params, API bodies). No secrets. | | `kv-crypto` | ✅ M0 | 2SKD (Argon2id + Secret Key), XChaCha20-Poly1305 envelopes, HPKE vault-key sealing, keyset, Ed25519, Secret Key format | | `kv-model` | ✅ M0 | Items, URL matching (PSL), TOTP (RFC 6238), password generator | -| `kv-ffi` | ✅ M0 (in-memory) | The exported API (`VaultCore`, `totp_code`, `match_url`, `generate_password`) | -| `kv-store` | 🔜 M1 | SQLite replica (ciphertext only) | -| `kv-sync` | 🔜 M1 | OPAQUE login, RFC 9421 signing, pull/push/merge, signed manifest | +| `kv-ffi` | ✅ M1 | The exported API (`VaultCore` with SQLite storage, local unlock key, sync; `totp_code`, `match_url`, `generate_password`) | +| `kv-store` | ✅ M1 | SQLite replica (ciphertext only), base copies for merges, 10-version history | +| `kv-sync` | ✅ M1 | TLS 1.3 + SPKI pins, OPAQUE login, RFC 9421 signing, pull/push/merge, signed manifest | Spec: `../kestrove-spec/docs/02-cryptography.md` and `03-sync-protocol.md`. @@ -30,6 +30,13 @@ cargo test --workspace The desktop (Tauri) app depends on `kv-ffi` directly as a Rust crate. +## Test vectors and the live-server test + +```bash +cargo run -p kv-crypto --example gen-vectors # writes crates/kv-crypto/tests/vectors and ../kestrove-spec/vectors +cargo test -p kv-ffi --test live_server -- --ignored # against a fresh local kestrove-server; see the test's docs +``` + ## Rules * No `unsafe` outside generated FFI glue. * Secrets live in `Zeroizing<_>`. Never log, `Debug`-print or `format!` a secret. diff --git a/crates/kv-crypto/Cargo.toml b/crates/kv-crypto/Cargo.toml index 12b0238..6d3ff56 100644 --- a/crates/kv-crypto/Cargo.toml +++ b/crates/kv-crypto/Cargo.toml @@ -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" diff --git a/crates/kv-crypto/examples/gen-vectors.rs b/crates/kv-crypto/examples/gen-vectors.rs new file mode 100644 index 0000000..7ad78ce --- /dev/null +++ b/crates/kv-crypto/examples/gen-vectors.rs @@ -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 -- # 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 = 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()); + } + } +} diff --git a/crates/kv-crypto/src/aead.rs b/crates/kv-crypto/src/aead.rs index a67817d..013358c 100644 --- a/crates/kv-crypto/src/aead.rs +++ b/crates/kv-crypto/src/aead.rs @@ -30,8 +30,13 @@ impl SymKey { } pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result { + 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 { 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)?; diff --git a/crates/kv-crypto/src/kdf.rs b/crates/kv-crypto/src/kdf.rs index a50d673..4d1d2c9 100644 --- a/crates/kv-crypto/src/kdf.rs +++ b/crates/kv-crypto/src/kdf.rs @@ -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)] diff --git a/crates/kv-crypto/src/seal.rs b/crates/kv-crypto/src/seal.rs index 6c0f5d5..495acab 100644 --- a/crates/kv-crypto/src/seal.rs +++ b/crates/kv-crypto/src/seal.rs @@ -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 { + 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( + recipient_id: Uuid, + recipient_pk: &[u8], + plaintext: &[u8], + aad: &[u8], + rng: &mut R, +) -> Result { let pk = ::PublicKey::from_bytes(recipient_pk).map_err(|_| CryptoError::Malformed)?; - let (encapped, ct) = hpke::single_shot_seal::( - &OpModeS::Base, - &pk, - INFO, - plaintext, - aad, - &mut OsRng, - ) - .map_err(|_| CryptoError::Encrypt)?; + let (encapped, ct) = + hpke::single_shot_seal::(&OpModeS::Base, &pk, INFO, plaintext, aad, rng) + .map_err(|_| CryptoError::Encrypt)?; Ok(Envelope { v: FORMAT_VERSION, alg: Alg::HpkeX25519ChaCha, diff --git a/crates/kv-crypto/tests/vectors.rs b/crates/kv-crypto/tests/vectors.rs new file mode 100644 index 0000000..f92cf78 --- /dev/null +++ b/crates/kv-crypto/tests/vectors.rs @@ -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"); +} diff --git a/crates/kv-crypto/tests/vectors/envelope.json b/crates/kv-crypto/tests/vectors/envelope.json new file mode 100644 index 0000000..909da69 --- /dev/null +++ b/crates/kv-crypto/tests/vectors/envelope.json @@ -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" + } + ] +} diff --git a/crates/kv-crypto/tests/vectors/hpke.json b/crates/kv-crypto/tests/vectors/hpke.json new file mode 100644 index 0000000..c96db69 --- /dev/null +++ b/crates/kv-crypto/tests/vectors/hpke.json @@ -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" + } + ] +} diff --git a/crates/kv-crypto/tests/vectors/kdf.json b/crates/kv-crypto/tests/vectors/kdf.json new file mode 100644 index 0000000..898daa3 --- /dev/null +++ b/crates/kv-crypto/tests/vectors/kdf.json @@ -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" + } + ] +} diff --git a/crates/kv-crypto/tests/vectors/matching.json b/crates/kv-crypto/tests/vectors/matching.json new file mode 100644 index 0000000..eeca2bf --- /dev/null +++ b/crates/kv-crypto/tests/vectors/matching.json @@ -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" + } + ] +} diff --git a/crates/kv-crypto/tests/vectors/secret_key_format.json b/crates/kv-crypto/tests/vectors/secret_key_format.json new file mode 100644 index 0000000..d4fcc8a --- /dev/null +++ b/crates/kv-crypto/tests/vectors/secret_key_format.json @@ -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" + } + ] +} diff --git a/crates/kv-crypto/tests/vectors/totp.json b/crates/kv-crypto/tests/vectors/totp.json new file mode 100644 index 0000000..798789c --- /dev/null +++ b/crates/kv-crypto/tests/vectors/totp.json @@ -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" + } + ] +} diff --git a/crates/kv-crypto/vectors/gen.rs b/crates/kv-crypto/vectors/gen.rs new file mode 100644 index 0000000..4d0e780 --- /dev/null +++ b/crates/kv-crypto/vectors/gen.rs @@ -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 { + (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}"#; + 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}) +} diff --git a/crates/kv-ffi/Cargo.toml b/crates/kv-ffi/Cargo.toml index e48fa9a..9f9772f 100644 --- a/crates/kv-ffi/Cargo.toml +++ b/crates/kv-ffi/Cargo.toml @@ -18,8 +18,18 @@ path = "src/bin/uniffi-bindgen.rs" kv-proto.workspace = true kv-crypto.workspace = true kv-model.workspace = true +kv-store.workspace = true +kv-sync.workspace = true +serde.workspace = true +hkdf.workspace = true +sha2.workspace = true +tokio = { workspace = true } uuid.workspace = true thiserror.workspace = true serde_json.workspace = true zeroize.workspace = true -uniffi = { workspace = true, features = ["cli"] } +uniffi = { workspace = true, features = ["cli", "tokio"] } + +[dev-dependencies] +kv-sync = { workspace = true, features = ["test-server"] } +tempfile.workspace = true diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 582c921..6b10a9d 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -1,25 +1,34 @@ //! The FFI contract between the core and every platform app (kestrove-spec/docs/07 "Work split"). //! Changing a public item here needs a spec PR. //! -//! M0 ships an **in-memory** vault so UI work on Apple, Android and desktop can start -//! now. M1 swaps the storage for `kv-store` + `kv-sync` behind the same API. +//! M1: the vault is stored in SQLite through `kv-store` (ciphertext only) and synced through +//! `kv-sync`. `new_in_memory()` keeps working for previews and tests. +//! +//! Network methods are `async` (Swift `async`, Kotlin `suspend`, tokio for Rust callers). use std::{ - collections::HashMap, sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; +use hkdf::Hkdf; use kv_crypto::{ + aad::{Aad, ObjectType}, aead::SymKey, kdf, keys::{open_item, seal_item, AccountKeyset, SealedItem}, + random_bytes, secret_key::SecretKey, CryptoError, }; use kv_model::{generator, matching, totp::Totp, Item, ItemKind}; -use kv_proto::{Envelope, KdfParams}; +use kv_proto::{b64, DeviceEnroll, Envelope, ItemRecord, KdfParams, Platform}; +use kv_store::{HistoryEntry, MemoryStore, SqliteStore, Store, StoreError, VaultRow}; +use kv_sync::{ApiClient, LoginOutcome, ServerConfig, SyncContext, SyncError}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; use uuid::Uuid; +use zeroize::Zeroizing; uniffi::setup_scaffolding!(); @@ -37,6 +46,26 @@ pub enum CoreError { NotFound, #[error("internal error: {msg}")] Internal { msg: String }, + /// `configure_server` / `set_device_signer` have not been called. + #[error("sync server is not configured")] + NotConfigured, + /// The session expired and the master password is needed to log in again + /// (after a biometric unlock the core cannot re-run OPAQUE on its own). + #[error("not logged in to the sync server")] + NotAuthenticated, + /// The device is waiting for approval from an enrolled device. + #[error("device approval pending")] + PendingApproval, + #[error("network error: {msg}")] + Network { msg: String }, + /// The server's TLS key matches none of the pins: possible interception. Never retry silently. + #[error("server certificate does not match the pinned keys")] + PinMismatch, + #[error("server error {status}: {msg}")] + Server { status: u16, msg: String }, + /// The server rolled back or tampered with a vault. Syncing that vault has stopped; alert the user. + #[error("integrity alarm on vault {vault_id}: {reason}")] + IntegrityAlarm { vault_id: String, reason: String }, } impl From for CoreError { @@ -49,8 +78,49 @@ impl From for CoreError { } } +impl From for CoreError { + fn from(e: StoreError) -> Self { + match e { + StoreError::NotFound => CoreError::NotFound, + StoreError::Io(msg) => CoreError::Internal { msg }, + } + } +} + +impl From for CoreError { + fn from(e: SyncError) -> Self { + match e { + SyncError::Network(msg) => CoreError::Network { msg }, + SyncError::PinMismatch => CoreError::PinMismatch, + SyncError::Config(msg) => CoreError::Invalid { msg }, + SyncError::Server { status, code, message } => { + CoreError::Server { status, msg: format!("{code}: {message}") } + } + SyncError::Integrity { vault_id, reason } => { + CoreError::IntegrityAlarm { vault_id: vault_id.to_string(), reason } + } + SyncError::NotAuthenticated => CoreError::NotAuthenticated, + SyncError::BadCredentials => CoreError::BadCredentials, + SyncError::NotFound => CoreError::NotFound, + other => CoreError::Internal { msg: other.to_string() }, + } + } +} + +impl From for CoreError { + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + CoreError::Internal { msg: format!("device signer: {}", e.reason) } + } +} + type Result = std::result::Result; +fn internal(e: impl std::fmt::Display) -> CoreError { + CoreError::Internal { msg: e.to_string() } +} + +// ---------------------------------------------------------------- DTOs + #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] pub enum ItemKindDto { Login, @@ -67,6 +137,15 @@ pub enum MatchLevelDto { Exact, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum PlatformDto { + Ios, + Macos, + Android, + Windows, + Linux, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct ItemSummary { pub id: String, @@ -101,40 +180,210 @@ pub struct TotpCode { pub period: u64, } -struct Unlocked { - keyset: AccountKeyset, - vault_key: SymKey, +#[derive(Debug, Clone, uniffi::Record)] +pub struct ServerInfoDto { + pub version: String, + pub api: u32, + pub setup_open: bool, + pub min_client: String, } -struct State { +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum LoginResult { + /// Logged in and unlocked. + Ok, + /// An enrolled device must approve this one (or pass `totp`); call `login` again afterwards. + PendingApproval, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SyncReport { + pub pulled: u32, + pub pushed: u32, + /// Items where both sides changed; merged field by field (losing values are in item history). + pub merged: u32, + /// Items an edit brought back after a delete elsewhere. Tell the user. + pub restored_item_ids: Vec, + pub vaults_synced: u32, + /// Remote vaults not yet trusted (no valid signed manifest). + pub vaults_skipped: u32, +} + +/// An enrolled (or pending) device, from `list_devices`. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct DeviceDto { + pub device_id: String, + pub name: String, + pub platform: PlatformDto, + pub approved: bool, + pub revoked: bool, + /// True for this device. + pub current: bool, + pub created_at: String, + /// Hex of the first 16 bytes of SHA-256(P-256 public key). Show it on both devices before approving. + pub key_fingerprint: String, +} + +/// The device's hardware-backed P-256 key (Secure Enclave, StrongBox, TPM), implemented by each platform. +/// Used to sign every request (RFC 9421). +#[uniffi::export(with_foreign)] +pub trait DeviceSigner: Send + Sync { + /// Stable UUID string for this device (generated once, stored by the app). + fn device_id(&self) -> String; + /// SEC1 uncompressed P-256 public key (65 bytes). + fn public_key(&self) -> Vec; + /// ECDSA P-256 / SHA-256 over `message`, as raw `r || s` (64 bytes, not DER). + fn sign(&self, message: Vec) -> std::result::Result, CoreError>; +} + +struct SignerAdapter { + id: Uuid, + inner: Arc, +} + +impl kv_sync::DeviceSigner for SignerAdapter { + fn device_id(&self) -> Uuid { + self.id + } + + fn public_key(&self) -> Vec { + self.inner.public_key() + } + + fn sign(&self, message: &[u8]) -> std::result::Result, SyncError> { + self.inner.sign(message.to_vec()).map_err(|e| SyncError::Signer(e.to_string())) + } +} + +// ---------------------------------------------------------------- persisted records + +const META_ACCOUNT: &str = "account"; +const META_SERVER: &str = "server"; +const META_SESSION: &str = "session"; + +/// Everything needed to unlock: all public or wrapped (docs/02 "Key hierarchy"). +#[derive(Clone, Serialize, Deserialize)] +struct AccountRecord { account_id: Uuid, kdf: KdfParams, wrapped_keyset: Envelope, - vault_id: Uuid, - sealed_vault_key: Envelope, - items: HashMap, + #[serde(with = "b64")] + enc_pk: Vec, + #[serde(with = "b64")] + sig_pk: Vec, + primary_vault: Uuid, + #[serde(default)] + local_unlock: Option, +} + +/// Keyset (and OPAQUE input) wrapped under the biometric-protected Local Unlock Key. +#[derive(Clone, Serialize, Deserialize)] +struct LocalUnlockRecord { + keyset: Envelope, + #[serde(default)] + auth_input: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +struct ServerRecord { + url: String, + pins: Vec, +} + +#[derive(Clone)] +struct Unlocked { + keyset: Arc, + vault_key: Arc, + /// OPAQUE password, kept while unlocked so an expired session can be renewed without prompting. + auth_input: Option>, +} + +struct Device { + signer: Arc, + name: String, + platform: Platform, +} + +#[derive(Default)] +struct Inner { + account: Option, unlocked: Option, + server: Option, + device: Option, + api: Option>, } /// One account's vault on this device. #[derive(uniffi::Object)] pub struct VaultCore { - state: Mutex>, + store: Arc, + inner: Mutex, + sync_lock: tokio::sync::Mutex<()>, } -#[uniffi::export] +fn luk_subkey(luk: &[u8], info: &[u8]) -> Result { + let mut out = [0u8; 32]; + Hkdf::::new(None, luk).expand(info, &mut out).map_err(internal)?; + Ok(SymKey::from_bytes(Uuid::nil(), out)) +} + +fn auth_input_aad(account_id: Uuid) -> Vec { + let mut aad = b"kestrove/v1/local/auth-input".to_vec(); + aad.extend_from_slice(account_id.as_bytes()); + aad +} + +fn platform_to_dto(p: Platform) -> PlatformDto { + match p { + Platform::Ios => PlatformDto::Ios, + Platform::Macos => PlatformDto::Macos, + Platform::Android => PlatformDto::Android, + Platform::Windows => PlatformDto::Windows, + Platform::Linux => PlatformDto::Linux, + } +} + +fn device_key_fingerprint(p256_pk: &[u8]) -> String { + use sha2::Digest; + Sha256::digest(p256_pk)[..16].iter().map(|b| format!("{b:02x}")).collect() +} + +fn platform_from_dto(p: PlatformDto) -> Platform { + match p { + PlatformDto::Ios => Platform::Ios, + PlatformDto::Macos => Platform::Macos, + PlatformDto::Android => Platform::Android, + PlatformDto::Windows => Platform::Windows, + PlatformDto::Linux => Platform::Linux, + } +} + +#[uniffi::export(async_runtime = "tokio")] impl VaultCore { + /// Non-persistent vault (previews, tests). #[uniffi::constructor] pub fn new_in_memory() -> Arc { - Arc::new(Self { state: Mutex::new(None) }) + Self::with_store(Arc::new(MemoryStore::new())).expect("memory store never fails") } - /// Creates the account and its first vault, leaving it unlocked. + /// Opens (or creates) the vault database at `path`. Put it in the app group container so the + /// AutoFill extension can open it too. + #[uniffi::constructor] + pub fn open(path: String) -> Result> { + Self::with_store(Arc::new(SqliteStore::open(path)?)) + } + + /// Creates the account and its first vault on this device, leaving it unlocked. /// Returns the Secret Key display string for the Emergency Kit. + /// To sync, then call `configure_server`, `set_device_signer` and `register_account`. pub fn create_account(&self, master_password: String) -> Result { if master_password.chars().count() < 10 { return Err(CoreError::Invalid { msg: "master password must be at least 10 characters".into() }); } + let mut inner = self.lock_inner(); + if inner.account.is_some() { + return Err(CoreError::Invalid { msg: "an account already exists on this device".into() }); + } let account_id = Uuid::new_v4(); let secret_key = SecretKey::generate(); let params = kdf::default_params(); @@ -144,83 +393,168 @@ impl VaultCore { let keyset = AccountKeyset::generate(account_id); let wrapped_keyset = keyset.wrap(&auk)?; let vault_id = Uuid::new_v4(); - let (vault_key, sealed_vault_key) = keyset.new_vault_key(vault_id)?; - - *self.lock_state() = Some(State { + let vault_key = self.create_vault(&keyset, vault_id)?; + let account = AccountRecord { account_id, kdf: params, wrapped_keyset, - vault_id, - sealed_vault_key, - items: HashMap::new(), - unlocked: Some(Unlocked { keyset, vault_key }), + enc_pk: keyset.enc.public_bytes(), + sig_pk: keyset.sig_public().to_vec(), + primary_vault: vault_id, + local_unlock: None, + }; + self.save_account(&account)?; + inner.account = Some(account); + inner.unlocked = Some(Unlocked { + keyset: Arc::new(keyset), + vault_key: Arc::new(vault_key), + auth_input: Some(derived.auth_input), }); Ok(secret_key.to_display().to_string()) } pub fn unlock(&self, master_password: String, secret_key: String) -> Result<()> { - let mut guard = self.lock_state(); - let st = guard.as_mut().ok_or(CoreError::NoAccount)?; + let mut inner = self.lock_inner(); + let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; let sk = SecretKey::parse(&secret_key)?; - let derived = kdf::derive_keys(&master_password, &sk, st.account_id, &st.kdf)?; + let derived = kdf::derive_keys(&master_password, &sk, acct.account_id, &acct.kdf)?; let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); - let keyset = AccountKeyset::unwrap(st.account_id, &auk, &st.wrapped_keyset)?; - let vault_key = keyset.open_vault_key(st.vault_id, &st.sealed_vault_key)?; - st.unlocked = Some(Unlocked { keyset, vault_key }); + let keyset = AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset)?; + let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?; + inner.unlocked = Some(Unlocked { + keyset: Arc::new(keyset), + vault_key: Arc::new(vault_key), + auth_input: Some(derived.auth_input), + }); Ok(()) } /// Drops all decrypted key material (zeroized on drop). pub fn lock(&self) { - if let Some(st) = self.lock_state().as_mut() { - st.unlocked = None; - } + self.lock_inner().unlocked = None; } pub fn is_locked(&self) -> bool { - self.lock_state().as_ref().is_none_or(|s| s.unlocked.is_none()) + self.lock_inner().unlocked.is_none() } pub fn has_account(&self) -> bool { - self.lock_state().is_some() + self.lock_inner().account.is_some() } + /// Account UUID (public), e.g. for the Emergency Kit or support. + pub fn account_id(&self) -> Result { + Ok(self.lock_inner().account.as_ref().ok_or(CoreError::NoAccount)?.account_id.to_string()) + } + + // ------------------------------------------------------------ local unlock (biometrics) + + /// Generates a random 32-byte Local Unlock Key and stores an extra keyset wrap under it. + /// The app keeps the returned LUK in the Keychain/Keystore behind biometrics and never persists it elsewhere. + /// Calling it again replaces the previous LUK. + pub fn enable_local_unlock(&self) -> Result> { + let mut inner = self.lock_inner(); + let u = inner.unlocked.as_ref().ok_or(CoreError::Locked)?; + let luk = Zeroizing::new(random_bytes::<32>()); + let keyset = u.keyset.wrap(&luk_subkey(luk.as_ref(), b"kestrove/v1/luk/keyset")?)?; + let account_id = u.keyset.account_id; + let auth_input = u + .auth_input + .as_ref() + .map(|a| { + luk_subkey(luk.as_ref(), b"kestrove/v1/luk/auth-input")? + .seal(a.as_ref(), &auth_input_aad(account_id)) + .map_err(CoreError::from) + }) + .transpose()?; + let mut acct = inner.account.clone().ok_or(CoreError::NoAccount)?; + acct.local_unlock = Some(LocalUnlockRecord { keyset, auth_input }); + self.save_account(&acct)?; + inner.account = Some(acct); + Ok(luk.to_vec()) + } + + /// Unlocks with the LUK the platform released after a successful biometric check. + pub fn unlock_with_local_key(&self, luk: Vec) -> Result<()> { + let luk = Zeroizing::new(luk); + if luk.len() != 32 { + return Err(CoreError::Invalid { msg: "local unlock key must be 32 bytes".into() }); + } + let mut inner = self.lock_inner(); + let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; + let rec = acct.local_unlock.as_ref().ok_or(CoreError::Invalid { msg: "local unlock is not enabled".into() })?; + let keyset = + AccountKeyset::unwrap(acct.account_id, &luk_subkey(&luk, b"kestrove/v1/luk/keyset")?, &rec.keyset)?; + let auth_input = match &rec.auth_input { + Some(env) => { + let pt = + luk_subkey(&luk, b"kestrove/v1/luk/auth-input")?.open(env, &auth_input_aad(acct.account_id))?; + let arr: [u8; 32] = pt.as_slice().try_into().map_err(|_| internal("auth input length"))?; + Some(Zeroizing::new(arr)) + } + None => None, + }; + let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?; + inner.unlocked = Some(Unlocked { keyset: Arc::new(keyset), vault_key: Arc::new(vault_key), auth_input }); + Ok(()) + } + + /// Removes the LUK wrap. The app should also delete the LUK from the Keychain/Keystore. + pub fn disable_local_unlock(&self) -> Result<()> { + let mut inner = self.lock_inner(); + let mut acct = inner.account.clone().ok_or(CoreError::NoAccount)?; + acct.local_unlock = None; + self.save_account(&acct)?; + inner.account = Some(acct); + Ok(()) + } + + pub fn has_local_unlock(&self) -> bool { + self.lock_inner().account.as_ref().is_some_and(|a| a.local_unlock.is_some()) + } + + // ------------------------------------------------------------ items + pub fn list_items(&self) -> Result> { - self.with_unlocked(|st, u| { - let mut out = st - .items - .iter() - .map(|(id, sealed)| decrypt(st, u, *id, sealed).map(|i| summary(&i))) - .collect::>>()?; - out.sort_by(|a, b| { - b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) - }); - Ok(out) - }) + let (acct, u) = self.unlocked_snapshot()?; + let mut out = Vec::new(); + for li in self.store.list(acct.primary_vault)? { + if let Some(item) = decrypt(&acct, &u.vault_key, &li.record)? { + out.push(summary(&item)); + } + } + out.sort_by(|a, b| { + b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) + }); + Ok(out) } pub fn get_item(&self, id: String) -> Result { let id = parse_id(&id)?; - self.with_unlocked(|st, u| { - let sealed = st.items.get(&id).ok_or(CoreError::NotFound)?; - Ok(to_dto(&decrypt(st, u, id, sealed)?)) - }) + let (acct, u) = self.unlocked_snapshot()?; + let li = self.store.get(acct.primary_vault, id)?.ok_or(CoreError::NotFound)?; + Ok(to_dto(&decrypt(&acct, &u.vault_key, &li.record)?.ok_or(CoreError::NotFound)?)) } - /// Inserts or updates an item. Returns its id. + /// Inserts or updates an item. Returns its id. The previous version goes to the item's history. pub fn save_item(&self, item: ItemDto) -> Result { - let mut guard = self.lock_state(); - let st = guard.as_mut().ok_or(CoreError::NoAccount)?; - let u = st.unlocked.as_ref().ok_or(CoreError::Locked)?; + let (acct, u) = self.unlocked_snapshot()?; + let vault = acct.primary_vault; let now = now(); let id = if item.id.is_empty() { Uuid::new_v4() } else { parse_id(&item.id)? }; - let created_at = match st.items.get(&id) { - Some(sealed) => decrypt(st, u, id, sealed)?.created_at, - None => now, + let existing = self.store.get(vault, id)?; + let previous = match &existing { + Some(li) => decrypt(&acct, &u.vault_key, &li.record)?, + None => None, + }; + let mut model = match &previous { + // Keep fields the DTO does not carry (passkeys, android apps, unknown future fields). + Some(p) => p.clone(), + None => Item::new_login(item.title.clone(), now), }; - let mut model = Item::new_login(item.title.clone(), created_at); model.id = id; model.kind = kind_from_dto(item.kind); + model.title = item.title; model.username = item.username; model.password = item.password; model.urls = item.urls; @@ -228,57 +562,434 @@ impl VaultCore { model.notes = item.notes; model.favorite = item.favorite; model.tags = item.tags; - model.modified_at = now; + model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1)); - let json = zeroize::Zeroizing::new( - serde_json::to_vec(&model).map_err(|e| CoreError::Internal { msg: e.to_string() })?, - ); - let sealed = seal_item(st.account_id, st.vault_id, id, &u.vault_key, &json)?; - st.items.insert(id, sealed); + let json = Zeroizing::new(serde_json::to_vec(&model).map_err(internal)?); + let sealed = seal_item(acct.account_id, vault, id, &u.vault_key, &json)?; + if let (Some(li), Some(_)) = (&existing, &previous) { + if let (Some(k), Some(p)) = (&li.record.wrapped_item_key, &li.record.payload) { + self.store.push_history( + vault, + id, + &HistoryEntry { + revision: li.record.revision, + wrapped_item_key: k.clone(), + payload: p.clone(), + saved_at: now, + }, + )?; + } + } + let device = self + .lock_inner() + .device + .as_ref() + .map_or(Uuid::nil(), |d| kv_sync::DeviceSigner::device_id(d.signer.as_ref())); + let record = ItemRecord { + item_id: id, + revision: 0, + deleted: false, + wrapped_item_key: Some(sealed.wrapped_item_key), + payload: Some(sealed.payload), + updated_by: device, + updated_at: String::new(), + }; + self.store.put_local(vault, &record)?; Ok(id.to_string()) } pub fn delete_item(&self, id: String) -> Result<()> { let id = parse_id(&id)?; - let mut guard = self.lock_state(); - let st = guard.as_mut().ok_or(CoreError::NoAccount)?; - st.unlocked.as_ref().ok_or(CoreError::Locked)?; - st.items.remove(&id).map(|_| ()).ok_or(CoreError::NotFound) + let (acct, _u) = self.unlocked_snapshot()?; + let vault = acct.primary_vault; + let li = self.store.get(vault, id)?.filter(|li| !li.record.deleted).ok_or(CoreError::NotFound)?; + if li.base.is_none() { + // Never reached the server: just forget it. + return Ok(self.store.remove(vault, id)?); + } + let tomb = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..li.record }; + Ok(self.store.put_local(vault, &tomb)?) + } + + /// Earlier versions of an item (local edits and merge losers), newest first. At most 10. + pub fn item_history(&self, id: String) -> Result> { + let id = parse_id(&id)?; + let (acct, u) = self.unlocked_snapshot()?; + let mut out = Vec::new(); + for h in self.store.history(acct.primary_vault, id)? { + let rec = ItemRecord { + item_id: id, + revision: h.revision, + deleted: false, + wrapped_item_key: Some(h.wrapped_item_key), + payload: Some(h.payload), + updated_by: Uuid::nil(), + updated_at: String::new(), + }; + if let Some(item) = decrypt(&acct, &u.vault_key, &rec)? { + out.push(to_dto(&item)); + } + } + Ok(out) } /// Autofill candidates for a page URL, best match first. pub fn items_for_url(&self, page_url: String) -> Result> { - self.with_unlocked(|st, u| { - let mut scored = Vec::new(); - for (id, sealed) in &st.items { - let item = decrypt(st, u, *id, sealed)?; - let best = item.urls.iter().map(|url| matching::match_url(url, &page_url)).max(); - if let Some(level) = best.filter(|l| *l > matching::MatchLevel::None) { - scored.push((level, summary(&item))); - } + let (acct, u) = self.unlocked_snapshot()?; + let mut scored = Vec::new(); + for li in self.store.list(acct.primary_vault)? { + let Some(item) = decrypt(&acct, &u.vault_key, &li.record)? else { continue }; + let best = item.urls.iter().map(|url| matching::match_url(url, &page_url)).max(); + if let Some(level) = best.filter(|l| *l > matching::MatchLevel::None) { + scored.push((level, summary(&item))); } - scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.title.cmp(&b.1.title))); - Ok(scored.into_iter().map(|(_, s)| s).collect()) - }) + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.title.cmp(&b.1.title))); + Ok(scored.into_iter().map(|(_, s)| s).collect()) } /// Ed25519 public key of the account (hex), shown when pairing devices. pub fn account_fingerprint(&self) -> Result { - self.with_unlocked(|_, u| Ok(u.keyset.sig_public().iter().map(|b| format!("{b:02x}")).collect())) + let (_, u) = self.unlocked_snapshot()?; + Ok(u.keyset.sig_public().iter().map(|b| format!("{b:02x}")).collect()) + } + + // ------------------------------------------------------------ sync server + + /// Sets the server URL (https; http only for localhost) and its pinned SPKI SHA-256 hashes + /// (base64; pass a primary and a backup pin). Persisted. An empty pin list means WebPKI only. + pub fn configure_server(&self, url: String, spki_pins: Vec) -> Result<()> { + // Validate now so the UI gets the error at configuration time. + kv_sync::transport::normalize_base_url(&url)?; + kv_sync::transport::tls_config(&spki_pins, &[])?; + let rec = ServerRecord { url, pins: spki_pins }; + self.store.put_meta(META_SERVER, &serde_json::to_vec(&rec).map_err(internal)?)?; + let mut inner = self.lock_inner(); + inner.server = Some(rec); + inner.api = None; + Ok(()) + } + + /// Registers the platform's device key. Call on every launch (the signer is a live platform object). + pub fn set_device_signer( + &self, + signer: Arc, + device_name: String, + platform: PlatformDto, + ) -> Result<()> { + let id = Uuid::parse_str(&signer.device_id()) + .map_err(|_| CoreError::Invalid { msg: "device id must be a UUID".into() })?; + let mut inner = self.lock_inner(); + inner.device = Some(Device { + signer: Arc::new(SignerAdapter { id, inner: signer }), + name: device_name, + platform: platform_from_dto(platform), + }); + inner.api = None; + Ok(()) + } + + pub async fn server_info(&self) -> Result { + let info = self.api()?.server_info().await?; + Ok(ServerInfoDto { + version: info.version, + api: info.api, + setup_open: info.setup_open, + min_client: info.min_client, + }) + } + + /// First device only: registers the account created by `create_account` with a fresh server, + /// using the one-time setup token the server printed on first boot. Must be unlocked with the + /// master password (not biometrics) in this session. Logs in afterwards. + pub async fn register_account(&self, setup_token: String) -> Result<()> { + let api = self.api()?; + let (acct, u) = self.unlocked_snapshot()?; + let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?; + let device = self.enroll()?; + api.register( + &setup_token, + acct.account_id, + &acct.kdf, + auth.as_ref(), + acct.enc_pk.clone(), + acct.sig_pk.clone(), + acct.wrapped_keyset.clone(), + device.clone(), + ) + .await?; + self.do_login(&api, acct.account_id, auth.as_ref(), None).await.map(|_| ()) + } + + /// Logs in with the master password and Secret Key (the server is single-tenant, so no account id is needed). + /// * New device (no local account): fetches the account from the server, stores it and unlocks. + /// Then call `sync_now` to download the vault. + /// * Existing account: renews the session and unlocks. + pub async fn login( + &self, + master_password: String, + secret_key: String, + totp: Option, + ) -> Result { + let api = self.api()?; + let sk = SecretKey::parse(&secret_key)?; + let local = self.lock_inner().account.clone(); + // A single-tenant server tells a new device which account it holds; an existing device + // keeps using the parameters it already trusts (no downgrade by the server). + let (account_id, kdf_params) = match &local { + Some(a) => (a.account_id, a.kdf.clone()), + None => { + let pre = api.prelogin().await?; + (pre.account_id, pre.kdf) + } + }; + let derived = kdf::derive_keys(&master_password, &sk, account_id, &kdf_params)?; + if self.do_login(&api, account_id, derived.auth_input.as_ref(), totp).await? == LoginResult::PendingApproval { + return Ok(LoginResult::PendingApproval); + } + let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); + + let acct = match local { + Some(a) => a, + None => { + let info = api.account().await?; + if info.account_id != account_id { + return Err(internal("server returned a different account")); + } + kdf::validate(&info.kdf)?; + let keyset = AccountKeyset::unwrap(account_id, &auk, &info.wrapped_keyset)?; + if keyset.sig_public().as_slice() != info.sig_pk || keyset.enc.public_bytes() != info.enc_pk { + return Err(internal("server account keys do not match the keyset")); + } + AccountRecord { + account_id, + kdf: info.kdf, + wrapped_keyset: info.wrapped_keyset, + enc_pk: info.enc_pk, + sig_pk: info.sig_pk, + primary_vault: Uuid::nil(), // chosen after the first sync + local_unlock: None, + } + } + }; + let keyset = AccountKeyset::unwrap(account_id, &auk, &acct.wrapped_keyset)?; + let vault_key = self.open_vault_key(&keyset, acct.primary_vault)?; + self.save_account(&acct)?; + let mut inner = self.lock_inner(); + inner.account = Some(acct); + inner.unlocked = Some(Unlocked { + keyset: Arc::new(keyset), + vault_key: Arc::new(vault_key), + auth_input: Some(derived.auth_input), + }); + Ok(LoginResult::Ok) + } + + /// Pull, merge, push and verify every vault. Renews an expired session automatically when the + /// master password was used to unlock in this session; otherwise fails with `NotAuthenticated`. + pub async fn sync_now(&self) -> Result { + let _guard = self.sync_lock.lock().await; + let api = self.api()?; + let (_, u) = self.unlocked_snapshot()?; + let device_id = kv_sync::DeviceSigner::device_id(api.signer().as_ref()); + let run = || async { + SyncContext { api: &api, store: self.store.as_ref(), keyset: &u.keyset, device_id }.sync_all().await + }; + let report = match run().await { + Err(SyncError::NotAuthenticated) => { + let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?; + if self.do_login(&api, u.keyset.account_id, auth.as_ref(), None).await? == LoginResult::PendingApproval + { + return Err(CoreError::PendingApproval); + } + run().await? + } + other => other?, + }; + self.after_sync()?; + Ok(SyncReport { + pulled: report.pulled, + pushed: report.pushed, + merged: report.merged, + restored_item_ids: report.restored.iter().map(Uuid::to_string).collect(), + vaults_synced: report.vaults_synced, + vaults_skipped: report.vaults_skipped.len() as u32, + }) + } + + /// Devices enrolled on the account, including ones waiting for approval. + pub async fn list_devices(&self) -> Result> { + let devices = self.api()?.devices().await?; + Ok(devices + .into_iter() + .map(|d| DeviceDto { + device_id: d.device_id.to_string(), + name: d.name, + platform: platform_to_dto(d.platform), + approved: d.approved, + revoked: d.revoked_at.is_some(), + current: d.current, + created_at: d.created_at, + key_fingerprint: device_key_fingerprint(&d.p256_pk), + }) + .collect()) + } + + /// Approves a pending device (after the user compared `key_fingerprint` on both screens). + pub async fn approve_device(&self, device_id: String) -> Result<()> { + Ok(self.api()?.approve_device(parse_id(&device_id)?).await?) + } + + /// Revokes a device permanently and ends its sessions. + pub async fn revoke_device(&self, device_id: String) -> Result<()> { + Ok(self.api()?.revoke_device(parse_id(&device_id)?).await?) + } + + /// This device's key fingerprint (same format as `DeviceDto.key_fingerprint`), or `NotConfigured`. + pub fn device_key_fingerprint(&self) -> Result { + let inner = self.lock_inner(); + let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; + Ok(device_key_fingerprint(&kv_sync::DeviceSigner::public_key(d.signer.as_ref()))) + } + + /// Ends the server session (local data stays). + pub async fn logout(&self) -> Result<()> { + let api = self.api()?; + let r = api.logout().await; + self.store.delete_meta(META_SESSION)?; + match r { + Ok(()) | Err(SyncError::NotAuthenticated) => Ok(()), + Err(e) => Err(e.into()), + } + } + + pub fn is_logged_in(&self) -> bool { + self.store.get_meta(META_SESSION).ok().flatten().is_some() } } impl VaultCore { - fn lock_state(&self) -> std::sync::MutexGuard<'_, Option> { - // A poisoned lock means a panic mid-operation; with panic=abort in release this can't happen. - self.state.lock().unwrap_or_else(|p| p.into_inner()) + fn with_store(store: Arc) -> Result> { + let account = store + .get_meta(META_ACCOUNT)? + .map(|b| serde_json::from_slice::(&b)) + .transpose() + .map_err(internal)?; + let server = store.get_meta(META_SERVER)?.and_then(|b| serde_json::from_slice::(&b).ok()); + Ok(Arc::new(Self { + store, + inner: Mutex::new(Inner { account, server, ..Default::default() }), + sync_lock: tokio::sync::Mutex::new(()), + })) } - fn with_unlocked(&self, f: impl FnOnce(&State, &Unlocked) -> Result) -> Result { - let guard = self.lock_state(); - let st = guard.as_ref().ok_or(CoreError::NoAccount)?; - let u = st.unlocked.as_ref().ok_or(CoreError::Locked)?; - f(st, u) + fn lock_inner(&self) -> std::sync::MutexGuard<'_, Inner> { + // A poisoned lock means a panic mid-operation; with panic=abort in release this can't happen. + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Clones what an operation needs so no lock is held while it runs. + fn unlocked_snapshot(&self) -> Result<(AccountRecord, Unlocked)> { + let inner = self.lock_inner(); + let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; + let u = inner.unlocked.clone().ok_or(CoreError::Locked)?; + Ok((acct, u)) + } + + fn save_account(&self, acct: &AccountRecord) -> Result<()> { + Ok(self.store.put_meta(META_ACCOUNT, &serde_json::to_vec(acct).map_err(internal)?)?) + } + + fn create_vault(&self, keyset: &AccountKeyset, vault_id: Uuid) -> Result { + let (vault_key, sealed) = keyset.new_vault_key(vault_id)?; + let meta = vault_key.seal( + br#"{"name":"Personal"}"#, + &Aad::build(ObjectType::VaultMeta, keyset.account_id, Some(vault_id), None), + )?; + self.store.put_vault(&VaultRow::new(vault_id, sealed, Some(meta)))?; + Ok(vault_key) + } + + fn open_vault_key(&self, keyset: &AccountKeyset, vault_id: Uuid) -> Result { + if vault_id.is_nil() { + // Enrolled on this device but the first sync has not brought the vault yet. + return Ok(SymKey::from_bytes(Uuid::nil(), [0; 32])); + } + let row = self.store.vault(vault_id)?.ok_or_else(|| internal("primary vault missing"))?; + Ok(keyset.open_vault_key(vault_id, &row.sealed_key)?) + } + + fn enroll(&self) -> Result { + let inner = self.lock_inner(); + let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; + Ok(DeviceEnroll { + device_id: kv_sync::DeviceSigner::device_id(d.signer.as_ref()), + name: d.name.chars().take(64).collect(), + platform: d.platform, + p256_pk: kv_sync::DeviceSigner::public_key(d.signer.as_ref()), + }) + } + + fn api(&self) -> Result> { + let mut inner = self.lock_inner(); + if let Some(api) = &inner.api { + return Ok(api.clone()); + } + let server = inner.server.clone().ok_or(CoreError::NotConfigured)?; + let signer = inner.device.as_ref().ok_or(CoreError::NotConfigured)?.signer.clone(); + let api = Arc::new(ApiClient::new(&ServerConfig::new(server.url, server.pins), signer)?); + if let Some(s) = self.store.get_meta(META_SESSION)? { + api.set_session(Some(String::from_utf8_lossy(&s).into_owned())); + } + inner.api = Some(api.clone()); + Ok(api) + } + + async fn do_login( + &self, + api: &ApiClient, + account_id: Uuid, + auth_input: &[u8], + totp: Option, + ) -> Result { + let device = self.enroll()?; + match api.login(account_id, auth_input, device, totp).await? { + LoginOutcome::Ok { session } => { + self.store.put_meta(META_SESSION, session.as_bytes())?; + Ok(LoginResult::Ok) + } + LoginOutcome::PendingApproval => Ok(LoginResult::PendingApproval), + } + } + + /// Persists a renewed session and picks the primary vault on a newly enrolled device. + fn after_sync(&self) -> Result<()> { + let api_session = self.lock_inner().api.as_ref().and_then(|a| a.session()); + if let Some(s) = api_session { + self.store.put_meta(META_SESSION, s.as_bytes())?; + } + let mut inner = self.lock_inner(); + let Some(mut acct) = inner.account.clone() else { return Ok(()) }; + if !acct.primary_vault.is_nil() { + return Ok(()); + } + let keyset = inner.unlocked.as_ref().ok_or(CoreError::Locked)?.keyset.clone(); + let vault_id = match self.store.vaults()?.first() { + Some(v) => v.vault_id, + None => { + let id = Uuid::new_v4(); + self.create_vault(&keyset, id)?; + id + } + }; + acct.primary_vault = vault_id; + let vault_key = self.open_vault_key(&keyset, vault_id)?; + self.save_account(&acct)?; + inner.account = Some(acct); + if let Some(u) = inner.unlocked.as_mut() { + u.vault_key = Arc::new(vault_key); + } + Ok(()) } } @@ -308,9 +1019,21 @@ pub fn core_version() -> String { env!("CARGO_PKG_VERSION").to_string() } -fn decrypt(st: &State, u: &Unlocked, id: Uuid, sealed: &SealedItem) -> Result { - let pt = open_item(st.account_id, st.vault_id, id, &u.vault_key, sealed)?; - serde_json::from_slice(&pt).map_err(|e| CoreError::Internal { msg: e.to_string() }) +/// `base64(SHA-256(SPKI))` of a DER certificate, the pin format `configure_server` takes. +#[uniffi::export] +pub fn spki_pin_for_certificate(cert_der: Vec) -> Result { + Ok(kv_sync::transport::spki_pin_of(&cert_der)?) +} + +/// `None` for tombstones. +fn decrypt(acct: &AccountRecord, vault_key: &SymKey, record: &ItemRecord) -> Result> { + let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) }; + if record.deleted { + return Ok(None); + } + let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() }; + let pt = open_item(acct.account_id, acct.primary_vault, record.item_id, vault_key, &sealed)?; + serde_json::from_slice(&pt).map(Some).map_err(internal) } fn summary(i: &Item) -> ItemSummary { @@ -360,7 +1083,7 @@ fn kind_from_dto(k: ItemKindDto) -> ItemKind { } fn parse_id(s: &str) -> Result { - Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "item id".into() }) + Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() }) } fn now() -> i64 { @@ -368,36 +1091,4 @@ fn now() -> i64 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn create_lock_unlock_crud() { - let core = VaultCore::new_in_memory(); - let sk = core.create_account("a long master password".into()).unwrap(); - let id = core - .save_item(ItemDto { - id: String::new(), - kind: ItemKindDto::Login, - title: "Example".into(), - username: Some("me".into()), - password: Some("s3cret".into()), - urls: vec!["https://example.com".into()], - totp: None, - notes: None, - favorite: false, - tags: vec![], - modified_at: 0, - }) - .unwrap(); - - core.lock(); - assert!(matches!(core.list_items(), Err(CoreError::Locked))); - assert!(matches!(core.unlock("wrong password!!".into(), sk.clone()), Err(CoreError::BadCredentials))); - - core.unlock("a long master password".into(), sk).unwrap(); - assert_eq!(core.get_item(id).unwrap().password.as_deref(), Some("s3cret")); - assert_eq!(core.items_for_url("https://login.example.com/x".into()).unwrap().len(), 1); - assert!(core.items_for_url("https://example.org".into()).unwrap().is_empty()); - } -} +mod tests; diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs new file mode 100644 index 0000000..e3914ce --- /dev/null +++ b/crates/kv-ffi/src/tests.rs @@ -0,0 +1,283 @@ +use kv_sync::{ + signing::SoftwareSigner, + test_server::{TestServer, SETUP_TOKEN}, +}; + +use super::*; + +const PW: &str = "a long master password"; + +fn login_item(title: &str, password: &str) -> ItemDto { + ItemDto { + id: String::new(), + kind: ItemKindDto::Login, + title: title.into(), + username: Some("me".into()), + password: Some(password.into()), + urls: vec!["https://example.com".into()], + totp: None, + notes: None, + favorite: false, + tags: vec![], + modified_at: 0, + } +} + +struct TestSigner(SoftwareSigner); + +impl DeviceSigner for TestSigner { + fn device_id(&self) -> String { + kv_sync::DeviceSigner::device_id(&self.0).to_string() + } + fn public_key(&self) -> Vec { + kv_sync::DeviceSigner::public_key(&self.0) + } + fn sign(&self, message: Vec) -> Result> { + kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal) + } +} + +fn connect(core: &VaultCore, server: &TestServer) { + core.configure_server(server.url(), vec![]).unwrap(); + core.set_device_signer( + Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))), + "test".into(), + PlatformDto::Macos, + ) + .unwrap(); +} + +#[test] +fn create_lock_unlock_crud() { + let core = VaultCore::new_in_memory(); + let sk = core.create_account(PW.into()).unwrap(); + let id = core.save_item(login_item("Example", "s3cret")).unwrap(); + + core.lock(); + assert!(matches!(core.list_items(), Err(CoreError::Locked))); + assert!(matches!(core.unlock("wrong password!!".into(), sk.clone()), Err(CoreError::BadCredentials))); + + core.unlock(PW.into(), sk).unwrap(); + assert_eq!(core.get_item(id.clone()).unwrap().password.as_deref(), Some("s3cret")); + assert_eq!(core.items_for_url("https://login.example.com/x".into()).unwrap().len(), 1); + assert!(core.items_for_url("https://example.org".into()).unwrap().is_empty()); + + // Edits keep history; deletes hide the item. + let mut dto = core.get_item(id.clone()).unwrap(); + dto.password = Some("n3w".into()); + core.save_item(dto).unwrap(); + assert_eq!(core.item_history(id.clone()).unwrap()[0].password.as_deref(), Some("s3cret")); + core.delete_item(id.clone()).unwrap(); + assert!(matches!(core.get_item(id.clone()), Err(CoreError::NotFound))); + assert!(core.list_items().unwrap().is_empty()); + assert!(matches!(core.delete_item(id), Err(CoreError::NotFound))); + assert!(matches!(core.create_account(PW.into()), Err(CoreError::Invalid { .. }))); +} + +#[test] +fn persistent_vault_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.sqlite").to_string_lossy().into_owned(); + let (sk, id, account) = { + let core = VaultCore::open(path.clone()).unwrap(); + assert!(!core.has_account()); + let sk = core.create_account(PW.into()).unwrap(); + let id = core.save_item(login_item("Bank", "pw")).unwrap(); + (sk, id, core.account_id().unwrap()) + }; + let core = VaultCore::open(path.clone()).unwrap(); + assert!(core.has_account() && core.is_locked()); + assert_eq!(core.account_id().unwrap(), account); + core.unlock(PW.into(), sk).unwrap(); + assert_eq!(core.get_item(id).unwrap().title, "Bank"); + + // Only ciphertext is on disk. + for f in [path.clone(), format!("{path}-wal")] { + let raw = std::fs::read(&f).unwrap_or_default(); + let hay = String::from_utf8_lossy(&raw); + assert!(!hay.contains("Bank") && !hay.contains(PW), "{f}"); + } +} + +#[test] +fn local_unlock_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("v.sqlite").to_string_lossy().into_owned(); + let core = VaultCore::open(path.clone()).unwrap(); + core.create_account(PW.into()).unwrap(); + let id = core.save_item(login_item("Mail", "pw")).unwrap(); + assert!(!core.has_local_unlock()); + + let luk = core.enable_local_unlock().unwrap(); + assert_eq!(luk.len(), 32); + assert!(core.has_local_unlock()); + core.lock(); + assert!(matches!(core.enable_local_unlock(), Err(CoreError::Locked))); + assert!(matches!(core.unlock_with_local_key(vec![0; 32]), Err(CoreError::BadCredentials))); + assert!(matches!(core.unlock_with_local_key(vec![0; 16]), Err(CoreError::Invalid { .. }))); + core.unlock_with_local_key(luk.clone()).unwrap(); + assert_eq!(core.get_item(id.clone()).unwrap().title, "Mail"); + drop(core); + + // Persisted, and the AutoFill extension (another process) can use it. + let core = VaultCore::open(path.clone()).unwrap(); + assert!(core.has_local_unlock()); + core.unlock_with_local_key(luk.clone()).unwrap(); + assert_eq!(core.list_items().unwrap().len(), 1); + + // Re-enabling rotates the key. + let luk2 = core.enable_local_unlock().unwrap(); + assert_ne!(luk, luk2); + core.lock(); + assert!(core.unlock_with_local_key(luk).is_err()); + core.unlock_with_local_key(luk2.clone()).unwrap(); + + core.disable_local_unlock().unwrap(); + core.lock(); + assert!(matches!(core.unlock_with_local_key(luk2), Err(CoreError::Invalid { .. }))); +} + +#[tokio::test] +async fn sync_needs_configuration() { + let core = VaultCore::new_in_memory(); + core.create_account(PW.into()).unwrap(); + assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured))); + assert!(core.configure_server("http://vault.example.com".into(), vec![]).is_err()); + assert!(core.configure_server("https://vault.example.com".into(), vec!["not a pin".into()]).is_err()); + core.configure_server("https://vault.example.com".into(), vec![]).unwrap(); + assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured)), "no device signer yet"); +} + +#[tokio::test] +async fn two_devices_sync_through_server() { + let server = TestServer::start().await; + let dir = tempfile::tempdir().unwrap(); + + // Device A: create, register, add an item, sync. + let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap(); + let sk = a.create_account(PW.into()).unwrap(); + connect(&a, &server); + assert!(a.server_info().await.unwrap().setup_open); + a.register_account(SETUP_TOKEN.into()).await.unwrap(); + assert!(a.is_logged_in()); + let id = a.save_item(login_item("Bank", "pw-a")).unwrap(); + let r = a.sync_now().await.unwrap(); + assert_eq!((r.pushed, r.vaults_synced), (1, 1)); + + // Device B: brand new, logs in with account id + password + Secret Key. + let b_path = dir.path().join("b.sqlite").to_string_lossy().into_owned(); + let b = VaultCore::open(b_path.clone()).unwrap(); + connect(&b, &server); + assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials))); + assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok); + assert!(b.has_account() && !b.is_locked()); + b.sync_now().await.unwrap(); + assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a")); + assert_eq!(b.account_fingerprint().unwrap(), a.account_fingerprint().unwrap()); + + // Concurrent edits to different fields merge. + let mut on_a = a.get_item(id.clone()).unwrap(); + on_a.notes = Some("from A".into()); + a.save_item(on_a).unwrap(); + let mut on_b = b.get_item(id.clone()).unwrap(); + on_b.username = Some("from-b".into()); + b.save_item(on_b).unwrap(); + a.sync_now().await.unwrap(); + assert_eq!(b.sync_now().await.unwrap().merged, 1); + a.sync_now().await.unwrap(); + for core in [&a, &b] { + let it = core.get_item(id.clone()).unwrap(); + assert_eq!(it.notes.as_deref(), Some("from A")); + assert_eq!(it.username.as_deref(), Some("from-b")); + } + + // B enables biometrics, restarts, unlocks with the LUK; an expired session renews itself. + let luk = b.enable_local_unlock().unwrap(); + drop(b); + let b = VaultCore::open(b_path).unwrap(); + connect(&b, &server); + b.unlock_with_local_key(luk).unwrap(); + server.state().sessions.clear(); + b.save_item(login_item("Second", "x")).unwrap(); + assert_eq!(b.sync_now().await.unwrap().pushed, 1); + a.sync_now().await.unwrap(); + assert_eq!(a.list_items().unwrap().len(), 2); + + // Delete propagates. + a.delete_item(id.clone()).unwrap(); + a.sync_now().await.unwrap(); + b.sync_now().await.unwrap(); + assert!(matches!(b.get_item(id), Err(CoreError::NotFound))); + + b.logout().await.unwrap(); + assert!(!b.is_logged_in()); +} + +#[tokio::test] +async fn integrity_alarm_reaches_the_app() { + let server = TestServer::start().await; + let a = VaultCore::new_in_memory(); + a.create_account(PW.into()).unwrap(); + connect(&a, &server); + a.register_account(SETUP_TOKEN.into()).await.unwrap(); + for i in 0..2 { + a.save_item(login_item(&format!("item {i}"), "p")).unwrap(); + a.sync_now().await.unwrap(); + } + let vid = *server.state().vaults.keys().next().unwrap(); + let first = server.state().vaults[&vid].manifest_history[0].clone(); + server.state().serve_manifest = Some(first); + match a.sync_now().await { + Err(CoreError::IntegrityAlarm { vault_id, reason }) => { + assert_eq!(vault_id, vid.to_string()); + assert!(reason.contains("rollback")); + } + other => panic!("expected integrity alarm, got {other:?}"), + } + // The vault stays readable locally. + assert_eq!(a.list_items().unwrap().len(), 2); +} + +#[tokio::test] +async fn pending_approval_and_biometric_session_expiry() { + let server = TestServer::start().await; + let a = VaultCore::new_in_memory(); + let sk = a.create_account(PW.into()).unwrap(); + connect(&a, &server); + a.register_account(SETUP_TOKEN.into()).await.unwrap(); + a.sync_now().await.unwrap(); + + server.state().require_approval = true; + let b = VaultCore::new_in_memory(); + connect(&b, &server); + assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval); + assert!(!b.has_account()); + // A approves B after comparing fingerprints, then B's next login succeeds. + let devices = a.list_devices().await.unwrap(); + let pending = devices.iter().find(|d| !d.approved).unwrap(); + assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap()); + assert!(devices.iter().any(|d| d.current && d.approved)); + a.approve_device(pending.device_id.clone()).await.unwrap(); + assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok); + b.sync_now().await.unwrap(); + // Revoking B kills its session. + a.revoke_device(pending.device_id.clone()).await.unwrap(); + assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated | CoreError::PendingApproval))); + + // A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated. + let c = VaultCore::new_in_memory(); + let sk_c = c.create_account(PW.into()).unwrap(); + let luk = { + // Simulate an old LUK record without auth input by unlocking via LUK after a fresh enable. + c.lock(); + c.unlock(PW.into(), sk_c).unwrap(); + let luk = c.enable_local_unlock().unwrap(); + let mut inner = c.lock_inner(); + inner.account.as_mut().unwrap().local_unlock.as_mut().unwrap().auth_input = None; + luk + }; + c.lock(); + c.unlock_with_local_key(luk).unwrap(); + connect(&c, &server); + assert!(matches!(c.register_account("x".into()).await, Err(CoreError::NotAuthenticated))); +} diff --git a/crates/kv-ffi/tests/live_server.rs b/crates/kv-ffi/tests/live_server.rs new file mode 100644 index 0000000..771df53 --- /dev/null +++ b/crates/kv-ffi/tests/live_server.rs @@ -0,0 +1,102 @@ +//! Two devices against a real, freshly started kestrove-server. Ignored by default. +//! +//! ```sh +//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot): +//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 cargo run +//! # here: +//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN= \ +//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture +//! ``` +//! The setup token works once, so restart the server with an empty database for every run. + +use std::sync::Arc; + +use kv_ffi::{CoreError, DeviceSigner, ItemDto, ItemKindDto, LoginResult, PlatformDto, VaultCore}; +use kv_sync::signing::SoftwareSigner; +use uuid::Uuid; + +struct Signer(SoftwareSigner); + +impl DeviceSigner for Signer { + fn device_id(&self) -> String { + kv_sync::DeviceSigner::device_id(&self.0).to_string() + } + fn public_key(&self) -> Vec { + kv_sync::DeviceSigner::public_key(&self.0) + } + fn sign(&self, message: Vec) -> Result, CoreError> { + kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| CoreError::Internal { msg: e.to_string() }) + } +} + +fn connect(core: &VaultCore, url: &str, name: &str) { + core.configure_server(url.into(), vec![]).unwrap(); + core.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), name.into(), PlatformDto::Linux) + .unwrap(); +} + +fn item(title: &str, password: &str) -> ItemDto { + ItemDto { + id: String::new(), + kind: ItemKindDto::Login, + title: title.into(), + username: Some("me".into()), + password: Some(password.into()), + urls: vec!["https://example.com".into()], + totp: None, + notes: None, + favorite: false, + tags: vec![], + modified_at: 0, + } +} + +#[tokio::test] +#[ignore = "needs a fresh local kestrove-server; see the module docs"] +async fn two_devices_against_local_server() { + let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into()); + let token = + std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed"); + let pw = "a long master password for the live test"; + + let a = VaultCore::new_in_memory(); + let sk = a.create_account(pw.into()).unwrap(); + connect(&a, &url, "device A"); + assert!(a.server_info().await.unwrap().setup_open, "server must be fresh (setup open)"); + a.register_account(token).await.unwrap(); + let id = a.save_item(item("Bank", "pw-a")).unwrap(); + let r = a.sync_now().await.unwrap(); + assert_eq!(r.pushed, 1); + + // The server allows 5 unauthenticated auth calls per minute per IP and both devices share one. + tokio::time::sleep(std::time::Duration::from_secs(61)).await; + let b = VaultCore::new_in_memory(); + connect(&b, &url, "device B"); + if b.login(pw.into(), sk.clone(), None).await.unwrap() == LoginResult::PendingApproval { + let devices = a.list_devices().await.unwrap(); + let pending = devices.iter().find(|d| !d.approved).expect("B listed as pending"); + assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap()); + a.approve_device(pending.device_id.clone()).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again + assert_eq!(b.login(pw.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok); + } + b.sync_now().await.unwrap(); + assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a")); + + // Conflicting edits merge. + let mut on_a = a.get_item(id.clone()).unwrap(); + on_a.notes = Some("A".into()); + a.save_item(on_a).unwrap(); + let mut on_b = b.get_item(id.clone()).unwrap(); + on_b.username = Some("b".into()); + b.save_item(on_b).unwrap(); + a.sync_now().await.unwrap(); + assert_eq!(b.sync_now().await.unwrap().merged, 1); + a.sync_now().await.unwrap(); + let (ia, ib) = (a.get_item(id.clone()).unwrap(), b.get_item(id).unwrap()); + assert_eq!((ia.notes.as_deref(), ia.username.as_deref()), (Some("A"), Some("b"))); + assert_eq!((ib.notes, ib.username), (ia.notes, ia.username)); + + b.logout().await.unwrap(); + println!("live sync OK against {url}"); +} diff --git a/crates/kv-store/Cargo.toml b/crates/kv-store/Cargo.toml index c29014b..7e55bbb 100644 --- a/crates/kv-store/Cargo.toml +++ b/crates/kv-store/Cargo.toml @@ -10,3 +10,8 @@ publish.workspace = true kv-proto.workspace = true uuid.workspace = true thiserror.workspace = true +serde_json.workspace = true +rusqlite.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/kv-store/src/lib.rs b/crates/kv-store/src/lib.rs index be42604..3cc2fd1 100644 --- a/crates/kv-store/src/lib.rs +++ b/crates/kv-store/src/lib.rs @@ -1,20 +1,38 @@ //! Local encrypted replica (milestone M1, kestrove-spec/docs/07). //! //! The store holds the same `Envelope` bytes the server holds, plus sync -//! bookkeeping. It never sees plaintext. Planned schema (rusqlite, bundled): +//! bookkeeping. It never sees plaintext. Schema (rusqlite, bundled): //! //! ```sql -//! CREATE TABLE vault (vault_id BLOB PRIMARY KEY, sealed_key TEXT, meta TEXT, cursor INTEGER, manifest_version INTEGER, manifest_hash BLOB); -//! CREATE TABLE item (vault_id BLOB, item_id BLOB, revision INTEGER, deleted INTEGER, -//! wrapped_item_key TEXT, payload TEXT, -//! state TEXT CHECK(state IN ('clean','pending')), base_revision INTEGER, base_payload TEXT, -//! PRIMARY KEY (vault_id, item_id)); -//! CREATE TABLE history (vault_id BLOB, item_id BLOB, revision INTEGER, wrapped_item_key TEXT, payload TEXT); +//! CREATE TABLE meta (key TEXT PRIMARY KEY, value BLOB NOT NULL); +//! CREATE TABLE vault (vault_id BLOB PRIMARY KEY, sealed_key TEXT, meta TEXT, cursor INTEGER, +//! manifest_version INTEGER, manifest_hash BLOB, integrity_alarm INTEGER); +//! CREATE TABLE item (vault_id BLOB, item_id BLOB, revision INTEGER, deleted INTEGER, +//! wrapped_item_key TEXT, payload TEXT, updated_by BLOB, updated_at TEXT, +//! state TEXT CHECK(state IN ('clean','pending')), +//! base_revision INTEGER, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT, +//! PRIMARY KEY (vault_id, item_id)); +//! CREATE TABLE history (seq INTEGER PRIMARY KEY, vault_id BLOB, item_id BLOB, revision INTEGER, +//! wrapped_item_key TEXT, payload TEXT, saved_at INTEGER); //! ``` +//! +//! Envelopes are stored as their JSON form (the same bytes the server stores). +//! The `base_*` columns hold the last version of the item the server confirmed; +//! they are the common ancestor for three-way merges and the input to the manifest's Merkle root. -use kv_proto::ItemRecord; +use std::{ + collections::{BTreeMap, HashMap}, + path::Path, + sync::Mutex, +}; + +use kv_proto::{Envelope, ItemRecord}; +use rusqlite::{params, Connection, OptionalExtension}; use uuid::Uuid; +/// Versions of an item kept in history. +pub const HISTORY_LIMIT: usize = 10; + #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("storage I/O: {0}")] @@ -23,22 +41,673 @@ pub enum StoreError { NotFound, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SyncState { - Clean, - /// Edited locally; `base_revision` is what the edit was made against. - Pending { - base_revision: u64, - }, +impl From for StoreError { + fn from(e: rusqlite::Error) -> Self { + StoreError::Io(e.to_string()) + } } -/// Storage backend. The SQLite implementation and an in-memory one for tests come in M1. -pub trait Store: Send + Sync { - fn cursor(&self, vault: Uuid) -> Result; - fn set_cursor(&self, vault: Uuid, revision: u64) -> Result<(), StoreError>; - fn put_remote(&self, vault: Uuid, record: &ItemRecord) -> Result<(), StoreError>; - fn put_local(&self, vault: Uuid, record: &ItemRecord, state: SyncState) -> Result<(), StoreError>; - fn pending(&self, vault: Uuid) -> Result, StoreError>; - fn get(&self, vault: Uuid, item: Uuid) -> Result<(ItemRecord, SyncState), StoreError>; - fn list(&self, vault: Uuid) -> Result, StoreError>; +impl From for StoreError { + fn from(e: serde_json::Error) -> Self { + StoreError::Io(format!("corrupt envelope: {e}")) + } +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncState { + /// Matches the server's version (`base`). + Clean, + /// Edited locally; not yet accepted by the server. The edit was made against `base`. + Pending, +} + +/// One item as replicated on this device. +#[derive(Debug, Clone)] +pub struct LocalItem { + /// The working copy. For pending items `record.revision` equals the base revision. + pub record: ItemRecord, + pub state: SyncState, + /// Last version confirmed by the server. `None` for items created here and never pushed. + pub base: Option, +} + +impl LocalItem { + pub fn base_revision(&self) -> u64 { + self.base.as_ref().map_or(0, |b| b.revision) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultRow { + pub vault_id: Uuid, + pub sealed_key: Envelope, + pub meta: Option, + /// Highest server revision pulled. + pub cursor: u64, + /// Highest manifest version seen and the blake3 hash of its body. + pub manifest_version: u64, + pub manifest_hash: Option<[u8; 32]>, + /// Set on a manifest rollback or tampering; syncing this vault stops. + pub integrity_alarm: bool, +} + +impl VaultRow { + pub fn new(vault_id: Uuid, sealed_key: Envelope, meta: Option) -> Self { + Self { vault_id, sealed_key, meta, cursor: 0, manifest_version: 0, manifest_hash: None, integrity_alarm: false } + } +} + +/// An earlier version of an item (local edit or merge loser). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryEntry { + pub revision: u64, + pub wrapped_item_key: Envelope, + pub payload: Envelope, + pub saved_at: i64, +} + +/// Storage backend: [`SqliteStore`] on devices, [`MemoryStore`] in tests. +pub trait Store: Send + Sync { + fn get_meta(&self, key: &str) -> Result>>; + fn put_meta(&self, key: &str, value: &[u8]) -> Result<()>; + fn delete_meta(&self, key: &str) -> Result<()>; + + /// Inserts or replaces a vault row. + fn put_vault(&self, vault: &VaultRow) -> Result<()>; + fn vault(&self, vault: Uuid) -> Result>; + fn vaults(&self) -> Result>; + + /// Inserts or replaces an item. + fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()>; + fn get(&self, vault: Uuid, item: Uuid) -> Result>; + /// All items, including tombstones. + fn list(&self, vault: Uuid) -> Result>; + fn remove(&self, vault: Uuid, item: Uuid) -> Result<()>; + + /// Appends a version and keeps only the newest [`HISTORY_LIMIT`]. + fn push_history(&self, vault: Uuid, item: Uuid, entry: &HistoryEntry) -> Result<()>; + /// Newest first. + fn history(&self, vault: Uuid, item: Uuid) -> Result>; + + /// Deletes everything (sign-out / reset). + fn wipe(&self) -> Result<()>; + + // ---- convenience ---- + + fn cursor(&self, vault: Uuid) -> Result { + Ok(self.vault(vault)?.ok_or(StoreError::NotFound)?.cursor) + } + + fn set_cursor(&self, vault: Uuid, revision: u64) -> Result<()> { + let mut v = self.vault(vault)?.ok_or(StoreError::NotFound)?; + v.cursor = revision; + self.put_vault(&v) + } + + /// Stores a server-confirmed record as clean (it becomes its own base). + fn put_remote(&self, vault: Uuid, record: &ItemRecord) -> Result<()> { + self.put(vault, &LocalItem { record: record.clone(), state: SyncState::Clean, base: Some(record.clone()) }) + } + + /// Stores a local edit as pending against the current base. + fn put_local(&self, vault: Uuid, record: &ItemRecord) -> Result<()> { + let base = self.get(vault, record.item_id)?.and_then(|i| i.base); + let mut record = record.clone(); + record.revision = base.as_ref().map_or(0, |b| b.revision); + self.put(vault, &LocalItem { record, state: SyncState::Pending, base }) + } + + fn pending(&self, vault: Uuid) -> Result> { + Ok(self.list(vault)?.into_iter().filter(|i| i.state == SyncState::Pending).collect()) + } +} + +// ---------------------------------------------------------------- SQLite + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL); +CREATE TABLE IF NOT EXISTS vault ( + vault_id BLOB PRIMARY KEY, sealed_key TEXT NOT NULL, meta TEXT, + cursor INTEGER NOT NULL DEFAULT 0, manifest_version INTEGER NOT NULL DEFAULT 0, manifest_hash BLOB, + integrity_alarm INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS item ( + vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL, + wrapped_item_key TEXT, payload TEXT, updated_by BLOB NOT NULL, updated_at TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('clean','pending')), + base_revision INTEGER NOT NULL DEFAULT 0, base_deleted INTEGER, base_wrapped_item_key TEXT, base_payload TEXT, + base_updated_by BLOB, base_updated_at TEXT, + PRIMARY KEY (vault_id, item_id)); +CREATE TABLE IF NOT EXISTS history ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, vault_id BLOB NOT NULL, item_id BLOB NOT NULL, revision INTEGER NOT NULL, + wrapped_item_key TEXT NOT NULL, payload TEXT NOT NULL, saved_at INTEGER NOT NULL); +CREATE INDEX IF NOT EXISTS history_by_item ON history (vault_id, item_id, seq); +"; + +const SCHEMA_VERSION: i64 = 1; + +pub struct SqliteStore { + conn: Mutex, +} + +impl SqliteStore { + pub fn open(path: impl AsRef) -> Result { + Self::init(Connection::open(path)?) + } + + pub fn open_in_memory() -> Result { + Self::init(Connection::open_in_memory()?) + } + + fn init(conn: Connection) -> Result { + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "FULL")?; + conn.pragma_update(None, "secure_delete", "ON")?; + let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + if version > SCHEMA_VERSION { + return Err(StoreError::Io(format!("database schema {version} is newer than this app"))); + } + conn.execute_batch(SCHEMA)?; + conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; + Ok(Self { conn: Mutex::new(conn) }) + } + + fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { + self.conn.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +fn env_to_sql(e: &Option) -> Result> { + e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into) +} + +fn env_from_sql(s: Option) -> Result> { + s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into) +} + +fn uuid_from_sql(b: Vec) -> Result { + Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string())) +} + +fn hash_from_sql(b: Option>) -> Result> { + b.map(|b| b.try_into().map_err(|_| StoreError::Io("bad manifest hash".into()))).transpose() +} + +const ITEM_COLS: &str = "item_id, revision, deleted, wrapped_item_key, payload, updated_by, updated_at, state, + base_revision, base_deleted, base_wrapped_item_key, base_payload, base_updated_by, base_updated_at"; + +type RawItem = ( + Vec, + i64, + bool, + Option, + Option, + Vec, + String, + String, + i64, + Option, + Option, + Option, + Option>, + Option, +); + +fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + r.get(9)?, + r.get(10)?, + r.get(11)?, + r.get(12)?, + r.get(13)?, + )) +} + +fn item_from_raw(raw: RawItem) -> Result { + let (id, rev, deleted, wik, payload, by, at, state, brev, bdel, bwik, bpayload, bby, bat) = raw; + let item_id = uuid_from_sql(id)?; + let record = ItemRecord { + item_id, + revision: rev as u64, + deleted, + wrapped_item_key: env_from_sql(wik)?, + payload: env_from_sql(payload)?, + updated_by: uuid_from_sql(by)?, + updated_at: at, + }; + let base = if brev > 0 { + Some(ItemRecord { + item_id, + revision: brev as u64, + deleted: bdel.unwrap_or(false), + wrapped_item_key: env_from_sql(bwik)?, + payload: env_from_sql(bpayload)?, + updated_by: bby.map(uuid_from_sql).transpose()?.unwrap_or_default(), + updated_at: bat.unwrap_or_default(), + }) + } else { + None + }; + let state = if state == "pending" { SyncState::Pending } else { SyncState::Clean }; + Ok(LocalItem { record, state, base }) +} + +fn vault_from_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) +} + +type RawVault = (Vec, String, Option, i64, i64, Option>, bool); + +fn vault_from_raw(raw: RawVault) -> Result { + let (id, sealed, meta, cursor, mver, mhash, alarm) = raw; + Ok(VaultRow { + vault_id: uuid_from_sql(id)?, + sealed_key: serde_json::from_str(&sealed)?, + meta: env_from_sql(meta)?, + cursor: cursor as u64, + manifest_version: mver as u64, + manifest_hash: hash_from_sql(mhash)?, + integrity_alarm: alarm, + }) +} + +const VAULT_COLS: &str = "vault_id, sealed_key, meta, cursor, manifest_version, manifest_hash, integrity_alarm"; + +impl Store for SqliteStore { + fn get_meta(&self, key: &str) -> Result>> { + Ok(self.conn().query_row("SELECT value FROM meta WHERE key = ?", [key], |r| r.get(0)).optional()?) + } + + fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> { + self.conn().execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", params![key, value])?; + Ok(()) + } + + fn delete_meta(&self, key: &str) -> Result<()> { + self.conn().execute("DELETE FROM meta WHERE key = ?", [key])?; + Ok(()) + } + + fn put_vault(&self, v: &VaultRow) -> Result<()> { + self.conn().execute( + &format!("INSERT OR REPLACE INTO vault ({VAULT_COLS}) VALUES (?,?,?,?,?,?,?)"), + params![ + v.vault_id.as_bytes().as_slice(), + serde_json::to_string(&v.sealed_key)?, + env_to_sql(&v.meta)?, + v.cursor as i64, + v.manifest_version as i64, + v.manifest_hash.as_ref().map(|h| h.as_slice()), + v.integrity_alarm, + ], + )?; + Ok(()) + } + + fn vault(&self, vault: Uuid) -> Result> { + let raw = self + .conn() + .query_row( + &format!("SELECT {VAULT_COLS} FROM vault WHERE vault_id = ?"), + [vault.as_bytes().as_slice()], + vault_from_row, + ) + .optional()?; + raw.map(vault_from_raw).transpose() + } + + fn vaults(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare(&format!("SELECT {VAULT_COLS} FROM vault ORDER BY rowid"))?; + let raws = stmt.query_map([], vault_from_row)?.collect::>>()?; + raws.into_iter().map(vault_from_raw).collect() + } + + fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> { + let r = &item.record; + let b = item.base.as_ref(); + self.conn().execute( + &format!("INSERT OR REPLACE INTO item (vault_id, {ITEM_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"), + params![ + vault.as_bytes().as_slice(), + r.item_id.as_bytes().as_slice(), + r.revision as i64, + r.deleted, + env_to_sql(&r.wrapped_item_key)?, + env_to_sql(&r.payload)?, + r.updated_by.as_bytes().as_slice(), + r.updated_at, + match item.state { + SyncState::Clean => "clean", + SyncState::Pending => "pending", + }, + b.map_or(0, |b| b.revision as i64), + b.map(|b| b.deleted), + env_to_sql(&b.and_then(|b| b.wrapped_item_key.clone()))?, + env_to_sql(&b.and_then(|b| b.payload.clone()))?, + b.map(|b| b.updated_by.as_bytes().to_vec()), + b.map(|b| b.updated_at.clone()), + ], + )?; + Ok(()) + } + + fn get(&self, vault: Uuid, item: Uuid) -> Result> { + let raw = self + .conn() + .query_row( + &format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? AND item_id = ?"), + [vault.as_bytes().as_slice(), item.as_bytes().as_slice()], + raw_item, + ) + .optional()?; + raw.map(item_from_raw).transpose() + } + + fn list(&self, vault: Uuid) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare(&format!("SELECT {ITEM_COLS} FROM item WHERE vault_id = ? ORDER BY item_id"))?; + let raws = stmt.query_map([vault.as_bytes().as_slice()], raw_item)?.collect::>>()?; + raws.into_iter().map(item_from_raw).collect() + } + + fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> { + self.conn().execute( + "DELETE FROM item WHERE vault_id = ? AND item_id = ?", + [vault.as_bytes().as_slice(), item.as_bytes().as_slice()], + )?; + Ok(()) + } + + fn push_history(&self, vault: Uuid, item: Uuid, e: &HistoryEntry) -> Result<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + let (v, i) = (vault.as_bytes().as_slice(), item.as_bytes().as_slice()); + tx.execute( + "INSERT INTO history (vault_id, item_id, revision, wrapped_item_key, payload, saved_at) VALUES (?,?,?,?,?,?)", + params![ + v, + i, + e.revision as i64, + serde_json::to_string(&e.wrapped_item_key)?, + serde_json::to_string(&e.payload)?, + e.saved_at + ], + )?; + tx.execute( + "DELETE FROM history WHERE vault_id = ?1 AND item_id = ?2 AND seq NOT IN + (SELECT seq FROM history WHERE vault_id = ?1 AND item_id = ?2 ORDER BY seq DESC LIMIT ?3)", + params![v, i, HISTORY_LIMIT as i64], + )?; + tx.commit()?; + Ok(()) + } + + fn history(&self, vault: Uuid, item: Uuid) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT revision, wrapped_item_key, payload, saved_at FROM history + WHERE vault_id = ? AND item_id = ? ORDER BY seq DESC", + )?; + let raws = stmt + .query_map([vault.as_bytes().as_slice(), item.as_bytes().as_slice()], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?)) + })? + .collect::>>()?; + raws.into_iter() + .map(|(rev, wik, payload, at)| { + Ok(HistoryEntry { + revision: rev as u64, + wrapped_item_key: serde_json::from_str(&wik)?, + payload: serde_json::from_str(&payload)?, + saved_at: at, + }) + }) + .collect() + } + + fn wipe(&self) -> Result<()> { + self.conn().execute_batch("DELETE FROM history; DELETE FROM item; DELETE FROM vault; DELETE FROM meta;")?; + Ok(()) + } +} + +// ---------------------------------------------------------------- in-memory + +#[derive(Default)] +struct Mem { + meta: HashMap>, + vaults: Vec, + items: HashMap>, + history: HashMap<(Uuid, Uuid), Vec>, +} + +/// Non-persistent store for tests and `VaultCore::new_in_memory`. +#[derive(Default)] +pub struct MemoryStore { + inner: Mutex, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } + + fn mem(&self) -> std::sync::MutexGuard<'_, Mem> { + self.inner.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +impl Store for MemoryStore { + fn get_meta(&self, key: &str) -> Result>> { + Ok(self.mem().meta.get(key).cloned()) + } + + fn put_meta(&self, key: &str, value: &[u8]) -> Result<()> { + self.mem().meta.insert(key.into(), value.to_vec()); + Ok(()) + } + + fn delete_meta(&self, key: &str) -> Result<()> { + self.mem().meta.remove(key); + Ok(()) + } + + fn put_vault(&self, vault: &VaultRow) -> Result<()> { + let mut m = self.mem(); + match m.vaults.iter_mut().find(|v| v.vault_id == vault.vault_id) { + Some(v) => *v = vault.clone(), + None => m.vaults.push(vault.clone()), + } + Ok(()) + } + + fn vault(&self, vault: Uuid) -> Result> { + Ok(self.mem().vaults.iter().find(|v| v.vault_id == vault).cloned()) + } + + fn vaults(&self) -> Result> { + Ok(self.mem().vaults.clone()) + } + + fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()> { + self.mem().items.entry(vault).or_default().insert(item.record.item_id, item.clone()); + Ok(()) + } + + fn get(&self, vault: Uuid, item: Uuid) -> Result> { + Ok(self.mem().items.get(&vault).and_then(|v| v.get(&item)).cloned()) + } + + fn list(&self, vault: Uuid) -> Result> { + Ok(self.mem().items.get(&vault).map(|v| v.values().cloned().collect()).unwrap_or_default()) + } + + fn remove(&self, vault: Uuid, item: Uuid) -> Result<()> { + if let Some(v) = self.mem().items.get_mut(&vault) { + v.remove(&item); + } + Ok(()) + } + + fn push_history(&self, vault: Uuid, item: Uuid, entry: &HistoryEntry) -> Result<()> { + let mut m = self.mem(); + let h = m.history.entry((vault, item)).or_default(); + h.insert(0, entry.clone()); + h.truncate(HISTORY_LIMIT); + Ok(()) + } + + fn history(&self, vault: Uuid, item: Uuid) -> Result> { + Ok(self.mem().history.get(&(vault, item)).cloned().unwrap_or_default()) + } + + fn wipe(&self) -> Result<()> { + *self.mem() = Mem::default(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use kv_proto::{Alg, FORMAT_VERSION}; + + use super::*; + + fn env(tag: u8) -> Envelope { + Envelope { + v: FORMAT_VERSION, + alg: Alg::XChaCha20Poly1305, + kid: Uuid::nil(), + n: vec![tag; 24], + ct: vec![tag; 20], + enc: vec![], + } + } + + fn record(id: Uuid, rev: u64, tag: u8) -> ItemRecord { + ItemRecord { + item_id: id, + revision: rev, + deleted: false, + wrapped_item_key: Some(env(tag)), + payload: Some(env(tag + 1)), + updated_by: Uuid::nil(), + updated_at: "2026-01-01T00:00:00Z".into(), + } + } + + fn exercise(store: &dyn Store) { + let vid = Uuid::new_v4(); + assert!(store.vault(vid).unwrap().is_none()); + store.put_vault(&VaultRow::new(vid, env(1), Some(env(2)))).unwrap(); + store.set_cursor(vid, 7).unwrap(); + let mut v = store.vault(vid).unwrap().unwrap(); + assert_eq!(v.cursor, 7); + v.manifest_version = 3; + v.manifest_hash = Some([5; 32]); + store.put_vault(&v).unwrap(); + assert_eq!(store.vaults().unwrap(), vec![v]); + + store.put_meta("account", b"{}").unwrap(); + assert_eq!(store.get_meta("account").unwrap().as_deref(), Some(&b"{}"[..])); + store.delete_meta("account").unwrap(); + assert!(store.get_meta("account").unwrap().is_none()); + + // Remote record, then a local edit keeps the base for three-way merge. + let id = Uuid::new_v4(); + store.put_remote(vid, &record(id, 4, 10)).unwrap(); + assert!(store.pending(vid).unwrap().is_empty()); + let mut edit = record(id, 999, 20); + edit.updated_at = "later".into(); + store.put_local(vid, &edit).unwrap(); + let got = store.get(vid, id).unwrap().unwrap(); + assert_eq!(got.state, SyncState::Pending); + assert_eq!(got.record.revision, 4); + assert_eq!(got.record.payload, Some(env(21))); + assert_eq!(got.base.as_ref().unwrap().payload, Some(env(11))); + assert_eq!(got.base_revision(), 4); + assert_eq!(store.pending(vid).unwrap().len(), 1); + + // A brand-new local item has no base. + let new_id = Uuid::new_v4(); + store.put_local(vid, &record(new_id, 0, 30)).unwrap(); + assert!(store.get(vid, new_id).unwrap().unwrap().base.is_none()); + + // Tombstones round-trip with no envelopes. + let tomb = ItemRecord { deleted: true, wrapped_item_key: None, payload: None, ..record(id, 9, 0) }; + store.put_remote(vid, &tomb).unwrap(); + let got = store.get(vid, id).unwrap().unwrap(); + assert!(got.record.deleted && got.record.payload.is_none() && got.base.unwrap().deleted); + assert_eq!(store.list(vid).unwrap().len(), 2); + store.remove(vid, new_id).unwrap(); + assert_eq!(store.list(vid).unwrap().len(), 1); + + for i in 0..15u64 { + store + .push_history( + vid, + id, + &HistoryEntry { revision: i, wrapped_item_key: env(1), payload: env(2), saved_at: 0 }, + ) + .unwrap(); + } + let h = store.history(vid, id).unwrap(); + assert_eq!(h.len(), HISTORY_LIMIT); + assert_eq!(h[0].revision, 14); + assert_eq!(h[9].revision, 5); + assert!(store.history(vid, Uuid::new_v4()).unwrap().is_empty()); + + store.wipe().unwrap(); + assert!(store.vaults().unwrap().is_empty()); + assert!(store.list(vid).unwrap().is_empty()); + } + + #[test] + fn memory_store() { + exercise(&MemoryStore::new()); + } + + #[test] + fn sqlite_store() { + exercise(&SqliteStore::open_in_memory().unwrap()); + } + + #[test] + fn sqlite_persists_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let vid = Uuid::new_v4(); + let id = Uuid::new_v4(); + { + let s = SqliteStore::open(&path).unwrap(); + s.put_vault(&VaultRow::new(vid, env(1), None)).unwrap(); + s.put_remote(vid, &record(id, 2, 3)).unwrap(); + } + let s = SqliteStore::open(&path).unwrap(); + assert_eq!(s.vault(vid).unwrap().unwrap().sealed_key, env(1)); + assert_eq!(s.get(vid, id).unwrap().unwrap().record.revision, 2); + } + + #[test] + fn sqlite_never_stores_plaintext_columns() { + // Guard against accidentally adding a plaintext column: only ciphertext/ids/bookkeeping exist. + let s = SqliteStore::open_in_memory().unwrap(); + let conn = s.conn(); + let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('item')").unwrap(); + let cols: Vec = stmt.query_map([], |r| r.get(0)).unwrap().map(|c| c.unwrap()).collect(); + for c in &cols { + assert!(!["title", "username", "password", "url", "notes"].contains(&c.as_str()), "{c}"); + } + } } diff --git a/crates/kv-sync/Cargo.toml b/crates/kv-sync/Cargo.toml index 7b02e05..694a583 100644 --- a/crates/kv-sync/Cargo.toml +++ b/crates/kv-sync/Cargo.toml @@ -8,5 +8,38 @@ publish.workspace = true [dependencies] kv-proto.workspace = true +kv-crypto.workspace = true +kv-model.workspace = true +kv-store.workspace = true uuid.workspace = true thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +base64.workspace = true +sha2.workspace = true +rand_core.workspace = true +zeroize.workspace = true +opaque-ke.workspace = true +reqwest.workspace = true +rustls.workspace = true +webpki-roots.workspace = true +rustls-webpki.workspace = true +tokio.workspace = true +ciborium.workspace = true +blake3.workspace = true +p256.workspace = true +time.workspace = true +url.workspace = true +wiremock = { workspace = true, optional = true } + +[features] +# In-memory fake of kestrove-server for tests (also used by kv-ffi's tests). +test-server = ["dep:wiremock"] + +[dev-dependencies] +kv-sync = { path = ".", features = ["test-server"] } +rcgen.workspace = true +tokio-rustls.workspace = true +tempfile.workspace = true +rustls.workspace = true +tokio = { workspace = true } diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs new file mode 100644 index 0000000..e452dba --- /dev/null +++ b/crates/kv-sync/src/client.rs @@ -0,0 +1,271 @@ +//! Typed HTTP client for the sync API (docs/03, api/openapi.yaml). + +use std::{ + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use kv_proto::{ + AccountInfo, ApiError, Changes, DeviceEnroll, DeviceInfo, ItemPut, ItemRecord, KdfParams, LoginFinish, + LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse, + RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault, +}; +use reqwest::{Method, StatusCode}; +use serde::{de::DeserializeOwned, Serialize}; +use uuid::Uuid; + +use crate::{ + opaque, signing, + transport::{classify, http_client, normalize_base_url}, + wire::*, + DeviceSigner, ServerConfig, SyncError, +}; + +pub const PAGE_LIMIT: u32 = 500; + +/// Result of an OPAQUE login. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoginOutcome { + /// Session established (and stored in the client). + Ok { session: String }, + /// New device: an enrolled device must approve it (or a TOTP must be supplied), then log in again. + PendingApproval, +} + +pub struct ApiClient { + base: String, + http: reqwest::Client, + signer: Arc, + session: Mutex>, +} + +fn now_unix() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +impl ApiClient { + pub fn new(cfg: &ServerConfig, signer: Arc) -> Result { + Ok(Self { + base: normalize_base_url(&cfg.base_url)?, + http: http_client(cfg)?, + signer, + session: Mutex::new(None), + }) + } + + pub fn base_url(&self) -> &str { + &self.base + } + + pub fn signer(&self) -> &Arc { + &self.signer + } + + pub fn session(&self) -> Option { + self.session.lock().unwrap_or_else(|p| p.into_inner()).clone() + } + + pub fn set_session(&self, s: Option) { + *self.session.lock().unwrap_or_else(|p| p.into_inner()) = s; + } + + async fn send( + &self, + method: Method, + path: &str, + body: Option>, + if_match: Option, + signed: bool, + ) -> Result<(StatusCode, Vec), SyncError> { + let url = format!("{}{}", self.base, path); + let body = body.unwrap_or_default(); + let mut req = self.http.request(method.clone(), &url); + if !body.is_empty() { + req = req.header("content-type", "application/json"); + } + if let Some(rev) = if_match { + req = req.header("if-match", format!("\"{rev}\"")); + } + if signed { + let session = self.session().ok_or(SyncError::NotAuthenticated)?; + let h = signing::sign_request(self.signer.as_ref(), method.as_str(), &url, &body, &session, now_unix())?; + for (k, v) in h.pairs() { + req = req.header(k, v); + } + } + let resp = req.body(body).send().await.map_err(classify)?; + let status = resp.status(); + let bytes = resp.bytes().await.map_err(classify)?.to_vec(); + if status.is_success() { + return Ok((status, bytes)); + } + let api: Option = serde_json::from_slice(&bytes).ok(); + let (code, message) = api.map(|e| (e.code, e.message)).unwrap_or_default(); + Err(match status { + StatusCode::PRECONDITION_FAILED => SyncError::RevisionConflict, + StatusCode::CONFLICT => SyncError::VersionConflict, + StatusCode::NOT_FOUND => SyncError::NotFound, + StatusCode::UNAUTHORIZED if signed => { + self.set_session(None); + SyncError::NotAuthenticated + } + _ => SyncError::Server { status: status.as_u16(), code, message }, + }) + } + + async fn json( + &self, + method: Method, + path: &str, + body: Option<&impl Serialize>, + if_match: Option, + signed: bool, + ) -> Result { + let body = body.map(serde_json::to_vec).transpose().map_err(|e| SyncError::Protocol(e.to_string()))?; + let (_, bytes) = self.send(method, path, body, if_match, signed).await?; + serde_json::from_slice(&bytes).map_err(|e| SyncError::Protocol(format!("{path}: {e}"))) + } + + async fn no_content( + &self, + method: Method, + path: &str, + body: Option<&impl Serialize>, + signed: bool, + ) -> Result<(), SyncError> { + let body = body.map(serde_json::to_vec).transpose().map_err(|e| SyncError::Protocol(e.to_string()))?; + self.send(method, path, body, None, signed).await.map(|_| ()) + } + + // ---------------------------------------------------------------- unauthenticated + + pub async fn server_info(&self) -> Result { + self.json(Method::GET, PATH_SERVER_INFO, None::<&()>, None, false).await + } + + /// Account id and KDF parameters of the (single) account: what a new device needs to derive + /// `AUTH_INPUT` before it can build KE1. + pub async fn prelogin(&self) -> Result { + self.json(Method::GET, PATH_PRELOGIN, None::<&()>, None, false).await + } + + /// OPAQUE registration plus account creation (first device only, needs the setup token). + #[allow(clippy::too_many_arguments)] + pub async fn register( + &self, + setup_token: &str, + account_id: Uuid, + kdf: &KdfParams, + auth_input: &[u8], + enc_pk: Vec, + sig_pk: Vec, + wrapped_keyset: kv_proto::Envelope, + device: DeviceEnroll, + ) -> Result<(), SyncError> { + let reg = opaque::registration_start(auth_input)?; + let start = + SetupStart { setup_token: setup_token.to_string(), account_id, registration_request: reg.request.clone() }; + let resp: RegistrationStartResponse = + self.json(Method::POST, PATH_SETUP_START, Some(&start), None, false).await?; + let upload = reg.finish(auth_input, &resp.registration_response)?; + let req = SetupFinish { + setup_token: setup_token.to_string(), + account_id, + kdf: kdf.clone(), + opaque_registration_upload: upload, + enc_pk, + sig_pk, + wrapped_keyset, + device, + }; + self.no_content(Method::POST, PATH_SETUP_FINISH, Some(&req), false).await + } + + /// OPAQUE login. On success the session is stored in this client. + pub async fn login( + &self, + account_id: Uuid, + auth_input: &[u8], + device: DeviceEnroll, + totp: Option, + ) -> Result { + let start = opaque::login_start(auth_input)?; + let req = LoginStart { account_id, ke1: start.ke1.clone(), device }; + let resp: LoginStartResponse = self.json(Method::POST, PATH_LOGIN_START, Some(&req), None, false).await?; + let done = start.finish(auth_input, &resp.ke2)?; + let fin = LoginFinish { login_id: resp.login_id, ke3: done.ke3, totp }; + let resp: LoginFinishResponse = match self.json(Method::POST, PATH_LOGIN_FINISH, Some(&fin), None, false).await + { + Err(SyncError::Server { status: 401, .. }) => return Err(SyncError::BadCredentials), + other => other?, + }; + match (resp.status, resp.session) { + (LoginStatus::Ok, Some(s)) => { + self.set_session(Some(s.clone())); + Ok(LoginOutcome::Ok { session: s }) + } + (LoginStatus::Ok, None) => Err(SyncError::Protocol("login ok without a session".into())), + (LoginStatus::PendingApproval, _) => Ok(LoginOutcome::PendingApproval), + } + } + + // ---------------------------------------------------------------- authenticated + + pub async fn logout(&self) -> Result<(), SyncError> { + let r = self.no_content(Method::POST, PATH_LOGOUT, None::<&()>, true).await; + self.set_session(None); + r + } + + pub async fn account(&self) -> Result { + self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await + } + + pub async fn devices(&self) -> Result, SyncError> { + self.json(Method::GET, PATH_DEVICES, None::<&()>, None, true).await + } + + /// Approves a pending device (this device must be approved itself). + pub async fn approve_device(&self, device: Uuid) -> Result<(), SyncError> { + self.no_content(Method::POST, &format!("{PATH_DEVICES}/{device}/approve"), None::<&()>, true).await + } + + /// Revokes a device permanently and ends its sessions. + pub async fn revoke_device(&self, device: Uuid) -> Result<(), SyncError> { + self.no_content(Method::DELETE, &format!("{PATH_DEVICES}/{device}"), None::<&()>, true).await + } + + pub async fn vaults(&self) -> Result, SyncError> { + self.json(Method::GET, PATH_VAULTS, None::<&()>, None, true).await + } + + pub async fn create_vault(&self, v: &Vault) -> Result<(), SyncError> { + self.no_content(Method::POST, PATH_VAULTS, Some(v), true).await + } + + pub async fn changes(&self, vault: Uuid, since: u64) -> Result { + let path = format!("{PATH_VAULTS}/{vault}/changes?since={since}&limit={PAGE_LIMIT}"); + self.json(Method::GET, &path, None::<&()>, None, true).await + } + + pub async fn put_item(&self, vault: Uuid, item: Uuid, base: u64, body: &ItemPut) -> Result { + self.json(Method::PUT, &format!("{PATH_VAULTS}/{vault}/items/{item}"), Some(body), Some(base), true).await + } + + pub async fn delete_item(&self, vault: Uuid, item: Uuid, base: u64) -> Result { + self.json(Method::DELETE, &format!("{PATH_VAULTS}/{vault}/items/{item}"), None::<&()>, Some(base), true).await + } + + /// `None` when the vault has no manifest yet. + pub async fn get_manifest(&self, vault: Uuid) -> Result, SyncError> { + match self.json(Method::GET, &format!("{PATH_VAULTS}/{vault}/manifest"), None::<&()>, None, true).await { + Ok(m) => Ok(Some(m)), + Err(SyncError::NotFound) => Ok(None), + Err(e) => Err(e), + } + } + + pub async fn put_manifest(&self, vault: Uuid, m: &Manifest) -> Result<(), SyncError> { + self.no_content(Method::PUT, &format!("{PATH_VAULTS}/{vault}/manifest"), Some(m), true).await + } +} diff --git a/crates/kv-sync/src/engine.rs b/crates/kv-sync/src/engine.rs new file mode 100644 index 0000000..bced099 --- /dev/null +++ b/crates/kv-sync/src/engine.rs @@ -0,0 +1,444 @@ +//! The client sync algorithm (docs/03 "Sync algorithm", "Conflict resolution", "Integrity"). +//! +//! Per vault: pull `/changes` from the cursor, verify the signed manifest, push pending edits with +//! `If-Match` (on 412: pull, merge, retry), pull again, then publish a new manifest. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use kv_crypto::{ + aead::SymKey, + keys::{open_item, seal_item, AccountKeyset, SealedItem}, +}; +use kv_proto::{ItemPut, ItemRecord, Vault}; +use kv_store::{HistoryEntry, LocalItem, Store, SyncState, VaultRow}; +use serde_json::Value; +use uuid::Uuid; + +use crate::{ + client::ApiClient, + manifest::{self, ManifestBody, ManifestError}, + merge::merge3, + SyncError, +}; + +/// What one `sync` did. Counts are summed over all vaults. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SyncReport { + /// Records received from the server and applied. + pub pulled: u32, + /// Local edits the server accepted. + pub pushed: u32, + /// Items where both sides changed and a three-way merge ran. + pub merged: u32, + /// Items an edit brought back after a delete on the other side (edit beats delete). + pub restored: Vec, + /// Vaults synced successfully. + pub vaults_synced: u32, + /// Remote vaults ignored because they have no valid signed manifest yet. + pub vaults_skipped: Vec, +} + +pub struct SyncContext<'a> { + pub api: &'a ApiClient, + pub store: &'a dyn Store, + pub keyset: &'a AccountKeyset, + pub device_id: Uuid, +} + +fn now() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) +} + +fn rfc3339_now() -> String { + time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default() +} + +fn crypto(e: kv_crypto::CryptoError) -> SyncError { + SyncError::Crypto(e.to_string()) +} + +/// Decrypts an item record's payload into JSON. +pub fn open_record( + account_id: Uuid, + vault_id: Uuid, + vk: &SymKey, + record: &ItemRecord, +) -> Result, SyncError> { + let (Some(k), Some(p)) = (&record.wrapped_item_key, &record.payload) else { return Ok(None) }; + if record.deleted { + return Ok(None); + } + let sealed = SealedItem { wrapped_item_key: k.clone(), payload: p.clone() }; + let pt = open_item(account_id, vault_id, record.item_id, vk, &sealed).map_err(crypto)?; + serde_json::from_slice(&pt).map(Some).map_err(|e| SyncError::Crypto(format!("item JSON: {e}"))) +} + +/// Encrypts item JSON into a new (local) record with a fresh item key. +pub fn seal_record( + account_id: Uuid, + vault_id: Uuid, + vk: &SymKey, + item_id: Uuid, + json: &Value, + device_id: Uuid, +) -> Result { + let pt = zeroize::Zeroizing::new(serde_json::to_vec(json).map_err(|e| SyncError::Protocol(e.to_string()))?); + let sealed = seal_item(account_id, vault_id, item_id, vk, &pt).map_err(crypto)?; + Ok(ItemRecord { + item_id, + revision: 0, + deleted: false, + wrapped_item_key: Some(sealed.wrapped_item_key), + payload: Some(sealed.payload), + updated_by: device_id, + updated_at: rfc3339_now(), + }) +} + +fn history_entry(r: &ItemRecord) -> Option { + Some(HistoryEntry { + revision: r.revision, + wrapped_item_key: r.wrapped_item_key.clone()?, + payload: r.payload.clone()?, + saved_at: now(), + }) +} + +/// The server's view of a vault: the base (last confirmed) version of every item. +fn server_view(store: &dyn Store, vault: Uuid) -> Result, SyncError> { + Ok(store.list(vault)?.into_iter().filter_map(|i| i.base).collect()) +} + +impl SyncContext<'_> { + fn account_id(&self) -> Uuid { + self.keyset.account_id + } + + /// Syncs every vault. Vaults with an integrity alarm are skipped and reported as an error + /// after the others have synced. + pub async fn sync_all(&self) -> Result { + let mut report = SyncReport::default(); + self.reconcile_vault_list(&mut report).await?; + + let mut alarm = None; + for row in self.store.vaults()? { + if row.integrity_alarm { + alarm.get_or_insert(SyncError::Integrity { + vault_id: row.vault_id, + reason: "an earlier sync detected tampering; syncing this vault is stopped".into(), + }); + continue; + } + match self.sync_vault(row.vault_id, &mut report).await { + Ok(()) => report.vaults_synced += 1, + Err(SyncError::Integrity { vault_id, reason }) => { + let mut row = self.store.vault(vault_id)?.ok_or(kv_store::StoreError::NotFound)?; + row.integrity_alarm = true; + self.store.put_vault(&row)?; + alarm.get_or_insert(SyncError::Integrity { vault_id, reason }); + } + Err(e) => return Err(e), + } + } + match alarm { + Some(e) => Err(e), + None => Ok(report), + } + } + + /// Uploads vaults created here; adopts remote vaults that carry a valid signed manifest. + async fn reconcile_vault_list(&self, report: &mut SyncReport) -> Result<(), SyncError> { + let remote = self.api.vaults().await?; + for local in self.store.vaults()? { + if remote.iter().any(|v| v.vault_id == local.vault_id) { + continue; + } + let meta = local.meta.clone().ok_or_else(|| SyncError::Protocol("vault without metadata".into()))?; + let v = Vault { vault_id: local.vault_id, head_revision: 0, sealed_key: local.sealed_key.clone(), meta }; + match self.api.create_vault(&v).await { + Ok(()) | Err(SyncError::VersionConflict) => {} + Err(e) => return Err(e), + } + } + for rv in remote { + if self.store.vault(rv.vault_id)?.is_some() { + continue; + } + // Anyone can HPKE-seal a key to our public key, so a vault is only trusted once a + // manifest signed by the account key vouches for it. + if self.keyset.open_vault_key(rv.vault_id, &rv.sealed_key).is_err() { + report.vaults_skipped.push(rv.vault_id); + continue; + } + let signed = match self.api.get_manifest(rv.vault_id).await? { + Some(m) => manifest::verify(&self.keyset.sig_public(), &m, rv.vault_id).is_ok(), + None => false, + }; + if !signed { + report.vaults_skipped.push(rv.vault_id); + continue; + } + self.store.put_vault(&VaultRow::new(rv.vault_id, rv.sealed_key, Some(rv.meta)))?; + } + Ok(()) + } + + async fn sync_vault(&self, vault: Uuid, report: &mut SyncReport) -> Result<(), SyncError> { + let row = self.store.vault(vault)?.ok_or(kv_store::StoreError::NotFound)?; + let vk = self.keyset.open_vault_key(vault, &row.sealed_key).map_err(crypto)?; + + self.pull(vault, &vk, report).await?; + let mut remote_manifest = self.verify_manifest(vault).await; + if matches!(remote_manifest, Err(SyncError::ManifestAhead)) { + // Someone wrote between our pull and the manifest fetch; one more pull must catch up. + self.pull(vault, &vk, report).await?; + remote_manifest = self.verify_manifest(vault).await; + } + let remote_manifest = match remote_manifest { + Err(SyncError::ManifestAhead) => { + return Err(self.alarm(vault, "manifest describes revisions the server will not send")) + } + other => other?, + }; + + let pushed = self.push(vault, &vk, report).await?; + if pushed > 0 || remote_manifest.is_none() { + self.pull(vault, &vk, report).await?; + self.publish_manifest(vault).await?; + } + Ok(()) + } + + fn alarm(&self, vault_id: Uuid, reason: impl Into) -> SyncError { + SyncError::Integrity { vault_id, reason: reason.into() } + } + + // ---------------------------------------------------------------- pull + + async fn pull(&self, vault: Uuid, vk: &SymKey, report: &mut SyncReport) -> Result<(), SyncError> { + loop { + let cursor = self.store.cursor(vault)?; + let page = self.api.changes(vault, cursor).await?; + let mut last = cursor; + for rec in &page.items { + if rec.revision <= last { + return Err(self.alarm(vault, "server sent changes out of order")); + } + last = rec.revision; + self.apply_remote(vault, vk, rec, report)?; + report.pulled += 1; + } + let more = page.more && !page.items.is_empty(); + let new_cursor = if more { last } else { last.max(page.head_revision) }; + if new_cursor < cursor { + return Err(self.alarm(vault, "server head revision went backwards")); + } + self.store.set_cursor(vault, new_cursor)?; + if !more { + return Ok(()); + } + } + } + + fn apply_remote( + &self, + vault: Uuid, + vk: &SymKey, + remote: &ItemRecord, + report: &mut SyncReport, + ) -> Result<(), SyncError> { + let Some(local) = self.store.get(vault, remote.item_id)? else { + return Ok(self.store.put_remote(vault, remote)?); + }; + if remote.revision <= local.base_revision() { + return Ok(()); // our own write coming back, or a replay of something older + } + if local.state == SyncState::Clean { + return Ok(self.store.put_remote(vault, remote)?); + } + self.resolve_conflict(vault, vk, local, remote, report) + } + + /// `local` is pending and `remote` is a newer server version of the same item. + fn resolve_conflict( + &self, + vault: Uuid, + vk: &SymKey, + local: LocalItem, + remote: &ItemRecord, + report: &mut SyncReport, + ) -> Result<(), SyncError> { + let id = remote.item_id; + let rebased = |record: ItemRecord| LocalItem { + record: ItemRecord { revision: remote.revision, ..record }, + state: SyncState::Pending, + base: Some(remote.clone()), + }; + match (local.record.deleted, remote.deleted) { + (true, true) => self.store.put_remote(vault, remote)?, + (true, false) => { + // Edit beats delete: the remote edit wins, the item is restored. + self.store.put_remote(vault, remote)?; + report.restored.push(id); + } + (false, true) => { + // Our edit beats their delete: keep ours and push it on top of the tombstone. + self.store.put(vault, &rebased(local.record))?; + report.restored.push(id); + } + (false, false) => { + let acct = self.account_id(); + let base = match &local.base { + Some(b) => open_record(acct, vault, vk, b)?, + None => None, + }; + let mine = + open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?; + let theirs = open_record(acct, vault, vk, remote)?.ok_or(SyncError::Protocol("empty item".into()))?; + let out = merge3(base.as_ref(), &mine, &theirs); + if out.local_lost { + if let Some(h) = history_entry(&local.record) { + self.store.push_history(vault, id, &h)?; + } + } + if out.remote_lost { + if let Some(h) = history_entry(remote) { + self.store.push_history(vault, id, &h)?; + } + } + if out.merged == theirs { + self.store.put_remote(vault, remote)?; + } else { + let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?; + self.store.put(vault, &rebased(sealed))?; + } + report.merged += 1; + } + } + Ok(()) + } + + // ---------------------------------------------------------------- manifest + + /// Verifies the server's manifest against what this device has seen and pulled. + /// Returns `None` if the vault has no manifest yet. + async fn verify_manifest(&self, vault: Uuid) -> Result, SyncError> { + let mut row = self.store.vault(vault)?.ok_or(kv_store::StoreError::NotFound)?; + let integrity = |e: ManifestError| SyncError::Integrity { vault_id: vault, reason: e.to_string() }; + let Some(m) = self.api.get_manifest(vault).await? else { + return if row.manifest_version > 0 { Err(integrity(ManifestError::Missing)) } else { Ok(None) }; + }; + let body = manifest::verify(&self.keyset.sig_public(), &m, vault).map_err(integrity)?; + let hash = manifest::manifest_hash(&m.body); + manifest::check_chain(row.manifest_version, row.manifest_hash, &body, hash).map_err(integrity)?; + + if body.head_revision > row.cursor { + return Err(SyncError::ManifestAhead); + } + if body.head_revision == row.cursor && manifest::merkle_root_of(&server_view(self.store, vault)?) != body.items + { + return Err(integrity(ManifestError::MerkleMismatch)); + } + if body.version > row.manifest_version { + row.manifest_version = body.version; + row.manifest_hash = Some(hash); + self.store.put_vault(&row)?; + } + Ok(Some(body)) + } + + async fn publish_manifest(&self, vault: Uuid) -> Result<(), SyncError> { + for _ in 0..3 { + let mut row = self.store.vault(vault)?.ok_or(kv_store::StoreError::NotFound)?; + let body = ManifestBody { + vault_id: vault, + version: row.manifest_version + 1, + head_revision: row.cursor, + items: manifest::merkle_root_of(&server_view(self.store, vault)?), + prev: if row.manifest_version == 0 { [0; 32] } else { row.manifest_hash.unwrap_or([0; 32]) }, + device_id: self.device_id, + created_at: now() as u64, + }; + let m = body.sign(self.keyset); + match self.api.put_manifest(vault, &m).await { + Ok(()) => { + row.manifest_version = body.version; + row.manifest_hash = Some(manifest::manifest_hash(&m.body)); + return self.store.put_vault(&row).map_err(Into::into); + } + Err(SyncError::VersionConflict) => { + // Another device published first: verify theirs (advances our chain), then retry. + match self.verify_manifest(vault).await { + Ok(_) | Err(SyncError::ManifestAhead) => {} + Err(e) => return Err(e), + } + } + Err(e) => return Err(e), + } + } + // Lost the race three times; the next sync publishes. + Ok(()) + } + + // ---------------------------------------------------------------- push + + async fn push(&self, vault: Uuid, vk: &SymKey, report: &mut SyncReport) -> Result { + let mut pushed = 0; + for _round in 0..4 { + let pending = self.store.pending(vault)?; + if pending.is_empty() { + break; + } + let mut conflict = false; + for item in pending { + let base = item.base_revision(); + let id = item.record.item_id; + let result = if item.record.deleted { + if base == 0 { + self.store.remove(vault, id)?; + continue; + } + self.api.delete_item(vault, id, base).await + } else { + let (Some(k), Some(p)) = (&item.record.wrapped_item_key, &item.record.payload) else { + return Err(SyncError::Protocol(format!("pending item {id} has no ciphertext"))); + }; + let body = ItemPut { wrapped_item_key: k.clone(), payload: p.clone() }; + self.api.put_item(vault, id, base, &body).await + }; + match result { + Ok(confirmed) => { + self.confirm(vault, &item, confirmed)?; + pushed += 1; + report.pushed += 1; + } + Err(SyncError::RevisionConflict) => conflict = true, + Err(e) => return Err(e), + } + } + if !conflict { + break; + } + // 412: fetch what changed, merge, and retry the remaining pending items. + self.pull(vault, vk, report).await?; + } + Ok(pushed) + } + + /// Records the server's acceptance, unless the item was edited again while the request was in flight. + fn confirm(&self, vault: Uuid, pushed: &LocalItem, confirmed: ItemRecord) -> Result<(), SyncError> { + let current = self.store.get(vault, confirmed.item_id)?; + let unchanged = current + .as_ref() + .is_some_and(|c| c.record.payload == pushed.record.payload && c.record.deleted == pushed.record.deleted); + if unchanged || current.is_none() { + self.store.put_remote(vault, &confirmed)?; + } else if let Some(mut c) = current { + c.record.revision = confirmed.revision; + c.base = Some(confirmed); + c.state = SyncState::Pending; + self.store.put(vault, &c)?; + } + Ok(()) + } +} diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs index a5ebe5d..08b4618 100644 --- a/crates/kv-sync/src/lib.rs +++ b/crates/kv-sync/src/lib.rs @@ -1,24 +1,72 @@ //! Sync engine (milestone M1, kestrove-spec/docs/03). //! //! Responsibilities: -//! * `Transport`: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). -//! * OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already Argon2id-stretched). -//! * RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is +//! * [`transport`]: HTTPS with rustls, TLS 1.3 only, SPKI pinning (primary + backup pin). +//! * [`opaque`]: OPAQUE login via `opaque-ke` (ristretto255 / SHA-512, identity KSF because the input is already +//! Argon2id-stretched). +//! * [`signing`]: RFC 9421 request signing with the device key. The key lives in the platform keystore, so signing is //! a callback into platform code ([`DeviceSigner`]). -//! * Pull, then push with `If-Match`, then three-way field merge on 412, then manifest verify and publish. +//! * [`engine`]: pull, then push with `If-Match`, then three-way field merge on 412 ([`merge`]), then manifest verify +//! and publish ([`manifest`]). +pub mod client; +pub mod engine; +pub mod manifest; +pub mod merge; +pub mod opaque; +pub mod signing; +#[cfg(feature = "test-server")] +pub mod test_server; +pub mod transport; +pub mod wire; + +pub use client::{ApiClient, LoginOutcome}; +pub use engine::{SyncContext, SyncReport}; use uuid::Uuid; #[derive(Debug, thiserror::Error)] pub enum SyncError { #[error("network: {0}")] Network(String), - #[error("server rejected request: {0}")] - Server(String), - #[error("integrity alarm on vault {0}: manifest rollback or tampering")] - Integrity(Uuid), + #[error("server certificate does not match the pinned keys")] + PinMismatch, + #[error("configuration: {0}")] + Config(String), + #[error("server returned {status} {code}: {message}")] + Server { status: u16, code: String, message: String }, + #[error("unexpected server response: {0}")] + Protocol(String), + #[error("integrity alarm on vault {vault_id}: {reason}")] + Integrity { vault_id: Uuid, reason: String }, #[error("not logged in")] NotAuthenticated, + #[error("wrong master password or Secret Key")] + BadCredentials, + #[error("authentication failed: {0}")] + Auth(String), + #[error("device signer: {0}")] + Signer(String), + #[error("local storage: {0}")] + Store(String), + #[error("crypto: {0}")] + Crypto(String), + #[error("not found")] + NotFound, + /// HTTP 412: the item changed on the server since `If-Match`. + #[error("revision conflict")] + RevisionConflict, + /// HTTP 409. + #[error("version conflict")] + VersionConflict, + /// Internal: the manifest is newer than our cursor; pull again. + #[error("manifest is ahead of the pulled changes")] + ManifestAhead, +} + +impl From for SyncError { + fn from(e: kv_store::StoreError) -> Self { + SyncError::Store(e.to_string()) + } } /// Implemented by each platform: Secure Enclave, StrongBox, TPM or software fallback. @@ -31,8 +79,16 @@ pub trait DeviceSigner: Send + Sync { } /// Server endpoint plus pinned SPKI SHA-256 hashes (base64). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ServerConfig { pub base_url: String, pub spki_pins: Vec, + /// Extra trust anchors (DER), e.g. a private CA for a self-hosted server. Empty in normal use. + pub extra_roots_der: Vec>, +} + +impl ServerConfig { + pub fn new(base_url: impl Into, spki_pins: Vec) -> Self { + Self { base_url: base_url.into(), spki_pins, extra_roots_der: vec![] } + } } diff --git a/crates/kv-sync/src/manifest.rs b/crates/kv-sync/src/manifest.rs new file mode 100644 index 0000000..5f9ffbb --- /dev/null +++ b/crates/kv-sync/src/manifest.rs @@ -0,0 +1,378 @@ +//! Signed vault manifest (docs/03 "Integrity and rollback protection"). +//! +//! Body: canonical CBOR (RFC 8949 §4.2.1 deterministic encoding) of a map with text keys: +//! +//! | key | type | meaning | +//! |----------------|-------------|-----------------------------------------------------------| +//! | `prev` | bstr (32) | blake3 of the previous manifest body, zeros for version 1 | +//! | `items` | bstr (32) | Merkle root (below) | +//! | `version` | uint | +1 per publish | +//! | `vault_id` | bstr (16) | | +//! | `device_id` | bstr (16) | publishing device | +//! | `created_at` | uint | unix seconds | +//! | `head_revision`| uint | server revision the Merkle root describes | +//! +//! Merkle tree over every item the server holds (tombstones included), sorted by `item_id` bytes: +//! * `content = blake3(cbor([envelope(wrapped_item_key), envelope(payload)]))`, or `blake3(cbor([]))` for a tombstone, +//! where `envelope(e)` is the canonical CBOR map `{n, v, ct, alg, enc, kid}` (`n`/`enc` omitted when empty). +//! * `leaf = blake3(0x00 || item_id[16] || revision as u64 big-endian || content)` +//! * `node = blake3(0x01 || left || right)`; an odd node at the end of a level is promoted unchanged. +//! * The root of an empty vault is 32 zero bytes. +//! +//! `signature = Ed25519(account sig_sk, body)`, `manifest hash = blake3(body)`. + +use kv_crypto::keys::{verify_signature, AccountKeyset}; +use kv_proto::{Envelope, ItemRecord, Manifest}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestBody { + pub vault_id: Uuid, + pub version: u64, + pub head_revision: u64, + pub items: [u8; 32], + pub prev: [u8; 32], + pub device_id: Uuid, + pub created_at: u64, +} + +/// Why a manifest was rejected. Every variant is an integrity alarm. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ManifestError { + #[error("manifest is not canonical CBOR or has the wrong shape")] + Malformed, + #[error("manifest signature is invalid")] + BadSignature, + #[error("manifest belongs to another vault")] + WrongVault, + #[error("manifest version {got} is older than the last seen {seen} (rollback)")] + Rollback { seen: u64, got: u64 }, + #[error("manifest version {0} differs from the one seen before (fork)")] + Fork(u64), + #[error("manifest prev hash does not chain to the last seen manifest")] + ChainBroken, + #[error("manifest Merkle root does not match the items the server sent")] + MerkleMismatch, + #[error("server has no manifest but one was seen before")] + Missing, +} + +// ---------------------------------------------------------------- minimal canonical CBOR writer + +mod cbor { + pub fn head(out: &mut Vec, major: u8, n: u64) { + let m = major << 5; + match n { + 0..=23 => out.push(m | n as u8), + 24..=0xff => out.extend_from_slice(&[m | 24, n as u8]), + 0x100..=0xffff => { + out.push(m | 25); + out.extend_from_slice(&(n as u16).to_be_bytes()) + } + 0x1_0000..=0xffff_ffff => { + out.push(m | 26); + out.extend_from_slice(&(n as u32).to_be_bytes()) + } + _ => { + out.push(m | 27); + out.extend_from_slice(&n.to_be_bytes()) + } + } + } + pub fn uint(out: &mut Vec, n: u64) { + head(out, 0, n) + } + pub fn bytes(out: &mut Vec, b: &[u8]) { + head(out, 2, b.len() as u64); + out.extend_from_slice(b); + } + pub fn text(out: &mut Vec, s: &str) { + head(out, 3, s.len() as u64); + out.extend_from_slice(s.as_bytes()); + } + pub fn array(out: &mut Vec, len: usize) { + head(out, 4, len as u64) + } + pub fn map(out: &mut Vec, len: usize) { + head(out, 5, len as u64) + } +} + +/// Canonical CBOR of an envelope (keys in deterministic order: n, v, ct, alg, enc, kid). +pub fn envelope_cbor(e: &Envelope) -> Vec { + let mut out = Vec::with_capacity(e.ct.len() + 96); + let alg = serde_json::to_value(e.alg).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let n = 4 + usize::from(!e.n.is_empty()) + usize::from(!e.enc.is_empty()); + cbor::map(&mut out, n); + if !e.n.is_empty() { + cbor::text(&mut out, "n"); + cbor::bytes(&mut out, &e.n); + } + cbor::text(&mut out, "v"); + cbor::uint(&mut out, e.v.into()); + cbor::text(&mut out, "ct"); + cbor::bytes(&mut out, &e.ct); + cbor::text(&mut out, "alg"); + cbor::text(&mut out, &alg); + if !e.enc.is_empty() { + cbor::text(&mut out, "enc"); + cbor::bytes(&mut out, &e.enc); + } + cbor::text(&mut out, "kid"); + cbor::bytes(&mut out, e.kid.as_bytes()); + out +} + +/// `blake3(envelope)` for one item record. +pub fn content_hash(record: &ItemRecord) -> [u8; 32] { + let mut out = Vec::new(); + match (&record.wrapped_item_key, &record.payload, record.deleted) { + (Some(k), Some(p), false) => { + cbor::array(&mut out, 2); + out.extend_from_slice(&envelope_cbor(k)); + out.extend_from_slice(&envelope_cbor(p)); + } + _ => cbor::array(&mut out, 0), + } + *blake3::hash(&out).as_bytes() +} + +/// Merkle root over `(item_id, revision, content_hash)`; sorts its input. +pub fn merkle_root(mut leaves: Vec<(Uuid, u64, [u8; 32])>) -> [u8; 32] { + leaves.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + let mut level: Vec<[u8; 32]> = leaves + .iter() + .map(|(id, rev, h)| { + let mut hs = blake3::Hasher::new(); + hs.update(&[0]); + hs.update(id.as_bytes()); + hs.update(&rev.to_be_bytes()); + hs.update(h); + *hs.finalize().as_bytes() + }) + .collect(); + if level.is_empty() { + return [0; 32]; + } + while level.len() > 1 { + level = level + .chunks(2) + .map(|pair| match pair { + [l, r] => { + let mut hs = blake3::Hasher::new(); + hs.update(&[1]); + hs.update(l); + hs.update(r); + *hs.finalize().as_bytes() + } + [single] => *single, + _ => unreachable!(), + }) + .collect(); + } + level[0] +} + +/// Merkle root of the server's view of a vault, from records with a server revision. +pub fn merkle_root_of<'a>(records: impl IntoIterator) -> [u8; 32] { + merkle_root(records.into_iter().map(|r| (r.item_id, r.revision, content_hash(r))).collect()) +} + +impl ManifestBody { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(200); + cbor::map(&mut out, 7); + cbor::text(&mut out, "prev"); + cbor::bytes(&mut out, &self.prev); + cbor::text(&mut out, "items"); + cbor::bytes(&mut out, &self.items); + cbor::text(&mut out, "version"); + cbor::uint(&mut out, self.version); + cbor::text(&mut out, "vault_id"); + cbor::bytes(&mut out, self.vault_id.as_bytes()); + cbor::text(&mut out, "device_id"); + cbor::bytes(&mut out, self.device_id.as_bytes()); + cbor::text(&mut out, "created_at"); + cbor::uint(&mut out, self.created_at); + cbor::text(&mut out, "head_revision"); + cbor::uint(&mut out, self.head_revision); + out + } + + /// Strict decode: the input must be exactly the canonical encoding of a valid body. + pub fn decode(bytes: &[u8]) -> Result { + use ciborium::Value; + let v: Value = ciborium::de::from_reader(bytes).map_err(|_| ManifestError::Malformed)?; + let Value::Map(entries) = v else { return Err(ManifestError::Malformed) }; + let get = |k: &str| entries.iter().find(|(key, _)| key.as_text() == Some(k)).map(|(_, v)| v); + let b32 = |k: &str| -> Result<[u8; 32], ManifestError> { + get(k).and_then(Value::as_bytes).and_then(|b| b.as_slice().try_into().ok()).ok_or(ManifestError::Malformed) + }; + let id = |k: &str| -> Result { + get(k).and_then(Value::as_bytes).and_then(|b| Uuid::from_slice(b).ok()).ok_or(ManifestError::Malformed) + }; + let uint = |k: &str| -> Result { + get(k).and_then(Value::as_integer).and_then(|i| u64::try_from(i).ok()).ok_or(ManifestError::Malformed) + }; + let body = ManifestBody { + vault_id: id("vault_id")?, + version: uint("version")?, + head_revision: uint("head_revision")?, + items: b32("items")?, + prev: b32("prev")?, + device_id: id("device_id")?, + created_at: uint("created_at")?, + }; + if body.encode() != bytes { + return Err(ManifestError::Malformed); + } + Ok(body) + } + + pub fn sign(&self, keyset: &AccountKeyset) -> Manifest { + let body = self.encode(); + let signature = keyset.sign(&body).to_vec(); + Manifest { version: self.version, body, signature } + } +} + +pub fn manifest_hash(body: &[u8]) -> [u8; 32] { + *blake3::hash(body).as_bytes() +} + +/// Signature, shape and vault check. Chain checks are in [`check_chain`]. +pub fn verify(sig_pk: &[u8; 32], m: &Manifest, vault_id: Uuid) -> Result { + let sig: [u8; 64] = m.signature.as_slice().try_into().map_err(|_| ManifestError::BadSignature)?; + verify_signature(sig_pk, &m.body, &sig).map_err(|_| ManifestError::BadSignature)?; + let body = ManifestBody::decode(&m.body)?; + if body.vault_id != vault_id { + return Err(ManifestError::WrongVault); + } + if body.version != m.version { + return Err(ManifestError::Malformed); + } + Ok(body) +} + +/// Rollback / fork / chain checks against the last manifest this device accepted. +pub fn check_chain( + seen_version: u64, + seen_hash: Option<[u8; 32]>, + body: &ManifestBody, + hash: [u8; 32], +) -> Result<(), ManifestError> { + if body.version < seen_version { + return Err(ManifestError::Rollback { seen: seen_version, got: body.version }); + } + if body.version == seen_version && seen_version > 0 { + return if seen_hash == Some(hash) { Ok(()) } else { Err(ManifestError::Fork(body.version)) }; + } + if body.version == seen_version + 1 { + let expected = if seen_version == 0 { [0u8; 32] } else { seen_hash.ok_or(ManifestError::ChainBroken)? }; + // A device that never saw version 1 (seen = 0) cannot check the chain of a later manifest, + // but version 1 itself must start from zeros. + if body.prev != expected { + return Err(ManifestError::ChainBroken); + } + } + // version > seen + 1: intermediate manifests were replaced on the server; only the signature + // and monotonicity can be checked. + Ok(()) +} + +#[cfg(test)] +mod tests { + use kv_proto::{Alg, FORMAT_VERSION}; + + use super::*; + + fn body(version: u64, prev: [u8; 32]) -> ManifestBody { + ManifestBody { + vault_id: Uuid::from_bytes([1; 16]), + version, + head_revision: 9, + items: [3; 32], + prev, + device_id: Uuid::from_bytes([2; 16]), + created_at: 1_700_000_000, + } + } + + #[test] + fn canonical_roundtrip_and_strictness() { + let b = body(1, [0; 32]); + let enc = b.encode(); + assert_eq!(ManifestBody::decode(&enc).unwrap(), b); + // Same content via a generic (non-canonical key order) encoder is rejected. + let mut generic = Vec::new(); + ciborium::ser::into_writer( + &ciborium::Value::Map(vec![ + (ciborium::Value::Text("version".into()), ciborium::Value::Integer(1.into())), + (ciborium::Value::Text("prev".into()), ciborium::Value::Bytes(vec![0; 32])), + ]), + &mut generic, + ) + .unwrap(); + assert_eq!(ManifestBody::decode(&generic), Err(ManifestError::Malformed)); + let mut trailing = enc.clone(); + trailing.push(0); + assert!(ManifestBody::decode(&trailing).is_err()); + } + + #[test] + fn sign_verify() { + let ks = AccountKeyset::generate(Uuid::new_v4()); + let m = body(1, [0; 32]).sign(&ks); + assert!(verify(&ks.sig_public(), &m, Uuid::from_bytes([1; 16])).is_ok()); + assert_eq!(verify(&ks.sig_public(), &m, Uuid::nil()), Err(ManifestError::WrongVault)); + let mut forged = m.clone(); + forged.body[5] ^= 1; + assert_eq!(verify(&ks.sig_public(), &forged, Uuid::from_bytes([1; 16])), Err(ManifestError::BadSignature)); + let other = AccountKeyset::generate(Uuid::new_v4()); + assert_eq!(verify(&other.sig_public(), &m, Uuid::from_bytes([1; 16])), Err(ManifestError::BadSignature)); + } + + #[test] + fn chain_rules() { + let b1 = body(1, [0; 32]); + let h1 = manifest_hash(&b1.encode()); + assert!(check_chain(0, None, &b1, h1).is_ok()); + assert!(check_chain(1, Some(h1), &b1, h1).is_ok()); + let b2 = body(2, h1); + let h2 = manifest_hash(&b2.encode()); + assert!(check_chain(1, Some(h1), &b2, h2).is_ok()); + assert_eq!(check_chain(2, Some(h2), &b1, h1), Err(ManifestError::Rollback { seen: 2, got: 1 })); + assert_eq!(check_chain(1, Some(h1), &body(2, [9; 32]), [0; 32]), Err(ManifestError::ChainBroken)); + assert_eq!(check_chain(2, Some(h2), &body(2, h1), [8; 32]), Err(ManifestError::Fork(2))); + assert!(check_chain(1, Some(h1), &body(5, [7; 32]), [0; 32]).is_ok()); + } + + #[test] + fn merkle() { + assert_eq!(merkle_root(vec![]), [0; 32]); + let a = (Uuid::from_bytes([1; 16]), 1, [1; 32]); + let b = (Uuid::from_bytes([2; 16]), 2, [2; 32]); + let c = (Uuid::from_bytes([3; 16]), 3, [3; 32]); + let r = merkle_root(vec![c, a, b]); + assert_eq!(r, merkle_root(vec![a, b, c]), "order independent"); + assert_ne!(r, merkle_root(vec![a, b])); + assert_ne!(r, merkle_root(vec![a, b, (c.0, 4, c.2)]), "revision bound"); + assert_ne!(r, merkle_root(vec![a, b, (c.0, 3, [4; 32])]), "content bound"); + } + + #[test] + fn envelope_cbor_is_deterministic() { + let e = Envelope { + v: FORMAT_VERSION, + alg: Alg::XChaCha20Poly1305, + kid: Uuid::nil(), + n: vec![1; 24], + ct: vec![2; 3], + enc: vec![], + }; + let enc = envelope_cbor(&e); + assert_eq!(&enc[..3], &[0xa5, 0x61, b'n']); + let v: ciborium::Value = ciborium::de::from_reader(enc.as_slice()).unwrap(); + assert_eq!(v.as_map().unwrap().len(), 5); + } +} diff --git a/crates/kv-sync/src/merge.rs b/crates/kv-sync/src/merge.rs new file mode 100644 index 0000000..acafb61 --- /dev/null +++ b/crates/kv-sync/src/merge.rs @@ -0,0 +1,151 @@ +//! Three-way, field-level merge of decrypted items (docs/03 "Conflict resolution"). +//! +//! Items are merged as JSON objects so every top-level field of `kv_model::Item` (and any field a +//! newer client adds) is a merge unit. `id` and `created_at` come from the base, `modified_at` is the +//! maximum of both sides. + +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Local, + Remote, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MergeOutcome { + pub merged: Value, + /// Sides that lost at least one field; their full version belongs in history. + pub local_lost: bool, + pub remote_lost: bool, + /// Fields both sides changed differently. + pub conflicting_fields: Vec, +} + +const FIXED: [&str; 3] = ["id", "created_at", "modified_at"]; + +fn modified_at(v: &Value) -> i64 { + v.get("modified_at").and_then(Value::as_i64).unwrap_or(0) +} + +/// `base` is `None` when the common ancestor is unknown; every differing field is then a conflict. +pub fn merge3(base: Option<&Value>, local: &Value, remote: &Value) -> MergeOutcome { + let empty = Value::Object(Map::new()); + let base = base.unwrap_or(&empty); + let winner = if modified_at(local) > modified_at(remote) { Side::Local } else { Side::Remote }; + + let (Some(l), Some(r)) = (local.as_object(), remote.as_object()) else { + // Not objects: whole-value last-writer-wins. + let merged = if winner == Side::Local { local.clone() } else { remote.clone() }; + return MergeOutcome { + merged, + local_lost: winner == Side::Remote && local != remote, + remote_lost: winner == Side::Local && local != remote, + conflicting_fields: vec![], + }; + }; + let b = base.as_object().cloned().unwrap_or_default(); + + let mut keys: Vec<&String> = l.keys().chain(r.keys()).chain(b.keys()).collect(); + keys.sort(); + keys.dedup(); + + let mut out = Map::new(); + let mut conflicting = Vec::new(); + for k in keys { + if FIXED.contains(&k.as_str()) { + continue; + } + let (bv, lv, rv) = (b.get(k), l.get(k), r.get(k)); + let pick = if lv == rv { + lv + } else if lv == bv { + rv + } else if rv == bv { + lv + } else { + conflicting.push(k.clone()); + if winner == Side::Local { + lv + } else { + rv + } + }; + if let Some(v) = pick { + out.insert(k.clone(), v.clone()); + } + } + + let id = b.get("id").or_else(|| l.get("id")).or_else(|| r.get("id")).cloned(); + if let Some(id) = id { + out.insert("id".into(), id); + } + let created = b.get("created_at").or_else(|| l.get("created_at")).or_else(|| r.get("created_at")).cloned(); + if let Some(c) = created { + out.insert("created_at".into(), c); + } + out.insert("modified_at".into(), Value::from(modified_at(local).max(modified_at(remote)))); + + let lost = !conflicting.is_empty(); + MergeOutcome { + merged: Value::Object(out), + local_lost: lost && winner == Side::Remote, + remote_lost: lost && winner == Side::Local, + conflicting_fields: conflicting, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn disjoint_changes_both_kept() { + let base = + json!({"id": "x", "title": "Bank", "password": "a", "notes": null, "created_at": 1, "modified_at": 10}); + let local = + json!({"id": "x", "title": "Bank", "password": "b", "notes": null, "created_at": 1, "modified_at": 20}); + let remote = + json!({"id": "x", "title": "My Bank", "password": "a", "notes": "n", "created_at": 1, "modified_at": 15}); + let m = merge3(Some(&base), &local, &remote); + assert_eq!(m.merged["password"], "b"); + assert_eq!(m.merged["title"], "My Bank"); + assert_eq!(m.merged["notes"], "n"); + assert_eq!(m.merged["modified_at"], 20); + assert!(!m.local_lost && !m.remote_lost && m.conflicting_fields.is_empty()); + } + + #[test] + fn same_field_later_wins() { + let base = json!({"id": "x", "password": "a", "modified_at": 10}); + let local = json!({"id": "x", "password": "local", "modified_at": 30}); + let remote = json!({"id": "x", "password": "remote", "modified_at": 20}); + let m = merge3(Some(&base), &local, &remote); + assert_eq!(m.merged["password"], "local"); + assert!(m.remote_lost && !m.local_lost); + assert_eq!(m.conflicting_fields, vec!["password".to_string()]); + + let m = merge3(Some(&base), &json!({"id": "x", "password": "local", "modified_at": 5}), &remote); + assert_eq!(m.merged["password"], "remote"); + assert!(m.local_lost); + } + + #[test] + fn removed_field_and_unknown_fields() { + let base = json!({"id": "x", "totp": "T", "future_field": 1, "modified_at": 1}); + let local = json!({"id": "x", "future_field": 1, "modified_at": 2}); + let remote = json!({"id": "x", "totp": "T", "future_field": 2, "modified_at": 3}); + let m = merge3(Some(&base), &local, &remote); + assert!(m.merged.get("totp").is_none(), "local removal kept"); + assert_eq!(m.merged["future_field"], 2); + } + + #[test] + fn no_base_everything_conflicts() { + let m = merge3(None, &json!({"a": 1, "modified_at": 1}), &json!({"a": 2, "modified_at": 2})); + assert_eq!(m.merged["a"], 2); + assert!(m.local_lost); + } +} diff --git a/crates/kv-sync/src/opaque.rs b/crates/kv-sync/src/opaque.rs new file mode 100644 index 0000000..1c3392d --- /dev/null +++ b/crates/kv-sync/src/opaque.rs @@ -0,0 +1,112 @@ +//! OPAQUE-3DH client (RFC 9807) via `opaque-ke` 3. +//! +//! Suite: ristretto255 OPRF and key group, TripleDH, SHA-512, identity KSF. The KSF is the +//! identity because the "password" is `AUTH_INPUT`, which Argon2id has already stretched (docs/02). +//! The suite and conventions are defined once in [`kv_proto::opaque`] and shared with the server. + +use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, ClientRegistrationFinishParameters, + CredentialResponse, RegistrationResponse, +}; +use rand_core::OsRng; +use zeroize::Zeroizing; + +use crate::SyncError; + +pub use kv_proto::opaque::OpaqueSuite as Suite; + +fn proto(e: opaque_ke::errors::ProtocolError) -> SyncError { + SyncError::Auth(format!("OPAQUE: {e:?}")) +} + +/// First half of registration. Send `request` to the server. +pub struct RegistrationStart { + pub request: Vec, + state: ClientRegistration, +} + +pub fn registration_start(auth_input: &[u8]) -> Result { + let r = ClientRegistration::::start(&mut OsRng, auth_input).map_err(proto)?; + Ok(RegistrationStart { request: r.message.serialize().to_vec(), state: r.state }) +} + +impl RegistrationStart { + /// Returns the `RegistrationUpload` bytes for `SetupRequest.opaque_registration_upload`. + pub fn finish(self, auth_input: &[u8], response: &[u8]) -> Result, SyncError> { + let resp = RegistrationResponse::::deserialize(response).map_err(proto)?; + let r = self + .state + .finish(&mut OsRng, auth_input, resp, ClientRegistrationFinishParameters::default()) + .map_err(proto)?; + Ok(r.message.serialize().to_vec()) + } +} + +/// First half of login. Send `ke1` to the server. +pub struct LoginStart { + pub ke1: Vec, + state: ClientLogin, +} + +pub struct LoginFinished { + pub ke3: Vec, + pub session_key: Zeroizing>, +} + +pub fn login_start(auth_input: &[u8]) -> Result { + let r = ClientLogin::::start(&mut OsRng, auth_input).map_err(proto)?; + Ok(LoginStart { ke1: r.message.serialize().to_vec(), state: r.state }) +} + +impl LoginStart { + /// A wrong password (or a server without our record) fails here with `BadCredentials`. + pub fn finish(self, auth_input: &[u8], ke2: &[u8]) -> Result { + let resp = CredentialResponse::::deserialize(ke2).map_err(proto)?; + let r = self.state.finish(auth_input, resp, ClientLoginFinishParameters::default()).map_err(|e| match e { + opaque_ke::errors::ProtocolError::InvalidLoginError => SyncError::BadCredentials, + other => proto(other), + })?; + Ok(LoginFinished { ke3: r.message.serialize().to_vec(), session_key: Zeroizing::new(r.session_key.to_vec()) }) + } +} + +#[cfg(test)] +mod tests { + use opaque_ke::{ServerLogin, ServerLoginStartParameters, ServerRegistration, ServerSetup}; + + use super::*; + + #[test] + fn register_then_login() { + let setup = ServerSetup::::new(&mut OsRng); + let pwd = [42u8; 32]; + let cred_id = b"account"; + + let reg = registration_start(&pwd).unwrap(); + let req = opaque_ke::RegistrationRequest::::deserialize(®.request).unwrap(); + let resp = ServerRegistration::::start(&setup, req, cred_id).unwrap(); + let upload = reg.finish(&pwd, &resp.message.serialize()).unwrap(); + let record = ServerRegistration::::finish(opaque_ke::RegistrationUpload::deserialize(&upload).unwrap()); + + let login = login_start(&pwd).unwrap(); + let ke1 = opaque_ke::CredentialRequest::::deserialize(&login.ke1).unwrap(); + let srv = ServerLogin::start( + &mut OsRng, + &setup, + Some(record.clone()), + ke1, + cred_id, + ServerLoginStartParameters::default(), + ) + .unwrap(); + let done = login.finish(&pwd, &srv.message.serialize()).unwrap(); + let fin = srv.state.finish(opaque_ke::CredentialFinalization::deserialize(&done.ke3).unwrap()).unwrap(); + assert_eq!(fin.session_key.to_vec(), *done.session_key); + + // Wrong password. + let login = login_start(&[1u8; 32]).unwrap(); + let ke1 = opaque_ke::CredentialRequest::::deserialize(&login.ke1).unwrap(); + let srv = ServerLogin::start(&mut OsRng, &setup, Some(record), ke1, cred_id, Default::default()).unwrap(); + assert!(matches!(login.finish(&[1u8; 32], &srv.message.serialize()), Err(SyncError::BadCredentials))); + } +} diff --git a/crates/kv-sync/src/signing.rs b/crates/kv-sync/src/signing.rs new file mode 100644 index 0000000..f0cd7fa --- /dev/null +++ b/crates/kv-sync/src/signing.rs @@ -0,0 +1,204 @@ +//! RFC 9421 HTTP Message Signatures with the device key (docs/02 "Authentication to the server"). +//! +//! The exact profile (label, components, nonce) lives in [`kv_proto::httpsig`] and is shared with +//! the server. Covered: `"@method" "@target-uri" "content-digest" "kv-session" "kv-ts"`, plus +//! `created`, `keyid` (device id), `alg="ecdsa-p256-sha256"` and a random `nonce`. +//! The signature is IEEE P1363 `r || s` (64 bytes). + +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +pub use kv_proto::httpsig::{ + content_digest, signature_base, signature_params, HEADER_CONTENT_DIGEST as HDR_CONTENT_DIGEST, + HEADER_SESSION as HDR_SESSION, HEADER_SIGNATURE as HDR_SIGNATURE, HEADER_SIGNATURE_INPUT as HDR_SIGNATURE_INPUT, + HEADER_TS as HDR_TIMESTAMP, LABEL, +}; +use kv_proto::httpsig::{signature_header, signature_input_header}; +use uuid::Uuid; + +use crate::{DeviceSigner, SyncError}; + +/// Headers to attach to one request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedHeaders { + pub content_digest: String, + pub session: String, + pub timestamp: String, + pub signature_input: String, + pub signature: String, +} + +impl SignedHeaders { + pub fn pairs(&self) -> [(&'static str, &str); 5] { + [ + (HDR_CONTENT_DIGEST, &self.content_digest), + (HDR_SESSION, &self.session), + (HDR_TIMESTAMP, &self.timestamp), + (HDR_SIGNATURE_INPUT, &self.signature_input), + (HDR_SIGNATURE, &self.signature), + ] + } +} + +pub fn sign_request( + signer: &dyn DeviceSigner, + method: &str, + target_uri: &str, + body: &[u8], + session: &str, + now_unix: u64, +) -> Result { + let nonce = URL_SAFE_NO_PAD.encode(kv_crypto::random_bytes::<16>()); + sign_request_with_nonce(signer, method, target_uri, body, session, now_unix, &nonce) +} + +fn sign_request_with_nonce( + signer: &dyn DeviceSigner, + method: &str, + target_uri: &str, + body: &[u8], + session: &str, + now_unix: u64, + nonce: &str, +) -> Result { + let digest = content_digest(body); + let ts = now_unix.to_string(); + let params = signature_params(now_unix as i64, signer.device_id(), nonce); + let base = signature_base(&method.to_ascii_uppercase(), target_uri, &digest, session, &ts, ¶ms); + let sig = signer.sign(base.as_bytes())?; + if sig.len() != 64 { + return Err(SyncError::Signer(format!("device signature must be 64 bytes (r||s), got {}", sig.len()))); + } + Ok(SignedHeaders { + content_digest: digest, + session: session.to_string(), + timestamp: ts, + signature_input: signature_input_header(¶ms), + signature: signature_header(&sig), + }) +} + +/// Software P-256 device key: for tests and as the desktop fallback when no TPM is available. +pub struct SoftwareSigner { + id: Uuid, + key: p256::ecdsa::SigningKey, +} + +impl SoftwareSigner { + pub fn generate(id: Uuid) -> Self { + Self { id, key: p256::ecdsa::SigningKey::random(&mut rand_core::OsRng) } + } + + /// From a 32-byte P-256 scalar. + pub fn from_secret(id: Uuid, secret: &[u8]) -> Result { + let key = p256::ecdsa::SigningKey::from_slice(secret).map_err(|e| SyncError::Signer(e.to_string()))?; + Ok(Self { id, key }) + } + + pub fn secret_bytes(&self) -> zeroize::Zeroizing> { + zeroize::Zeroizing::new(self.key.to_bytes().to_vec()) + } +} + +impl DeviceSigner for SoftwareSigner { + fn device_id(&self) -> Uuid { + self.id + } + + fn public_key(&self) -> Vec { + self.key.verifying_key().to_encoded_point(false).as_bytes().to_vec() + } + + fn sign(&self, message: &[u8]) -> Result, SyncError> { + use p256::ecdsa::signature::Signer; + let sig: p256::ecdsa::Signature = self.key.sign(message); + Ok(sig.to_bytes().to_vec()) + } +} + +/// Server-side check, used by the test server and handy for the server crate's own tests. +pub fn verify_request( + p256_pk_sec1: &[u8], + method: &str, + target_uri: &str, + body: &[u8], + headers: &dyn Fn(&str) -> Option, +) -> Result { + use p256::ecdsa::signature::Verifier; + let bad = |m: &str| SyncError::Server { status: 401, code: "bad_signature".into(), message: m.into() }; + let digest = headers(HDR_CONTENT_DIGEST).ok_or_else(|| bad("missing content-digest"))?; + if digest != content_digest(body) { + return Err(bad("content-digest mismatch")); + } + let session = headers(HDR_SESSION).ok_or_else(|| bad("missing session"))?; + let ts = headers(HDR_TIMESTAMP).ok_or_else(|| bad("missing timestamp"))?; + let input = headers(HDR_SIGNATURE_INPUT).ok_or_else(|| bad("missing signature-input"))?; + let params = input.strip_prefix(&format!("{LABEL}=")).ok_or_else(|| bad("signature label"))?; + let key_id = params + .split(";keyid=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or_else(|| bad("keyid"))?; + let sig = headers(HDR_SIGNATURE).ok_or_else(|| bad("missing signature"))?; + let sig_b64 = + sig.strip_prefix(&format!("{LABEL}=:")).and_then(|s| s.strip_suffix(':')).ok_or_else(|| bad("signature"))?; + let sig_raw = STANDARD.decode(sig_b64).map_err(|_| bad("signature b64"))?; + let base = signature_base(method, target_uri, &digest, &session, &ts, params); + let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(p256_pk_sec1).map_err(|_| bad("device key"))?; + let sig = p256::ecdsa::Signature::from_slice(&sig_raw).map_err(|_| bad("signature encoding"))?; + vk.verify(base.as_bytes(), &sig).map_err(|_| bad("signature invalid"))?; + Ok(key_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_follow_the_shared_profile() { + let signer = SoftwareSigner::generate(Uuid::parse_str("11111111-2222-4333-8444-555555555555").unwrap()); + let h = sign_request_with_nonce( + &signer, + "put", + "https://v.example/v1/x", + b"{}", + "sess", + 1_700_000_000, + "AAAAAAAAAAAAAAAAAAAAAA", + ) + .unwrap(); + assert_eq!(h.content_digest, "sha-256=:RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=:"); + assert_eq!(h.timestamp, "1700000000"); + assert_eq!( + h.signature_input, + r#"kv=("@method" "@target-uri" "content-digest" "kv-session" "kv-ts");created=1700000000;keyid="11111111-2222-4333-8444-555555555555";alg="ecdsa-p256-sha256";nonce="AAAAAAAAAAAAAAAAAAAAAA""# + ); + assert!(h.signature.starts_with("kv=:") && h.signature.ends_with(':')); + // Fresh nonce per request. + let a = sign_request(&signer, "GET", "https://v.example/v1/x", b"", "s", 1).unwrap(); + let b = sign_request(&signer, "GET", "https://v.example/v1/x", b"", "s", 1).unwrap(); + assert_ne!(a.signature_input, b.signature_input); + assert!(kv_proto::httpsig::valid_nonce( + a.signature_input.split("nonce=\"").nth(1).unwrap().trim_end_matches('"') + )); + } + + #[test] + fn sign_and_verify() { + let signer = SoftwareSigner::generate(Uuid::new_v4()); + let url = "https://vault.example.com/v1/vaults"; + let h = sign_request(&signer, "get", url, b"", "s1", 42).unwrap(); + let lookup = |name: &str| h.pairs().iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()); + assert_eq!(verify_request(&signer.public_key(), "GET", url, b"", &lookup).unwrap(), signer.device_id()); + // Different body or URL fails. + assert!(verify_request(&signer.public_key(), "GET", url, b"x", &lookup).is_err()); + assert!(verify_request(&signer.public_key(), "GET", "https://evil/v1/vaults", b"", &lookup).is_err()); + let other = SoftwareSigner::generate(Uuid::new_v4()); + assert!(verify_request(&other.public_key(), "GET", url, b"", &lookup).is_err()); + // Round-trip of the software key. + let again = SoftwareSigner::from_secret(signer.device_id(), &signer.secret_bytes()).unwrap(); + assert_eq!(again.public_key(), signer.public_key()); + } +} diff --git a/crates/kv-sync/src/test_server.rs b/crates/kv-sync/src/test_server.rs new file mode 100644 index 0000000..e6a0f99 --- /dev/null +++ b/crates/kv-sync/src/test_server.rs @@ -0,0 +1,437 @@ +//! An in-memory fake of kestrove-server on top of `wiremock`, for tests (feature `test-server`). +//! +//! It runs real OPAQUE, verifies RFC 9421 signatures and implements the item / changes / manifest +//! semantics of docs/03, plus hooks to misbehave (roll back the manifest, hide changes, …). + +// Handlers short-circuit with the error response itself; size does not matter in a test helper. +#![allow(clippy::result_large_err)] + +use std::{ + collections::{BTreeMap, HashMap}, + sync::{Arc, Mutex, MutexGuard}, +}; + +use kv_proto::{ + AccountInfo, ApiError, Changes, DeviceInfo, Envelope, ItemPut, ItemRecord, KdfParams, LoginFinish, + LoginFinishResponse, LoginStart, LoginStartResponse, LoginStatus, Manifest, PreloginResponse, + RegistrationStartResponse, ServerInfo, SetupFinish, SetupStart, Vault, +}; +use opaque_ke::{ + CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLogin, + ServerLoginStartParameters, ServerRegistration, ServerSetup, +}; +use rand_core::OsRng; +use serde::{de::DeserializeOwned, Serialize}; +use uuid::Uuid; +use wiremock::{matchers::any, Mock, MockServer, Request, Respond, ResponseTemplate}; + +use crate::{opaque::Suite, signing}; + +pub struct Account { + pub account_id: Uuid, + pub kdf: KdfParams, + pub record: ServerRegistration, + pub enc_pk: Vec, + pub sig_pk: Vec, + pub wrapped_keyset: Envelope, +} + +#[derive(Default, Clone)] +pub struct VaultState { + pub head: u64, + pub sealed_key: Option, + pub meta: Option, + pub items: BTreeMap, + pub manifest: Option, + /// Every manifest ever stored, oldest first (for rollback attacks). + pub manifest_history: Vec, +} + +pub struct Device { + pub p256_pk: Vec, + pub approved: bool, + pub name: String, + pub revoked: bool, +} + +pub struct State { + pub setup: ServerSetup, + pub setup_token: Option, + pub account: Option, + pub devices: HashMap, + pub sessions: HashMap, + logins: HashMap, Uuid)>, + pub vaults: BTreeMap, + /// New devices need approval (else they are auto-approved). + pub require_approval: bool, + /// Requests seen, as "METHOD /path". + pub log: Vec, + /// Item ids `/changes` silently omits (a hiding attack). + pub hide_items: Vec, + /// When set, `GET /manifest` serves this instead of the latest. + pub serve_manifest: Option, + /// Number of upcoming item writes to reject with 412 regardless of revision. + pub force_412: u32, + base_url: String, +} + +impl State { + fn new(setup_token: &str) -> Self { + Self { + setup: ServerSetup::new(&mut OsRng), + setup_token: Some(setup_token.into()), + account: None, + devices: HashMap::new(), + sessions: HashMap::new(), + logins: HashMap::new(), + vaults: BTreeMap::new(), + require_approval: false, + log: vec![], + hide_items: vec![], + serve_manifest: None, + force_412: 0, + base_url: String::new(), + } + } + + /// Writes an item as another device would (bumps the head). + pub fn write_item(&mut self, vault: Uuid, record: ItemRecord) -> u64 { + let v = self.vaults.entry(vault).or_default(); + v.head += 1; + let rec = ItemRecord { revision: v.head, ..record }; + v.items.insert(rec.item_id, rec); + v.head + } +} + +#[derive(Clone)] +pub struct TestServer { + pub server: Arc, + pub state: Arc>, +} + +pub const SETUP_TOKEN: &str = "test-setup-token"; + +impl TestServer { + pub async fn start() -> Self { + let server = MockServer::start().await; + let mut st = State::new(SETUP_TOKEN); + st.base_url = server.uri(); + let state = Arc::new(Mutex::new(st)); + Mock::given(any()).respond_with(Handler { state: state.clone() }).mount(&server).await; + Self { server: Arc::new(server), state } + } + + pub fn url(&self) -> String { + self.server.uri() + } + + pub fn state(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +struct Handler { + state: Arc>, +} + +fn json(status: u16, v: &T) -> ResponseTemplate { + ResponseTemplate::new(status).set_body_json(v) +} + +fn err(status: u16, code: &str) -> ResponseTemplate { + json(status, &ApiError { code: code.into(), message: code.into() }) +} + +fn parse(req: &Request) -> Result { + serde_json::from_slice(&req.body).map_err(|_| err(400, "bad_request")) +} + +fn if_match(req: &Request) -> Result { + req.headers + .get("if-match") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim_matches('"').parse().ok()) + .ok_or_else(|| err(400, "if_match_required")) +} + +fn now() -> String { + "2026-10-15T00:00:00Z".into() +} + +impl Respond for Handler { + fn respond(&self, req: &Request) -> ResponseTemplate { + let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner()); + st.log.push(format!("{} {}", req.method, req.url.path())); + handle(&mut st, req).unwrap_or_else(|e| e) + } +} + +fn authenticate(st: &State, req: &Request) -> Result { + let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok()).map(str::to_string); + let session = header(signing::HDR_SESSION).ok_or_else(|| err(401, "unauthorized"))?; + let device = *st.sessions.get(&session).ok_or_else(|| err(401, "unauthorized"))?; + let dev = st.devices.get(&device).ok_or_else(|| err(401, "unauthorized"))?; + // wiremock rebuilds `req.url` without the port; the signed @target-uri is the URL the client used. + let mut target = format!("{}{}", st.base_url, req.url.path()); + if let Some(q) = req.url.query() { + target = format!("{target}?{q}"); + } + let key_id = signing::verify_request(&dev.p256_pk, req.method.as_str(), &target, &req.body, &header) + .map_err(|_| err(401, "bad_signature"))?; + if key_id != device || !dev.approved { + return Err(err(401, "unauthorized")); + } + Ok(device) +} + +fn handle(st: &mut State, req: &Request) -> Result { + let path = req.url.path().to_string(); + let method = req.method.as_str().to_string(); + let segs: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + match (method.as_str(), segs.as_slice()) { + ("GET", ["v1", "server-info"]) => Ok(json( + 200, + &ServerInfo { + version: "test".into(), + api: 1, + single_tenant: true, + setup_open: st.setup_token.is_some(), + min_client: "0.1.0".into(), + }, + )), + ("POST", ["v1", "setup", "start"]) => { + let r: SetupStart = parse(req)?; + if st.setup_token.as_deref() != Some(r.setup_token.as_str()) { + return Err(err(if st.setup_token.is_none() { 404 } else { 401 }, "setup")); + } + let msg = RegistrationRequest::::deserialize(&r.registration_request) + .map_err(|_| err(400, "bad_request"))?; + let resp = ServerRegistration::::start(&st.setup, msg, r.account_id.as_bytes()) + .map_err(|_| err(400, "bad_request"))?; + Ok(json(200, &RegistrationStartResponse { registration_response: resp.message.serialize().to_vec() })) + } + ("POST", ["v1", "setup", "finish"]) => { + let r: SetupFinish = parse(req)?; + if st.setup_token.as_deref() != Some(r.setup_token.as_str()) { + return Err(err(if st.setup_token.is_none() { 404 } else { 401 }, "setup")); + } + let upload = RegistrationUpload::::deserialize(&r.opaque_registration_upload) + .map_err(|_| err(400, "bad_request"))?; + st.account = Some(Account { + account_id: r.account_id, + kdf: r.kdf, + record: ServerRegistration::finish(upload), + enc_pk: r.enc_pk, + sig_pk: r.sig_pk, + wrapped_keyset: r.wrapped_keyset, + }); + st.devices.insert( + r.device.device_id, + Device { p256_pk: r.device.p256_pk, approved: true, name: r.device.name, revoked: false }, + ); + st.setup_token = None; + Ok(ResponseTemplate::new(201)) + } + ("GET", ["v1", "auth", "prelogin"]) => { + let acct = st.account.as_ref().ok_or_else(|| err(404, "not_found"))?; + Ok(json(200, &PreloginResponse { account_id: acct.account_id, kdf: acct.kdf.clone() })) + } + ("POST", ["v1", "auth", "login", "start"]) => { + let r: LoginStart = parse(req)?; + let acct = st.account.as_ref().filter(|a| a.account_id == r.account_id); + let ke1 = CredentialRequest::::deserialize(&r.ke1).map_err(|_| err(400, "bad_request"))?; + let res = ServerLogin::start( + &mut OsRng, + &st.setup, + acct.map(|a| a.record.clone()), + ke1, + r.account_id.as_bytes(), + ServerLoginStartParameters::default(), + ) + .map_err(|_| err(400, "bad_request"))?; + let kdf = acct.map(|a| a.kdf.clone()).ok_or_else(|| err(401, "unauthorized"))?; + let require = st.require_approval; + st.devices.entry(r.device.device_id).or_insert(Device { + p256_pk: r.device.p256_pk, + approved: !require, + name: r.device.name, + revoked: false, + }); + let login_id = Uuid::new_v4().to_string(); + st.logins.insert(login_id.clone(), (res.state.serialize().to_vec(), r.device.device_id)); + Ok(json(200, &LoginStartResponse { login_id, ke2: res.message.serialize().to_vec(), kdf })) + } + ("POST", ["v1", "auth", "login", "finish"]) => { + let r: LoginFinish = parse(req)?; + let (state, device) = st.logins.remove(&r.login_id).ok_or_else(|| err(401, "unauthorized"))?; + let login = ServerLogin::::deserialize(&state).map_err(|_| err(401, "unauthorized"))?; + let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?; + login.finish(ke3).map_err(|_| err(401, "unauthorized"))?; + if !st.devices.get(&device).is_some_and(|d| d.approved) { + return Ok(json( + 200, + &LoginFinishResponse { status: LoginStatus::PendingApproval, session: None, expires_at: None }, + )); + } + let session = Uuid::new_v4().to_string(); + st.sessions.insert(session.clone(), device); + Ok(json( + 200, + &LoginFinishResponse { status: LoginStatus::Ok, session: Some(session), expires_at: Some(now()) }, + )) + } + _ => { + let device = authenticate(st, req)?; + authed(st, req, device, &method, &segs) + } + } +} + +fn authed( + st: &mut State, + req: &Request, + device: Uuid, + method: &str, + segs: &[&str], +) -> Result { + match (method, segs) { + ("POST", ["v1", "auth", "logout"]) => { + st.sessions.retain(|_, d| *d != device); + Ok(ResponseTemplate::new(204)) + } + ("GET", ["v1", "account"]) => { + let a = st.account.as_ref().ok_or_else(|| err(404, "not_found"))?; + Ok(json( + 200, + &AccountInfo { + account_id: a.account_id, + kdf: a.kdf.clone(), + enc_pk: a.enc_pk.clone(), + sig_pk: a.sig_pk.clone(), + wrapped_keyset: a.wrapped_keyset.clone(), + }, + )) + } + ("GET", ["v1", "devices"]) => { + let list: Vec = st + .devices + .iter() + .map(|(id, d)| DeviceInfo { + device_id: *id, + name: d.name.clone(), + platform: kv_proto::Platform::Linux, + p256_pk: d.p256_pk.clone(), + approved: d.approved, + revoked_at: d.revoked.then(now), + created_at: now(), + current: *id == device, + }) + .collect(); + Ok(json(200, &list)) + } + ("POST", ["v1", "devices", id, "approve"]) => { + let id = Uuid::parse_str(id).map_err(|_| err(404, "not_found"))?; + let d = st.devices.get_mut(&id).filter(|d| !d.revoked).ok_or_else(|| err(404, "not_found"))?; + d.approved = true; + Ok(ResponseTemplate::new(204)) + } + ("DELETE", ["v1", "devices", id]) => { + let id = Uuid::parse_str(id).map_err(|_| err(404, "not_found"))?; + let d = st.devices.get_mut(&id).filter(|d| !d.revoked).ok_or_else(|| err(404, "not_found"))?; + d.approved = false; + d.revoked = true; + st.sessions.retain(|_, dev| *dev != id); + Ok(ResponseTemplate::new(204)) + } + ("GET", ["v1", "vaults"]) => { + let list: Vec = st + .vaults + .iter() + .filter_map(|(id, v)| { + Some(Vault { + vault_id: *id, + head_revision: v.head, + sealed_key: v.sealed_key.clone()?, + meta: v.meta.clone()?, + }) + }) + .collect(); + Ok(json(200, &list)) + } + ("POST", ["v1", "vaults"]) => { + let v: Vault = parse(req)?; + if st.vaults.get(&v.vault_id).is_some_and(|x| x.sealed_key.is_some()) { + return Err(err(409, "version_conflict")); + } + let e = st.vaults.entry(v.vault_id).or_default(); + e.sealed_key = Some(v.sealed_key); + e.meta = Some(v.meta); + Ok(ResponseTemplate::new(201)) + } + ("GET", ["v1", "vaults", vid, "changes"]) => { + let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?; + let q: HashMap = req.url.query_pairs().into_owned().collect(); + let since: u64 = q.get("since").and_then(|s| s.parse().ok()).unwrap_or(0); + let limit: usize = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(500); + let hide = st.hide_items.clone(); + let v = st.vaults.get(&vid).ok_or_else(|| err(404, "not_found"))?; + let mut items: Vec = + v.items.values().filter(|r| r.revision > since && !hide.contains(&r.item_id)).cloned().collect(); + items.sort_by_key(|r| r.revision); + let more = items.len() > limit; + items.truncate(limit); + Ok(json(200, &Changes { items, head_revision: v.head, more })) + } + ("PUT" | "DELETE", ["v1", "vaults", vid, "items", iid]) => { + let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?; + let iid = Uuid::parse_str(iid).map_err(|_| err(404, "not_found"))?; + let base = if_match(req)?; + let body: Option = if method == "PUT" { Some(parse(req)?) } else { None }; + if st.force_412 > 0 { + st.force_412 -= 1; + return Err(err(412, "revision_conflict")); + } + let v = st.vaults.get_mut(&vid).ok_or_else(|| err(404, "not_found"))?; + let current = v.items.get(&iid).map_or(0, |r| r.revision); + if current != base { + return Err(err(412, "revision_conflict")); + } + if body.is_none() && current == 0 { + return Err(err(404, "not_found")); + } + v.head += 1; + let rec = ItemRecord { + item_id: iid, + revision: v.head, + deleted: body.is_none(), + wrapped_item_key: body.as_ref().map(|b| b.wrapped_item_key.clone()), + payload: body.map(|b| b.payload), + updated_by: device, + updated_at: now(), + }; + v.items.insert(iid, rec.clone()); + Ok(json(200, &rec)) + } + ("GET", ["v1", "vaults", vid, "manifest"]) => { + let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?; + if let Some(m) = &st.serve_manifest { + return Ok(json(200, m)); + } + let v = st.vaults.get(&vid).ok_or_else(|| err(404, "not_found"))?; + v.manifest.as_ref().map(|m| json(200, m)).ok_or_else(|| err(404, "not_found")) + } + ("PUT", ["v1", "vaults", vid, "manifest"]) => { + let vid = Uuid::parse_str(vid).map_err(|_| err(404, "not_found"))?; + let m: Manifest = parse(req)?; + let v = st.vaults.get_mut(&vid).ok_or_else(|| err(404, "not_found"))?; + if m.version != v.manifest.as_ref().map_or(0, |x| x.version) + 1 { + return Err(err(409, "version_conflict")); + } + v.manifest_history.push(m.clone()); + v.manifest = Some(m); + Ok(ResponseTemplate::new(204)) + } + _ => Err(err(404, "not_found")), + } +} diff --git a/crates/kv-sync/src/transport.rs b/crates/kv-sync/src/transport.rs new file mode 100644 index 0000000..014e133 --- /dev/null +++ b/crates/kv-sync/src/transport.rs @@ -0,0 +1,216 @@ +//! HTTPS transport: rustls, TLS 1.3 only, SPKI pinning (docs/02 "Transport"). +//! +//! A pin is the base64 SHA-256 of a certificate's DER `SubjectPublicKeyInfo` (the HPKP +//! `pin-sha256` format). Normal WebPKI chain validation always runs first; on top of that, at +//! least one certificate the server presents (leaf or intermediate) must match a pin; a root that the +//! server does not send cannot be pinned. Configure +//! a primary and a backup pin so a key rotation never locks the client out. +//! +//! Plain `http://` is accepted only for loopback hosts (local development and tests). + +use std::sync::Arc; + +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +use rustls::{ + client::{ + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + WebPkiServerVerifier, + }, + pki_types::{CertificateDer, ServerName, UnixTime}, + CertificateError, DigitallySignedStruct, OtherError, RootCertStore, SignatureScheme, +}; +use sha2::{Digest, Sha256}; + +use crate::{ServerConfig, SyncError}; + +/// Raised inside the TLS handshake when no certificate matches a pin. +#[derive(Debug)] +pub struct PinMismatch; + +impl std::fmt::Display for PinMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("server certificate does not match any pinned SPKI hash") + } +} + +impl std::error::Error for PinMismatch {} + +/// Decodes `sha256/…`, standard or URL-safe base64 pins. +pub fn parse_pin(pin: &str) -> Result<[u8; 32], SyncError> { + let p = pin.trim().trim_start_matches("sha256/"); + let raw = STANDARD + .decode(p) + .or_else(|_| URL_SAFE_NO_PAD.decode(p.trim_end_matches('='))) + .map_err(|_| SyncError::Config(format!("pin is not base64: {pin}")))?; + raw.try_into().map_err(|_| SyncError::Config(format!("pin is not a SHA-256 hash: {pin}"))) +} + +/// `base64(SHA-256(SPKI))` of a DER certificate. Useful for tooling and tests. +pub fn spki_pin_of(cert_der: &[u8]) -> Result { + Ok(STANDARD.encode(spki_hash(&CertificateDer::from(cert_der))?)) +} + +fn spki_hash(cert: &CertificateDer<'_>) -> Result<[u8; 32], SyncError> { + let parsed = webpki::EndEntityCert::try_from(cert).map_err(|e| SyncError::Config(format!("certificate: {e}")))?; + Ok(Sha256::digest(parsed.subject_public_key_info().as_ref()).into()) +} + +#[derive(Debug)] +struct PinningVerifier { + inner: Arc, + pins: Vec<[u8; 32]>, +} + +impl ServerCertVerifier for PinningVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + let verified = self.inner.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)?; + if self.pins.is_empty() { + return Ok(verified); + } + let matched = std::iter::once(end_entity) + .chain(intermediates) + .filter_map(|c| spki_hash(c).ok()) + .any(|h| self.pins.contains(&h)); + if matched { + Ok(verified) + } else { + Err(rustls::Error::InvalidCertificate(CertificateError::Other(OtherError(Arc::new(PinMismatch))))) + } + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + // Unreachable: the config only enables TLS 1.3. + Err(rustls::Error::PeerIncompatible(rustls::PeerIncompatible::Tls12NotOffered)) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +/// Builds the rustls config: TLS 1.3 only, ring provider, Mozilla roots plus `extra_roots`, pin check. +pub fn tls_config(pins: &[String], extra_roots: &[Vec]) -> Result { + let pins = pins.iter().map(|p| parse_pin(p)).collect::, _>>()?; + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut roots = RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() }; + for der in extra_roots { + roots.add(CertificateDer::from(der.clone())).map_err(|e| SyncError::Config(format!("root: {e}")))?; + } + let inner = WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(|e| SyncError::Config(e.to_string()))?; + let mut cfg = rustls::ClientConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| SyncError::Config(e.to_string()))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(PinningVerifier { inner, pins })) + .with_no_client_auth(); + cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(cfg) +} + +fn is_loopback(url: &reqwest::Url) -> bool { + match url.host() { + Some(url::Host::Domain(d)) => d == "localhost" || d.ends_with(".localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + +/// Validates the base URL (https, or http on loopback) and normalizes it to end without a slash. +pub fn normalize_base_url(raw: &str) -> Result { + let url = reqwest::Url::parse(raw.trim()).map_err(|e| SyncError::Config(format!("server URL: {e}")))?; + match url.scheme() { + "https" => {} + "http" if is_loopback(&url) => {} + _ => return Err(SyncError::Config("server URL must use https".into())), + } + if url.query().is_some() || url.fragment().is_some() { + return Err(SyncError::Config("server URL must not have a query or fragment".into())); + } + Ok(url.as_str().trim_end_matches('/').to_string()) +} + +pub fn http_client(cfg: &ServerConfig) -> Result { + reqwest::Client::builder() + .use_preconfigured_tls(tls_config(&cfg.spki_pins, &cfg.extra_roots_der)?) + .https_only(false) // enforced by normalize_base_url (http only on loopback) + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(60)) + .user_agent(concat!("kestrove-core/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| SyncError::Config(e.to_string())) +} + +/// Maps a reqwest error to `PinMismatch` when the handshake failed on our pin check. +pub(crate) fn classify(e: reqwest::Error) -> SyncError { + let mut src: Option<&(dyn std::error::Error + 'static)> = Some(&e); + while let Some(err) = src { + if err.downcast_ref::().is_some() { + return SyncError::PinMismatch; + } + // `io::Error::source()` skips the wrapped error itself, so look inside explicitly. + let tls = err + .downcast_ref::() + .or_else(|| err.downcast_ref::().and_then(|io| io.get_ref()?.downcast_ref())); + if let Some(rustls::Error::InvalidCertificate(CertificateError::Other(o))) = tls { + if o.0.downcast_ref::().is_some() { + return SyncError::PinMismatch; + } + } + src = err.source(); + } + if format!("{e:?}").contains("PinMismatch") { + return SyncError::PinMismatch; + } + SyncError::Network(e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_url_rules() { + assert_eq!(normalize_base_url("https://vault.example.com/").unwrap(), "https://vault.example.com"); + assert_eq!(normalize_base_url("http://127.0.0.1:8080").unwrap(), "http://127.0.0.1:8080"); + assert!(normalize_base_url("http://localhost:3000").is_ok()); + assert!(normalize_base_url("http://vault.example.com").is_err()); + assert!(normalize_base_url("ftp://x").is_err()); + } + + #[test] + fn pin_formats() { + let raw = [7u8; 32]; + assert_eq!(parse_pin(&STANDARD.encode(raw)).unwrap(), raw); + assert_eq!(parse_pin(&format!("sha256/{}", STANDARD.encode(raw))).unwrap(), raw); + assert_eq!(parse_pin(&URL_SAFE_NO_PAD.encode(raw)).unwrap(), raw); + assert!(parse_pin("AAAA").is_err()); + assert!(tls_config(&["nope!".into()], &[]).is_err()); + } +} diff --git a/crates/kv-sync/src/wire.rs b/crates/kv-sync/src/wire.rs new file mode 100644 index 0000000..9b4fac7 --- /dev/null +++ b/crates/kv-sync/src/wire.rs @@ -0,0 +1,12 @@ +//! API paths (docs/03, api/openapi.yaml). The request/response types live in `kv-proto`. + +pub const PATH_SERVER_INFO: &str = "/v1/server-info"; +pub const PATH_SETUP_START: &str = "/v1/setup/start"; +pub const PATH_SETUP_FINISH: &str = "/v1/setup/finish"; +pub const PATH_PRELOGIN: &str = "/v1/auth/prelogin"; +pub const PATH_LOGIN_START: &str = "/v1/auth/login/start"; +pub const PATH_LOGIN_FINISH: &str = "/v1/auth/login/finish"; +pub const PATH_LOGOUT: &str = "/v1/auth/logout"; +pub const PATH_ACCOUNT: &str = "/v1/account"; +pub const PATH_VAULTS: &str = "/v1/vaults"; +pub const PATH_DEVICES: &str = "/v1/devices"; diff --git a/crates/kv-sync/tests/sync.rs b/crates/kv-sync/tests/sync.rs new file mode 100644 index 0000000..2126e56 --- /dev/null +++ b/crates/kv-sync/tests/sync.rs @@ -0,0 +1,524 @@ +//! End-to-end sync tests against the in-memory fake server (wiremock). + +use std::sync::Arc; + +use kv_crypto::{ + aad::{Aad, ObjectType}, + aead::SymKey, + keys::AccountKeyset, +}; +use kv_proto::{DeviceEnroll, ItemRecord, Platform}; +use kv_store::{MemoryStore, Store, SyncState, VaultRow}; +use kv_sync::{ + engine::{open_record, seal_record}, + signing::SoftwareSigner, + test_server::{TestServer, SETUP_TOKEN}, + ApiClient, DeviceSigner, LoginOutcome, ServerConfig, SyncContext, SyncError, SyncReport, +}; +use serde_json::{json, Value}; +use uuid::Uuid; + +const AUTH: [u8; 32] = [7; 32]; + +fn test_kdf() -> kv_proto::KdfParams { + kv_crypto::kdf::default_params() +} + +struct Device { + store: MemoryStore, + api: ApiClient, + keyset: AccountKeyset, + signer: Arc, +} + +fn copy_keyset(ks: &AccountKeyset) -> AccountKeyset { + let k = SymKey::generate(); + AccountKeyset::unwrap(ks.account_id, &k, &ks.wrap(&k).unwrap()).unwrap() +} + +fn enroll(signer: &SoftwareSigner) -> DeviceEnroll { + DeviceEnroll { + device_id: signer.device_id(), + name: "test".into(), + platform: Platform::Linux, + p256_pk: signer.public_key(), + } +} + +impl Device { + fn new(server: &TestServer, keyset: AccountKeyset) -> Self { + let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4())); + let api = ApiClient::new(&ServerConfig::new(server.url(), vec![]), signer.clone()).unwrap(); + Self { store: MemoryStore::new(), api, keyset, signer } + } + + /// First device: registers the account and creates a vault locally. + async fn first(server: &TestServer) -> (Self, Uuid) { + let keyset = AccountKeyset::generate(Uuid::new_v4()); + let d = Self::new(server, keyset); + let wrapped = d.keyset.wrap(&SymKey::generate()).unwrap(); + d.api + .register( + SETUP_TOKEN, + d.keyset.account_id, + &test_kdf(), + &AUTH, + d.keyset.enc.public_bytes(), + d.keyset.sig_public().to_vec(), + wrapped, + enroll(&d.signer), + ) + .await + .unwrap(); + assert!(matches!(d.login().await, LoginOutcome::Ok { .. })); + let vid = Uuid::new_v4(); + let (vk, sealed) = d.keyset.new_vault_key(vid).unwrap(); + let meta = vk + .seal(br#"{"name":"Personal"}"#, &Aad::build(ObjectType::VaultMeta, d.keyset.account_id, Some(vid), None)) + .unwrap(); + d.store.put_vault(&VaultRow::new(vid, sealed, Some(meta))).unwrap(); + (d, vid) + } + + async fn second(server: &TestServer, first: &Device) -> Self { + let d = Self::new(server, copy_keyset(&first.keyset)); + assert!(matches!(d.login().await, LoginOutcome::Ok { .. })); + d + } + + async fn login(&self) -> LoginOutcome { + self.api.login(self.keyset.account_id, &AUTH, enroll(&self.signer), None).await.unwrap() + } + + fn ctx(&self) -> SyncContext<'_> { + SyncContext { api: &self.api, store: &self.store, keyset: &self.keyset, device_id: self.signer.device_id() } + } + + async fn sync(&self) -> Result { + self.ctx().sync_all().await + } + + fn vk(&self, vid: Uuid) -> SymKey { + self.keyset.open_vault_key(vid, &self.store.vault(vid).unwrap().unwrap().sealed_key).unwrap() + } + + fn save(&self, vid: Uuid, id: Uuid, item: Value) { + let rec = seal_record(self.keyset.account_id, vid, &self.vk(vid), id, &item, self.signer.device_id()).unwrap(); + self.store.put_local(vid, &rec).unwrap(); + } + + fn delete(&self, vid: Uuid, id: Uuid) { + let mut cur = self.store.get(vid, id).unwrap().unwrap().record; + cur.deleted = true; + cur.wrapped_item_key = None; + cur.payload = None; + self.store.put_local(vid, &cur).unwrap(); + } + + fn read(&self, vid: Uuid, id: Uuid) -> Option { + let item = self.store.get(vid, id).unwrap()?; + open_record(self.keyset.account_id, vid, &self.vk(vid), &item.record).unwrap() + } +} + +fn item(id: Uuid, title: &str, password: &str, modified_at: i64) -> Value { + json!({"id": id, "kind": "login", "title": title, "password": password, "created_at": 1, "modified_at": modified_at}) +} + +#[tokio::test] +async fn two_devices_roundtrip_and_manifest_chain() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let id = Uuid::new_v4(); + a.save(vid, id, item(id, "Bank", "pw1", 10)); + + let r = a.sync().await.unwrap(); + assert_eq!(r.pushed, 1); + assert_eq!(a.store.get(vid, id).unwrap().unwrap().state, SyncState::Clean); + let m1 = server.state().vaults[&vid].manifest.clone().unwrap(); + // First sync publishes v1 (new vault) … + assert_eq!(m1.version, 1); + + let b = Device::second(&server, &a).await; + let r = b.sync().await.unwrap(); + assert_eq!(r.vaults_synced, 1); + assert_eq!(b.read(vid, id).unwrap()["password"], "pw1"); + assert_eq!(b.store.vault(vid).unwrap().unwrap().manifest_version, 1); + + b.save(vid, id, item(id, "Bank", "pw2", 20)); + assert_eq!(b.sync().await.unwrap().pushed, 1); + assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, 2); + + a.sync().await.unwrap(); + assert_eq!(a.read(vid, id).unwrap()["password"], "pw2"); + assert_eq!(a.store.vault(vid).unwrap().unwrap().manifest_version, 2); + // Nothing to do: no new manifest is published. + a.sync().await.unwrap(); + assert_eq!(server.state().vaults[&vid].manifest.as_ref().unwrap().version, 2); + // The server only ever saw ciphertext. + let raw = serde_json::to_string(&server.state().vaults[&vid].items).unwrap(); + assert!(!raw.contains("pw2") && !raw.contains("Bank")); +} + +#[tokio::test] +async fn concurrent_edits_merge_field_by_field() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let id = Uuid::new_v4(); + a.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me", "password": "p0", "notes": "n0", "created_at": 1, "modified_at": 10})); + a.sync().await.unwrap(); + let b = Device::second(&server, &a).await; + b.sync().await.unwrap(); + + // A changes username + password (later), B changes notes + password (earlier). + a.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me2", "password": "pA", "notes": "n0", "created_at": 1, "modified_at": 30})); + b.save(vid, id, json!({"id": id, "kind": "login", "title": "Mail", "username": "me", "password": "pB", "notes": "nB", "created_at": 1, "modified_at": 20})); + a.sync().await.unwrap(); + + let r = b.sync().await.unwrap(); + assert_eq!(r.merged, 1, "B hit 412 and merged"); + assert_eq!(r.pushed, 1); + let merged = b.read(vid, id).unwrap(); + assert_eq!(merged["username"], "me2"); + assert_eq!(merged["notes"], "nB"); + assert_eq!(merged["password"], "pA", "later modified_at wins the same field"); + assert_eq!(merged["modified_at"], 30); + // B's losing version is kept in history. + let hist = b.store.history(vid, id).unwrap(); + assert_eq!(hist.len(), 1); + let lost = open_record( + b.keyset.account_id, + vid, + &b.vk(vid), + &ItemRecord { + item_id: id, + revision: hist[0].revision, + deleted: false, + wrapped_item_key: Some(hist[0].wrapped_item_key.clone()), + payload: Some(hist[0].payload.clone()), + updated_by: Uuid::nil(), + updated_at: String::new(), + }, + ) + .unwrap() + .unwrap(); + assert_eq!(lost["password"], "pB"); + + a.sync().await.unwrap(); + assert_eq!(a.read(vid, id).unwrap(), merged); + assert!(a.store.pending(vid).unwrap().is_empty() && b.store.pending(vid).unwrap().is_empty()); +} + +#[tokio::test] +async fn edit_beats_delete_both_ways() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let (x, y) = (Uuid::new_v4(), Uuid::new_v4()); + a.save(vid, x, item(x, "X", "x", 1)); + a.save(vid, y, item(y, "Y", "y", 1)); + a.sync().await.unwrap(); + let b = Device::second(&server, &a).await; + b.sync().await.unwrap(); + + // x: A deletes, B edits (B syncs second → B's edit is pushed over the tombstone). + // y: A edits, B deletes (B syncs second → A's edit wins, B restores y). + a.delete(vid, x); + a.save(vid, y, item(y, "Y", "y-edited", 5)); + a.sync().await.unwrap(); + b.save(vid, x, item(x, "X", "x-edited", 5)); + b.delete(vid, y); + let r = b.sync().await.unwrap(); + assert_eq!(r.restored.len(), 2); + assert_eq!(b.read(vid, x).unwrap()["password"], "x-edited"); + assert_eq!(b.read(vid, y).unwrap()["password"], "y-edited"); + + a.sync().await.unwrap(); + assert_eq!(a.read(vid, x).unwrap()["password"], "x-edited"); + assert!(!server.state().vaults[&vid].items[&x].deleted); +} + +#[tokio::test] +async fn deleting_and_never_synced_items() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let (x, local_only) = (Uuid::new_v4(), Uuid::new_v4()); + a.save(vid, x, item(x, "X", "x", 1)); + a.sync().await.unwrap(); + a.delete(vid, x); + a.save(vid, local_only, item(local_only, "L", "l", 1)); + a.delete(vid, local_only); + a.sync().await.unwrap(); + assert!(server.state().vaults[&vid].items[&x].deleted); + assert!(!server.state().vaults[&vid].items.contains_key(&local_only)); + assert!(a.store.get(vid, local_only).unwrap().is_none()); +} + +#[tokio::test] +async fn manifest_rollback_is_an_integrity_alarm() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let id = Uuid::new_v4(); + for (i, pw) in ["1", "2", "3"].iter().enumerate() { + a.save(vid, id, item(id, "T", pw, i as i64)); + a.sync().await.unwrap(); + } + let history = server.state().vaults[&vid].manifest_history.clone(); + assert_eq!(history.len(), 3); + server.state().serve_manifest = Some(history[0].clone()); + + let err = a.sync().await.unwrap_err(); + assert!( + matches!(err, SyncError::Integrity { vault_id, ref reason } if vault_id == vid && reason.contains("rollback")), + "{err}" + ); + assert!(a.store.vault(vid).unwrap().unwrap().integrity_alarm); + + // Syncing that vault stays stopped even after the server "fixes" itself. + server.state().serve_manifest = None; + a.save(vid, id, item(id, "T", "4", 9)); + assert!(matches!(a.sync().await, Err(SyncError::Integrity { .. }))); + assert!(server.state().vaults[&vid].items[&id].revision < 4, "nothing was pushed"); +} + +#[tokio::test] +async fn forged_or_foreign_manifest_is_rejected() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + a.sync().await.unwrap(); + + let mut forged = server.state().vaults[&vid].manifest.clone().unwrap(); + forged.signature[0] ^= 1; + server.state().serve_manifest = Some(forged); + let err = a.sync().await.unwrap_err(); + assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("signature")), "{err}"); +} + +#[tokio::test] +async fn hidden_item_breaks_merkle_root() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let (x, y) = (Uuid::new_v4(), Uuid::new_v4()); + a.save(vid, x, item(x, "X", "x", 1)); + a.save(vid, y, item(y, "Y", "y", 1)); + a.sync().await.unwrap(); + + let b = Device::new(&server, copy_keyset(&a.keyset)); + b.login().await; + server.state().hide_items = vec![y]; + let err = b.sync().await.unwrap_err(); + assert!(matches!(err, SyncError::Integrity { ref reason, .. } if reason.contains("Merkle")), "{err}"); +} + +#[tokio::test] +async fn unsigned_remote_vault_is_ignored() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + a.sync().await.unwrap(); + // The server injects a vault whose key it sealed to our public key itself. + let evil = Uuid::new_v4(); + let sealed = kv_crypto::seal::seal_to( + a.keyset.enc.id, + &a.keyset.enc.public_bytes(), + &[0u8; 48], + &Aad::build(ObjectType::VaultKey, a.keyset.account_id, Some(evil), None), + ) + .unwrap(); + { + let mut st = server.state(); + let v = st.vaults.entry(evil).or_default(); + v.sealed_key = Some(sealed.clone()); + v.meta = Some(sealed); + } + let r = a.sync().await.unwrap(); + assert_eq!(r.vaults_skipped, vec![evil]); + assert!(a.store.vault(evil).unwrap().is_none()); + assert!(a.store.vault(vid).unwrap().is_some()); +} + +#[tokio::test] +async fn pagination_and_writes_from_elsewhere() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + a.sync().await.unwrap(); + // 520 items written by "another device" directly into the server. + let vk = a.vk(vid); + let ids: Vec = (0..520).map(|_| Uuid::new_v4()).collect(); + for id in &ids { + let rec = seal_record(a.keyset.account_id, vid, &vk, *id, &item(*id, "bulk", "p", 1), Uuid::nil()).unwrap(); + server.state().write_item(vid, rec); + } + let r = a.sync().await.unwrap(); + assert_eq!(r.pulled, 520); + assert_eq!(a.store.list(vid).unwrap().len(), 520); + assert_eq!(a.store.cursor(vid).unwrap(), 520); + let changes_calls = server.state().log.iter().filter(|l| l.contains("/changes")).count(); + assert!(changes_calls >= 2, "paged"); +} + +#[tokio::test] +async fn repeated_412_is_bounded() { + let server = TestServer::start().await; + let (a, vid) = Device::first(&server).await; + let id = Uuid::new_v4(); + a.save(vid, id, item(id, "T", "p", 1)); + server.state().force_412 = 100; + let r = a.sync().await.unwrap(); + assert_eq!(r.pushed, 0); + assert_eq!(a.store.pending(vid).unwrap().len(), 1, "still pending for the next sync"); + server.state().force_412 = 0; + assert_eq!(a.sync().await.unwrap().pushed, 1); +} + +#[tokio::test] +async fn auth_errors() { + let server = TestServer::start().await; + let (a, _) = Device::first(&server).await; + // Wrong OPAQUE password. + let err = a.api.login(a.keyset.account_id, &[0u8; 32], enroll(&a.signer), None).await.unwrap_err(); + assert!(matches!(err, SyncError::BadCredentials), "{err}"); + // Setup is closed after the first account. + let err = a + .api + .register( + SETUP_TOKEN, + Uuid::new_v4(), + &test_kdf(), + &AUTH, + vec![], + vec![], + a.keyset.wrap(&SymKey::generate()).unwrap(), + enroll(&a.signer), + ) + .await + .unwrap_err(); + assert!(matches!(err, SyncError::NotFound | SyncError::Server { .. }), "{err}"); + + // No session → NotAuthenticated without touching the network. + let fresh = Device::new(&server, copy_keyset(&a.keyset)); + assert!(matches!(fresh.sync().await, Err(SyncError::NotAuthenticated))); + // A revoked session → 401 → NotAuthenticated, and the session is dropped. + server.state().sessions.clear(); + assert!(matches!(a.sync().await, Err(SyncError::NotAuthenticated))); + assert!(a.api.session().is_none()); + + // New device needing approval. + server.state().require_approval = true; + let c = Device::new(&server, copy_keyset(&a.keyset)); + assert_eq!(c.login().await, LoginOutcome::PendingApproval); + server.state().devices.get_mut(&c.signer.device_id()).unwrap().approved = true; + assert!(matches!(c.login().await, LoginOutcome::Ok { .. })); + // The account endpoint gives a new device what it needs to unlock. + let info = c.api.account().await.unwrap(); + assert_eq!(info.sig_pk, a.keyset.sig_public().to_vec()); +} + +// ---------------------------------------------------------------- TLS pinning + +mod tls { + use std::sync::Arc; + + use kv_sync::{signing::SoftwareSigner, transport::spki_pin_of, ApiClient, ServerConfig, SyncError}; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + use uuid::Uuid; + + struct Pki { + ca_der: Vec, + leaf_der: Vec, + leaf_key: Vec, + } + + fn pki() -> Pki { + let ca_key = rcgen::KeyPair::generate().unwrap(); + let mut ca = rcgen::CertificateParams::new(vec![]).unwrap(); + ca.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca.distinguished_name.push(rcgen::DnType::CommonName, "Kestrove Test CA"); + let ca_cert = ca.self_signed(&ca_key).unwrap(); + let leaf_key = rcgen::KeyPair::generate().unwrap(); + let leaf = rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap(); + let leaf_cert = leaf.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + Pki { ca_der: ca_cert.der().to_vec(), leaf_der: leaf_cert.der().to_vec(), leaf_key: leaf_key.serialize_der() } + } + + /// Minimal HTTPS server answering every request with a ServerInfo JSON. Returns the port. + async fn serve(p: &Pki, tls13_only: bool) -> u16 { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let versions: &[&rustls::SupportedProtocolVersion] = + if tls13_only { &[&rustls::version::TLS13] } else { &[&rustls::version::TLS12] }; + let cfg = rustls::ServerConfig::builder_with_provider(provider) + .with_protocol_versions(versions) + .unwrap() + .with_no_client_auth() + .with_single_cert( + // Like a real server, send the chain (here leaf + issuing CA). + vec![CertificateDer::from(p.leaf_der.clone()), CertificateDer::from(p.ca_der.clone())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(p.leaf_key.clone())), + ) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(cfg)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((sock, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut tls) = acceptor.accept(sock).await else { return }; + let mut buf = [0u8; 4096]; + let _ = tls.read(&mut buf).await; + let body = + r#"{"version":"t","api":1,"single_tenant":true,"setup_open":false,"min_client":"0.1.0"}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = tls.write_all(resp.as_bytes()).await; + let _ = tls.shutdown().await; + }); + } + }); + port + } + + fn client(p: &Pki, port: u16, pins: Vec) -> ApiClient { + let cfg = ServerConfig { + base_url: format!("https://localhost:{port}"), + spki_pins: pins, + extra_roots_der: vec![p.ca_der.clone()], + }; + ApiClient::new(&cfg, Arc::new(SoftwareSigner::generate(Uuid::new_v4()))).unwrap() + } + + #[tokio::test] + async fn pins() { + let p = pki(); + let port = serve(&p, true).await; + let leaf_pin = spki_pin_of(&p.leaf_der).unwrap(); + let ca_pin = spki_pin_of(&p.ca_der).unwrap(); + let wrong = spki_pin_of(&pki().leaf_der).unwrap(); + + // Primary pin on the leaf. + assert!(client(&p, port, vec![leaf_pin.clone()]).server_info().await.is_ok()); + // Backup pin matches (primary is a stale key). + assert!(client(&p, port, vec![wrong.clone(), leaf_pin]).server_info().await.is_ok()); + // Pinning the issuing CA key (it is in the presented chain). + assert!(client(&p, port, vec![ca_pin]).server_info().await.is_ok()); + // Mismatch. + let err = client(&p, port, vec![wrong.clone()]).server_info().await.unwrap_err(); + assert!(matches!(err, SyncError::PinMismatch), "{err:?}"); + // Without our CA, chain validation fails before pins are even considered. + let cfg = ServerConfig::new(format!("https://localhost:{port}"), vec![wrong]); + let c = ApiClient::new(&cfg, Arc::new(SoftwareSigner::generate(Uuid::new_v4()))).unwrap(); + assert!(matches!(c.server_info().await, Err(SyncError::Network(_)))); + } + + #[tokio::test] + async fn tls12_is_refused() { + let p = pki(); + let port = serve(&p, false).await; + let pin = spki_pin_of(&p.leaf_der).unwrap(); + assert!(matches!(client(&p, port, vec![pin]).server_info().await, Err(SyncError::Network(_)))); + } +} diff --git a/deny.toml b/deny.toml index e816349..e990c9c 100644 --- a/deny.toml +++ b/deny.toml @@ -5,7 +5,9 @@ yanked = "deny" [licenses] version = 2 -allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Unicode-DFS-2016", "Zlib", "CC0-1.0", "MPL-2.0"] +allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Unicode-DFS-2016", "Zlib", "CC0-1.0", "MPL-2.0", + # webpki-roots (Mozilla's CA bundle, used by kv-sync's TLS): permissive data license. + "CDLA-Permissive-2.0"] confidence-threshold = 0.9 [bans]