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