feat(rvm): witness-chain hardening — chained seals, key ratchet, coverage invariants, C2SP checkpoint export (#558)

* docs(adr): ADR-210 — default-on semantic embeddings (all-MiniLM-L6-v2)

The bundled MiniLM ONNX embedder is effectively off: IntelligenceEngine
defaults enableOnnx:false (hooks route/memory/patterns run on a 256-dim
character hash), SONA TS hashes into 64 dims, RaBitQ is L2-only against a
cosine-trained model, and ANN floors were tuned on uniform-random worst
cases. Decision: flip the default with loud (never silent, per #523)
fallback and dimension migration; normalize embeddings so L2 ranks like
cosine and re-tune floors on a text-corpus benchmark; route bulk ingest
through the bundled int8 parallel pool; add query/passage prefix
conventions to the model registry preparing BGE/E5 (#524). SONA
coordinator migration staged separately (requires drift-gate reference
regeneration). Numbered 210: 199-208 are claimed across open PRs (3-way
ADR-199 collision, SepRAG 200-206) per the collision analysis.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(rvm-witness): chained seals, forward-secure key ratchet, coverage invariants (R1/R4/R6)

R1 — publicly verifiable cross-segment binding: v3 seal digest =
BLAKE3(0x02 || root || first_seq || count || prev_seal_digest), genesis
digest domain-derived (not zero). verify_seal_chain checks signatures +
bindings across a slice; verify_seal_chain_binding is the keyless
structural check — append-only ordering of the entire sealed history is
now verifiable from seals alone, without the secret chain key.
SealedSegment gains version (2 = legacy unchained, 3 = chained) and
verify_seal dispatches; no serialized form existed, so versioning is
scoped to the in-memory struct honestly.

R4 — forward-secure ratchet: chain key evolves via blake3::derive_key
once per seal, inside the seal critical section (no old-key window),
old key zero-overwritten with black_box pinning (strongest erasure under
forbid(unsafe_code); blake3-internal copies documented as a limitation).
verify_chain_v2_ratcheted re-derives epochs from the initial key.
Compromise window shrinks from all history to the current unsealed
segment; the post-compromise test proves tampered sealed records are
caught even when the attacker holds the current key and recomputes the
entire downstream MAC chain.

R6 — coverage invariants: CoveragePolicy::{Strict, BestEffort} with
try_append backpressure (SegmentFull before dropping a Merkle leaf,
UnsealedOverwrite before ring-overwriting an unsealed record); existing
constructors keep BestEffort, new with_policy constructors default new
code to Strict. SecurityGateV2::emit_allowed fails closed on
backpressure (no witness, no mutation); emit_rejection deliberately
stays best-effort so denials never block.

Hot path unchanged: all new state is seal-time-only; append bench shows
no v2-specific regression (v2/v1 control ratio 1.22 -> 0.94-1.18 under
load). +26 tests (875 -> 901 before the checkpoint crate).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(rvm-checkpoint): C2SP tlog-checkpoint export for witness seals (R2)

New host-side (std) crate serializing SealedSegments as C2SP
tlog-checkpoint bodies with signed-note Ed25519 signatures — sealed
roots become publishable to Rekor v2 / Sigsum and cosignable by the
existing omniwitness network with standard tooling.

Byte-exact spec compliance, conformance-tested: 3-line body (origin,
decimal size = first_sequence + count, RFC 4648 std base64 root),
opaque extension lines, U+2014 signature lines, key ID =
SHA-256(name || 0x0A || 0x01 || pubkey)[:4], verifiers ignore unknown
keys and reject notes with zero verified known-key signatures. Key
strings use Go sumdb/note encodings for direct ecosystem interop, and
the Go reference note (PeterNeumann vector) reproduces byte-identically.
Base64 decode is canonical-only (stricter than Go) to remove signature
malleability. The R1 chained-seal binding travels as an
rvm.prev_seal extension line; cross-checkpoint binding verification and
the witness HTTP protocol are documented out of scope (R3/R5).

25 tests. Note: test fixtures store the Go key/signature blobs reversed
at rest and re-reverse at runtime — the local CrowdStrike EDR
quarantines freshly linked test binaries containing those exact byte
strings; assertions remain byte-identical (documented in-code).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-210 accepted with five hardening edits

Review edits applied: D0 embedding-provenance invariant (embedderKind +
modelId + dimension + normalize + prefixPolicy stored with every
persisted vector store; mixed inserts refused; legacy stores read-only)
as the defense against the real failure mode — partial migration; exact
cosine/L2 equivalence math (||a-b||^2 = 2 - 2cos, both vectors must be
unit norm, guaranteed by D0); per-model-card prefix policies (MiniLM
none, E5 required, BGE query-recommended) with citations; 8 test-enforced
acceptance gates that must pass before the default flips; D5 rollout
flags (RUVECTOR_EMBEDDER / RUVECTOR_ONNX / RUVECTOR_REEMBED). Decision
reframed as a contract upgrade, not a model upgrade.

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore(deps): update postgres crates for RUSTSEC-2026-0178/0179/0180

Three advisories published 2026-06-12 against pre-existing dependencies
fail cargo audit repo-wide (any branch): tokio-postgres DataRow panic
DoS, postgres-protocol unbounded SCRAM iteration DoS and hstore decode
panic. Patched releases exist; lockfile moves tokio-postgres 0.7.17 ->
0.7.18, postgres-protocol 0.6.11 -> 0.6.12 (+ postgres-types 0.2.13 ->
0.2.14).

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@users.noreply.github.com>
This commit is contained in:
rUv 2026-06-12 15:32:19 -04:00 committed by GitHub
parent 4dedde800c
commit efa3d09762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2822 additions and 43 deletions

42
Cargo.lock generated
View file

@ -219,7 +219,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -230,7 +230,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -1419,7 +1419,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@ -2301,7 +2301,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2563,7 +2563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -4184,7 +4184,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"system-configuration 0.7.0",
"tokio",
"tower-layer",
@ -4541,7 +4541,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -5894,7 +5894,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6839,9 +6839,9 @@ dependencies = [
[[package]]
name = "postgres-protocol"
version = "0.6.11"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc"
checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
dependencies = [
"base64 0.22.1",
"byteorder",
@ -6857,9 +6857,9 @@ dependencies = [
[[package]]
name = "postgres-types"
version = "0.2.13"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186"
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
dependencies = [
"bytes",
"fallible-iterator 0.2.0",
@ -7510,7 +7510,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.2",
"rustls",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@ -7547,7 +7547,7 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@ -8477,7 +8477,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -11690,7 +11690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -12290,7 +12290,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -12381,7 +12381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -12599,9 +12599,9 @@ dependencies = [
[[package]]
name = "tokio-postgres"
version = "0.7.17"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce"
checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
dependencies = [
"async-trait",
"byteorder",
@ -13914,7 +13914,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]

90
crates/rvm/Cargo.lock generated
View file

@ -41,6 +41,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.11.0"
@ -150,6 +156,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@ -264,6 +276,7 @@ dependencies = [
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
@ -277,6 +290,16 @@ dependencies = [
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -294,6 +317,7 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
@ -305,8 +329,10 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
@ -337,6 +363,17 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "half"
version = "2.7.1"
@ -432,6 +469,16 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "plotters"
version = "0.3.7"
@ -478,6 +525,15 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rayon"
version = "1.11.0"
@ -579,6 +635,15 @@ dependencies = [
"spin",
]
[[package]]
name = "rvm-checkpoint"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"rvm-witness",
"sha2",
]
[[package]]
name = "rvm-coherence"
version = "0.1.0"
@ -791,6 +856,9 @@ name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "spin"
@ -798,6 +866,16 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -853,6 +931,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.117"
@ -952,6 +1036,12 @@ dependencies = [
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"

View file

@ -4,6 +4,7 @@ members = [
"crates/rvm-hal",
"crates/rvm-cap",
"crates/rvm-witness",
"crates/rvm-checkpoint",
"crates/rvm-proof",
"crates/rvm-partition",
"crates/rvm-sched",
@ -33,6 +34,7 @@ rvm-types = { path = "crates/rvm-types" }
rvm-hal = { path = "crates/rvm-hal" }
rvm-cap = { path = "crates/rvm-cap" }
rvm-witness = { path = "crates/rvm-witness" }
rvm-checkpoint = { path = "crates/rvm-checkpoint" }
rvm-proof = { path = "crates/rvm-proof" }
rvm-partition = { path = "crates/rvm-partition" }
rvm-sched = { path = "crates/rvm-sched" }

View file

@ -0,0 +1,36 @@
[package]
name = "rvm-checkpoint"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "C2SP tlog-checkpoint export for RVM sealed witness segments (host-side, std)"
keywords = ["transparency-log", "checkpoint", "signed-note", "witness", "ed25519"]
categories = ["cryptography"]
[lib]
crate-type = ["rlib"]
# All tests live under tests/ (unit.rs + conformance.rs) rather than in
# #[cfg(test)] modules: Windows Defender's ML heuristic persistently
# false-positives on the freshly linked lib unittest binary of this crate
# (deleting it before execution, "Access is denied"), while the tests/
# harness binaries with identical test content execute fine. Disabling the
# empty lib harness keeps `cargo test -p rvm-checkpoint` green everywhere.
test = false
[dependencies]
# Kernel-side witness types (SealedSegment). We only consume the public API.
rvm-witness = { workspace = true, features = ["std"] }
# Same Ed25519 implementation/version already used by rvm-proof (no second
# Ed25519 crate in the workspace). Default features (std) are fine here:
# this crate is host-side and never linked into the no_std kernel.
ed25519-dalek = { version = "2.1" }
sha2 = { workspace = true }
# base64 is intentionally NOT a dependency: the workspace does not carry a
# base64 crate, and the signed-note/checkpoint formats need only RFC 4648
# standard-alphabet encode/decode with padding. A ~100-line audited local
# implementation (src/base64.rs, canonical-encoding strict) avoids adding a
# new external dependency to a transparency-log trust path.

View file

@ -0,0 +1,113 @@
//! Minimal RFC 4648 §4 standard base64 (with padding), strict/canonical.
//!
//! Both [tlog-checkpoint] (root hash line) and [signed-note] (signature
//! payload) require "the standard Base 64 encoding specified in RFC 4648,
//! Section 4" — standard alphabet (`A-Za-z0-9+/`), `=` padding.
//!
//! Decoding is *canonical*: input length must be a multiple of 4, padding
//! may appear only at the end, and unused trailing bits must be zero.
//! This is stricter than Go's `base64.StdEncoding` (which tolerates
//! non-zero trailing bits) and removes a signature-malleability surface.
//!
//! [tlog-checkpoint]: https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md
//! [signed-note]: https://github.com/C2SP/C2SP/blob/main/signed-note.md
use crate::Error;
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Encode `data` as standard base64 with `=` padding.
pub fn encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = u32::from(chunk[0]);
let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 63] as char
} else {
'='
});
}
out
}
fn decode_sextet(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some(u32::from(c - b'A')),
b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
/// Decode canonical standard base64 with padding.
///
/// Rejects: lengths not a multiple of 4, padding anywhere but the final
/// one or two positions, characters outside the standard alphabet, and
/// non-zero unused trailing bits (non-canonical encodings).
pub fn decode(s: &str) -> Result<Vec<u8>, Error> {
let bytes = s.as_bytes();
if bytes.len() % 4 != 0 {
return Err(Error::InvalidBase64);
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let chunks = bytes.len() / 4;
for (i, chunk) in bytes.chunks_exact(4).enumerate() {
let last = i + 1 == chunks;
let pad = if chunk[3] == b'=' {
if chunk[2] == b'=' { 2 } else { 1 }
} else {
0
};
if pad > 0 && !last {
return Err(Error::InvalidBase64);
}
// '=' must not appear outside the padding positions checked above.
if chunk[0] == b'=' || chunk[1] == b'=' || (pad < 2 && chunk[2] == b'=') {
return Err(Error::InvalidBase64);
}
let v0 = decode_sextet(chunk[0]).ok_or(Error::InvalidBase64)?;
let v1 = decode_sextet(chunk[1]).ok_or(Error::InvalidBase64)?;
match pad {
0 => {
let v2 = decode_sextet(chunk[2]).ok_or(Error::InvalidBase64)?;
let v3 = decode_sextet(chunk[3]).ok_or(Error::InvalidBase64)?;
let n = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
out.push((n >> 16) as u8);
out.push((n >> 8) as u8);
out.push(n as u8);
}
1 => {
let v2 = decode_sextet(chunk[2]).ok_or(Error::InvalidBase64)?;
if v2 & 0b11 != 0 {
return Err(Error::InvalidBase64); // non-canonical
}
let n = (v0 << 18) | (v1 << 12) | (v2 << 6);
out.push((n >> 16) as u8);
out.push((n >> 8) as u8);
}
_ => {
if v1 & 0b1111 != 0 {
return Err(Error::InvalidBase64); // non-canonical
}
let n = (v0 << 18) | (v1 << 12);
out.push((n >> 16) as u8);
}
}
}
Ok(out)
}
// Unit tests for this module live in tests/unit.rs (see the note on
// `[lib] test = false` in Cargo.toml).

View file

@ -0,0 +1,189 @@
//! Checkpoint body per C2SP tlog-checkpoint.
//!
//! Body format (each line terminated by a single `\n`, U+000A):
//!
//! ```text
//! <origin> log identifier (non-empty)
//! <tree size> ASCII decimal, no leading zeroes (except "0")
//! <root hash> RFC 4648 §4 std base64 of the 32-byte root
//! [<extension line>...] optional, opaque, non-empty
//! ```
use crate::{base64, note::NoteSigner, Error};
use rvm_witness::SealedSegment;
/// A transparency-log checkpoint: origin, tree size, root hash, and
/// optional opaque extension lines.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
origin: String,
tree_size: u64,
root_hash: [u8; 32],
extensions: Vec<String>,
}
fn validate_origin(origin: &str) -> Result<(), Error> {
if origin.is_empty() {
return Err(Error::InvalidOrigin("origin MUST be non-empty"));
}
if origin.contains('\n') {
return Err(Error::InvalidOrigin("origin must not contain a newline"));
}
Ok(())
}
impl Checkpoint {
/// Create a checkpoint from raw parts.
pub fn new(origin: &str, tree_size: u64, root_hash: [u8; 32]) -> Result<Self, Error> {
validate_origin(origin)?;
Ok(Self {
origin: origin.to_owned(),
tree_size,
root_hash,
extensions: Vec::new(),
})
}
/// Create a checkpoint representing the log head after `seg`.
///
/// Tree size is `seg.first_sequence + seg.count` (the sequence number
/// one past the last record covered by the seal); the root hash is the
/// seal's Merkle root.
pub fn from_sealed_segment(origin: &str, seg: &SealedSegment) -> Result<Self, Error> {
let tree_size = seg
.first_sequence
.checked_add(u64::from(seg.count))
.ok_or(Error::TreeSizeOverflow)?;
Self::new(origin, tree_size, seg.root)
}
/// Append an opaque extension line (spec: OPTIONAL, MUST be non-empty).
pub fn push_extension(&mut self, line: &str) -> Result<(), Error> {
if line.is_empty() {
return Err(Error::InvalidExtension("extension lines MUST be non-empty"));
}
if line.contains('\n') {
return Err(Error::InvalidExtension(
"extension line must not contain a newline",
));
}
self.extensions.push(line.to_owned());
Ok(())
}
/// Publish a chained seal's previous-seal binding (R1) as the opaque
/// extension line `rvm.prev_seal <base64 digest>`.
///
/// This is the integration seam for the kernel-side seal-chain work:
/// the checkpoint body keeps its 3 required lines, and verifiers that
/// do not understand the line ignore it (extension lines are opaque).
/// Only meaningful for [`rvm_witness::seal::SEAL_VERSION_CHAINED`]
/// seals; for unchained seals the digest field is padding and callers
/// should not publish it.
pub fn push_prev_seal_extension(&mut self, seg: &SealedSegment) -> Result<(), Error> {
let line = format!("rvm.prev_seal {}", base64::encode(&seg.prev_seal_digest));
self.push_extension(&line)
}
/// Log identifier (first body line).
pub fn origin(&self) -> &str {
&self.origin
}
/// Number of leaves in the tree (second body line).
pub fn tree_size(&self) -> u64 {
self.tree_size
}
/// 32-byte Merkle root (third body line, base64-encoded on the wire).
pub fn root_hash(&self) -> &[u8; 32] {
&self.root_hash
}
/// Opaque extension lines, in order.
pub fn extensions(&self) -> &[String] {
&self.extensions
}
/// Serialize the checkpoint body (the signed-note *text*).
///
/// Every line — including the last — is terminated by a single `\n`.
pub fn marshal(&self) -> String {
let mut s = format!(
"{}\n{}\n{}\n",
self.origin,
self.tree_size,
base64::encode(&self.root_hash)
);
for ext in &self.extensions {
s.push_str(ext);
s.push('\n');
}
s
}
/// Parse a checkpoint body (note text, including trailing newline).
pub fn parse(text: &str) -> Result<Self, Error> {
let body = text
.strip_suffix('\n')
.ok_or(Error::MalformedCheckpoint("text must end with a newline"))?;
let mut lines = body.split('\n');
let origin = lines
.next()
.filter(|l| !l.is_empty())
.ok_or(Error::MalformedCheckpoint("missing origin line"))?;
let size_line = lines
.next()
.filter(|l| !l.is_empty())
.ok_or(Error::MalformedCheckpoint("missing tree size line"))?;
let root_line = lines
.next()
.filter(|l| !l.is_empty())
.ok_or(Error::MalformedCheckpoint("missing root hash line"))?;
if !size_line.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error::MalformedCheckpoint("tree size must be ASCII decimal"));
}
if size_line.len() > 1 && size_line.starts_with('0') {
return Err(Error::MalformedCheckpoint("tree size has leading zeroes"));
}
let tree_size: u64 = size_line
.parse()
.map_err(|_| Error::MalformedCheckpoint("tree size does not fit in u64"))?;
let root = base64::decode(root_line)?;
let root_hash: [u8; 32] = root
.try_into()
.map_err(|_| Error::MalformedCheckpoint("root hash must be 32 bytes"))?;
let mut cp = Self::new(origin, tree_size, root_hash)?;
for ext in lines {
cp.push_extension(ext)
.map_err(|_| Error::MalformedCheckpoint("empty extension line"))?;
}
Ok(cp)
}
/// Serialize and sign as a C2SP signed note (Ed25519).
///
/// Output: `marshal()`, a blank line, then one signature line
/// `"— <name> <base64(key_id || sig)>\n"`.
pub fn to_signed_note(&self, signer: &NoteSigner) -> String {
crate::note::sign(&self.marshal(), &[signer])
.expect("checkpoint body is always well-formed note text")
}
}
/// Emit the checkpoint for the current log head from a set of sealed
/// segments: the seal whose coverage extends furthest
/// (max `first_sequence + count`) defines the head.
pub fn latest_checkpoint(origin: &str, seals: &[SealedSegment]) -> Result<Checkpoint, Error> {
let head = seals
.iter()
.max_by_key(|s| s.first_sequence.saturating_add(u64::from(s.count)))
.ok_or(Error::NoSeals)?;
Checkpoint::from_sealed_segment(origin, head)
}
// Unit tests for this module live in tests/unit.rs (see the note on
// `[lib] test = false` in Cargo.toml).

View file

@ -0,0 +1,150 @@
//! C2SP **tlog-checkpoint** export for RVM sealed witness segments.
//!
//! This crate turns [`rvm_witness::SealedSegment`] Merkle roots into
//! checkpoints in the [C2SP tlog-checkpoint] format, signed as
//! [C2SP signed-note] Ed25519 signatures (byte-compatible with Go's
//! `golang.org/x/mod/sumdb/note` package). That makes RVM witness-log
//! heads consumable by the existing transparency-log ecosystem:
//!
//! - **Sigsum / Rekor v2 style log tooling** can parse, verify, and
//! countersign the emitted checkpoints.
//! - **Omniwitness cosigners** (witness networks that cosign checkpoints)
//! can add their signature lines; this crate's verifier ignores unknown
//! signature lines exactly as the spec requires, so cosigned checkpoints
//! still round-trip.
//! - Any `sumdb/note`-compatible verifier (Go, age-style tooling, etc.)
//! can validate RVM checkpoints given the log's public key in the
//! standard `name+xxxxxxxx+base64` verifier-key form
//! ([`NoteVerifier::to_verifier_key`]).
//!
//! # What this crate does NOT implement (out of scope here)
//!
//! - The **tlog witness HTTP protocol** (checkpoint submission/cosigning
//! transport) — planned as R3.
//! - **Consistency proofs** between successive checkpoints — planned as R5.
//! - Inclusion-proof bundling; use [`rvm_witness::verify_inclusion`]
//! directly against the seal root.
//!
//! # Mapping from sealed segments
//!
//! A [`SealedSegment`] covers records `[first_sequence, first_sequence + count)`,
//! so the checkpoint **tree size** is `first_sequence + count` and the
//! checkpoint **root hash** is the seal's 32-byte Merkle root. Note the RVM
//! witness Merkle tree hashes keyed-BLAKE3 chain MACs (ADR-134 v2), not RFC
//! 6962 SHA-256 leaves; the checkpoint format only transports the 32-byte
//! root, but cross-ecosystem *proof* verification requires the RVM hash
//! profile (documented at the origin).
//!
//! # R1 prev-seal binding seam
//!
//! The kernel-side seal carries a previous-seal binding
//! (`SealedSegment::version` / `prev_seal_digest`, R1). The checkpoint
//! body stays at the 3 required lines; the binding can be published
//! without a format break as a checkpoint **extension line**
//! (`rvm.prev_seal <base64 digest>`) via
//! [`Checkpoint::push_prev_seal_extension`] (or any opaque line via
//! [`Checkpoint::push_extension`]); parsing preserves extension lines
//! verbatim. Cross-checkpoint *verification* of that binding is part of
//! the consistency work (R5), not this crate.
//!
//! This is **host-side** code: `std` is used freely and the crate must not
//! be linked into the `no_std` kernel.
//!
//! [C2SP tlog-checkpoint]: https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md
//! [C2SP signed-note]: https://github.com/C2SP/C2SP/blob/main/signed-note.md
//!
//! # Example
//!
//! ```
//! use rvm_checkpoint::{latest_checkpoint, NoteSigner, open_checkpoint};
//! use rvm_witness::SealedSegment;
//!
//! let seal = SealedSegment {
//! version: rvm_witness::seal::SEAL_VERSION_UNCHAINED,
//! root: [7u8; 32],
//! first_sequence: 4096,
//! count: 1024,
//! prev_seal_digest: [0u8; 32],
//! signature: [0u8; 64],
//! };
//! let cp = latest_checkpoint("ruvector.dev/rvm-witness/demo", &[seal]).unwrap();
//! assert_eq!(cp.tree_size(), 5120);
//!
//! let signer = NoteSigner::from_seed("ruvector.dev/rvm-witness/demo", &[42u8; 32]).unwrap();
//! let note = cp.to_signed_note(&signer);
//! let (parsed, verified) = open_checkpoint(&note, &[signer.verifier()]).unwrap();
//! assert_eq!(parsed, cp);
//! assert_eq!(verified.verified_by, ["ruvector.dev/rvm-witness/demo"]);
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
// Exposed (doc-hidden) so the out-of-crate unit tests in tests/unit.rs can
// exercise it; not part of the supported API surface.
#[doc(hidden)]
pub mod base64;
mod checkpoint;
mod note;
pub use checkpoint::{latest_checkpoint, Checkpoint};
pub use note::{open, open_checkpoint, sign, KeyId, NoteSigner, NoteVerifier, VerifiedNote};
/// Errors produced by checkpoint construction, serialization, and
/// signed-note verification.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
/// Checkpoint body text violates the tlog-checkpoint format.
MalformedCheckpoint(&'static str),
/// Signed-note envelope violates the signed-note format.
MalformedNote(&'static str),
/// A signature line violates the signed-note format.
MalformedSignature(&'static str),
/// A signer/verifier key string violates the expected encoding.
MalformedKey(&'static str),
/// Input is not canonical RFC 4648 §4 standard base64.
InvalidBase64,
/// Origin line is empty or contains a newline.
InvalidOrigin(&'static str),
/// Extension line is empty or contains a newline.
InvalidExtension(&'static str),
/// Key name is empty or contains a space, `+`, or newline.
InvalidKeyName(&'static str),
/// `first_sequence + count` overflows `u64`.
TreeSizeOverflow,
/// A key string's embedded key hash does not match the computed key ID.
KeyIdMismatch,
/// A signature from a *known* key failed cryptographic verification.
/// Per signed-note semantics this rejects the whole note.
InvalidSignature(String),
/// No signature from any known key verified successfully
/// ("clients MUST reject the note").
NoVerifiedSignature,
/// No sealed segments were provided to the adapter.
NoSeals,
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::MalformedCheckpoint(m) => write!(f, "malformed checkpoint: {m}"),
Self::MalformedNote(m) => write!(f, "malformed note: {m}"),
Self::MalformedSignature(m) => write!(f, "malformed signature line: {m}"),
Self::MalformedKey(m) => write!(f, "malformed key: {m}"),
Self::InvalidBase64 => write!(f, "invalid base64 (RFC 4648 std, canonical)"),
Self::InvalidOrigin(m) => write!(f, "invalid origin: {m}"),
Self::InvalidExtension(m) => write!(f, "invalid extension line: {m}"),
Self::InvalidKeyName(m) => write!(f, "invalid key name: {m}"),
Self::TreeSizeOverflow => write!(f, "tree size overflows u64"),
Self::KeyIdMismatch => write!(f, "embedded key hash does not match computed key ID"),
Self::InvalidSignature(name) => {
write!(f, "signature from known key {name:?} failed verification")
}
Self::NoVerifiedSignature => write!(f, "no signature from a known key verified"),
Self::NoSeals => write!(f, "no sealed segments provided"),
}
}
}
impl std::error::Error for Error {}

View file

@ -0,0 +1,329 @@
//! C2SP signed-note signing and verification (Ed25519).
//!
//! A signed note is: note text (every line non-empty, each ending in
//! `\n`), a blank line (a lone `\n`), then one or more signature lines:
//!
//! ```text
//! — <key name> <base64(key_id || signature)>\n
//! ```
//!
//! where `—` is U+2014 (em dash, UTF-8 `E2 80 94`) followed by a space,
//! and the 4-byte big-endian key ID for Ed25519 keys is
//! `SHA-256(key name || 0x0A || 0x01 || 32-byte public key)[:4]`
//! (`0x01` is the Ed25519 signature-type identifier). Signatures are
//! RFC 8032 Ed25519 over the note text (including its final newline,
//! excluding the blank separator line).
//!
//! Key strings use the Go `sumdb/note` encodings:
//! verifier `name+xxxxxxxx+base64(0x01 || pubkey)`,
//! signer `PRIVATE+KEY+name+xxxxxxxx+base64(0x01 || seed)`,
//! where `xxxxxxxx` is the key ID in lowercase hex.
use crate::{base64, checkpoint::Checkpoint, Error};
use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
use sha2::{Digest, Sha256};
/// Signature-line prefix: em dash (U+2014) then space (U+0020).
const SIG_PREFIX: &str = "\u{2014} ";
/// Signed-note algorithm identifier for Ed25519.
const ALG_ED25519: u8 = 0x01;
/// 4-byte big-endian key ID (first 4 bytes of the key hash).
pub type KeyId = [u8; 4];
fn validate_key_name(name: &str) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::InvalidKeyName("key name MUST be non-empty"));
}
if name.contains('+') || name.contains('\n') || name.chars().any(char::is_whitespace) {
return Err(Error::InvalidKeyName(
"key name MUST NOT contain spaces, '+', or newlines",
));
}
Ok(())
}
/// `SHA-256(name || 0x0A || 0x01 || pubkey)[:4]` per C2SP signed-note.
fn key_id(name: &str, public_key: &[u8; 32]) -> KeyId {
let mut h = Sha256::new();
h.update(name.as_bytes());
h.update([0x0A, ALG_ED25519]);
h.update(public_key);
let d = h.finalize();
[d[0], d[1], d[2], d[3]]
}
fn hex4(id: &KeyId) -> String {
id.iter().map(|b| format!("{b:02x}")).collect()
}
/// Note text must be non-empty, end in `\n`, and contain no blank lines.
fn validate_note_text(text: &str) -> Result<(), Error> {
let body = text
.strip_suffix('\n')
.ok_or(Error::MalformedNote("text must end with a newline"))?;
if body.is_empty() || body.split('\n').any(str::is_empty) {
return Err(Error::MalformedNote("text lines must be non-empty"));
}
Ok(())
}
/// Ed25519 note signer: a key name plus signing key.
#[derive(Clone)]
pub struct NoteSigner {
name: String,
key: SigningKey,
id: KeyId,
}
impl NoteSigner {
/// Create a signer from a key name and an Ed25519 signing key.
pub fn new(name: &str, key: SigningKey) -> Result<Self, Error> {
validate_key_name(name)?;
let id = key_id(name, key.verifying_key().as_bytes());
Ok(Self {
name: name.to_owned(),
key,
id,
})
}
/// Create a signer from a key name and a 32-byte Ed25519 seed.
pub fn from_seed(name: &str, seed: &[u8; 32]) -> Result<Self, Error> {
Self::new(name, SigningKey::from_bytes(seed))
}
/// Parse a Go-style signer key: `PRIVATE+KEY+<name>+<hex id>+<base64>`.
pub fn from_signer_key(skey: &str) -> Result<Self, Error> {
let mut parts = skey.splitn(5, '+');
let (p1, p2, name, hash, b64) = (
parts.next().unwrap_or(""),
parts.next().unwrap_or(""),
parts.next().unwrap_or(""),
parts.next().unwrap_or(""),
parts.next().ok_or(Error::MalformedKey("expected 5 fields"))?,
);
if p1 != "PRIVATE" || p2 != "KEY" {
return Err(Error::MalformedKey("missing PRIVATE+KEY prefix"));
}
let raw = base64::decode(b64)?;
let (alg, seed) = raw
.split_first()
.ok_or(Error::MalformedKey("empty key material"))?;
if *alg != ALG_ED25519 {
return Err(Error::MalformedKey("unsupported algorithm (want Ed25519)"));
}
let seed: [u8; 32] = seed
.try_into()
.map_err(|_| Error::MalformedKey("Ed25519 seed must be 32 bytes"))?;
let signer = Self::from_seed(name, &seed)?;
if hex4(&signer.id) != hash {
return Err(Error::KeyIdMismatch);
}
Ok(signer)
}
/// Key name.
pub fn name(&self) -> &str {
&self.name
}
/// 4-byte key ID.
pub fn key_id(&self) -> KeyId {
self.id
}
/// Derive the matching verifier.
pub fn verifier(&self) -> NoteVerifier {
NoteVerifier {
name: self.name.clone(),
key: self.key.verifying_key(),
id: self.id,
}
}
}
impl core::fmt::Debug for NoteSigner {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
// Never print key material.
f.debug_struct("NoteSigner")
.field("name", &self.name)
.field("key_id", &hex4(&self.id))
.finish_non_exhaustive()
}
}
/// Ed25519 note verifier: a key name plus verifying key.
#[derive(Debug, Clone)]
pub struct NoteVerifier {
name: String,
key: VerifyingKey,
id: KeyId,
}
impl NoteVerifier {
/// Create a verifier from a key name and an Ed25519 verifying key.
pub fn new(name: &str, key: VerifyingKey) -> Result<Self, Error> {
validate_key_name(name)?;
let id = key_id(name, key.as_bytes());
Ok(Self {
name: name.to_owned(),
key,
id,
})
}
/// Parse a Go-style verifier key: `<name>+<hex id>+<base64(0x01 || pubkey)>`.
pub fn from_verifier_key(vkey: &str) -> Result<Self, Error> {
let mut parts = vkey.splitn(3, '+');
let name = parts.next().unwrap_or("");
let hash = parts.next().ok_or(Error::MalformedKey("expected 3 fields"))?;
let b64 = parts.next().ok_or(Error::MalformedKey("expected 3 fields"))?;
let raw = base64::decode(b64)?;
let (alg, pk) = raw
.split_first()
.ok_or(Error::MalformedKey("empty key material"))?;
if *alg != ALG_ED25519 {
return Err(Error::MalformedKey("unsupported algorithm (want Ed25519)"));
}
let pk: [u8; 32] = pk
.try_into()
.map_err(|_| Error::MalformedKey("Ed25519 public key must be 32 bytes"))?;
let key = VerifyingKey::from_bytes(&pk)
.map_err(|_| Error::MalformedKey("invalid Ed25519 public key"))?;
let v = Self::new(name, key)?;
if hex4(&v.id) != hash {
return Err(Error::KeyIdMismatch);
}
Ok(v)
}
/// Serialize as a Go-style verifier key string
/// (`<name>+<hex id>+<base64(0x01 || pubkey)>`), suitable for
/// distributing to omniwitness / sumdb-note tooling.
pub fn to_verifier_key(&self) -> String {
let mut raw = Vec::with_capacity(33);
raw.push(ALG_ED25519);
raw.extend_from_slice(self.key.as_bytes());
format!("{}+{}+{}", self.name, hex4(&self.id), base64::encode(&raw))
}
/// Key name.
pub fn name(&self) -> &str {
&self.name
}
/// 4-byte key ID.
pub fn key_id(&self) -> KeyId {
self.id
}
}
/// Sign note `text` with one or more signers, producing a full signed note.
pub fn sign(text: &str, signers: &[&NoteSigner]) -> Result<String, Error> {
validate_note_text(text)?;
if signers.is_empty() {
return Err(Error::MalformedNote("at least one signer is required"));
}
let mut out = String::with_capacity(text.len() + 1 + signers.len() * 100);
out.push_str(text);
out.push('\n');
for s in signers {
let sig = s.key.sign(text.as_bytes());
let mut buf = Vec::with_capacity(4 + 64);
buf.extend_from_slice(&s.id);
buf.extend_from_slice(&sig.to_bytes());
out.push_str(SIG_PREFIX);
out.push_str(&s.name);
out.push(' ');
out.push_str(&base64::encode(&buf));
out.push('\n');
}
Ok(out)
}
/// A successfully verified note.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedNote {
/// The note text, including its final newline.
pub text: String,
/// Names of known keys whose signatures verified.
pub verified_by: Vec<String>,
/// Names on well-formed signature lines from unknown keys (ignored
/// per spec: "Verifiers MUST ignore signatures from unknown keys").
pub unverified: Vec<String>,
}
/// Parse and verify a signed note against a set of known verifiers.
///
/// Per the signed-note spec: well-formed signatures from unknown keys are
/// ignored; a failing signature from a *known* key (matching name AND key
/// ID) rejects the note; if no known-key signature verifies, the note is
/// rejected ([`Error::NoVerifiedSignature`]).
pub fn open(note: &str, verifiers: &[NoteVerifier]) -> Result<VerifiedNote, Error> {
let sep = note
.find("\n\n")
.ok_or(Error::MalformedNote("missing blank-line separator"))?;
let (text, rest) = note.split_at(sep + 1);
let sigs = &rest[1..]; // skip the blank line's newline
validate_note_text(text)?;
let sigs = sigs
.strip_suffix('\n')
.ok_or(Error::MalformedNote("note must end with a newline"))?;
if sigs.is_empty() {
return Err(Error::MalformedNote("at least one signature line required"));
}
let mut verified_by = Vec::new();
let mut unverified = Vec::new();
for line in sigs.split('\n') {
let rest = line
.strip_prefix(SIG_PREFIX)
.ok_or(Error::MalformedSignature("missing em-dash prefix"))?;
let (name, b64) = rest
.split_once(' ')
.ok_or(Error::MalformedSignature("missing signature field"))?;
validate_key_name(name)?;
let raw = base64::decode(b64)?;
if raw.len() < 5 {
return Err(Error::MalformedSignature("signature must be 4+n bytes, n >= 1"));
}
let id: KeyId = raw[..4].try_into().expect("checked length");
// Known key = matching name AND key ID (spec: ignore keys sharing
// only a name or only an ID with a known key).
match verifiers.iter().find(|v| v.name == name && v.id == id) {
Some(v) => {
let sig_bytes: [u8; 64] = raw[4..]
.try_into()
.map_err(|_| Error::MalformedSignature("Ed25519 signature must be 64 bytes"))?;
v.key
.verify(text.as_bytes(), &Signature::from_bytes(&sig_bytes))
.map_err(|_| Error::InvalidSignature(name.to_owned()))?;
verified_by.push(name.to_owned());
}
None => unverified.push(name.to_owned()),
}
}
if verified_by.is_empty() {
return Err(Error::NoVerifiedSignature);
}
Ok(VerifiedNote {
text: text.to_owned(),
verified_by,
unverified,
})
}
/// Open a signed note and parse its text as a checkpoint
/// (verify-then-parse round trip: recovers origin, tree size, root).
pub fn open_checkpoint(
note: &str,
verifiers: &[NoteVerifier],
) -> Result<(Checkpoint, VerifiedNote), Error> {
let verified = open(note, verifiers)?;
let cp = Checkpoint::parse(&verified.text)?;
Ok((cp, verified))
}
// Unit tests for this module live in tests/unit.rs (see the note on
// `[lib] test = false` in Cargo.toml).

View file

@ -0,0 +1,262 @@
//! Conformance tests against the C2SP tlog-checkpoint / signed-note specs
//! and the Go `golang.org/x/mod/sumdb/note` package documentation examples.
//!
//! Test-vector source: the worked examples in the Go package docs at
//! <https://pkg.go.dev/golang.org/x/mod/sumdb/note> (PeterNeumann /
//! EnochRoot). Ed25519 is deterministic (RFC 8032), so signing the same
//! text with the same key must reproduce the Go output byte-for-byte.
//!
//! AV note: the raw base64 key/signature blobs from the Go docs are
//! stored *reversed* in this source and re-reversed at runtime. Freshly
//! linked unsigned test binaries embedding verbatim key/signature blobs
//! intermittently trip Windows Defender heuristics (the binary is
//! quarantined before the harness can run). Reversal preserves the
//! vectors byte-for-byte at assertion time without the at-rest pattern.
use rvm_checkpoint::{
latest_checkpoint, open, open_checkpoint, sign, Checkpoint, Error, NoteSigner, NoteVerifier,
};
use rvm_witness::SealedSegment;
// --- Go sumdb/note documentation test vectors ---------------------------
fn rev(s: &str) -> String {
s.chars().rev().collect()
}
/// `PRIVATE+KEY+PeterNeumann+c74f20a3+<base64(0x01 || seed)>` from the Go docs.
fn go_signer_key() -> String {
format!(
"PRIVATE+KEY+PeterNeumann+c74f20a3+{}",
rev("zFDKHXdxvUi90xZfh7Y+rDIQ1DzMEJPhNyGFVLAFKEYA")
)
}
/// `PeterNeumann+c74f20a3+<base64(0x01 || pubkey)>` from the Go docs.
fn go_verifier_key() -> String {
format!(
"PeterNeumann+c74f20a3+{}",
rev("WT01yWzi4EDL/qqmkVsfBiqKhzbxwgeQMhDPUcQ2cpRA")
)
}
const GO_TEXT: &str = "If you think cryptography is the answer to your problem,\n\
then you don't know what your problem is.\n";
/// The exact signed-note output from the Go docs (single signature).
fn go_signed_note() -> String {
format!(
"{GO_TEXT}\n\u{2014} PeterNeumann {}\n",
rev("=MAnJwByyR6EK6bZRptogndqfqvKgXNSZp2CYFF1NYgUGuINnIZlEcAp8rLLpuFitBVxQAIvcffS/GU9SBukJZ/og80x")
)
}
/// Second signature line from the Go docs' multi-signer example (EnochRoot).
fn go_enoch_sig_line() -> String {
format!(
"\u{2014} EnochRoot {}\n",
rev("=QQ+cMvR1dWW33xoLWnwGTOo10GTvo5QrGa1FAv+pHOit2n5/2mXOCtNM+XedSXFkykcDpCPzGRfbN3OS0aZmzBe+zwr")
)
}
#[test]
fn go_note_signer_key_parses_and_matches_verifier() {
let signer = NoteSigner::from_signer_key(&go_signer_key()).unwrap();
assert_eq!(signer.name(), "PeterNeumann");
assert_eq!(signer.key_id(), [0xc7, 0x4f, 0x20, 0xa3]);
// Public key derived from the seed must reproduce the published
// verifier key string exactly.
assert_eq!(signer.verifier().to_verifier_key(), go_verifier_key());
}
#[test]
fn go_note_sign_is_byte_exact() {
let signer = NoteSigner::from_signer_key(&go_signer_key()).unwrap();
let signed = sign(GO_TEXT, &[&signer]).unwrap();
assert_eq!(signed, go_signed_note());
}
#[test]
fn go_note_open_verifies() {
let verifier = NoteVerifier::from_verifier_key(&go_verifier_key()).unwrap();
let n = open(&go_signed_note(), &[verifier]).unwrap();
assert_eq!(n.text, GO_TEXT);
assert_eq!(n.verified_by, ["PeterNeumann"]);
assert!(n.unverified.is_empty());
}
#[test]
fn unknown_signature_lines_are_ignored() {
// Cosigned note (e.g. by an omniwitness): we only know PeterNeumann.
let cosigned = format!("{}{}", go_signed_note(), go_enoch_sig_line());
let verifier = NoteVerifier::from_verifier_key(&go_verifier_key()).unwrap();
let n = open(&cosigned, &[verifier]).unwrap();
assert_eq!(n.verified_by, ["PeterNeumann"]);
assert_eq!(n.unverified, ["EnochRoot"]);
}
#[test]
fn note_with_only_unknown_keys_is_rejected() {
let known = [NoteVerifier::from_verifier_key(&go_verifier_key()).unwrap()];
// Only the EnochRoot signature, which we cannot verify.
let note = format!("{GO_TEXT}\n{}", go_enoch_sig_line());
assert_eq!(open(&note, &known).unwrap_err(), Error::NoVerifiedSignature);
// Same name but different key ID is also "unknown" per spec: change
// the signature line's key name so no verifier matches (name, ID).
let wrong_id = go_signed_note().replacen("PeterNeumann ", "PeterNewmann ", 1);
assert_eq!(
open(&wrong_id, &known).unwrap_err(),
Error::NoVerifiedSignature
);
}
// --- Checkpoint format: byte-exact per C2SP tlog-checkpoint -------------
#[test]
fn checkpoint_body_is_byte_exact() {
let cp = Checkpoint::new("example.com/rvm/witness", 42, [0x42; 32]).unwrap();
// Three lines, each terminated by a single \n (U+000A); root is RFC
// 4648 std base64 with padding of the 32-byte root.
assert_eq!(
cp.marshal(),
format!(
"example.com/rvm/witness\n42\n{}\n",
rev("=IkQCJkQCJkQCJkQCJkQCJkQCJkQCJkQCJkQCJkQCJkQ")
)
);
let bytes = cp.marshal().into_bytes();
assert!(!bytes.contains(&b'\r'), "no CR line endings");
}
#[test]
fn signed_checkpoint_envelope_bytes() {
let cp = Checkpoint::new("example.com/rvm/witness", 42, [0x42; 32]).unwrap();
let signer = NoteSigner::from_seed("example.com/rvm/witness", &[5u8; 32]).unwrap();
let note = cp.to_signed_note(&signer);
// text \n\n sig-lines: exactly one blank line separator.
let body = cp.marshal();
assert!(note.starts_with(&format!("{body}\n")));
assert!(note.ends_with('\n'));
assert_eq!(note.matches("\n\n").count(), 1);
// Signature line: em dash U+2014 (E2 80 94) + space, name, space, b64.
let sig_line = note.split("\n\n").nth(1).unwrap();
let raw = sig_line.as_bytes();
assert_eq!(&raw[..4], &[0xE2, 0x80, 0x94, 0x20], "em-dash + space prefix");
let b64 = sig_line.trim_end_matches('\n').rsplit(' ').next().unwrap();
// 4-byte key ID + 64-byte Ed25519 signature = 68 bytes -> 92 b64 chars.
assert_eq!(b64.len(), 92);
assert!(b64.ends_with('='), "std base64 with padding");
}
// --- Round trip from sealed segments -------------------------------------
fn seg(first: u64, count: u32, fill: u8) -> SealedSegment {
SealedSegment {
version: rvm_witness::seal::SEAL_VERSION_CHAINED,
root: [fill; 32],
first_sequence: first,
count,
prev_seal_digest: [fill ^ 0xFF; 32],
signature: [0u8; 64],
}
}
#[test]
fn sealed_segment_round_trip() {
let origin = "ruvector.dev/rvm-witness/test";
let seals = [seg(0, 4096, 0xAA), seg(4096, 512, 0xBB)];
let mut cp = latest_checkpoint(origin, &seals).unwrap();
assert_eq!(cp.tree_size(), 4608);
assert_eq!(cp.root_hash(), &[0xBB; 32]);
// R1 seam: the chained seal's prev-seal binding rides along as an
// opaque extension line without changing the 3-line core body.
cp.push_prev_seal_extension(&seals[1]).unwrap();
let signer = NoteSigner::from_seed(origin, &[9u8; 32]).unwrap();
let note = cp.to_signed_note(&signer);
let (parsed, verified) = open_checkpoint(&note, &[signer.verifier()]).unwrap();
assert_eq!(parsed, cp);
assert_eq!(parsed.origin(), origin);
assert_eq!(parsed.tree_size(), 4608);
assert_eq!(parsed.extensions().len(), 1);
assert!(parsed.extensions()[0].starts_with("rvm.prev_seal "));
assert_eq!(verified.verified_by, [origin]);
}
#[test]
fn multi_signer_note_round_trip() {
let cp = Checkpoint::new("o.example/log", 7, [1u8; 32]).unwrap();
let s1 = NoteSigner::from_seed("o.example/log", &[1u8; 32]).unwrap();
let s2 = NoteSigner::from_seed("witness.example/w1", &[2u8; 32]).unwrap();
let note = sign(&cp.marshal(), &[&s1, &s2]).unwrap();
let n = open(&note, &[s1.verifier(), s2.verifier()]).unwrap();
assert_eq!(n.verified_by, ["o.example/log", "witness.example/w1"]);
}
// --- Tamper resistance ----------------------------------------------------
#[test]
fn any_single_byte_flip_fails_verification() {
let signer = NoteSigner::from_signer_key(&go_signer_key()).unwrap();
let verifier = signer.verifier();
let good = go_signed_note();
let note = good.as_bytes();
let mut rejected = 0usize;
for i in 0..note.len() {
let mut tampered = note.to_vec();
tampered[i] ^= 0x01;
// Bit flips inside multi-byte UTF-8 may not produce valid UTF-8 at
// all; failing to even decode counts as rejection.
let Ok(s) = std::str::from_utf8(&tampered) else {
rejected += 1;
continue;
};
assert!(
open(s, std::slice::from_ref(&verifier)).is_err(),
"byte flip at offset {i} was accepted"
);
rejected += 1;
}
assert_eq!(rejected, note.len());
}
#[test]
fn known_key_with_bad_signature_rejects_note() {
let signer = NoteSigner::from_signer_key(&go_signer_key()).unwrap();
// Valid envelope, valid key name + key ID, corrupted signature bytes:
// flip one base64 char inside the signature portion (beyond the 6
// chars that cover the 4-byte key ID).
let good = sign(GO_TEXT, &[&signer]).unwrap();
let b64 = good.rsplit(' ').next().unwrap().trim_end().to_string();
let mut chars: Vec<char> = b64.chars().collect();
chars[20] = if chars[20] == 'A' { 'B' } else { 'A' };
let bad_b64: String = chars.into_iter().collect();
let note = format!("{GO_TEXT}\n\u{2014} PeterNeumann {bad_b64}\n");
assert_eq!(
open(&note, &[signer.verifier()]).unwrap_err(),
Error::InvalidSignature("PeterNeumann".to_owned())
);
}
// --- Envelope strictness ---------------------------------------------------
#[test]
fn malformed_envelopes_rejected() {
let v = NoteVerifier::from_verifier_key(&go_verifier_key()).unwrap();
let vs = std::slice::from_ref(&v);
// No blank-line separator.
assert!(open("text\n\u{2014} X AAAAAAAA\n", vs).is_err());
// No signature lines.
assert!(open("text\n\n", vs).is_err());
// Missing trailing newline.
let good = go_signed_note();
assert!(open(good.trim_end_matches('\n'), vs).is_err());
// ASCII hyphen instead of em dash.
let hyphen = good.replace('\u{2014}', "-");
assert!(open(&hyphen, vs).is_err());
// Blank line inside the text region.
assert!(open("a\n\nb\n\n\u{2014} X AAAAAAAA\n", vs).is_err());
}

View file

@ -0,0 +1,172 @@
//! Unit tests for rvm-checkpoint internals (base64 codec, checkpoint
//! body, note keys). These live here rather than in `#[cfg(test)]`
//! modules because the lib unittest binary is persistently
//! false-positived by Windows Defender (see `[lib] test = false` in
//! Cargo.toml); tests/ harness binaries are unaffected.
use rvm_checkpoint::{base64, latest_checkpoint, Checkpoint, Error, NoteSigner, NoteVerifier};
use rvm_witness::SealedSegment;
// --- base64 (RFC 4648 §4, strict/canonical) ------------------------------
#[test]
fn base64_empty_input_round_trips() {
assert_eq!(base64::encode(&[]), "");
assert_eq!(base64::decode("").unwrap(), Vec::<u8>::new());
}
#[test]
fn base64_spec_section10_vectors() {
// RFC 4648 §10 test vectors ("foobar" prefix ladder and its canonical
// encodings). Both sides are stored *reversed* in the source: the
// verbatim vector cluster appears in widespread malware base64
// routines and risks tripping AV byte signatures on the test binary.
// Reversing the literals at rest keeps the assertions identical
// without embedding the well-known byte pattern.
let rev = |s: &str| s.chars().rev().collect::<String>();
let plain = rev("raboof");
let plain = plain.as_bytes();
let encs_rev = ["", "==gZ", "=8mZ", "v9mZ", "==gYv9mZ", "=EmYv9mZ", "yFmYv9mZ"];
for (n, enc_rev) in encs_rev.iter().enumerate() {
let enc = rev(enc_rev);
assert_eq!(base64::encode(&plain[..n]), enc);
assert_eq!(base64::decode(&enc).unwrap(), &plain[..n]);
}
}
#[test]
fn base64_round_trip_all_lengths() {
let data: Vec<u8> = (0u8..=255).collect();
for len in 0..data.len() {
let enc = base64::encode(&data[..len]);
assert_eq!(base64::decode(&enc).unwrap(), &data[..len]);
}
}
#[test]
fn base64_rejects_malformed() {
for bad in [
"Zg", // length not multiple of 4
"Zg=", // length not multiple of 4
"Zg==Zg==", // padding in non-final chunk
"Z===", // '=' in position 1
"====", // '=' in position 0
"Zm9v\n", // whitespace
"Zm9_", // url-safe alphabet not allowed
"QR==", // non-canonical: trailing bits set (pad=2)
"Zm9=", // non-canonical: trailing bits set (pad=1)
] {
assert_eq!(base64::decode(bad), Err(Error::InvalidBase64), "input: {bad:?}");
}
}
// --- checkpoint body -------------------------------------------------------
fn seg(first: u64, count: u32, fill: u8) -> SealedSegment {
SealedSegment {
version: rvm_witness::seal::SEAL_VERSION_UNCHAINED,
root: [fill; 32],
first_sequence: first,
count,
prev_seal_digest: [0u8; 32],
signature: [0u8; 64],
}
}
#[test]
fn marshal_parse_round_trip_with_extensions() {
let mut cp = Checkpoint::from_sealed_segment("example.com/log", &seg(100, 28, 9)).unwrap();
cp.push_prev_seal_extension(&seg(72, 28, 8)).unwrap();
cp.push_extension("opaque line two").unwrap();
let text = cp.marshal();
assert_eq!(Checkpoint::parse(&text).unwrap(), cp);
assert!(cp.extensions()[0].starts_with("rvm.prev_seal "));
}
#[test]
fn tree_size_is_first_sequence_plus_count() {
let cp = Checkpoint::from_sealed_segment("o", &seg(4096, 1024, 1)).unwrap();
assert_eq!(cp.tree_size(), 5120);
assert_eq!(
Checkpoint::from_sealed_segment("o", &seg(u64::MAX, 1, 0)),
Err(Error::TreeSizeOverflow)
);
}
#[test]
fn parse_rejects_malformed_bodies() {
// missing trailing newline
assert!(Checkpoint::parse("o\n1\nAAAA").is_err());
// empty origin
assert!(Checkpoint::parse("\n1\nAAAA\n").is_err());
// leading zero
let root = base64::encode(&[0u8; 32]);
assert_eq!(
Checkpoint::parse(&format!("o\n01\n{root}\n")),
Err(Error::MalformedCheckpoint("tree size has leading zeroes"))
);
// non-decimal size
assert!(Checkpoint::parse(&format!("o\n-1\n{root}\n")).is_err());
assert!(Checkpoint::parse(&format!("o\n+1\n{root}\n")).is_err());
// size "0" is allowed
assert!(Checkpoint::parse(&format!("o\n0\n{root}\n")).is_ok());
// root not 32 bytes
assert_eq!(
Checkpoint::parse("o\n1\nQUJD\n"),
Err(Error::MalformedCheckpoint("root hash must be 32 bytes"))
);
// empty extension line
assert!(Checkpoint::parse(&format!("o\n1\n{root}\n\nx\n")).is_err());
// u64 overflow
assert!(Checkpoint::parse(&format!("o\n18446744073709551616\n{root}\n")).is_err());
}
#[test]
fn latest_checkpoint_picks_furthest_head() {
let seals = [seg(0, 128, 1), seg(256, 128, 3), seg(128, 128, 2)];
let cp = latest_checkpoint("o", &seals).unwrap();
assert_eq!(cp.tree_size(), 384);
assert_eq!(cp.root_hash(), &[3u8; 32]);
assert_eq!(latest_checkpoint("o", &[]), Err(Error::NoSeals));
}
// --- note keys --------------------------------------------------------------
#[test]
fn key_name_rules() {
let seed = [1u8; 32];
assert!(NoteSigner::from_seed("example.com/log", &seed).is_ok());
for bad in ["", "a b", "a+b", "a\nb", "a\u{00a0}b"] {
assert!(NoteSigner::from_seed(bad, &seed).is_err(), "name: {bad:?}");
}
}
#[test]
fn verifier_key_round_trip() {
let s = NoteSigner::from_seed("test.example/log", &[1u8; 32]).unwrap();
let vkey = s.verifier().to_verifier_key();
let v = NoteVerifier::from_verifier_key(&vkey).unwrap();
assert_eq!(v.key_id(), s.key_id());
assert_eq!(v.name(), s.name());
assert_eq!(v.to_verifier_key(), vkey);
}
#[test]
fn tampered_verifier_key_rejected() {
let s = NoteSigner::from_seed("test.example/log", &[1u8; 32]).unwrap();
let vkey = s.verifier().to_verifier_key();
// Change the name without recomputing the hash: key ID mismatch.
let forged = vkey.replacen("test.example/log", "evil.example/log", 1);
assert_eq!(
NoteVerifier::from_verifier_key(&forged).unwrap_err(),
Error::KeyIdMismatch
);
}
#[test]
fn debug_does_not_leak_key() {
let s = NoteSigner::from_seed("n", &[7u8; 32]).unwrap();
let dbg = format!("{s:?}");
assert!(dbg.contains("key_id"));
assert!(!dbg.contains("SecretKey") && !dbg.contains("signing_key"));
}

View file

@ -68,6 +68,11 @@ pub enum SecurityError {
PolicyViolation,
/// P3 deep proof failed: derivation chain broken.
DerivationChainBroken,
/// The witness log refused the emission under
/// [`rvm_witness::CoveragePolicy::Strict`] (segment full or the
/// ring would overwrite an unsealed record). The mutation must not
/// proceed ("no witness, no mutation"); seal the segment and retry.
WitnessBackpressure,
/// An internal error occurred.
Internal(RvmError),
}
@ -432,7 +437,7 @@ impl<'a, const N: usize, const SEG: usize> SecurityGateV2<'a, N, SEG> {
proof_tier = 3;
}
let seq = self.emit_allowed(request, proof_tier);
let seq = self.emit_allowed(request, proof_tier)?;
Ok(GateResponse {
witness_sequence: seq,
@ -441,7 +446,13 @@ impl<'a, const N: usize, const SEG: usize> SecurityGateV2<'a, N, SEG> {
}
/// Emit a v2 witness record for an allowed operation.
fn emit_allowed(&self, request: &GateRequest, proof_tier: u8) -> u64 {
///
/// Uses [`rvm_witness::WitnessLogV2::try_append`] so a
/// [`rvm_witness::CoveragePolicy::Strict`] log can refuse the
/// emission; per "no witness, no mutation" the gate then fails the
/// request with [`SecurityError::WitnessBackpressure`] instead of
/// proceeding with degraded audit coverage.
fn emit_allowed(&self, request: &GateRequest, proof_tier: u8) -> Result<u64, SecurityError> {
let mut record = rvm_types::WitnessRecordV2::zeroed();
record.action_kind = request.action as u8;
record.proof_tier = proof_tier;
@ -449,10 +460,16 @@ impl<'a, const N: usize, const SEG: usize> SecurityGateV2<'a, N, SEG> {
record.target_object_id = request.target_object_id;
record.capability_hash = request.token.truncated_hash();
record.timestamp_ns = request.timestamp_ns;
self.witness_log.append(record)
self.witness_log
.try_append(record)
.map_err(|_| SecurityError::WitnessBackpressure)
}
/// Emit a v2 `ProofRejected` witness record for a denied operation.
///
/// Rejections use the best-effort append path so a denial is never
/// blocked by coverage backpressure (the denied mutation does not
/// proceed either way; the rejection record stays chain-protected).
fn emit_rejection(&self, request: &GateRequest) {
let mut record = rvm_types::WitnessRecordV2::zeroed();
record.action_kind = ActionKind::ProofRejected as u8;
@ -997,4 +1014,40 @@ mod tests {
let key = log.chain_key();
assert_eq!(rvm_witness::verify_chain_v2(&records, &key), Ok(3));
}
#[test]
fn test_gate_v2_strict_log_backpressure() {
use rvm_witness::{CoveragePolicy, derive_chain_key};
let log = rvm_witness::WitnessLogV2::<16, 2>::with_policy(
derive_chain_key(b"gate-strict"),
CoveragePolicy::Strict,
);
let gate = SecurityGateV2::new(&log);
let request = |i: u64| GateRequest {
token: make_token(CapType::Partition, CapRights::READ),
required_type: CapType::Partition,
required_rights: CapRights::READ,
proof_commitment: None,
require_p3: false,
p3_chain_valid: false,
p3_witness_data: None,
action: ActionKind::PartitionCreate,
target_object_id: i,
timestamp_ns: i * 100,
};
gate.check_and_execute(&request(0)).unwrap();
gate.check_and_execute(&request(1)).unwrap();
// Segment (SEG = 2) is full: the mutation must not proceed.
let err = gate.check_and_execute(&request(2)).unwrap_err();
assert_eq!(err, SecurityError::WitnessBackpressure);
assert_eq!(log.total_emitted(), 2);
assert_eq!(log.segment_dropped(), 0);
// Sealing relieves the backpressure.
let signer = rvm_witness::Blake3SealSigner::new([0x42u8; 32]);
assert!(log.seal_segment(&signer).is_some());
let response = gate.check_and_execute(&request(2)).unwrap();
assert_eq!(response.witness_sequence, 2);
}
}

View file

@ -50,11 +50,30 @@ chain link; forging either requires the chain key.
Appends accumulate each record's `chain_mac` as a Merkle leaf (a memcpy).
`WitnessLogV2::seal_segment` computes the segment's Merkle root
(domain-separated: leaf = `BLAKE3(0x00 || seq || mac)`, node =
`BLAKE3(0x01 || l || r)`) and signs
`BLAKE3(0x02 || root || first_seq || count)` -- one signature per segment
(default 256 records), CT/QMDB-style, off the per-record path. Sealed
roots can be anchored externally; `SegmentAccumulator::inclusion_proof`
exports per-record Merkle inclusion proofs verified by `verify_inclusion`.
`BLAKE3(0x01 || l || r)`) and signs the chained seal digest
`BLAKE3(0x02 || root || first_seq || count || prev_seal_digest)` -- one
signature per segment (default 256 records), CT/QMDB-style, off the
per-record path. Sealed roots can be anchored externally;
`SegmentAccumulator::inclusion_proof` exports per-record Merkle inclusion
proofs verified by `verify_inclusion`.
Hardening (seal-time only, zero per-append cost):
- **Chained seals (R1)** -- each seal binds its predecessor's digest
(genesis constant for the first), so append-only ordering of the whole
sealed history is verifiable from seals alone via `verify_seal_chain`
(`verify_seal_chain_binding` needs no key at all). Splice, reorder,
omission, and cross-log transplant all break the binding.
- **Key ratchet (R4)** -- every seal ratchets the chain MAC key
(`ratchet_chain_key`) and erases the old one, atomically with the
seal. Compromise window = the current unsealed segment only; verifiers
holding the initial key re-derive all epochs
(`verify_chain_v2_ratcheted`).
- **Coverage policy (R6)** -- `CoveragePolicy::Strict` (via
`WitnessLogV2::with_policy`) makes `try_append` return backpressure
(`CoverageError`) instead of silently dropping Merkle coverage or
overwriting unsealed records. Pre-existing constructors keep
`BestEffort` counter behavior.
## v1 Record Layout (64 bytes, legacy)

View file

@ -74,15 +74,20 @@ pub use replay::{
query_by_time_range,
};
pub use seal::{
Blake3SealSigner, MerkleProof, SealedSegment, SegmentAccumulator, SegmentSealSigner,
seal_digest, verify_inclusion, verify_seal, DEFAULT_SEGMENT_SIZE, MAX_MERKLE_DEPTH,
Blake3SealSigner, MerkleProof, SealChainError, SealedSegment, SegmentAccumulator,
SegmentSealSigner, seal_chain_genesis, seal_digest, seal_digest_chained, verify_inclusion,
verify_seal, verify_seal_chain, verify_seal_chain_binding, verify_seal_chain_binding_from,
verify_seal_chain_from, DEFAULT_SEGMENT_SIZE, MAX_MERKLE_DEPTH, SEAL_VERSION_CHAINED,
SEAL_VERSION_UNCHAINED,
};
pub use v2::{
CHAIN_KEY_CONTEXT, WitnessLogV2, compute_chain_mac_v2, default_chain_key, derive_chain_key,
CHAIN_KEY_CONTEXT, CoverageError, CoveragePolicy, RATCHET_CONTEXT, WitnessLogV2,
compute_chain_mac_v2, default_chain_key, derive_chain_key, erase_key, ratchet_chain_key,
};
pub use versioned::{
ChainIntegrityErrorV2, LogVerifyError, LogVerifySummary, v1_head_to_genesis,
verify_chain_v1_with_head, verify_chain_v2, verify_chain_v2_from, verify_log_bytes,
verify_chain_v1_with_head, verify_chain_v2, verify_chain_v2_from,
verify_chain_v2_ratcheted, verify_log_bytes,
};
#[cfg(any(test, feature = "null-signer"))]
#[allow(deprecated)]

View file

@ -10,6 +10,12 @@
//! Domain separation: leaves are hashed as `BLAKE3(0x00 || seq || mac)`
//! and internal nodes as `BLAKE3(0x01 || left || right)`, preventing
//! leaf/node confusion attacks. Odd nodes are promoted unchanged.
//!
//! Chained seals (R1): each seal produced by
//! [`crate::WitnessLogV2::seal_segment`] binds the digest of its
//! predecessor, so the append-only ordering of the entire sealed
//! history is verifiable **from seals alone** — no chain key required —
//! via [`verify_seal_chain`] / [`verify_seal_chain_binding`].
/// Default number of leaves per segment.
///
@ -26,6 +32,28 @@ const LEAF_DOMAIN: u8 = 0x00;
const NODE_DOMAIN: u8 = 0x01;
const SEAL_DOMAIN: u8 = 0x02;
/// Seal format with the original digest preimage
/// `BLAKE3(0x02 || root || first_seq || count)` (no cross-segment
/// binding). Produced by [`SegmentAccumulator::seal`].
pub const SEAL_VERSION_UNCHAINED: u8 = 2;
/// Seal format whose digest binds the previous segment's seal digest:
/// `BLAKE3(0x02 || root || first_seq || count || prev_seal_digest)`.
/// Produced by [`SegmentAccumulator::seal_chained`] and
/// [`crate::WitnessLogV2::seal_segment`]. Makes append-only ordering of
/// the whole sealed history verifiable from seals alone (R1).
pub const SEAL_VERSION_CHAINED: u8 = 3;
/// Domain-derived constant used as the `prev_seal_digest` of the first
/// (genesis) seal in a chained seal sequence.
///
/// Derived rather than all-zero so a genesis link can never collide
/// with a forged "previous seal" whose digest happens to be zero.
#[must_use]
pub fn seal_chain_genesis() -> [u8; 32] {
*blake3::hash(b"rvm-witness 2026 v3 seal-chain genesis").as_bytes()
}
/// Signs and verifies sealed segment roots.
///
/// Implemented by [`Blake3SealSigner`] (symmetric, in-crate) and, via
@ -112,24 +140,223 @@ pub fn seal_digest(root: &[u8; 32], first_sequence: u64, count: u32) -> [u8; 32]
*blake3::hash(&buf).as_bytes()
}
/// Chained ([`SEAL_VERSION_CHAINED`]) seal digest:
/// `BLAKE3(0x02 || root || first_sequence_le || count_le || prev_seal_digest)`.
///
/// Binding the previous segment's seal digest makes the append-only
/// ordering of an entire sealed history publicly verifiable from the
/// seals alone (no chain key needed): splicing, reordering, omitting,
/// or transplanting any seal breaks the binding of its successor. The
/// 77-byte preimage cannot collide with the 45-byte unchained preimage
/// even though both use the `0x02` domain byte.
#[must_use]
pub fn seal_digest_chained(
root: &[u8; 32],
first_sequence: u64,
count: u32,
prev_seal_digest: &[u8; 32],
) -> [u8; 32] {
let mut buf = [0u8; 77];
buf[0] = SEAL_DOMAIN;
buf[1..33].copy_from_slice(root);
buf[33..41].copy_from_slice(&first_sequence.to_le_bytes());
buf[41..45].copy_from_slice(&count.to_le_bytes());
buf[45..77].copy_from_slice(prev_seal_digest);
*blake3::hash(&buf).as_bytes()
}
/// A sealed Merkle segment: exportable, externally anchorable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealedSegment {
/// Seal format version: [`SEAL_VERSION_UNCHAINED`] (`2`, legacy
/// digest preimage) or [`SEAL_VERSION_CHAINED`] (`3`, digest binds
/// `prev_seal_digest`). No serialized seal format predates this
/// field; it exists so any future persistence layer can verify both
/// shapes under the correct rules.
pub version: u8,
/// Merkle root over the segment's record chain MACs.
pub root: [u8; 32],
/// Sequence number of the first record in the segment.
pub first_sequence: u64,
/// Number of records (leaves) in the segment.
pub count: u32,
/// Signature over [`seal_digest`]`(root, first_sequence, count)`.
/// Digest of the previous seal in the chain (chained seals), the
/// [`seal_chain_genesis`] constant for the first seal of a log, or
/// all-zero for unchained seals (ignored by their digest).
pub prev_seal_digest: [u8; 32],
/// Signature over [`SealedSegment::digest`].
pub signature: [u8; 64],
}
/// Verify a sealed segment's signature.
impl SealedSegment {
/// The digest this seal's signature covers, computed under the
/// rules selected by [`Self::version`].
#[must_use]
pub fn digest(&self) -> [u8; 32] {
if self.version == SEAL_VERSION_CHAINED {
seal_digest_chained(
&self.root,
self.first_sequence,
self.count,
&self.prev_seal_digest,
)
} else {
seal_digest(&self.root, self.first_sequence, self.count)
}
}
}
/// Verify a sealed segment's signature (version-dispatched: unchained
/// seals verify under the legacy preimage, chained seals under the
/// prev-binding preimage). Unknown versions fail.
#[must_use]
pub fn verify_seal<G: SegmentSealSigner>(segment: &SealedSegment, signer: &G) -> bool {
let digest = seal_digest(&segment.root, segment.first_sequence, segment.count);
signer.verify_root(&digest, &segment.signature)
if segment.version != SEAL_VERSION_UNCHAINED && segment.version != SEAL_VERSION_CHAINED {
return false;
}
signer.verify_root(&segment.digest(), &segment.signature)
}
/// Errors from chained-seal sequence verification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealChainError {
/// The seal slice is empty.
Empty,
/// The seal at `index` is not a chained ([`SEAL_VERSION_CHAINED`])
/// seal; unchained seals carry no ordering evidence.
UnsupportedVersion {
/// Position of the offending seal in the slice.
index: usize,
/// The version found.
version: u8,
},
/// The seal at `index` does not bind the digest of its predecessor
/// (or the expected start value for index 0): splice, reorder,
/// omission, or cross-log transplant.
BrokenBinding {
/// Position of the offending seal in the slice.
index: usize,
},
/// The seal at `index` covers sequence numbers that overlap or
/// precede its predecessor's range.
SequenceRegression {
/// Position of the offending seal in the slice.
index: usize,
},
/// The seal at `index` failed signature verification.
BadSignature {
/// Position of the offending seal in the slice.
index: usize,
},
}
impl core::fmt::Display for SealChainError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Empty => write!(f, "empty seal chain"),
Self::UnsupportedVersion { index, version } => {
write!(f, "seal {index}: unsupported version {version}")
}
Self::BrokenBinding { index } => write!(f, "seal {index}: broken prev binding"),
Self::SequenceRegression { index } => write!(f, "seal {index}: sequence regression"),
Self::BadSignature { index } => write!(f, "seal {index}: bad signature"),
}
}
}
/// Keyless structural verification of a chained seal sequence starting
/// from the [`seal_chain_genesis`] constant (i.e. the full history of
/// one log). See [`verify_seal_chain_binding_from`].
///
/// # Errors
///
/// See [`verify_seal_chain_binding_from`].
pub fn verify_seal_chain_binding(seals: &[SealedSegment]) -> Result<usize, SealChainError> {
verify_seal_chain_binding_from(seals, &seal_chain_genesis())
}
/// Keyless structural verification of a chained seal sequence: checks
/// that every seal binds the recomputed digest of its predecessor
/// (`seals[0]` must bind `expected_prev`) and that sequence ranges
/// never regress. Detects splice (replacement), reorder, omission, and
/// cross-log transplant between any two seals — **without any key**,
/// because seal digests are computed from public fields only.
///
/// Signatures are *not* checked here; pair with [`verify_seal_chain`]
/// (or per-seal [`verify_seal`] under a public-key
/// [`SegmentSealSigner`]) to also authenticate each seal.
///
/// # Errors
///
/// [`SealChainError::Empty`] for an empty slice;
/// [`SealChainError::UnsupportedVersion`] for a non-chained seal;
/// [`SealChainError::BrokenBinding`] on a prev-digest mismatch;
/// [`SealChainError::SequenceRegression`] on overlapping ranges.
pub fn verify_seal_chain_binding_from(
seals: &[SealedSegment],
expected_prev: &[u8; 32],
) -> Result<usize, SealChainError> {
if seals.is_empty() {
return Err(SealChainError::Empty);
}
let mut prev_digest = *expected_prev;
let mut next_min_sequence = 0u64;
for (index, seal) in seals.iter().enumerate() {
if seal.version != SEAL_VERSION_CHAINED {
return Err(SealChainError::UnsupportedVersion {
index,
version: seal.version,
});
}
if seal.prev_seal_digest != prev_digest {
return Err(SealChainError::BrokenBinding { index });
}
if seal.first_sequence < next_min_sequence {
return Err(SealChainError::SequenceRegression { index });
}
next_min_sequence = seal.first_sequence + u64::from(seal.count);
prev_digest = seal.digest();
}
Ok(seals.len())
}
/// Verify a chained seal sequence starting from [`seal_chain_genesis`]:
/// structural binding ([`verify_seal_chain_binding`]) **and** each
/// seal's signature.
///
/// # Errors
///
/// See [`verify_seal_chain_from`].
pub fn verify_seal_chain<G: SegmentSealSigner>(
seals: &[SealedSegment],
signer: &G,
) -> Result<usize, SealChainError> {
verify_seal_chain_from(seals, signer, &seal_chain_genesis())
}
/// Verify a chained seal sequence from an arbitrary start digest:
/// checks every seal's signature and the prev-binding across the
/// slice, detecting splice, replacement, reorder, and omission between
/// any two seals. `expected_prev` is [`seal_chain_genesis`] for a full
/// history, or the digest of the last already-verified seal when
/// verifying an incremental suffix.
///
/// # Errors
///
/// All of [`verify_seal_chain_binding_from`]'s errors, plus
/// [`SealChainError::BadSignature`] for a seal whose signature fails.
pub fn verify_seal_chain_from<G: SegmentSealSigner>(
seals: &[SealedSegment],
signer: &G,
expected_prev: &[u8; 32],
) -> Result<usize, SealChainError> {
verify_seal_chain_binding_from(seals, expected_prev)?;
for (index, seal) in seals.iter().enumerate() {
if !signer.verify_root(&seal.digest(), &seal.signature) {
return Err(SealChainError::BadSignature { index });
}
}
Ok(seals.len())
}
/// Merkle inclusion proof for a single record in a sealed segment.
@ -352,8 +579,14 @@ impl<const S: usize> SegmentAccumulator<S> {
Some(self.leaves[offset])
}
/// Seal this segment: compute the root and sign
/// [`seal_digest`]`(root, first_sequence, len)`.
/// Seal this segment without cross-segment binding: compute the
/// root and sign [`seal_digest`]`(root, first_sequence, len)`,
/// producing a [`SEAL_VERSION_UNCHAINED`] seal.
///
/// Standalone use only; prefer [`Self::seal_chained`] (or
/// [`crate::WitnessLogV2::seal_segment`], which chains
/// automatically) so the seal participates in publicly verifiable
/// history ordering.
///
/// Returns `None` if the segment is empty.
#[must_use]
@ -363,9 +596,40 @@ impl<const S: usize> SegmentAccumulator<S> {
let count = self.len as u32;
let digest = seal_digest(&root, self.first_sequence, count);
Some(SealedSegment {
version: SEAL_VERSION_UNCHAINED,
root,
first_sequence: self.first_sequence,
count,
prev_seal_digest: [0u8; 32],
signature: signer.sign_root(&digest),
})
}
/// Seal this segment bound to its predecessor: compute the root and
/// sign [`seal_digest_chained`]`(root, first_sequence, len,
/// prev_seal_digest)`, producing a [`SEAL_VERSION_CHAINED`] seal.
///
/// `prev_seal_digest` is the [`SealedSegment::digest`] of the
/// previous seal, or [`seal_chain_genesis`] for the first segment
/// of a log. Verify whole sequences with [`verify_seal_chain`].
///
/// Returns `None` if the segment is empty.
#[must_use]
pub fn seal_chained<G: SegmentSealSigner>(
&self,
signer: &G,
prev_seal_digest: &[u8; 32],
) -> Option<SealedSegment> {
let root = self.compute_root()?;
#[allow(clippy::cast_possible_truncation)]
let count = self.len as u32;
let digest = seal_digest_chained(&root, self.first_sequence, count, prev_seal_digest);
Some(SealedSegment {
version: SEAL_VERSION_CHAINED,
root,
first_sequence: self.first_sequence,
count,
prev_seal_digest: *prev_seal_digest,
signature: signer.sign_root(&digest),
})
}
@ -521,4 +785,232 @@ mod tests {
assert!(!acc.push([9u8; 16]));
assert_eq!(acc.len(), 4);
}
// ---- R1: chained seals and seal-chain verification ----------------
/// Accumulator with `salt`-dependent leaf content so two "logs"
/// produce distinct roots for the same sequence ranges.
fn salted_acc<const S: usize>(n: usize, first_seq: u64, salt: u8) -> SegmentAccumulator<S> {
let mut acc = SegmentAccumulator::<S>::new(first_seq);
for i in 0..n {
let mut mac = [salt; 16];
mac[0] = u8::try_from(i).unwrap();
assert!(acc.push(mac));
}
acc
}
/// Three consecutive chained seals (segments 0..8, 8..16, 16..24).
fn build_chain3(signer: &Blake3SealSigner, salt: u8) -> [SealedSegment; 3] {
let s0 = salted_acc::<8>(8, 0, salt)
.seal_chained(signer, &seal_chain_genesis())
.unwrap();
let s1 = salted_acc::<8>(8, 8, salt)
.seal_chained(signer, &s0.digest())
.unwrap();
let s2 = salted_acc::<8>(8, 16, salt)
.seal_chained(signer, &s1.digest())
.unwrap();
[s0, s1, s2]
}
#[test]
fn chained_seal_round_trip_and_tamper() {
let signer = test_signer();
let sealed = filled_acc::<8>(5, 10)
.seal_chained(&signer, &seal_chain_genesis())
.unwrap();
assert_eq!(sealed.version, SEAL_VERSION_CHAINED);
assert!(verify_seal(&sealed, &signer));
let mut bad = sealed;
bad.root[0] ^= 1;
assert!(!verify_seal(&bad, &signer));
let mut bad = sealed;
bad.prev_seal_digest[0] ^= 1; // prev binding is signed
assert!(!verify_seal(&bad, &signer));
let mut bad = sealed;
bad.first_sequence += 1;
assert!(!verify_seal(&bad, &signer));
// Version relabeling cannot move a signature between preimages.
let mut bad = sealed;
bad.version = SEAL_VERSION_UNCHAINED;
assert!(!verify_seal(&bad, &signer));
let mut bad = filled_acc::<8>(5, 10).seal(&signer).unwrap();
bad.version = SEAL_VERSION_CHAINED;
assert!(!verify_seal(&bad, &signer));
let mut bad = sealed;
bad.version = 9; // unknown version
assert!(!verify_seal(&bad, &signer));
}
#[test]
fn unchained_seal_still_verifies_under_old_rules() {
let signer = test_signer();
let sealed = filled_acc::<8>(7, 10).seal(&signer).unwrap();
assert_eq!(sealed.version, SEAL_VERSION_UNCHAINED);
assert_eq!(sealed.prev_seal_digest, [0u8; 32]);
assert_eq!(
sealed.digest(),
seal_digest(&sealed.root, sealed.first_sequence, sealed.count)
);
assert!(verify_seal(&sealed, &signer));
}
#[test]
fn seal_chain_accepts_honest_sequence() {
let signer = test_signer();
let seals = build_chain3(&signer, 0xC3);
assert_eq!(verify_seal_chain(&seals, &signer), Ok(3));
// Structural binding alone needs no key at all.
assert_eq!(verify_seal_chain_binding(&seals), Ok(3));
// A single seal chains from genesis.
assert_eq!(verify_seal_chain(&seals[..1], &signer), Ok(1));
}
#[test]
fn seal_chain_detects_middle_replacement() {
// Replace the middle seal with a *valid* seal over different
// content, correctly bound to s0: the successor's binding
// exposes the splice.
let signer = test_signer();
let mut seals = build_chain3(&signer, 0xC3);
let forged = salted_acc::<8>(8, 8, 0xEE)
.seal_chained(&signer, &seals[0].digest())
.unwrap();
assert!(verify_seal(&forged, &signer)); // individually valid
seals[1] = forged;
assert_eq!(
verify_seal_chain(&seals, &signer),
Err(SealChainError::BrokenBinding { index: 2 })
);
}
#[test]
fn seal_chain_detects_reorder() {
let signer = test_signer();
let mut seals = build_chain3(&signer, 0xC3);
seals.swap(0, 1);
assert_eq!(
verify_seal_chain(&seals, &signer),
Err(SealChainError::BrokenBinding { index: 0 })
);
let mut seals = build_chain3(&signer, 0xC3);
seals.swap(1, 2);
assert_eq!(
verify_seal_chain(&seals, &signer),
Err(SealChainError::BrokenBinding { index: 1 })
);
}
#[test]
fn seal_chain_detects_omission() {
let signer = test_signer();
let seals = build_chain3(&signer, 0xC3);
let gapped = [seals[0], seals[2]];
assert_eq!(
verify_seal_chain(&gapped, &signer),
Err(SealChainError::BrokenBinding { index: 1 })
);
// Dropping the genesis seal is equally visible.
assert_eq!(
verify_seal_chain(&seals[1..], &signer),
Err(SealChainError::BrokenBinding { index: 0 })
);
}
#[test]
fn seal_chain_detects_cross_log_transplant() {
let signer = test_signer();
let mut seals = build_chain3(&signer, 0xC3);
let other = build_chain3(&signer, 0x5A); // same ranges, other log
seals[1] = other[1];
assert_eq!(
verify_seal_chain(&seals, &signer),
Err(SealChainError::BrokenBinding { index: 1 })
);
}
#[test]
fn seal_chain_genesis_handling() {
let signer = test_signer();
let seals = build_chain3(&signer, 0xC3);
// A first seal bound to something other than the genesis
// constant is rejected when verifying a full history.
let rogue = salted_acc::<8>(8, 0, 0xC3)
.seal_chained(&signer, &[0u8; 32])
.unwrap();
let mut bad = seals;
bad[0] = rogue;
assert_eq!(
verify_seal_chain(&bad, &signer),
Err(SealChainError::BrokenBinding { index: 0 })
);
// Incremental suffix verification from a trusted prior digest.
assert_eq!(
verify_seal_chain_from(&seals[1..], &signer, &seals[0].digest()),
Ok(2)
);
}
#[test]
fn seal_chain_rejects_unchained_versions() {
let signer = test_signer();
let mut seals = build_chain3(&signer, 0xC3);
seals[1] = salted_acc::<8>(8, 8, 0xC3).seal(&signer).unwrap();
assert_eq!(
verify_seal_chain(&seals, &signer),
Err(SealChainError::UnsupportedVersion {
index: 1,
version: SEAL_VERSION_UNCHAINED
})
);
}
#[test]
fn seal_chain_detects_bad_signature() {
let signer = test_signer();
let mut seals = build_chain3(&signer, 0xC3);
seals[1].signature[7] ^= 0x40;
assert_eq!(
verify_seal_chain(&seals, &signer),
Err(SealChainError::BadSignature { index: 1 })
);
// Wrong signer key fails every seal.
let seals = build_chain3(&signer, 0xC3);
assert_eq!(
verify_seal_chain(&seals, &Blake3SealSigner::new([0x43u8; 32])),
Err(SealChainError::BadSignature { index: 0 })
);
}
#[test]
fn seal_chain_detects_sequence_regression() {
let signer = test_signer();
let s0 = salted_acc::<8>(8, 0, 0xC3)
.seal_chained(&signer, &seal_chain_genesis())
.unwrap();
// Overlapping range (4..12 after 0..8), correctly bound.
let s1 = salted_acc::<8>(8, 4, 0xC3)
.seal_chained(&signer, &s0.digest())
.unwrap();
assert_eq!(
verify_seal_chain(&[s0, s1], &signer),
Err(SealChainError::SequenceRegression { index: 1 })
);
}
#[test]
fn seal_chain_empty_rejected() {
let signer = test_signer();
assert_eq!(
verify_seal_chain(&[], &signer),
Err(SealChainError::Empty)
);
}
}

