M1: persistent vault, local unlock key, sync client, test vectors
kv-store: SQLite (bundled) and in-memory Store holding only ciphertext envelopes, with base copies for three-way merges and a 10-version history. kv-ffi: VaultCore::open(path); persisted account record; local unlock key (enable/unlock_with_local_key/disable); async sync API (configure_server, set_device_signer, register_account, login, sync_now, logout, devices); DeviceSigner foreign trait; IntegrityAlarm/PinMismatch/... error variants. kv-sync: reqwest + rustls (TLS 1.3 only, ring) with SPKI pinning; OPAQUE client on kv-proto's OpaqueSuite; RFC 9421 signing per kv_proto::httpsig; pull/push with If-Match, field-level three-way merge, edit-beats-delete; signed manifest (canonical CBOR, Merkle root, prev chain, Ed25519) with rollback/fork/Merkle checks as hard integrity alarms. Tested against a wiremock fake server, plus an #[ignore] test against kestrove-server. kv-crypto: gen-vectors example and tests reproducing kestrove-spec/vectors. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Generated
+1147
-11
File diff suppressed because it is too large
Load Diff
+15
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Writes the cross-implementation test vectors.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p kv-crypto --example gen-vectors # in-repo copy + ../kestrove-spec/vectors if present
|
||||
//! cargo run -p kv-crypto --example gen-vectors -- <dir> # explicit output directory
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "../vectors/gen.rs"]
|
||||
mod gen;
|
||||
|
||||
fn main() {
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut dirs: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if dirs.is_empty() {
|
||||
dirs.push(manifest.join("tests/vectors"));
|
||||
let spec = manifest.join("../../../kestrove-spec/vectors");
|
||||
if spec.is_dir() {
|
||||
dirs.push(spec);
|
||||
}
|
||||
}
|
||||
let docs = gen::generate();
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir).expect("create output dir");
|
||||
for (name, doc) in &docs {
|
||||
let path = dir.join(format!("{name}.json"));
|
||||
std::fs::write(&path, gen::render(doc)).expect("write vector file");
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,13 @@ impl SymKey {
|
||||
}
|
||||
|
||||
pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
|
||||
self.seal_with_nonce(random_bytes(), plaintext, aad)
|
||||
}
|
||||
|
||||
/// Deterministic variant for test vectors. Never reuse a nonce with the same key.
|
||||
#[doc(hidden)]
|
||||
pub fn seal_with_nonce(&self, nonce: [u8; 24], plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.bytes[..]));
|
||||
let nonce: [u8; 24] = random_bytes();
|
||||
let ct = cipher
|
||||
.encrypt(XNonce::from_slice(&nonce), Payload { msg: plaintext, aad })
|
||||
.map_err(|_| CryptoError::Encrypt)?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -60,16 +60,23 @@ impl EncKeypair {
|
||||
|
||||
/// Seals `plaintext` to a recipient public key (raw 32-byte X25519).
|
||||
pub fn seal_to(recipient_id: Uuid, recipient_pk: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Envelope> {
|
||||
seal_to_with_rng(recipient_id, recipient_pk, plaintext, aad, &mut OsRng)
|
||||
}
|
||||
|
||||
/// Same as [`seal_to`] with a caller-supplied RNG. The first 32 bytes it yields are the
|
||||
/// ephemeral `ikmE` of RFC 9180 `DeriveKeyPair`. Only for test vectors.
|
||||
#[doc(hidden)]
|
||||
pub fn seal_to_with_rng<R: rand_core::CryptoRng + rand_core::RngCore>(
|
||||
recipient_id: Uuid,
|
||||
recipient_pk: &[u8],
|
||||
plaintext: &[u8],
|
||||
aad: &[u8],
|
||||
rng: &mut R,
|
||||
) -> Result<Envelope> {
|
||||
let pk = <Kem as KemTrait>::PublicKey::from_bytes(recipient_pk).map_err(|_| CryptoError::Malformed)?;
|
||||
let (encapped, ct) = hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
|
||||
&OpModeS::Base,
|
||||
&pk,
|
||||
INFO,
|
||||
plaintext,
|
||||
aad,
|
||||
&mut OsRng,
|
||||
)
|
||||
.map_err(|_| CryptoError::Encrypt)?;
|
||||
let (encapped, ct) =
|
||||
hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(&OpModeS::Base, &pk, INFO, plaintext, aad, rng)
|
||||
.map_err(|_| CryptoError::Encrypt)?;
|
||||
Ok(Envelope {
|
||||
v: FORMAT_VERSION,
|
||||
alg: Alg::HpkeX25519ChaCha,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! The code must reproduce the published test vectors byte for byte, and the vectors must decode.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kv_crypto::{aead::SymKey, seal::EncKeypair, secret_key::SecretKey};
|
||||
use kv_proto::Envelope;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[path = "../vectors/gen.rs"]
|
||||
mod gen;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors")
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Value {
|
||||
let text = std::fs::read_to_string(dir().join(format!("{name}.json"))).expect("vector file (run gen-vectors)");
|
||||
serde_json::from_str(&text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_reproduces_vector_files() {
|
||||
let spec = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../kestrove-spec/vectors");
|
||||
for (name, doc) in gen::generate() {
|
||||
let rendered = gen::render(&doc);
|
||||
let on_disk = std::fs::read_to_string(dir().join(format!("{name}.json"))).unwrap();
|
||||
assert_eq!(rendered, on_disk, "{name}.json differs from what the code produces");
|
||||
// When the spec repo is checked out next to this one, it must carry the same files.
|
||||
if let Ok(published) = std::fs::read_to_string(spec.join(format!("{name}.json"))) {
|
||||
assert_eq!(published, on_disk, "kestrove-spec/vectors/{name}.json is stale");
|
||||
}
|
||||
}
|
||||
assert_eq!(gen::FILES.len(), gen::generate().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelopes_open() {
|
||||
for v in load("envelope")["vectors"].as_array().unwrap() {
|
||||
let key: [u8; 32] = gen::unhex(v["key"].as_str().unwrap()).try_into().unwrap();
|
||||
let key = SymKey::from_bytes(Uuid::parse_str(v["kid"].as_str().unwrap()).unwrap(), key);
|
||||
let env: Envelope = serde_json::from_value(v["envelope"].clone()).unwrap();
|
||||
let pt = key.open(&env, &gen::unhex(v["aad"].as_str().unwrap())).unwrap();
|
||||
assert_eq!(gen::hex(&pt), v["plaintext"].as_str().unwrap());
|
||||
assert_eq!(gen::hex(&env.ct), v["ciphertext"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpke_opens() {
|
||||
for v in load("hpke")["vectors"].as_array().unwrap() {
|
||||
let kp = EncKeypair::from_secret_bytes(
|
||||
Uuid::parse_str(v["recipient_kid"].as_str().unwrap()).unwrap(),
|
||||
&gen::unhex(v["recipient_sk"].as_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(gen::hex(&kp.public_bytes()), v["recipient_pk"].as_str().unwrap());
|
||||
let env: Envelope = serde_json::from_value(v["envelope"].clone()).unwrap();
|
||||
let pt = kp.open(&env, &gen::unhex(v["aad"].as_str().unwrap())).unwrap();
|
||||
assert_eq!(gen::hex(&pt), v["plaintext"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_keys_parse() {
|
||||
let doc = load("secret_key_format");
|
||||
for v in doc["valid"].as_array().unwrap() {
|
||||
let k = SecretKey::parse(v["display"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(gen::hex(k.expose()), v["raw"].as_str().unwrap());
|
||||
}
|
||||
for v in doc["alternate_inputs"].as_array().unwrap() {
|
||||
let k = SecretKey::parse(v["input"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(gen::hex(k.expose()), v["raw"].as_str().unwrap());
|
||||
}
|
||||
for v in doc["invalid"].as_array().unwrap() {
|
||||
assert!(SecretKey::parse(v["input"].as_str().unwrap()).is_err(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc6238_codes_are_the_published_ones() {
|
||||
// Spot-check against the RFC 6238 Appendix B table itself, not only our own output.
|
||||
let doc = load("totp");
|
||||
let find = |t: u64, alg: &str| {
|
||||
doc["vectors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["time"] == t && v["uri"].as_str().unwrap().contains(&format!("algorithm={alg}&")))
|
||||
.unwrap()["code"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(find(59, "SHA1"), "94287082");
|
||||
assert_eq!(find(59, "SHA256"), "46119246");
|
||||
assert_eq!(find(59, "SHA512"), "90693936");
|
||||
assert_eq!(find(20_000_000_000, "SHA1"), "65353130");
|
||||
assert_eq!(find(20_000_000_000, "SHA512"), "47863826");
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"aad_layout": "\"kestrove/v1/\" || type || 0x00 || account || 0x00 || vault || 0x00 || item (absent ids = nil UUID)",
|
||||
"account_id": "01010101-0101-4101-8101-010101010101",
|
||||
"description": "XChaCha20-Poly1305 envelopes with location-binding AAD and 256-byte padding (docs/02 'Envelope format')",
|
||||
"item_id": "03030303-0303-4303-8303-030303030303",
|
||||
"vault_id": "02020202-0202-4202-8202-020202020202",
|
||||
"vectors": [
|
||||
{
|
||||
"aad": "6b657374726f76652f76312f6974656d000101010101014101810101010101010100020202020202420282020202020202020003030303030343038303030303030303",
|
||||
"ciphertext": "0ae824b61b2253f40015d22f52ee7412e5be95cf17185bd8490e0e77200d50b5f629c6dfd2e2bccc1d86a06c6453150a96e1c8fc81f1a82b4830f511e831aa66438d35ed9c7d6d11d904b3d53641f05175066f63c0d985ea91738efcd7afb6a205b4a8471fc827a11c51cb598e429227ef2c3020ccdd749f254538ca5429e95f70b7258d87a6b52018976695a15b6e77e6c47a49609fa691f602dbeb4a15ea73c87909c7267d7bf84de31762480feab0b96553c2d4046b5ed00c899e30175fbd7333e9da01329d89a3158a715a85a83d81b8a0105e3c2cdd0ffb2f52e1971d0c7ac1c57504fea274b883fd4b65dae757d758cb3ecd7828e7edd44b35cbba452261039e436beeb3aeba178d379a54abf9",
|
||||
"envelope": {
|
||||
"alg": "xc20p",
|
||||
"ct": "CugkthsiU_QAFdIvUu50EuW-lc8XGFvYSQ4OdyANULX2Kcbf0uK8zB2GoGxkUxUKluHI_IHxqCtIMPUR6DGqZkONNe2cfW0R2QSz1TZB8FF1Bm9jwNmF6pFzjvzXr7aiBbSoRx_IJ6EcUctZjkKSJ-8sMCDM3XSfJUU4ylQp6V9wtyWNh6a1IBiXZpWhW2535sR6SWCfppH2AtvrShXqc8h5CccmfXv4TeMXYkgP6rC5ZVPC1ARrXtAMiZ4wF1-9czPp2gEynYmjFYpxWoWoPYG4oBBePCzdD_svUuGXHQx6wcV1BP6idLiD_Utl2udX11jLPs14KOft1Es1y7pFImEDnkNr7rOuuheNN5pUq_k",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"n": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
|
||||
"v": 1
|
||||
},
|
||||
"key": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"name": "item payload (padded)",
|
||||
"nonce": "010101010101010101010101010101010101010101010101",
|
||||
"plaintext": "6d0000007b226964223a2230333033303330332d303330332d343330332d383330332d303330333033303330333033222c226b696e64223a226c6f67696e222c227469746c65223a224578616d706c65222c22637265617465645f6174223a312c226d6f6469666965645f6174223a317d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"aad": "6b657374726f76652f76312f6974656d2d6b6579000101010101014101810101010101010100020202020202420282020202020202020003030303030343038303030303030303",
|
||||
"ciphertext": "89b9440109da7c2a9fa4c5d03a25c7a047b11f9133e7e8ddcff986029675c5106f9240f5ca69911f73dc1106e3f3d32bcc8470e999d6818689daee36907279ec",
|
||||
"envelope": {
|
||||
"alg": "xc20p",
|
||||
"ct": "iblEAQnafCqfpMXQOiXHoEexH5Ez5-jdz_mGApZ1xRBvkkD1ymmRH3PcEQbj89MrzIRw6ZnWgYaJ2u42kHJ57A",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"n": "AgICAgICAgICAgICAgICAgICAgICAgIC",
|
||||
"v": 1
|
||||
},
|
||||
"key": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"name": "item key: kid || key",
|
||||
"nonce": "020202020202020202020202020202020202020202020202",
|
||||
"plaintext": "050505050505450585050505050505055555555555555555555555555555555555555555555555555555555555555555"
|
||||
},
|
||||
{
|
||||
"aad": "6b657374726f76652f76312f7661756c742d6d657461000101010101014101810101010101010100020202020202420282020202020202020000000000000000000000000000000000",
|
||||
"ciphertext": "0ee485ea37c4e9b6f4ea55e7381f12dbd4390a34b4d1342f9043da961bdcd3b47d1986",
|
||||
"envelope": {
|
||||
"alg": "xc20p",
|
||||
"ct": "DuSF6jfE6bb06lXnOB8S29Q5CjS00TQvkEPalhvc07R9GYY",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"n": "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD",
|
||||
"v": 1
|
||||
},
|
||||
"key": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"name": "vault metadata",
|
||||
"nonce": "030303030303030303030303030303030303030303030303",
|
||||
"plaintext": "7b226e616d65223a22506572736f6e616c227d"
|
||||
},
|
||||
{
|
||||
"aad": "6b657374726f76652f76312f6b6579736574000101010101014101810101010101010100000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"ciphertext": "e7b3287dd61ee4062f51d81ed4bc13841733671cfd05429e42f7c820906a73e8d8ca3f083b0713fa560a597c65c18cceccbf90ae2a17e711f8d3a9b7c94ba4561d2e0f5d63bbd4bb4f6d5c81b0d903cad7f19e5a89abb43973269f37f8aad8e5",
|
||||
"envelope": {
|
||||
"alg": "xc20p",
|
||||
"ct": "57MofdYe5AYvUdge1LwThBczZxz9BUKeQvfIIJBqc-jYyj8IOwcT-lYKWXxlwYzOzL-QrioX5xH406m3yUukVh0uD11ju9S7T21cgbDZA8rX8Z5aiau0OXMmnzf4qtjl",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"n": "BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
|
||||
"v": 1
|
||||
},
|
||||
"key": "4242424242424242424242424242424242424242424242424242424242424242",
|
||||
"kid": "04040404-0404-4404-8404-040404040404",
|
||||
"name": "keyset (80 bytes)",
|
||||
"nonce": "040404040404040404040404040404040404040404040404",
|
||||
"plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "2SKD (docs/02 'Derivation'): Argon2id(NFKD(trim(password))) + HKDF(secret_key) -> root -> AUK, AUTH_INPUT",
|
||||
"vectors": [
|
||||
{
|
||||
"account_id": "11111111-1111-4111-9111-111111111111",
|
||||
"auk": "33e9bfb720e3cb5092677bf71d58781f7c032840299d5cc0f10c59b6d0b868d6",
|
||||
"auth_input": "0eeae51fc4c439082b91211477c5e6fd792c98f1781a7b9808dbb39b54483096",
|
||||
"params": {
|
||||
"alg": "argon2id",
|
||||
"m_kib": 65536,
|
||||
"p": 4,
|
||||
"salt": "a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5",
|
||||
"t": 3
|
||||
},
|
||||
"password": "correct horse battery staple",
|
||||
"root": "c6b0513bc1244cdabe9b2ec388b5cd441b8d25a3ba0ab8834f9fb231ad89a5e7",
|
||||
"secret_key": "01010101010101010101010101010101",
|
||||
"secret_key_display": "K1-01040G-20810-40G20-81040-G2081-C"
|
||||
},
|
||||
{
|
||||
"account_id": "22222222-2222-4222-a222-222222222222",
|
||||
"auk": "fc443a82cb371b0a33672509e61b64c08cd26bdc4ce0cdc726e98adc25906068",
|
||||
"auth_input": "77a83f16adf68bae8fe9ee448e7b2ab60ff2ea5234fba69816d5b560700c18a1",
|
||||
"params": {
|
||||
"alg": "argon2id",
|
||||
"m_kib": 65536,
|
||||
"p": 4,
|
||||
"salt": "00000000000000000000000000000000",
|
||||
"t": 3
|
||||
},
|
||||
"password": " café fish ",
|
||||
"root": "da0438d0cb15483dff9f4f569741911fdde9dee37b116c11402480c8f4d9b406",
|
||||
"secret_key": "ffffffffffffffffffffffffffffffff",
|
||||
"secret_key_display": "K1-7ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-T"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"description": "Autofill URL matching (docs/05): exact > host > domain (eTLD+1 via the PSL) > none. http items may match https pages, never the reverse.",
|
||||
"vectors": [
|
||||
{
|
||||
"item_url": "https://example.com",
|
||||
"level": "exact",
|
||||
"page_url": "https://example.com/login"
|
||||
},
|
||||
{
|
||||
"item_url": "example.com",
|
||||
"level": "exact",
|
||||
"page_url": "https://example.com/"
|
||||
},
|
||||
{
|
||||
"item_url": "https://example.com:8443",
|
||||
"level": "host",
|
||||
"page_url": "https://example.com"
|
||||
},
|
||||
{
|
||||
"item_url": "https://example.com",
|
||||
"level": "domain",
|
||||
"page_url": "https://login.example.com"
|
||||
},
|
||||
{
|
||||
"item_url": "https://login.example.com",
|
||||
"level": "domain",
|
||||
"page_url": "https://example.com"
|
||||
},
|
||||
{
|
||||
"item_url": "https://a.github.io",
|
||||
"level": "none",
|
||||
"page_url": "https://b.github.io"
|
||||
},
|
||||
{
|
||||
"item_url": "https://example.com",
|
||||
"level": "none",
|
||||
"page_url": "https://example.com.evil.net"
|
||||
},
|
||||
{
|
||||
"item_url": "https://example.com",
|
||||
"level": "none",
|
||||
"page_url": "http://example.com"
|
||||
},
|
||||
{
|
||||
"item_url": "http://example.com",
|
||||
"level": "host",
|
||||
"page_url": "https://example.com"
|
||||
},
|
||||
{
|
||||
"item_url": "https://example.co.uk",
|
||||
"level": "none",
|
||||
"page_url": "https://other.co.uk"
|
||||
},
|
||||
{
|
||||
"item_url": "https://shop.example.co.uk",
|
||||
"level": "domain",
|
||||
"page_url": "https://www.example.co.uk/cart"
|
||||
},
|
||||
{
|
||||
"item_url": "https://EXAMPLE.com",
|
||||
"level": "exact",
|
||||
"page_url": "https://example.COM/"
|
||||
},
|
||||
{
|
||||
"item_url": "https://192.168.1.10:8080",
|
||||
"level": "exact",
|
||||
"page_url": "https://192.168.1.10:8080/admin"
|
||||
},
|
||||
{
|
||||
"item_url": "not a url",
|
||||
"level": "none",
|
||||
"page_url": "https://example.com"
|
||||
},
|
||||
{
|
||||
"item_url": "ftp://example.com",
|
||||
"level": "none",
|
||||
"page_url": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
|
||||
"alternate_inputs": [
|
||||
{
|
||||
"input": "k1-014d2p-f2dbs-qqzxq-5tk1v-58cgg-1",
|
||||
"raw": "0123456789abcdeffedcba9876543210"
|
||||
},
|
||||
{
|
||||
"input": "K1 014D2P F2DBS QQZXQ 5TK1V 58CGG 1",
|
||||
"raw": "0123456789abcdeffedcba9876543210"
|
||||
},
|
||||
{
|
||||
"input": "K1014D2PF2DBSQQZXQ5TK1V58CGG1",
|
||||
"raw": "0123456789abcdeffedcba9876543210"
|
||||
},
|
||||
{
|
||||
"input": "K1-OI4D2P-F2DBS-QQZXQ-5TKIV-58CGG-I",
|
||||
"raw": "0123456789abcdeffedcba9876543210"
|
||||
}
|
||||
],
|
||||
"description": "Secret Key display format K1-XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX-C: 26 Crockford base32 symbols of the 128-bit key (MSB first, 2 zero pad bits at the top), check symbol = SHA-256(raw)[0] mod 32",
|
||||
"invalid": [
|
||||
{
|
||||
"input": "K1-014D2P-F2DBS-QQZXQ-5TK1V-58CGG-0",
|
||||
"reason": "checksum"
|
||||
},
|
||||
{
|
||||
"input": "K2-014D2P-F2DBS-QQZXQ-5TK1V-58CGG-1",
|
||||
"reason": "prefix"
|
||||
},
|
||||
{
|
||||
"input": "K1-014D2P-F2DBS-QQZXQ-5TK1V-58CGG",
|
||||
"reason": "length"
|
||||
},
|
||||
{
|
||||
"input": "K1-8ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-T",
|
||||
"reason": "overflow: first symbol carries only 3 bits"
|
||||
},
|
||||
{
|
||||
"input": "K1-U014D2P-F2DBS-QQZXQ-5TK1V-58CGG-",
|
||||
"reason": "invalid symbol U"
|
||||
}
|
||||
],
|
||||
"valid": [
|
||||
{
|
||||
"display": "K1-000000-00000-00000-00000-00000-Q",
|
||||
"raw": "00000000000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"display": "K1-7ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-T",
|
||||
"raw": "ffffffffffffffffffffffffffffffff"
|
||||
},
|
||||
{
|
||||
"display": "K1-40G208-1040G-20810-40G20-81040-E",
|
||||
"raw": "80808080808080808080808080808080"
|
||||
},
|
||||
{
|
||||
"display": "K1-5BNENT-QAXBN-ENTQA-XBNEN-TQAXB-T",
|
||||
"raw": "abababababababababababababababab"
|
||||
},
|
||||
{
|
||||
"display": "K1-014D2P-F2DBS-QQZXQ-5TK1V-58CGG-1",
|
||||
"raw": "0123456789abcdeffedcba9876543210"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Deterministic test-vector generation (kestrove-spec/vectors). Shared by `examples/gen-vectors.rs`
|
||||
//! and `tests/vectors.rs`. Every input is fixed, so regenerating must reproduce the files byte for byte.
|
||||
//!
|
||||
//! Byte strings are lowercase hex unless noted. Envelopes are given in their JSON wire form (base64url).
|
||||
|
||||
#![allow(dead_code)] // each includer uses a different subset
|
||||
|
||||
use kv_crypto::{
|
||||
aad::{Aad, ObjectType},
|
||||
aead::{pad, SymKey},
|
||||
kdf,
|
||||
seal::{seal_to_with_rng, EncKeypair},
|
||||
secret_key::SecretKey,
|
||||
};
|
||||
use kv_model::{matching, totp::Totp};
|
||||
use kv_proto::KdfParams;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const FILES: [&str; 6] = ["kdf", "envelope", "hpke", "secret_key_format", "totp", "matching"];
|
||||
|
||||
pub fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn id(n: u8) -> Uuid {
|
||||
// Valid v4 layout so every implementation's UUID parser accepts it.
|
||||
let mut b = [n; 16];
|
||||
b[6] = 0x40 | (n & 0x0f);
|
||||
b[8] = 0x80 | (n & 0x3f);
|
||||
Uuid::from_bytes(b)
|
||||
}
|
||||
|
||||
/// Returns `(file stem, JSON document)` for every vector file.
|
||||
pub fn generate() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("kdf", kdf_vectors()),
|
||||
("envelope", envelope_vectors()),
|
||||
("hpke", hpke_vectors()),
|
||||
("secret_key_format", secret_key_vectors()),
|
||||
("totp", totp_vectors()),
|
||||
("matching", matching_vectors()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn render(doc: &Value) -> String {
|
||||
let mut s = serde_json::to_string_pretty(doc).unwrap();
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
|
||||
fn kdf_vectors() -> Value {
|
||||
let cases = [
|
||||
("correct horse battery staple", [0x01u8; 16], [0xa5u8; 16], id(0x11)),
|
||||
// Leading/trailing whitespace is trimmed and the password NFKD-normalized ("é" precomposed, "fi" ligature).
|
||||
(" caf\u{e9} \u{fb01}sh ", [0xffu8; 16], [0x00u8; 16], id(0x22)),
|
||||
];
|
||||
let vectors: Vec<Value> = cases
|
||||
.iter()
|
||||
.map(|(pw, sk, salt, account)| {
|
||||
let params = KdfParams { alg: "argon2id".into(), m_kib: 64 * 1024, t: 3, p: 4, salt: salt.to_vec() };
|
||||
let secret_key = SecretKey::from_bytes(*sk);
|
||||
let d = kdf::derive_keys(pw, &secret_key, *account, ¶ms).unwrap();
|
||||
json!({
|
||||
"password": pw,
|
||||
"secret_key": hex(sk),
|
||||
"secret_key_display": secret_key.to_display().as_str(),
|
||||
"account_id": account.to_string(),
|
||||
"params": {"alg": "argon2id", "m_kib": params.m_kib, "t": params.t, "p": params.p, "salt": hex(salt)},
|
||||
"root": hex(d.root.as_ref()),
|
||||
"auk": hex(d.auk.as_ref()),
|
||||
"auth_input": hex(d.auth_input.as_ref()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"description": "2SKD (docs/02 'Derivation'): Argon2id(NFKD(trim(password))) + HKDF(secret_key) -> root -> AUK, AUTH_INPUT",
|
||||
"vectors": vectors,
|
||||
})
|
||||
}
|
||||
|
||||
fn envelope_vectors() -> Value {
|
||||
let account = id(0x01);
|
||||
let vault = id(0x02);
|
||||
let item = id(0x03);
|
||||
let key = SymKey::from_bytes(id(0x04), [0x42; 32]);
|
||||
let item_json = br#"{"id":"03030303-0303-4303-8303-030303030303","kind":"login","title":"Example","created_at":1,"modified_at":1}"#;
|
||||
type Case = (&'static str, ObjectType, Option<Uuid>, Option<Uuid>, Vec<u8>, [u8; 24]);
|
||||
let cases: Vec<Case> = vec![
|
||||
("item payload (padded)", ObjectType::Item, Some(vault), Some(item), pad(item_json).to_vec(), [0x01; 24]),
|
||||
(
|
||||
"item key: kid || key",
|
||||
ObjectType::ItemKey,
|
||||
Some(vault),
|
||||
Some(item),
|
||||
[id(0x05).as_bytes().as_slice(), &[0x55; 32]].concat(),
|
||||
[0x02; 24],
|
||||
),
|
||||
("vault metadata", ObjectType::VaultMeta, Some(vault), None, br#"{"name":"Personal"}"#.to_vec(), [0x03; 24]),
|
||||
("keyset (80 bytes)", ObjectType::Keyset, None, None, (0u8..80).collect(), [0x04; 24]),
|
||||
];
|
||||
let vectors: Vec<Value> = cases
|
||||
.into_iter()
|
||||
.map(|(name, ty, v, i, pt, nonce)| {
|
||||
let aad = Aad::build(ty, account, v, i);
|
||||
let env = key.seal_with_nonce(nonce, &pt, &aad).unwrap();
|
||||
json!({
|
||||
"name": name,
|
||||
"key": hex(key.expose()),
|
||||
"kid": key.id.to_string(),
|
||||
"nonce": hex(&nonce),
|
||||
"aad": hex(&aad),
|
||||
"plaintext": hex(&pt),
|
||||
"ciphertext": hex(&env.ct),
|
||||
"envelope": env,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"description": "XChaCha20-Poly1305 envelopes with location-binding AAD and 256-byte padding (docs/02 'Envelope format')",
|
||||
"aad_layout": "\"kestrove/v1/\" || type || 0x00 || account || 0x00 || vault || 0x00 || item (absent ids = nil UUID)",
|
||||
"account_id": account.to_string(),
|
||||
"vault_id": vault.to_string(),
|
||||
"item_id": item.to_string(),
|
||||
"vectors": vectors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Yields fixed bytes: the HPKE ephemeral `ikmE`.
|
||||
struct FixedRng(Vec<u8>, usize);
|
||||
|
||||
impl rand_core::RngCore for FixedRng {
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
let mut b = [0u8; 4];
|
||||
self.fill_bytes(&mut b);
|
||||
u32::from_le_bytes(b)
|
||||
}
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut b = [0u8; 8];
|
||||
self.fill_bytes(&mut b);
|
||||
u64::from_le_bytes(b)
|
||||
}
|
||||
fn fill_bytes(&mut self, dest: &mut [u8]) {
|
||||
for d in dest {
|
||||
*d = self.0[self.1 % self.0.len()];
|
||||
self.1 += 1;
|
||||
}
|
||||
}
|
||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
|
||||
self.fill_bytes(dest);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl rand_core::CryptoRng for FixedRng {}
|
||||
|
||||
fn hpke_vectors() -> Value {
|
||||
let account = id(0x01);
|
||||
let cases = [([0x11u8; 32], [0x77u8; 32], id(0x02)), ([0x2au8; 32], [0x03u8; 32], id(0x09))];
|
||||
let vectors: Vec<Value> = cases
|
||||
.iter()
|
||||
.map(|(sk, ikm_e, vault)| {
|
||||
let kp = EncKeypair::from_secret_bytes(id(0x0e), sk).unwrap();
|
||||
let aad = Aad::build(ObjectType::VaultKey, account, Some(*vault), None);
|
||||
let pt = [id(0x0a).as_bytes().as_slice(), &[0xab; 32]].concat();
|
||||
let env = seal_to_with_rng(kp.id, &kp.public_bytes(), &pt, &aad, &mut FixedRng(ikm_e.to_vec(), 0)).unwrap();
|
||||
json!({
|
||||
"recipient_sk": hex(sk),
|
||||
"recipient_pk": hex(&kp.public_bytes()),
|
||||
"recipient_kid": kp.id.to_string(),
|
||||
"ikm_e": hex(ikm_e),
|
||||
"info": hex(b"kestrove/v1/hpke"),
|
||||
"vault_id": vault.to_string(),
|
||||
"aad": hex(&aad),
|
||||
"plaintext": hex(&pt),
|
||||
"enc": hex(&env.enc),
|
||||
"ciphertext": hex(&env.ct),
|
||||
"envelope": env,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"description": "HPKE base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305 sealing a vault key (vault key id || key) to the account. ikm_e is the RFC 9180 DeriveKeyPair input for the ephemeral key.",
|
||||
"account_id": account.to_string(),
|
||||
"vectors": vectors,
|
||||
})
|
||||
}
|
||||
|
||||
fn secret_key_vectors() -> Value {
|
||||
let raws: [[u8; 16]; 5] = [
|
||||
[0x00; 16],
|
||||
[0xff; 16],
|
||||
[0x80; 16],
|
||||
[0xab; 16],
|
||||
*b"\x01\x23\x45\x67\x89\xab\xcd\xef\xfe\xdc\xba\x98\x76\x54\x32\x10",
|
||||
];
|
||||
let valid: Vec<Value> = raws
|
||||
.iter()
|
||||
.map(|r| json!({"raw": hex(r), "display": SecretKey::from_bytes(*r).to_display().as_str()}))
|
||||
.collect();
|
||||
let base = SecretKey::from_bytes(raws[4]).to_display().to_string();
|
||||
let lower = base.to_lowercase();
|
||||
let spaced = base.replace('-', " ");
|
||||
let compact = base.replace('-', "");
|
||||
// Crockford confusables: 0 may be typed as O, 1 as I or L.
|
||||
let confusable = format!("K1{}", base[2..].replace('0', "O").replace('1', "I"));
|
||||
let alternate: Vec<Value> = [lower, spaced, compact, confusable]
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
assert_eq!(SecretKey::parse(&s).unwrap().expose(), &raws[4], "{s}");
|
||||
json!({"input": s, "raw": hex(&raws[4])})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let check = base.chars().last().unwrap();
|
||||
let other_check = if check == '0' { '1' } else { '0' };
|
||||
let mut bad_checksum = base.clone();
|
||||
bad_checksum.pop();
|
||||
bad_checksum.push(other_check);
|
||||
let max = SecretKey::from_bytes([0xff; 16]).to_display().to_string();
|
||||
let invalid: Vec<Value> = [
|
||||
(bad_checksum, "checksum"),
|
||||
(base.replacen("K1", "K2", 1), "prefix"),
|
||||
(base[..base.len() - 2].to_string(), "length"),
|
||||
(max.replacen("K1-7", "K1-8", 1), "overflow: first symbol carries only 3 bits"),
|
||||
(base.replacen('-', "-U", 1)[..base.len()].to_string(), "invalid symbol U"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(s, why)| {
|
||||
assert!(SecretKey::parse(&s).is_err(), "{s} should be invalid ({why})");
|
||||
json!({"input": s, "reason": why})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"description": "Secret Key display format K1-XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX-C: 26 Crockford base32 symbols of the 128-bit key (MSB first, 2 zero pad bits at the top), check symbol = SHA-256(raw)[0] mod 32",
|
||||
"alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
|
||||
"valid": valid,
|
||||
"alternate_inputs": alternate,
|
||||
"invalid": invalid,
|
||||
})
|
||||
}
|
||||
|
||||
fn totp_vectors() -> Value {
|
||||
let b32 = |s: &[u8]| data_encoding::BASE32_NOPAD.encode(s);
|
||||
let seeds: [(&str, &[u8]); 3] = [
|
||||
("SHA1", b"12345678901234567890"),
|
||||
("SHA256", b"12345678901234567890123456789012"),
|
||||
("SHA512", b"1234567890123456789012345678901234567890123456789012345678901234"),
|
||||
];
|
||||
let mut vectors = Vec::new();
|
||||
for t in [59u64, 1_111_111_109, 1_111_111_111, 1_234_567_890, 2_000_000_000, 20_000_000_000] {
|
||||
for (alg, seed) in seeds {
|
||||
let uri = format!("otpauth://totp/RFC6238?secret={}&algorithm={alg}&digits=8&period=30", b32(seed));
|
||||
let code = Totp::parse(&uri).unwrap().code_at(t);
|
||||
vectors.push(json!({"source": "RFC 6238 Appendix B", "uri": uri, "time": t, "code": code}));
|
||||
}
|
||||
}
|
||||
let defaults = b32(b"12345678901234567890");
|
||||
for t in [59u64, 1_111_111_109] {
|
||||
vectors.push(json!({"source": "bare base32 secret, defaults (SHA1, 6 digits, 30 s)", "uri": defaults, "time": t, "code": Totp::parse(&defaults).unwrap().code_at(t)}));
|
||||
}
|
||||
let custom = format!(
|
||||
"otpauth://totp/x?secret={}&digits=6&period=60&algorithm=SHA256",
|
||||
b32(b"12345678901234567890123456789012")
|
||||
);
|
||||
vectors.push(json!({"source": "60 s period", "uri": custom, "time": 1_111_111_109u64, "code": Totp::parse(&custom).unwrap().code_at(1_111_111_109)}));
|
||||
json!({"description": "RFC 6238 TOTP (HMAC-SHA1/256/512). `uri` is what Totp::parse accepts.", "vectors": vectors})
|
||||
}
|
||||
|
||||
fn matching_vectors() -> Value {
|
||||
let cases = [
|
||||
("https://example.com", "https://example.com/login"),
|
||||
("example.com", "https://example.com/"),
|
||||
("https://example.com:8443", "https://example.com"),
|
||||
("https://example.com", "https://login.example.com"),
|
||||
("https://login.example.com", "https://example.com"),
|
||||
("https://a.github.io", "https://b.github.io"),
|
||||
("https://example.com", "https://example.com.evil.net"),
|
||||
("https://example.com", "http://example.com"),
|
||||
("http://example.com", "https://example.com"),
|
||||
("https://example.co.uk", "https://other.co.uk"),
|
||||
("https://shop.example.co.uk", "https://www.example.co.uk/cart"),
|
||||
("https://EXAMPLE.com", "https://example.COM/"),
|
||||
("https://192.168.1.10:8080", "https://192.168.1.10:8080/admin"),
|
||||
("not a url", "https://example.com"),
|
||||
("ftp://example.com", "https://example.com"),
|
||||
];
|
||||
let vectors: Vec<Value> = cases
|
||||
.iter()
|
||||
.map(|(item, page)| {
|
||||
let level = match matching::match_url(item, page) {
|
||||
matching::MatchLevel::None => "none",
|
||||
matching::MatchLevel::Domain => "domain",
|
||||
matching::MatchLevel::Host => "host",
|
||||
matching::MatchLevel::Exact => "exact",
|
||||
};
|
||||
json!({"item_url": item, "page_url": page, "level": level})
|
||||
})
|
||||
.collect();
|
||||
json!({"description": "Autofill URL matching (docs/05): exact > host > domain (eTLD+1 via the PSL) > none. http items may match https pages, never the reverse.", "vectors": vectors})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+814
-123
File diff suppressed because it is too large
Load Diff
@@ -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<u8> {
|
||||
kv_sync::DeviceSigner::public_key(&self.0)
|
||||
}
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>> {
|
||||
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)));
|
||||
}
|
||||
@@ -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=<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<u8> {
|
||||
kv_sync::DeviceSigner::public_key(&self.0)
|
||||
}
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, 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}");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+693
-24
@@ -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<rusqlite::Error> 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<u64, StoreError>;
|
||||
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<Vec<(ItemRecord, u64)>, StoreError>;
|
||||
fn get(&self, vault: Uuid, item: Uuid) -> Result<(ItemRecord, SyncState), StoreError>;
|
||||
fn list(&self, vault: Uuid) -> Result<Vec<ItemRecord>, StoreError>;
|
||||
impl From<serde_json::Error> for StoreError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
StoreError::Io(format!("corrupt envelope: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, StoreError>;
|
||||
|
||||
#[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<ItemRecord>,
|
||||
}
|
||||
|
||||
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<Envelope>,
|
||||
/// 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<Envelope>) -> 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<Option<Vec<u8>>>;
|
||||
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<Option<VaultRow>>;
|
||||
fn vaults(&self) -> Result<Vec<VaultRow>>;
|
||||
|
||||
/// Inserts or replaces an item.
|
||||
fn put(&self, vault: Uuid, item: &LocalItem) -> Result<()>;
|
||||
fn get(&self, vault: Uuid, item: Uuid) -> Result<Option<LocalItem>>;
|
||||
/// All items, including tombstones.
|
||||
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>>;
|
||||
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<Vec<HistoryEntry>>;
|
||||
|
||||
/// Deletes everything (sign-out / reset).
|
||||
fn wipe(&self) -> Result<()>;
|
||||
|
||||
// ---- convenience ----
|
||||
|
||||
fn cursor(&self, vault: Uuid) -> Result<u64> {
|
||||
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<Vec<LocalItem>> {
|
||||
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<Connection>,
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::init(Connection::open(path)?)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
Self::init(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
fn init(conn: Connection) -> Result<Self> {
|
||||
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<Envelope>) -> Result<Option<String>> {
|
||||
e.as_ref().map(serde_json::to_string).transpose().map_err(Into::into)
|
||||
}
|
||||
|
||||
fn env_from_sql(s: Option<String>) -> Result<Option<Envelope>> {
|
||||
s.as_deref().map(serde_json::from_str).transpose().map_err(Into::into)
|
||||
}
|
||||
|
||||
fn uuid_from_sql(b: Vec<u8>) -> Result<Uuid> {
|
||||
Uuid::from_slice(&b).map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn hash_from_sql(b: Option<Vec<u8>>) -> Result<Option<[u8; 32]>> {
|
||||
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<u8>,
|
||||
i64,
|
||||
bool,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Vec<u8>,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<Vec<u8>>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
fn raw_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<RawItem> {
|
||||
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<LocalItem> {
|
||||
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<RawVault> {
|
||||
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<u8>, String, Option<String>, i64, i64, Option<Vec<u8>>, bool);
|
||||
|
||||
fn vault_from_raw(raw: RawVault) -> Result<VaultRow> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Option<VaultRow>> {
|
||||
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<Vec<VaultRow>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
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<Option<LocalItem>> {
|
||||
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<Vec<LocalItem>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
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<Vec<HistoryEntry>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
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<String, Vec<u8>>,
|
||||
vaults: Vec<VaultRow>,
|
||||
items: HashMap<Uuid, BTreeMap<Uuid, LocalItem>>,
|
||||
history: HashMap<(Uuid, Uuid), Vec<HistoryEntry>>,
|
||||
}
|
||||
|
||||
/// Non-persistent store for tests and `VaultCore::new_in_memory`.
|
||||
#[derive(Default)]
|
||||
pub struct MemoryStore {
|
||||
inner: Mutex<Mem>,
|
||||
}
|
||||
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Option<VaultRow>> {
|
||||
Ok(self.mem().vaults.iter().find(|v| v.vault_id == vault).cloned())
|
||||
}
|
||||
|
||||
fn vaults(&self) -> Result<Vec<VaultRow>> {
|
||||
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<Option<LocalItem>> {
|
||||
Ok(self.mem().items.get(&vault).and_then(|v| v.get(&item)).cloned())
|
||||
}
|
||||
|
||||
fn list(&self, vault: Uuid) -> Result<Vec<LocalItem>> {
|
||||
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<Vec<HistoryEntry>> {
|
||||
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<String> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<dyn DeviceSigner>,
|
||||
session: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
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<dyn DeviceSigner>) -> Result<Self, SyncError> {
|
||||
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<dyn DeviceSigner> {
|
||||
&self.signer
|
||||
}
|
||||
|
||||
pub fn session(&self) -> Option<String> {
|
||||
self.session.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
||||
}
|
||||
|
||||
pub fn set_session(&self, s: Option<String>) {
|
||||
*self.session.lock().unwrap_or_else(|p| p.into_inner()) = s;
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
if_match: Option<u64>,
|
||||
signed: bool,
|
||||
) -> Result<(StatusCode, Vec<u8>), 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<ApiError> = 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<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&impl Serialize>,
|
||||
if_match: Option<u64>,
|
||||
signed: bool,
|
||||
) -> Result<T, SyncError> {
|
||||
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<ServerInfo, SyncError> {
|
||||
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<PreloginResponse, SyncError> {
|
||||
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<u8>,
|
||||
sig_pk: Vec<u8>,
|
||||
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<String>,
|
||||
) -> Result<LoginOutcome, SyncError> {
|
||||
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<AccountInfo, SyncError> {
|
||||
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
|
||||
}
|
||||
|
||||
pub async fn devices(&self) -> Result<Vec<DeviceInfo>, 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<Vec<Vault>, 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<Changes, SyncError> {
|
||||
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<ItemRecord, SyncError> {
|
||||
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<ItemRecord, SyncError> {
|
||||
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<Option<Manifest>, 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
|
||||
}
|
||||
}
|
||||
@@ -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<Uuid>,
|
||||
/// Vaults synced successfully.
|
||||
pub vaults_synced: u32,
|
||||
/// Remote vaults ignored because they have no valid signed manifest yet.
|
||||
pub vaults_skipped: Vec<Uuid>,
|
||||
}
|
||||
|
||||
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<Option<Value>, 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<ItemRecord, SyncError> {
|
||||
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<HistoryEntry> {
|
||||
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<Vec<ItemRecord>, 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<SyncReport, SyncError> {
|
||||
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<String>) -> 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<Option<ManifestBody>, 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<u32, SyncError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<kv_store::StoreError> 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<String>,
|
||||
/// Extra trust anchors (DER), e.g. a private CA for a self-hosted server. Empty in normal use.
|
||||
pub extra_roots_der: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn new(base_url: impl Into<String>, spki_pins: Vec<String>) -> Self {
|
||||
Self { base_url: base_url.into(), spki_pins, extra_roots_der: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8>, 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<u8>, n: u64) {
|
||||
head(out, 0, n)
|
||||
}
|
||||
pub fn bytes(out: &mut Vec<u8>, b: &[u8]) {
|
||||
head(out, 2, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
pub fn text(out: &mut Vec<u8>, s: &str) {
|
||||
head(out, 3, s.len() as u64);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
pub fn array(out: &mut Vec<u8>, len: usize) {
|
||||
head(out, 4, len as u64)
|
||||
}
|
||||
pub fn map(out: &mut Vec<u8>, 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<u8> {
|
||||
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<Item = &'a ItemRecord>) -> [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<u8> {
|
||||
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<Self, ManifestError> {
|
||||
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<Uuid, ManifestError> {
|
||||
get(k).and_then(Value::as_bytes).and_then(|b| Uuid::from_slice(b).ok()).ok_or(ManifestError::Malformed)
|
||||
};
|
||||
let uint = |k: &str| -> Result<u64, ManifestError> {
|
||||
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<ManifestBody, ManifestError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
state: ClientRegistration<Suite>,
|
||||
}
|
||||
|
||||
pub fn registration_start(auth_input: &[u8]) -> Result<RegistrationStart, SyncError> {
|
||||
let r = ClientRegistration::<Suite>::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<Vec<u8>, SyncError> {
|
||||
let resp = RegistrationResponse::<Suite>::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<u8>,
|
||||
state: ClientLogin<Suite>,
|
||||
}
|
||||
|
||||
pub struct LoginFinished {
|
||||
pub ke3: Vec<u8>,
|
||||
pub session_key: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub fn login_start(auth_input: &[u8]) -> Result<LoginStart, SyncError> {
|
||||
let r = ClientLogin::<Suite>::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<LoginFinished, SyncError> {
|
||||
let resp = CredentialResponse::<Suite>::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::<Suite>::new(&mut OsRng);
|
||||
let pwd = [42u8; 32];
|
||||
let cred_id = b"account";
|
||||
|
||||
let reg = registration_start(&pwd).unwrap();
|
||||
let req = opaque_ke::RegistrationRequest::<Suite>::deserialize(®.request).unwrap();
|
||||
let resp = ServerRegistration::<Suite>::start(&setup, req, cred_id).unwrap();
|
||||
let upload = reg.finish(&pwd, &resp.message.serialize()).unwrap();
|
||||
let record = ServerRegistration::<Suite>::finish(opaque_ke::RegistrationUpload::deserialize(&upload).unwrap());
|
||||
|
||||
let login = login_start(&pwd).unwrap();
|
||||
let ke1 = opaque_ke::CredentialRequest::<Suite>::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::<Suite>::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)));
|
||||
}
|
||||
}
|
||||
@@ -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<SignedHeaders, SyncError> {
|
||||
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<SignedHeaders, SyncError> {
|
||||
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<Self, SyncError> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
self.key.verifying_key().to_encoded_point(false).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, 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<String>,
|
||||
) -> Result<Uuid, SyncError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<Suite>,
|
||||
pub enc_pk: Vec<u8>,
|
||||
pub sig_pk: Vec<u8>,
|
||||
pub wrapped_keyset: Envelope,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct VaultState {
|
||||
pub head: u64,
|
||||
pub sealed_key: Option<Envelope>,
|
||||
pub meta: Option<Envelope>,
|
||||
pub items: BTreeMap<Uuid, ItemRecord>,
|
||||
pub manifest: Option<Manifest>,
|
||||
/// Every manifest ever stored, oldest first (for rollback attacks).
|
||||
pub manifest_history: Vec<Manifest>,
|
||||
}
|
||||
|
||||
pub struct Device {
|
||||
pub p256_pk: Vec<u8>,
|
||||
pub approved: bool,
|
||||
pub name: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub setup: ServerSetup<Suite>,
|
||||
pub setup_token: Option<String>,
|
||||
pub account: Option<Account>,
|
||||
pub devices: HashMap<Uuid, Device>,
|
||||
pub sessions: HashMap<String, Uuid>,
|
||||
logins: HashMap<String, (Vec<u8>, Uuid)>,
|
||||
pub vaults: BTreeMap<Uuid, VaultState>,
|
||||
/// New devices need approval (else they are auto-approved).
|
||||
pub require_approval: bool,
|
||||
/// Requests seen, as "METHOD /path".
|
||||
pub log: Vec<String>,
|
||||
/// Item ids `/changes` silently omits (a hiding attack).
|
||||
pub hide_items: Vec<Uuid>,
|
||||
/// When set, `GET /manifest` serves this instead of the latest.
|
||||
pub serve_manifest: Option<Manifest>,
|
||||
/// 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<MockServer>,
|
||||
pub state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
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<Mutex<State>>,
|
||||
}
|
||||
|
||||
fn json<T: Serialize>(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<T: DeserializeOwned>(req: &Request) -> Result<T, ResponseTemplate> {
|
||||
serde_json::from_slice(&req.body).map_err(|_| err(400, "bad_request"))
|
||||
}
|
||||
|
||||
fn if_match(req: &Request) -> Result<u64, ResponseTemplate> {
|
||||
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<Uuid, ResponseTemplate> {
|
||||
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<ResponseTemplate, ResponseTemplate> {
|
||||
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::<Suite>::deserialize(&r.registration_request)
|
||||
.map_err(|_| err(400, "bad_request"))?;
|
||||
let resp = ServerRegistration::<Suite>::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::<Suite>::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::<Suite>::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::<Suite>::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<ResponseTemplate, ResponseTemplate> {
|
||||
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<DeviceInfo> = 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<Vault> = 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<String, String> = 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<ItemRecord> =
|
||||
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<ItemPut> = 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")),
|
||||
}
|
||||
}
|
||||
@@ -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<String, SyncError> {
|
||||
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<WebPkiServerVerifier>,
|
||||
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<ServerCertVerified, rustls::Error> {
|
||||
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<HandshakeSignatureValid, rustls::Error> {
|
||||
// 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<HandshakeSignatureValid, rustls::Error> {
|
||||
self.inner.verify_tls13_signature(message, cert, dss)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
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<u8>]) -> Result<rustls::ClientConfig, SyncError> {
|
||||
let pins = pins.iter().map(|p| parse_pin(p)).collect::<Result<Vec<_>, _>>()?;
|
||||
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<String, SyncError> {
|
||||
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, SyncError> {
|
||||
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::<PinMismatch>().is_some() {
|
||||
return SyncError::PinMismatch;
|
||||
}
|
||||
// `io::Error::source()` skips the wrapped error itself, so look inside explicitly.
|
||||
let tls = err
|
||||
.downcast_ref::<rustls::Error>()
|
||||
.or_else(|| err.downcast_ref::<std::io::Error>().and_then(|io| io.get_ref()?.downcast_ref()));
|
||||
if let Some(rustls::Error::InvalidCertificate(CertificateError::Other(o))) = tls {
|
||||
if o.0.downcast_ref::<PinMismatch>().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());
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<SoftwareSigner>,
|
||||
}
|
||||
|
||||
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<SyncReport, SyncError> {
|
||||
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<Value> {
|
||||
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<Uuid> = (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<u8>,
|
||||
leaf_der: Vec<u8>,
|
||||
leaf_key: Vec<u8>,
|
||||
}
|
||||
|
||||
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<String>) -> 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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user