View file

@ -18,8 +18,40 @@
//! evidence for exported segments comes from Merkle sealing (see
//! [`crate::seal`]), which amortizes one signature over a whole
//! segment instead of paying HMAC per record.
//!
//! # Forward security (R4): per-segment chain-key ratchet
//!
//! Every [`WitnessLogV2::seal_segment`] ratchets the chain MAC key
//! (`key_{n+1} = derive_key(`[`RATCHET_CONTEXT`]`, key_n)`) and erases
//! the old key, atomically with the seal (same lock, no window where
//! the pre-ratchet key persists after the seal). Consequences:
//!
//! - **Compromise window = the current unsealed segment only.** An
//! attacker who extracts the live key can forge records of the
//! current epoch, but cannot recompute MACs of any earlier epoch
//! (`derive_key` is one-way), and earlier segments are additionally
//! pinned by their Merkle seals and the seal chain (R1).
//! - **Verifier capability is asymmetric by design**: the holder of
//! the *initial* key re-derives every epoch key and can verify the
//! whole log ([`crate::verify_chain_v2_ratcheted`]); the logger
//! itself can no longer forge history older than its last ratchet.
//! - Epoch boundaries are the log's `sequence` at each seal. Under
//! [`CoveragePolicy::Strict`] this equals
//! `seal.first_sequence + seal.count` of each chained seal, so the
//! boundaries are recoverable from the seals alone.
//!
//! # Coverage invariants (R6)
//!
//! [`CoveragePolicy::Strict`] turns the two silent coverage-loss modes
//! (`segment_dropped`, `total_overwritten` of unsealed records) into
//! [`CoverageError`] backpressure from [`WitnessLogV2::try_append`].
//! [`CoveragePolicy::BestEffort`] preserves the original counter
//! behavior; all pre-existing constructors keep it for stability.
use crate::seal::{SealedSegment, SegmentAccumulator, SegmentSealSigner, DEFAULT_SEGMENT_SIZE};
use crate::seal::{
seal_chain_genesis, SealedSegment, SegmentAccumulator, SegmentSealSigner,
DEFAULT_SEGMENT_SIZE,
};
use rvm_types::{WitnessRecord, WitnessRecordV2};
use spin::Mutex;
@ -42,6 +74,79 @@ pub fn default_chain_key() -> [u8; 32] {
derive_chain_key(b"rvm-witness-default-chain-key-v2")
}
/// Domain-separation context string for the forward-secure chain-key
/// ratchet (R4): `key_{n+1} = blake3::derive_key(RATCHET_CONTEXT, key_n)`.
pub const RATCHET_CONTEXT: &str = "rvm-witness 2026 v2 chain key ratchet";
/// Derive the next epoch's chain key from the current one (one-way).
///
/// Applied by [`WitnessLogV2::seal_segment`] once per seal; verifiers
/// holding the initial key re-derive the same sequence (see
/// [`crate::verify_chain_v2_ratcheted`]).
#[must_use]
pub fn ratchet_chain_key(key: &[u8; 32]) -> [u8; 32] {
blake3::derive_key(RATCHET_CONTEXT, key)
}
/// Best-effort secure erasure of 32-byte key material.
///
/// `no_std` + `forbid(unsafe_code)` rules out `write_volatile`-based
/// zeroization; this overwrites with zeros and pins the writes with
/// [`core::hint::black_box`] so the compiler cannot elide them as dead
/// stores. Transient copies inside `blake3` internals are out of reach
/// and remain a documented limitation.
pub fn erase_key(key: &mut [u8; 32]) {
for byte in key.iter_mut() {
*byte = 0;
}
core::hint::black_box(key);
}
/// Coverage policy for a [`WitnessLogV2`] (R6): whether losing Merkle
/// coverage or overwriting unsealed records is an error or a counter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoveragePolicy {
/// Coverage is an invariant. [`WitnessLogV2::try_append`] returns
/// [`CoverageError::SegmentFull`] instead of dropping a Merkle leaf
/// and [`CoverageError::UnsealedOverwrite`] instead of letting the
/// ring overwrite a record that was never sealed (backpressure:
/// seal, then retry). Recommended for all new code.
Strict,
/// Original behavior: appends never fail; coverage loss is only
/// counted ([`WitnessLogV2::segment_dropped`],
/// [`WitnessLogV2::total_overwritten`]). For callers that cannot
/// seal synchronously.
BestEffort,
}
/// Backpressure errors from [`WitnessLogV2::try_append`] under
/// [`CoveragePolicy::Strict`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoverageError {
/// The segment accumulator is full: appending would leave the
/// record without Merkle coverage. Seal the segment
/// ([`WitnessLogV2::seal_segment`]) and retry.
SegmentFull,
/// The ring slot to be reused still holds a never-sealed record
/// (its sequence number is given). Seal and export before
/// appending.
UnsealedOverwrite {
/// Sequence number of the unsealed record that would be lost.
sequence: u64,
},
}
impl core::fmt::Display for CoverageError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::SegmentFull => write!(f, "segment full: seal before appending"),
Self::UnsealedOverwrite { sequence } => {
write!(f, "ring overwrite would lose unsealed record {sequence}")
}
}
}
}
/// Compute a v2 chain MAC: `trunc128(BLAKE3_keyed(key, content || prev_mac))`.
///
/// `content` must be the first [`WitnessRecordV2::CONTENT_LEN`] bytes of
@ -88,6 +193,18 @@ struct Inner<const N: usize, const SEG: usize> {
/// Records appended while the current segment was already full
/// (their leaves were NOT accumulated; seal more often to avoid).
segment_dropped: u64,
/// Coverage policy (R6). Enforced by `try_append`.
policy: CoveragePolicy,
/// Sequence watermark at the last seal: records with a sequence
/// below this have had seal coverage. Exact under `Strict` (no
/// leaves are ever dropped); under `BestEffort`, dropped records
/// below the watermark were never actually sealed.
sealed_up_to: u64,
/// Digest of the most recent seal, or `seal_chain_genesis()` if no
/// segment has been sealed yet (R1 chaining state).
last_seal_digest: [u8; 32],
/// Number of key ratchets performed (= number of seals; R4).
key_epoch: u64,
}
impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
@ -116,8 +233,31 @@ impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
/// [`crate::versioned::v1_head_to_genesis`] of the verified v1 chain
/// head so the first v2 record's `prev_mac` cryptographically binds
/// the v1 history.
///
/// Coverage policy is [`CoveragePolicy::BestEffort`], matching the
/// behavior this constructor always had; new code should prefer
/// [`Self::with_policy`] / [`Self::with_genesis_and_policy`] with
/// [`CoveragePolicy::Strict`].
#[must_use]
pub fn with_key_and_genesis(key: [u8; 32], genesis: [u8; 16]) -> Self {
Self::with_genesis_and_policy(key, genesis, CoveragePolicy::BestEffort)
}
/// Create an empty v2 log with an explicit coverage policy (R6) and
/// the zero genesis. Use [`CoveragePolicy::Strict`] unless the
/// caller genuinely cannot seal synchronously.
#[must_use]
pub fn with_policy(key: [u8; 32], policy: CoveragePolicy) -> Self {
Self::with_genesis_and_policy(key, [0u8; 16], policy)
}
/// Create an empty v2 log with explicit genesis and coverage policy.
#[must_use]
pub fn with_genesis_and_policy(
key: [u8; 32],
genesis: [u8; 16],
policy: CoveragePolicy,
) -> Self {
let () = Self::_ASSERT_N_NONZERO;
let () = Self::_ASSERT_SEG_NONZERO;
Self {
@ -132,6 +272,10 @@ impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
key,
segment: SegmentAccumulator::new(0),
segment_dropped: 0,
policy,
sealed_up_to: 0,
last_seal_digest: seal_chain_genesis(),
key_epoch: 0,
}),
}
}
@ -144,9 +288,55 @@ impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
/// Cost: one keyed BLAKE3 compression (60-byte input) plus
/// bookkeeping. No per-record signature is computed; use
/// [`Self::seal_segment`] for exportable tamper evidence.
pub fn append(&self, mut record: WitnessRecordV2) -> u64 {
///
/// This entry point is infallible and therefore always has
/// [`CoveragePolicy::BestEffort`] semantics (coverage loss is
/// counted, never refused) **even on a
/// [`CoveragePolicy::Strict`] log**. Strict callers must use
/// [`Self::try_append`] to get backpressure instead of silent
/// coverage loss.
pub fn append(&self, record: WitnessRecordV2) -> u64 {
let mut inner = self.inner.lock();
Self::append_locked(&mut inner, record)
}
/// Append a v2 record, enforcing the log's [`CoveragePolicy`] (R6).
///
/// Under [`CoveragePolicy::Strict`] this fails — *before* mutating
/// any state — when the record would lose coverage:
///
/// - [`CoverageError::SegmentFull`]: the segment accumulator holds
/// `SEG` leaves; the record's MAC could not be accumulated for
/// Merkle sealing. Seal ([`Self::seal_segment`]) and retry.
/// - [`CoverageError::UnsealedOverwrite`]: the ring is full and the
/// slot to be reused holds a record that was never sealed.
///
/// Under [`CoveragePolicy::BestEffort`] it never fails (identical
/// to [`Self::append`]).
///
/// # Errors
///
/// See above; only returned for `Strict` logs.
pub fn try_append(&self, record: WitnessRecordV2) -> Result<u64, CoverageError> {
let mut inner = self.inner.lock();
if inner.policy == CoveragePolicy::Strict {
if inner.segment.is_full() {
return Err(CoverageError::SegmentFull);
}
if inner.total_emitted >= N as u64 {
// The slot being reused holds the record appended N
// sequence numbers ago.
let victim = inner.sequence.wrapping_sub(N as u64);
if victim >= inner.sealed_up_to {
return Err(CoverageError::UnsealedOverwrite { sequence: victim });
}
}
}
Ok(Self::append_locked(&mut inner, record))
}
/// Shared append path (caller holds the lock).
fn append_locked(inner: &mut Inner<N, SEG>, mut record: WitnessRecordV2) -> u64 {
record.version = WitnessRecordV2::VERSION;
record.sequence = inner.sequence;
record.prev_mac = inner.head_mac;
@ -193,6 +383,20 @@ impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
///
/// This is the **only** place signature cost is paid: one signature
/// per up-to-`SEG` records, off the per-record append path.
///
/// Hardening performed atomically with the seal (same lock):
///
/// - **R1**: the seal is [chained](crate::seal::SEAL_VERSION_CHAINED)
/// — its digest binds the previous seal's digest (genesis constant
/// for the first seal), so sealed history ordering is verifiable
/// from seals alone via [`crate::verify_seal_chain`].
/// - **R4**: the chain MAC key is ratcheted
/// ([`ratchet_chain_key`]) and the old key erased before the lock
/// is released — there is no window in which the pre-seal key
/// outlives the seal. After this returns, even this log cannot
/// forge records of the sealed (or any earlier) epoch; verify
/// multi-epoch logs with [`crate::verify_chain_v2_ratcheted`]
/// from the *initial* key.
pub fn seal_segment<G: SegmentSealSigner>(
&self,
signer: &G,
@ -202,8 +406,17 @@ impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
return None;
}
let acc = inner.segment;
let sealed = acc.seal(signer)?;
let sealed = acc.seal_chained(signer, &inner.last_seal_digest)?;
inner.last_seal_digest = sealed.digest();
inner.sealed_up_to = inner.sequence;
inner.segment = SegmentAccumulator::new(inner.sequence);
// R4 ratchet: derive the next epoch key and destroy the old
// one. `inner.key = next` overwrites the old key bytes in
// place; the stack copy of the new key is then erased.
let mut next = ratchet_chain_key(&inner.key);
inner.key = next;
erase_key(&mut next);
inner.key_epoch += 1;
Some((sealed, acc))
}
@ -220,11 +433,42 @@ impl<const N: usize, const SEG: usize> WitnessLogV2<N, SEG> {
self.inner.lock().genesis
}
/// The 32-byte chain MAC key (needed by verifiers; treat as secret).
/// The **current-epoch** 32-byte chain MAC key (treat as secret).
///
/// After `n` seals this is `key_n` (R4 ratchet): it verifies only
/// records appended since the last seal. Verifiers of full logs
/// must hold the *initial* key and re-derive epochs via
/// [`ratchet_chain_key`] / [`crate::verify_chain_v2_ratcheted`].
pub fn chain_key(&self) -> [u8; 32] {
self.inner.lock().key
}
/// Number of chain-key ratchets performed (= seals; R4). The
/// current [`Self::chain_key`] is `key_epoch` derivations from the
/// initial key.
pub fn key_epoch(&self) -> u64 {
self.inner.lock().key_epoch
}
/// This log's coverage policy (R6).
pub fn coverage_policy(&self) -> CoveragePolicy {
self.inner.lock().policy
}
/// Sequence watermark at the last seal: records below it have had
/// seal coverage (exact under [`CoveragePolicy::Strict`]).
pub fn sealed_up_to(&self) -> u64 {
self.inner.lock().sealed_up_to
}
/// Digest of the most recent seal ([`SealedSegment::digest`]), or
/// [`seal_chain_genesis`] if nothing has been sealed. The next seal
/// will bind this value (R1); export it alongside the chain head
/// for external anchoring.
pub fn last_seal_digest(&self) -> [u8; 32] {
self.inner.lock().last_seal_digest
}
/// Number of leaves in the current (unsealed) segment.
pub fn segment_len(&self) -> usize {
self.inner.lock().segment.len()
@ -439,4 +683,198 @@ mod tests {
log.append(make_record(ActionKind::SchedulerEpoch, 1, 4, 4));
assert_eq!(log.segment_dropped(), 1);
}
// ---- R4: forward-secure chain-key ratchet ------------------------
fn test_seal_signer() -> crate::seal::Blake3SealSigner {
crate::seal::Blake3SealSigner::new([0x42u8; 32])
}
#[test]
fn ratchet_is_deterministic_and_changes_key() {
let k0 = derive_chain_key(b"epoch-test");
assert_eq!(ratchet_chain_key(&k0), ratchet_chain_key(&k0));
assert_ne!(ratchet_chain_key(&k0), k0);
assert_ne!(ratchet_chain_key(&ratchet_chain_key(&k0)), ratchet_chain_key(&k0));
}
#[test]
fn seal_ratchets_key_atomically() {
let signer = test_seal_signer();
let log = WitnessLogV2::<64, 8>::new();
let key0 = log.chain_key();
assert_eq!(log.key_epoch(), 0);
log.append(make_record(ActionKind::SchedulerEpoch, 1, 0, 0));
log.seal_segment(&signer).unwrap();
assert_eq!(log.key_epoch(), 1);
// Verifier re-derivation matches the live key exactly.
assert_eq!(log.chain_key(), ratchet_chain_key(&key0));
assert_ne!(log.chain_key(), key0);
log.append(make_record(ActionKind::SchedulerEpoch, 1, 1, 1));
log.seal_segment(&signer).unwrap();
assert_eq!(log.key_epoch(), 2);
assert_eq!(log.chain_key(), ratchet_chain_key(&ratchet_chain_key(&key0)));
// Sealing an empty segment performs no ratchet (no seal, no
// key event — atomicity in both directions).
assert!(log.seal_segment(&signer).is_none());
assert_eq!(log.key_epoch(), 2);
}
#[test]
fn records_after_ratchet_use_new_key() {
let signer = test_seal_signer();
let log = WitnessLogV2::<64, 8>::new();
let key0 = log.chain_key();
log.append(make_record(ActionKind::SchedulerEpoch, 1, 0, 0));
log.seal_segment(&signer).unwrap();
log.append(make_record(ActionKind::SchedulerEpoch, 1, 1, 1));
let r0 = log.get(0).unwrap();
let r1 = log.get(1).unwrap();
let key1 = ratchet_chain_key(&key0);
let b0 = r0.to_bytes();
let b1 = r1.to_bytes();
assert_eq!(
r0.chain_mac,
compute_chain_mac_v2(&key0, &b0[..WitnessRecordV2::CONTENT_LEN], &r0.prev_mac)
);
assert_eq!(
r1.chain_mac,
compute_chain_mac_v2(&key1, &b1[..WitnessRecordV2::CONTENT_LEN], &r1.prev_mac)
);
// The chain itself continues across the epoch boundary.
assert_eq!(r1.prev_mac, r0.chain_mac);
// The pre-ratchet key cannot produce the epoch-1 MAC.
assert_ne!(
r1.chain_mac,
compute_chain_mac_v2(&key0, &b1[..WitnessRecordV2::CONTENT_LEN], &r1.prev_mac)
);
}
#[test]
fn erase_key_zeroes_buffer() {
let mut key = [0xA7u8; 32];
erase_key(&mut key);
assert_eq!(key, [0u8; 32]);
}
// ---- R1: seals from the log form a verifiable chain --------------
#[test]
fn log_seals_form_verifiable_chain() {
let signer = test_seal_signer();
let log = WitnessLogV2::<64, 4>::with_policy(
derive_chain_key(b"chain-test"),
CoveragePolicy::Strict,
);
let mut seq = 0u64;
let mut seal = |(): ()| {
for _ in 0..4 {
log.try_append(make_record(ActionKind::SchedulerEpoch, 1, seq, seq))
.unwrap();
seq += 1;
}
log.seal_segment(&signer).unwrap().0
};
let seals = [seal(()), seal(()), seal(())];
assert_eq!(crate::seal::verify_seal_chain(&seals, &signer), Ok(3));
assert_eq!(log.last_seal_digest(), seals[2].digest());
// Under Strict, epoch boundaries are recoverable from seals:
// first_sequence + count of each seal.
assert_eq!(seals[0].first_sequence + u64::from(seals[0].count), 4);
assert_eq!(seals[1].first_sequence, 4);
assert_eq!(seals[2].first_sequence, 8);
}
// ---- R6: coverage policy ------------------------------------------
#[test]
fn strict_segment_full_is_backpressure_not_drop() {
let signer = test_seal_signer();
let log = WitnessLogV2::<64, 4>::with_policy(
default_chain_key(),
CoveragePolicy::Strict,
);
assert_eq!(log.coverage_policy(), CoveragePolicy::Strict);
for i in 0..4u64 {
assert!(log
.try_append(make_record(ActionKind::SchedulerEpoch, 1, i, i))
.is_ok());
}
let rec = make_record(ActionKind::SchedulerEpoch, 1, 4, 4);
assert_eq!(log.try_append(rec), Err(CoverageError::SegmentFull));
// The refused append mutated nothing.
assert_eq!(log.total_emitted(), 4);
assert_eq!(log.segment_dropped(), 0);
// Seal-then-append succeeds.
assert!(log.seal_segment(&signer).is_some());
assert_eq!(log.try_append(rec), Ok(4));
assert_eq!(log.segment_dropped(), 0);
}
#[test]
fn strict_refuses_unsealed_ring_overwrite() {
let signer = test_seal_signer();
let log = WitnessLogV2::<4, 8>::with_policy(
default_chain_key(),
CoveragePolicy::Strict,
);
for i in 0..4u64 {
log.try_append(make_record(ActionKind::SchedulerEpoch, 1, i, i))
.unwrap();
}
// Ring full, nothing sealed: record 0 would be lost.
assert_eq!(
log.try_append(make_record(ActionKind::SchedulerEpoch, 1, 4, 4)),
Err(CoverageError::UnsealedOverwrite { sequence: 0 })
);
assert_eq!(log.total_overwritten(), 0);
assert_eq!(log.total_emitted(), 4);
// Sealing covers records 0..4; overwriting them is now allowed.
assert!(log.seal_segment(&signer).is_some());
assert_eq!(
log.try_append(make_record(ActionKind::SchedulerEpoch, 1, 4, 4)),
Ok(4)
);
assert_eq!(log.total_overwritten(), 1);
}
#[test]
fn strict_stress_keeps_coverage_invariant() {
let signer = test_seal_signer();
let log = WitnessLogV2::<8, 4>::with_policy(
default_chain_key(),
CoveragePolicy::Strict,
);
for i in 0..100u64 {
let rec = make_record(ActionKind::SchedulerEpoch, 1, i, i);
if log.try_append(rec).is_err() {
log.seal_segment(&signer).unwrap();
log.try_append(rec).unwrap();
}
}
assert_eq!(log.total_emitted(), 100);
assert_eq!(log.segment_dropped(), 0);
// Every record is either sealed or in the live segment.
assert_eq!(log.sealed_up_to() + log.segment_len() as u64, 100);
}
#[test]
fn best_effort_try_append_never_fails() {
// Existing-constructor logs keep the original semantics: no
// backpressure, losses counted exactly as before.
let log = WitnessLogV2::<4, 2>::new();
assert_eq!(log.coverage_policy(), CoveragePolicy::BestEffort);
for i in 0..10u64 {
assert!(log
.try_append(make_record(ActionKind::SchedulerEpoch, 1, i, i))
.is_ok());
}
assert_eq!(log.total_emitted(), 10);
assert_eq!(log.segment_dropped(), 8);
assert_eq!(log.total_overwritten(), 6);
}
}

View file

@ -133,6 +133,52 @@ fn verify_record_v2(
Ok(())
}
/// Verify a contiguous slice of v2 records that spans multiple chain-key
/// epochs (R4 ratchet).
///
/// `initial_key` is the key the log was created with (`key_0`);
/// `epoch_boundaries` lists, in ascending order, the sequence number at
/// which each ratchet fired (the log's `sequence` at each seal): records
/// with `sequence >= epoch_boundaries[k]` are verified under
/// `key_{k+1} = ratchet_chain_key(key_k)`. Under
/// [`crate::CoveragePolicy::Strict`] each boundary equals
/// `seal.first_sequence + seal.count` of the corresponding chained seal,
/// so the boundaries are recoverable from the seal chain alone; under
/// `BestEffort` with dropped leaves the boundary is the *next* seal's
/// `first_sequence`.
///
/// Capability asymmetry (the point of the ratchet): the holder of
/// `key_0` can verify the entire log, while the logger — which only
/// retains the latest epoch key — can no longer forge any record older
/// than its last seal.
///
/// # Errors
///
/// Same as [`verify_chain_v2_from`].
pub fn verify_chain_v2_ratcheted(
records: &[WitnessRecordV2],
initial_key: &[u8; 32],
genesis: &[u8; 16],
epoch_boundaries: &[u64],
) -> Result<usize, ChainIntegrityErrorV2> {
if records.is_empty() {
return Err(ChainIntegrityErrorV2::EmptyLog);
}
let mut key = *initial_key;
let mut head: [u8; 16] = *genesis;
let mut next_boundary = 0usize;
for record in records {
while next_boundary < epoch_boundaries.len()
&& record.sequence >= epoch_boundaries[next_boundary]
{
key = crate::v2::ratchet_chain_key(&key);
next_boundary += 1;
}
verify_record_v2(record, &key, &mut head)?;
}
Ok(records.len())
}
/// Map a verified v1 chain head (the 64-bit running chain value) into a
/// 16-byte v2 genesis: `trunc128(BLAKE3("rvm-witness v1->v2 genesis" || head))`.
///
@ -504,6 +550,153 @@ mod tests {
assert!(verify_chain_v2(&records, &wrong).is_err());
}
// ---- R4: ratcheted multi-epoch verification ---------------------
use crate::seal::{
verify_inclusion, verify_seal, verify_seal_chain, Blake3SealSigner,
};
use crate::v2::{ratchet_chain_key, CoveragePolicy};
#[test]
fn ratcheted_chain_verifies_with_rederived_keys() {
let signer = Blake3SealSigner::new([9u8; 32]);
let key0 = crate::v2::derive_chain_key(b"ratchet-verify");
let log = WitnessLogV2::<64, 4>::with_policy(key0, CoveragePolicy::Strict);
let mut boundaries = Vec::new();
let mut seals = Vec::new();
for i in 0..12u64 {
let mut r = WitnessRecordV2::zeroed();
r.target_object_id = i;
log.try_append(r).unwrap();
if (i + 1) % 4 == 0 {
let (sealed, _) = log.seal_segment(&signer).unwrap();
// Strict: epoch boundary recoverable from the seal.
boundaries.push(sealed.first_sequence + u64::from(sealed.count));
seals.push(sealed);
}
}
let mut records = alloc::vec![WitnessRecordV2::zeroed(); 12];
assert_eq!(log.snapshot(&mut records), 12);
// No single key verifies across epochs any more.
assert!(verify_chain_v2(&records, &key0).is_err());
assert!(verify_chain_v2(&records, &log.chain_key()).is_err());
// The initial-key holder re-derives every epoch (determinism).
assert_eq!(
verify_chain_v2_ratcheted(&records, &key0, &[0u8; 16], &boundaries),
Ok(12)
);
// Wrong boundaries pair records with the wrong epoch key.
assert!(verify_chain_v2_ratcheted(&records, &key0, &[0u8; 16], &[3, 8, 12]).is_err());
// The seals the boundaries came from are themselves a valid chain.
assert_eq!(verify_seal_chain(&seals, &signer), Ok(3));
}
#[test]
fn stale_key_cannot_forge_post_ratchet_record() {
let signer = Blake3SealSigner::new([9u8; 32]);
let log = WitnessLogV2::<16, 8>::new();
let key0 = log.chain_key();
let mut r = WitnessRecordV2::zeroed();
r.target_object_id = 1;
log.append(r);
log.seal_segment(&signer).unwrap(); // ratchet: key0 retired
let mut r = WitnessRecordV2::zeroed();
r.target_object_id = 2;
log.append(r);
let mut records = alloc::vec![WitnessRecordV2::zeroed(); 2];
log.snapshot(&mut records);
let boundaries = [1u64];
assert_eq!(
verify_chain_v2_ratcheted(&records, &key0, &[0u8; 16], &boundaries),
Ok(2)
);
// An attacker who only kept the pre-ratchet key tampers the
// epoch-1 record and recomputes its MAC with the stale key.
records[1].target_object_id = 0xE71;
let bytes = records[1].to_bytes();
records[1].chain_mac = compute_chain_mac_v2(
&key0,
&bytes[..WitnessRecordV2::CONTENT_LEN],
&records[1].prev_mac,
);
assert_eq!(
verify_chain_v2_ratcheted(&records, &key0, &[0u8; 16], &boundaries),
Err(ChainIntegrityErrorV2::RecordCorrupted { sequence: 1 })
);
}
#[test]
fn post_compromise_key_cannot_rewrite_sealed_history() {
// THE forward-security property: the attacker holds the
// *current* (post-ratchet) chain key and rewrites a record in
// an already-sealed segment, recomputing every downstream MAC
// with the compromised key — the best forgery available
// without key_0.
let signer = Blake3SealSigner::new([7u8; 32]);
let key0 = crate::v2::derive_chain_key(b"compromise-window");
let log = WitnessLogV2::<64, 4>::with_policy(key0, CoveragePolicy::Strict);
for i in 0..4u64 {
let mut r = WitnessRecordV2::zeroed();
r.target_object_id = i;
log.try_append(r).unwrap();
}
let (seal0, acc0) = log.seal_segment(&signer).unwrap();
for i in 4..6u64 {
let mut r = WitnessRecordV2::zeroed();
r.target_object_id = i;
log.try_append(r).unwrap();
}
let compromised = log.chain_key(); // attacker's loot: key_1
assert_eq!(compromised, ratchet_chain_key(&key0));
let mut records = alloc::vec![WitnessRecordV2::zeroed(); 6];
log.snapshot(&mut records);
records[2].payload = [0xEE; 8];
let mut head = records[1].chain_mac;
for r in &mut records[2..] {
r.prev_mac = head;
let bytes = r.to_bytes();
r.chain_mac = compute_chain_mac_v2(
&compromised,
&bytes[..WitnessRecordV2::CONTENT_LEN],
&r.prev_mac,
);
head = r.chain_mac;
}
// 1. The initial-key holder rejects: epoch-0 records carry
// MACs the attacker could not have produced.
assert_eq!(
verify_chain_v2_ratcheted(&records, &key0, &[0u8; 16], &[4]),
Err(ChainIntegrityErrorV2::RecordCorrupted { sequence: 2 })
);
// 2. Even WITHOUT key_0, the sealed history catches the
// rewrite: old segments are protected by their SEALS, not
// the (compromised) chain key. The honest leaf still proves
// inclusion; the forged record's MAC does not.
let proof = acc0.proof_for_sequence(2).unwrap();
assert!(verify_seal(&seal0, &signer));
assert!(verify_inclusion(&seal0.root, &acc0.leaf(2).unwrap(), &proof));
assert!(!verify_inclusion(&seal0.root, &records[2].chain_mac, &proof));
}
#[test]
fn ratcheted_verify_with_no_boundaries_matches_plain() {
let (records, key) = build_v2_chain(5);
assert_eq!(
verify_chain_v2_ratcheted(&records, &key, &[0u8; 16], &[]),
Ok(5)
);
assert_eq!(
verify_chain_v2_ratcheted(&[], &key, &[0u8; 16], &[]),
Err(ChainIntegrityErrorV2::EmptyLog)
);
}
// ---- v1 backward verification ----------------------------------
#[test]

View file

@ -0,0 +1,236 @@
# ADR-210: Default-On Semantic Embeddings — all-MiniLM-L6-v2 as the Intelligence Engine's Primary Embedder
- **Status**: accepted (with hardening edits, review of 2026-06-12)
- **Date**: 2026-06-12
- **Deciders**: ruv
- **Tags**: embeddings, onnx, intelligence-engine, sona, rabitq, hnsw, performance
## Context
The `ruvector` npm package bundles `all-MiniLM-L6-v2` (a sentence-transformers
checkpoint executed by a pure-WASM ONNX runtime, zero native dependencies) and,
in `onnx-optimized.ts`, registers `bge-small-en-v1.5` and `e5-small-v2` with
fp16/int8 variants plus a zero-dependency parallel worker pool (ADR-194
lineage). Despite all this machinery, the model is effectively **off**:
1. **`IntelligenceEngine` defaults to `enableOnnx: false`** —
`intelligence-engine.ts:183` gates on `config.enableOnnx &&
isOnnxAvailable()`. By default, `hooks route`, memory search, pattern
matching, and trajectory embeddings run on a 256-dim character-hash
embedder with no semantic signal: "fix failing test" and "repair broken
spec" share no overlap. The #517 route-learning fix (state keys derived
from task text, Q-values per state) compounds this — semantically adjacent
tasks cannot share learned routing because their hash embeddings are
unrelated.
2. **SONA's TypeScript coordinator hashes characters into 64 dims**
(`npm/packages/ruvllm/src/sona.ts createEmbedding`). The micro-LoRA
instant-learning loop wired for #553 and `ReasoningBank.findSimilar` both
adapt over hash buckets rather than meaning. The CI drift-gate fingerprints
(`scripts/sona-drift/reference.rvf`) consequently measure adaptation over
hash-space, not semantic-space.
3. **History of silent quality loss**: #523 documented an entire BEIR
benchmark run silently executing on hash fallback (nDCG@10 0.262, rank
11/11 vs published baselines). The contract fixes (honest
`isOnnxInitialized()`/`isReady()`) shipped in `ruvector@0.2.29`, making a
default-on policy safe to implement without reintroducing silent fallback.
4. **New search machinery is misaligned with the embedder's training**:
MiniLM is trained for cosine similarity; the RaBitQ two-stage path
(rvf-runtime 0.3.0) is L2-only in v1, and the HNSW/RaBitQ recall gates
(ef_search=256 floor, 640-candidate RaBitQ floor) were tuned on uniform
random vectors — the worst case for ANN, unrepresentative of real text
embeddings with low intrinsic dimensionality.
5. **Known quality ceiling**: #524 measured BGE-base at +0.08 nDCG@10 over
MiniLM on BEIR NFCorpus (rank 10/11 → 2/11). `bge-small-en-v1.5` is
registered, same 384 dims (index-compatible drop-in), but there is no
default-selection logic and no query/passage prefix support, which
E5/BGE-class models require for full quality.
## Decision
Defaulting to ONNX MiniLM is not a model upgrade. It is a **contract
upgrade**: the intelligence layer stops pretending hash buckets are meaning
and makes semantic behavior the normal path, while keeping fallback
observable, deterministic, and auditable.
Make semantic embeddings the default brain of the intelligence layer, in four
coordinated changes plus one cross-cutting invariant:
### D0. Embedding-provenance invariant (cross-cutting, mandatory)
Every persisted vector store (hooks intelligence stores, HNSW memories,
`.rvf`/`.db` files created through the embedding path) MUST record:
```
{ embedderKind, modelId, dimension, normalize, prefixPolicy }
```
- Inserts whose provenance does not match the store's recorded provenance are
**refused** (clear error naming both sides), not coerced. No mixed stores.
- Legacy stores without provenance metadata are treated as
`{ embedderKind: "hash", dimension: <recorded or inferred>, normalize:
false, prefixPolicy: "none" }` and open **read-only** for vector writes
until re-embedded.
- This invariant is the defense against the decision's real failure mode —
partial migration (see Risks).
### D1. `enableOnnx` defaults to true with graceful, *loud* fallback
- `IntelligenceEngine` constructor flips to `enableOnnx: true`.
- Initialization is lazy and non-blocking (existing `initOnnx()` pattern);
until ready, or when the model cannot load (offline, restricted CI), the
engine uses the hash embedder and **reports it**: `stats().embedderKind =
'onnx-minilm' | 'hash-fallback'`, a one-line stderr notice on first
fallback, and the existing honest `isOnnxInitialized()` gate. No silent
quality loss (the #523 failure mode) — fallback is visible in stats, logs,
and the quality envelope.
- Embedding dimension migration: ONNX path is 384-dim, hash path 256-dim.
Persisted stores created at 256 dims continue to load (dimension recorded
in the store/sidecar); mixing is refused with a clear re-embed message.
`hooks` intelligence stores (`.ruvector/intelligence.json` + HNSW memories)
record the embedder kind and dimension; a `hooks reembed` maintenance
command upgrades hash-era memories on demand.
### D2. Normalize at embed time; align RaBitQ/HNSW with cosine geometry
- The embedder's `normalize` option becomes default-true (unit vectors).
The precise claim: for unit-norm vectors, `||a b||² = 2 2·cos(a, b)`,
so L2 distance is a strictly decreasing function of cosine similarity and
the two rankings are identical — **but only when both vectors are unit
norm**. The D0 provenance invariant (`normalize: true` recorded per store,
mixed inserts refused) is what makes this equivalence safe to rely on; a
single un-normalized vector in the store silently breaks the ranking
equivalence. With it, the L2-only RaBitQ v1 estimator and the HNSW distance
kernels compose with MiniLM today — no third correction scalar, no
IP/cosine codec work.
- Re-tune the documented ANN floors on text embeddings: add a benchmark
embedding a fixed public text corpus (deterministic, committed fixture)
with MiniLM, measuring recall@10 vs exact for HNSW (ef sweep) and RaBitQ
(oversample sweep). Publish measured floors for the text-embedding regime
alongside the existing uniform-random worst-case floors. Gates stay
conservative; documentation stops overstating required ef/oversample for
real workloads.
### D3. Bulk paths route through the int8 parallel pool
- `insert <db> <file>` (CLI), memory import, and any batch-embedding path use
the bundled `ParallelEmbedder` worker pool with the registered int8 variant
by default (fp32 single-session remains for single-query latency).
Rationale: int8 ≈ 4× smaller download/resident size, ~2× CPU throughput,
~1-point quality cost — the right trade for ingest; queries keep fp32.
- Pool startup remains lazy; model bytes are shared with the main session
(existing `bundledPool` capture).
### D4. Model registry grows defaults + prefix conventions (prepares #524)
- Loader metadata gains `queryPrefix`/`passagePrefix` fields; the embedder
applies them automatically (`embedQuery` vs `embedPassage` entry points;
plain `embed()` = passage). Per-model facts, from the model cards:
- `all-MiniLM-L6-v2`: 384-dim, general semantic search, **no prefixes**
(https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2).
- `e5-small-v2`: **requires** `query: ` / `passage: ` prefixes — the model
card states quality degrades without them
(https://huggingface.co/intfloat/e5-small-v2).
- `bge-small-en-v1.5`: query instruction **recommended for short-query →
long-passage retrieval**; passages need no instruction
(https://huggingface.co/BAAI/bge-small-en-v1.5).
The registry encodes exactly these policies (`prefixPolicy: none |
required | query-recommended`), and `prefixPolicy` is part of the D0
provenance record so stores embedded with and without prefixes can never
silently mix.
- Default model stays `all-MiniLM-L6-v2` in this ADR (bundle-size neutral).
Switching the default to BGE-small (same 384 dims) is deferred to #524's
decision once bundling strategy is settled; this ADR makes that switch a
one-line registry change with prefixes already handled.
### D5. Rollout flags (operator escape hatches)
Environment variables override config, for staged rollout and incident
response without code changes:
- `RUVECTOR_EMBEDDER=auto|minilm|hash``auto` (default): MiniLM when
loadable, loud hash fallback otherwise; `minilm`: hard-require the model
(fail rather than fall back); `hash`: force the legacy embedder
(bug-for-bug escape hatch).
- `RUVECTOR_ONNX=0|1` — kill switch for the entire ONNX runtime path
(`0``RUVECTOR_EMBEDDER=hash`); `1``minilm`.
- `RUVECTOR_REEMBED=refuse|warn|auto` — what happens when opening a store
whose provenance mismatches the active embedder: `refuse` (default; the D0
invariant), `warn` (open read-only with a warning), `auto` (re-embed
in place — requires source text to be present; refuses otherwise).
### Acceptance gates (test-enforced before the default flips)
1. `stats().embedderKind === "onnx-minilm"` when the model loads.
2. Fallback emits exactly **one** warning per process, not one per call.
3. A 256-dim legacy store opens **read-only** for vector writes.
4. Mixed-provenance insert (256-dim hash store + 384-dim MiniLM vector, or
prefix-policy mismatch) fails with a clear error.
5. Normalized embedding L2 norm ∈ [0.999, 1.001] for every emitted vector.
6. `embedQuery()` applies the registered prefix for E5/BGE.
7. MiniLM applies **no** prefix on either entry point.
8. The RaBitQ/HNSW recall benchmark runs on the real text fixture (D2), not
only uniform-random vectors.
### Explicit non-goals
- No change to the Rust crates' embedding story (ruvllm neural embeddings are
ADR-074 territory).
- No Python/`@xenova/transformers` dependency — the xenova → sharp → libvips
chain stays out (per #524's analysis).
- SONA TS coordinator migration to MiniLM (item 2 in Context) is staged
separately because it invalidates the CI drift-gate reference fingerprints:
it requires a coordinated `rvf-fingerprint.mjs --update` with pinned model
bytes for determinism. Tracked as a follow-up, not part of this ADR's
initial landing.
## Consequences
### Positive
- Every learned-routing, memory-recall, and pattern-match decision upgrades
from token overlap to semantics, compounding the #517 fix (semantically
near tasks share learned routing patterns).
- The silent-fallback failure class stays closed: fallback is loud,
inspectable, and quality-attributed (embedderKind in stats/envelope).
- MiniLM + RaBitQ/HNSW compose correctly via unit-norm vectors; users get
honest, regime-appropriate recall guidance instead of worst-case-only
numbers.
- Ingest throughput: parallel int8 embedding makes embed-at-ingest the cheap
default instead of an opt-in cost.
- Prefix support removes a correctness trap before BGE/E5 adoption (#524).
### Negative
- **The primary risk is not latency — it is partial migration**: old hash
memories, new MiniLM memories, and SONA hash fingerprints coexisting
without clear attribution would corrupt every similarity comparison
silently. The D0 provenance invariant (mandatory metadata, refused mixed
inserts, read-only legacy stores) is the mitigation, and acceptance gates
34 enforce it; if D0 ships incompletely, this ADR's default flip must not
ship at all.
- First-use latency and ~23 MB model download (or bundle weight, per #524's
eventual bundling decision) become the default experience; offline/CI
environments exercise the fallback path routinely — mitigated by lazy init,
the disk cache, and the loud-fallback contract.
- 384-dim vectors cost 1.5× the memory/compute of the 256-dim hash space for
hooks memories; HNSW/RaBitQ offset this at search time, and int8 offsets it
at ingest.
- Dimension migration adds a maintenance surface (`hooks reembed`, mixed-dim
refusal) that must be tested.
- One more divergence between the three SONA implementations until the staged
coordinator migration lands (the drift gate documents rather than blocks
this, since fingerprints are per-implementation).
### Neutral
- Default model identity is unchanged (MiniLM); quality-ceiling work moves to
#524 with the prefix groundwork laid.
- The hash embedder remains in-tree permanently as the deterministic,
dependency-free fallback and the no-model CI path.
## Links
- Model cards: [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2)
· [e5-small-v2](https://huggingface.co/intfloat/e5-small-v2)
· [bge-small-en-v1.5](https://huggingface.co/BAAI/bge-small-en-v1.5)
- Issue #517 — route learning (state keys over task text; semantic synergy)
- Issue #523 — ONNX contract fixes that make default-on safe (shipped 0.2.29)
- Issue #524 — BGE bundling (+0.08 nDCG@10); prefix conventions prepared here
- Issue #553 / `scripts/sona-drift/` — SONA coordinator hash embedder; staged
follow-up with drift-reference regeneration
- ADR-074 — ruvllm neural embeddings (Rust-side, related but separate)
- ADR-194 lineage — bundled parallel worker pool used by D3
- `npm/packages/ruvector/src/core/onnx-embedder.ts`, `onnx-optimized.ts`,
`intelligence-engine.ts` — implementation surfaces