1023 lines
37 KiB
Rust
1023 lines
37 KiB
Rust
//! Verification algorithm from the v1.1 trust-root revision §3.6.
|
||
//!
|
||
//! Provides:
|
||
//! - `verify_signed_object`: per-object signature check.
|
||
//! - `verify_genesis`: validates a candidate genesis authority.
|
||
//! - `verify_authority_chain`: walks back to genesis verifying each step.
|
||
//! - `verify_commit`: full commit verification including authority chain.
|
||
//!
|
||
//! The verifier accepts any object source that implements `ObjectSource` so
|
||
//! the same logic works against an `ObjectStore`, an in-memory map, or a
|
||
//! remote-fetching shim.
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
|
||
use thiserror::Error;
|
||
|
||
use levcs_core::object::{ObjectType, SignatureEntry, SignedObject};
|
||
use levcs_core::{ObjectId, RawObject, Tree};
|
||
|
||
use crate::authority::{AuthorityBody, Role};
|
||
use crate::error::IdentityError;
|
||
use crate::keys::PublicKey;
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum VerifyError {
|
||
#[error("object {hash}: {kind}")]
|
||
Object { hash: String, kind: String },
|
||
|
||
#[error("authority chain: {0}")]
|
||
Authority(String),
|
||
|
||
#[error("commit {hash}: {reason}")]
|
||
Commit { hash: String, reason: String },
|
||
|
||
#[error("identity error: {0}")]
|
||
Identity(#[from] IdentityError),
|
||
|
||
#[error(transparent)]
|
||
Core(#[from] levcs_core::Error),
|
||
|
||
#[error("missing object: {0}")]
|
||
Missing(String),
|
||
}
|
||
|
||
pub type Verification<T> = std::result::Result<T, VerifyError>;
|
||
|
||
/// Trait implemented by anything that can supply objects by hash.
|
||
pub trait ObjectSource {
|
||
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>>;
|
||
}
|
||
|
||
impl ObjectSource for levcs_core::ObjectStore {
|
||
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>> {
|
||
Ok(self.read_raw(id)?)
|
||
}
|
||
}
|
||
|
||
/// In-memory object source for unit tests.
|
||
pub struct MemorySource(pub HashMap<ObjectId, Vec<u8>>);
|
||
|
||
impl ObjectSource for MemorySource {
|
||
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>> {
|
||
self.0
|
||
.get(&id)
|
||
.cloned()
|
||
.ok_or_else(|| VerifyError::Missing(id.to_hex()))
|
||
}
|
||
}
|
||
|
||
fn read_signed<S: ObjectSource>(src: &S, id: ObjectId) -> Verification<SignedObject> {
|
||
let bytes = src.read_raw(id)?;
|
||
Ok(
|
||
SignedObject::parse(&bytes).map_err(|e| VerifyError::Object {
|
||
hash: id.to_hex(),
|
||
kind: e.to_string(),
|
||
})?,
|
||
)
|
||
}
|
||
|
||
/// Verify the signature(s) on a SignedObject. Each signature in the trailer
|
||
/// is checked against `BLAKE3(header || body)`. Returns Ok if every
|
||
/// signature is valid; the policy (role checks, count constraints) is
|
||
/// enforced by the caller.
|
||
pub fn verify_signed_object(signed: &SignedObject) -> Verification<()> {
|
||
let h = signed.signing_hash();
|
||
for s in &signed.signatures {
|
||
let pk = PublicKey(s.public_key);
|
||
pk.verify(h.as_bytes(), &s.signature)
|
||
.map_err(IdentityError::from)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Verify that `genesis` is a well-formed genesis authority object, including
|
||
/// repo_id derivation and self-signature by an owner.
|
||
pub fn verify_genesis(genesis: &SignedObject) -> Verification<AuthorityBody> {
|
||
if genesis.object_type != ObjectType::Authority {
|
||
return Err(VerifyError::Authority(format!(
|
||
"expected authority object, got {}",
|
||
genesis.object_type.name()
|
||
)));
|
||
}
|
||
let body =
|
||
AuthorityBody::parse(&genesis.body).map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
if !body.previous_authority.is_zero() {
|
||
return Err(VerifyError::Authority(
|
||
"genesis must have zero previous_authority".into(),
|
||
));
|
||
}
|
||
if body.version != 1 {
|
||
return Err(VerifyError::Authority("genesis version must be 1".into()));
|
||
}
|
||
let zeroed = body
|
||
.encode_with_repo_id_zero()
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
let derived = ObjectId(*blake3::hash(&zeroed).as_bytes());
|
||
if derived != body.repo_id {
|
||
return Err(VerifyError::Authority(format!(
|
||
"repo_id derivation invalid: derived {}, body says {}",
|
||
derived, body.repo_id
|
||
)));
|
||
}
|
||
// Genesis must be self-signed by an owner.
|
||
let h = genesis.signing_hash();
|
||
let mut found = false;
|
||
for s in &genesis.signatures {
|
||
let pk = PublicKey(s.public_key);
|
||
let member = match body.find_member(&pk) {
|
||
Some(m) => m,
|
||
None => continue,
|
||
};
|
||
if member.role == Role::Owner && pk.verify(h.as_bytes(), &s.signature).is_ok() {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if !found {
|
||
return Err(VerifyError::Authority(
|
||
"no valid owner self-signature on genesis".into(),
|
||
));
|
||
}
|
||
Ok(body)
|
||
}
|
||
|
||
fn verify_authority_step(
|
||
new_signed: &SignedObject,
|
||
new_body: &AuthorityBody,
|
||
prev_body: &AuthorityBody,
|
||
prev_id: ObjectId,
|
||
) -> Verification<()> {
|
||
if new_body.repo_id != prev_body.repo_id {
|
||
return Err(VerifyError::Authority("repo_id mismatch".into()));
|
||
}
|
||
if new_body.version != prev_body.version + 1 {
|
||
return Err(VerifyError::Authority(format!(
|
||
"version not sequential: prev {} -> next {}",
|
||
prev_body.version, new_body.version
|
||
)));
|
||
}
|
||
if new_body.previous_authority != prev_id {
|
||
return Err(VerifyError::Authority(
|
||
"previous_authority does not match predecessor hash".into(),
|
||
));
|
||
}
|
||
// At least one signature on the new authority must be by an owner of prev.
|
||
let h = new_signed.signing_hash();
|
||
let mut found = false;
|
||
for s in &new_signed.signatures {
|
||
let pk = PublicKey(s.public_key);
|
||
let member = match prev_body.find_member(&pk) {
|
||
Some(m) => m,
|
||
None => continue,
|
||
};
|
||
if member.role == Role::Owner && pk.verify(h.as_bytes(), &s.signature).is_ok() {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if !found {
|
||
return Err(VerifyError::Authority(
|
||
"no valid owner signature on successor authority".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Walk an authority back to genesis, verifying each step. Returns the body
|
||
/// of the genesis authority on success.
|
||
///
|
||
/// Standalone calls allocate a fresh verifier each time. Callers that
|
||
/// verify many tips in a loop (mirror sync, push handler iterating
|
||
/// commits) should use `ChainVerifier` directly so the chain walk can be
|
||
/// shared — without it, verifying N commits that all cite the same
|
||
/// authority is O(N × chain_depth).
|
||
pub fn verify_authority_chain<S: ObjectSource>(
|
||
src: &S,
|
||
start: ObjectId,
|
||
) -> Verification<AuthorityBody> {
|
||
let mut v = ChainVerifier::new();
|
||
let body = v.verify_chain(src, start)?;
|
||
Ok((*body).clone())
|
||
}
|
||
|
||
/// Caches authority chains that have been fully verified back to genesis.
|
||
/// Hand the same verifier to a sequence of `verify_chain` / `verify_commit`
|
||
/// / `verify_release` calls and the per-call cost drops from O(chain
|
||
/// depth) to O(1) once an ancestor has been seen.
|
||
///
|
||
/// The cache is keyed by authority id, which is a BLAKE3 of the signed
|
||
/// object — collisions are infeasible — so a hit is sound: the underlying
|
||
/// bytes are guaranteed identical to whatever produced the original
|
||
/// success. Insertions are atomic per call: a partial walk that fails
|
||
/// midway leaves the cache untouched.
|
||
///
|
||
/// Not internally synchronized. Callers that share a verifier across
|
||
/// threads should wrap it in `Mutex<_>`.
|
||
#[derive(Default)]
|
||
pub struct ChainVerifier {
|
||
/// Maps any id along a verified chain → the chain's genesis body.
|
||
/// The same `Arc<AuthorityBody>` is shared across every entry that
|
||
/// belongs to one chain, so memory cost scales with the number of
|
||
/// distinct chains, not with the number of authorities.
|
||
verified: HashMap<ObjectId, Arc<AuthorityBody>>,
|
||
}
|
||
|
||
impl ChainVerifier {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Verify the authority chain rooted at `start` back to genesis.
|
||
/// On a cache hit (any id along a previously-verified chain), returns
|
||
/// the cached genesis body in O(1). On a cache miss, walks the chain,
|
||
/// verifies each step, and on full success records every walked id
|
||
/// against a single shared genesis body.
|
||
pub fn verify_chain<S: ObjectSource>(
|
||
&mut self,
|
||
src: &S,
|
||
start: ObjectId,
|
||
) -> Verification<Arc<AuthorityBody>> {
|
||
if let Some(g) = self.verified.get(&start) {
|
||
return Ok(g.clone());
|
||
}
|
||
// Walk back, accumulating the path. We don't insert anything into
|
||
// the cache until the entire walk succeeds — a partial walk that
|
||
// errors out must not leave half-trusted ids cached.
|
||
let mut walked: Vec<ObjectId> = Vec::new();
|
||
let mut cur_id = start;
|
||
let mut cur_signed = read_signed(src, cur_id)?;
|
||
let mut cur_body = AuthorityBody::parse(&cur_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
let genesis: Arc<AuthorityBody> = loop {
|
||
walked.push(cur_id);
|
||
if cur_body.previous_authority.is_zero() {
|
||
break Arc::new(verify_genesis(&cur_signed)?);
|
||
}
|
||
let prev_id = cur_body.previous_authority;
|
||
// Cache hit on the predecessor: we still need to verify the
|
||
// step from cur → prev (because the *step's* signature isn't
|
||
// covered by prev being known-good), but the rest of the
|
||
// chain back to genesis is already trusted.
|
||
if let Some(g) = self.verified.get(&prev_id).cloned() {
|
||
let prev_signed = read_signed(src, prev_id)?;
|
||
let prev_body = AuthorityBody::parse(&prev_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
verify_authority_step(&cur_signed, &cur_body, &prev_body, prev_id)?;
|
||
break g;
|
||
}
|
||
let prev_signed = read_signed(src, prev_id)?;
|
||
let prev_body = AuthorityBody::parse(&prev_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
verify_authority_step(&cur_signed, &cur_body, &prev_body, prev_id)?;
|
||
cur_signed = prev_signed;
|
||
cur_body = prev_body;
|
||
cur_id = prev_id;
|
||
};
|
||
for id in walked {
|
||
self.verified.insert(id, genesis.clone());
|
||
}
|
||
Ok(genesis)
|
||
}
|
||
|
||
/// Cache-aware variant of `verify_commit`. Identical semantics; the
|
||
/// only behavioural difference is that authority chains visited in
|
||
/// prior calls don't get re-walked.
|
||
pub fn verify_commit<S: ObjectSource>(
|
||
&mut self,
|
||
src: &S,
|
||
commit_id: ObjectId,
|
||
target_ref: Option<&str>,
|
||
) -> Verification<()> {
|
||
verify_commit_inner(src, commit_id, target_ref, self)
|
||
}
|
||
|
||
/// Cache-aware variant of `verify_release`.
|
||
pub fn verify_release<S: ObjectSource>(
|
||
&mut self,
|
||
src: &S,
|
||
release_id: ObjectId,
|
||
) -> Verification<()> {
|
||
verify_release_inner(src, release_id, self)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn cache_size(&self) -> usize {
|
||
self.verified.len()
|
||
}
|
||
}
|
||
|
||
/// Verify a successor authority object against `A_old`, given the signer key
|
||
/// that will be checked. The signer must be an owner of `A_old` AND must
|
||
/// appear among `A_new`'s signatures.
|
||
pub fn verify_successor(
|
||
a_new: &SignedObject,
|
||
a_new_body: &AuthorityBody,
|
||
a_old_id: ObjectId,
|
||
a_old_body: &AuthorityBody,
|
||
signer: PublicKey,
|
||
) -> Verification<()> {
|
||
if a_new_body.previous_authority != a_old_id {
|
||
return Err(VerifyError::Authority(
|
||
"successor previous_authority != hash(A_old)".into(),
|
||
));
|
||
}
|
||
if a_new_body.version != a_old_body.version + 1 {
|
||
return Err(VerifyError::Authority("version not sequential".into()));
|
||
}
|
||
if a_new_body.repo_id != a_old_body.repo_id {
|
||
return Err(VerifyError::Authority("repo_id mismatch".into()));
|
||
}
|
||
let m = a_old_body
|
||
.find_member(&signer)
|
||
.ok_or_else(|| VerifyError::Authority("signer not in predecessor authority".into()))?;
|
||
if m.role < Role::Owner {
|
||
return Err(VerifyError::Authority(
|
||
"signer must hold owner role in predecessor".into(),
|
||
));
|
||
}
|
||
let found_in_new = a_new.signatures.iter().any(|s| s.public_key == signer.0);
|
||
if !found_in_new {
|
||
return Err(VerifyError::Authority(
|
||
"predecessor owner must also sign the new authority".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Verify a fork-genesis authority pair (per §3.5). `a_source_body` is the
|
||
/// source repository's authority at the parent commit; `a_new` is the fork's
|
||
/// new genesis.
|
||
pub fn verify_fork(
|
||
fork_author: PublicKey,
|
||
a_source_body: &AuthorityBody,
|
||
a_new: &SignedObject,
|
||
a_new_body: &AuthorityBody,
|
||
) -> Verification<()> {
|
||
let _ = verify_genesis(a_new)?;
|
||
if a_new_body.repo_id == a_source_body.repo_id {
|
||
return Err(VerifyError::Authority("fork must have new repo_id".into()));
|
||
}
|
||
if a_source_body.public_read() {
|
||
return Ok(());
|
||
}
|
||
let m = a_source_body.find_member(&fork_author).ok_or_else(|| {
|
||
VerifyError::Authority("fork author not authorized to read source".into())
|
||
})?;
|
||
if m.role < Role::Reader {
|
||
return Err(VerifyError::Authority(
|
||
"fork author lacks reader role".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Determine the role required for a commit given the active authority. In
|
||
/// v1.1 this is:
|
||
/// - Owner if the commit modifies authority (flag bit 0 or fork bit).
|
||
/// - Maintainer if the target ref matches a `protected_branches` glob.
|
||
/// - Contributor otherwise.
|
||
pub fn role_for_commit(
|
||
flags: levcs_core::CommitFlags,
|
||
authority: &AuthorityBody,
|
||
target_ref: Option<&str>,
|
||
) -> Role {
|
||
if flags.modifies_authority() || flags.is_fork() {
|
||
return Role::Owner;
|
||
}
|
||
if let Some(name) = target_ref {
|
||
for pat in authority.protected_branches() {
|
||
if glob::Pattern::new(&pat)
|
||
.map(|p| p.matches(name))
|
||
.unwrap_or(false)
|
||
{
|
||
return Role::Maintainer;
|
||
}
|
||
}
|
||
}
|
||
Role::Contributor
|
||
}
|
||
|
||
/// Full commit verification per §3.6 algorithm. `target_ref` is the ref the
|
||
/// commit is being applied to (used for protected-branch role checks); pass
|
||
/// `None` if not applicable (e.g., during walking).
|
||
///
|
||
/// Each call walks the authority chain from scratch. To share that work
|
||
/// across many commits — e.g., during a mirror sync that verifies a
|
||
/// branch's worth of tips — use `ChainVerifier::verify_commit` instead.
|
||
pub fn verify_commit<S: ObjectSource>(
|
||
src: &S,
|
||
commit_id: ObjectId,
|
||
target_ref: Option<&str>,
|
||
) -> Verification<()> {
|
||
verify_commit_inner(src, commit_id, target_ref, &mut ChainVerifier::new())
|
||
}
|
||
|
||
fn verify_commit_inner<S: ObjectSource>(
|
||
src: &S,
|
||
commit_id: ObjectId,
|
||
target_ref: Option<&str>,
|
||
verifier: &mut ChainVerifier,
|
||
) -> Verification<()> {
|
||
let bytes = src.read_raw(commit_id)?;
|
||
let actual = blake3::hash(&bytes);
|
||
if *actual.as_bytes() != commit_id.0 {
|
||
return Err(VerifyError::Object {
|
||
hash: commit_id.to_hex(),
|
||
kind: "stored bytes do not match expected hash".into(),
|
||
});
|
||
}
|
||
let signed = SignedObject::parse(&bytes).map_err(|e| VerifyError::Object {
|
||
hash: commit_id.to_hex(),
|
||
kind: e.to_string(),
|
||
})?;
|
||
if signed.object_type != ObjectType::Commit {
|
||
return Err(VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: format!("expected commit, got {}", signed.object_type.name()),
|
||
});
|
||
}
|
||
if signed.signatures.len() != 1 {
|
||
return Err(VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: format!(
|
||
"commit must have 1 signature, got {}",
|
||
signed.signatures.len()
|
||
),
|
||
});
|
||
}
|
||
let sig = signed.signatures[0];
|
||
let commit = levcs_core::Commit::from_signed(&signed).map_err(|e| VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: e.to_string(),
|
||
})?;
|
||
if sig.public_key != commit.author_key {
|
||
return Err(VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: "author_key does not match trailer signature key".into(),
|
||
});
|
||
}
|
||
let pk = PublicKey(sig.public_key);
|
||
pk.verify(signed.signing_hash().as_bytes(), &sig.signature)
|
||
.map_err(|_| VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: "Ed25519 signature invalid".into(),
|
||
})?;
|
||
// Verify authority chain.
|
||
let auth_signed = read_signed(src, commit.authority)?;
|
||
let auth_body = AuthorityBody::parse(&auth_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
let _ = verifier.verify_chain(src, commit.authority)?;
|
||
let member = auth_body
|
||
.find_member(&pk)
|
||
.ok_or_else(|| VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: "author not in authority".into(),
|
||
})?;
|
||
let required = role_for_commit(commit.flags, &auth_body, target_ref);
|
||
if member.role < required {
|
||
return Err(VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: format!(
|
||
"insufficient role: have {}, need {}",
|
||
member.role.name(),
|
||
required.name()
|
||
),
|
||
});
|
||
}
|
||
if commit.flags.modifies_authority() || commit.flags.is_fork() {
|
||
// The new authority must be reachable via the tree at .levcs/authority.
|
||
let new_auth_id = locate_new_authority(src, commit.tree)?;
|
||
let new_signed = read_signed(src, new_auth_id)?;
|
||
let new_body = AuthorityBody::parse(&new_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
verify_signed_object(&new_signed)?;
|
||
if commit.flags.is_fork() {
|
||
// Per §3.5.3, fork-commit signature is verified against the new
|
||
// genesis authority (already done above via auth_body, which for
|
||
// fork commits is the new authority since C.body.authority points
|
||
// to the new genesis). For *read authorization* against the
|
||
// source, look up the parent commit's authority.
|
||
if commit.parents.len() != 1 {
|
||
return Err(VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: format!(
|
||
"fork commit must have exactly 1 parent, got {}",
|
||
commit.parents.len()
|
||
),
|
||
});
|
||
}
|
||
let parent_signed = read_signed(src, commit.parents[0])?;
|
||
let parent_commit = levcs_core::Commit::from_signed(&parent_signed).map_err(|e| {
|
||
VerifyError::Commit {
|
||
hash: commit_id.to_hex(),
|
||
reason: e.to_string(),
|
||
}
|
||
})?;
|
||
let source_auth_signed = read_signed(src, parent_commit.authority)?;
|
||
let source_auth_body = AuthorityBody::parse(&source_auth_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
verify_fork(pk, &source_auth_body, &new_signed, &new_body)?;
|
||
} else {
|
||
verify_successor(&new_signed, &new_body, commit.authority, &auth_body, pk)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Verify a release object: check stored bytes hash to the claimed id,
|
||
/// validate the signed envelope, parse the release body, walk the
|
||
/// authority chain it cites back to genesis, and confirm every signing
|
||
/// key is a member of that authority.
|
||
///
|
||
/// Releases (§4.1) don't have an "author key" trailer the way commits do;
|
||
/// the signers are simply the authority members who minted it. So the
|
||
/// membership check loops over `signed.signatures` and pins each one
|
||
/// against the authority body. A release with no listed members signing
|
||
/// it is rejected.
|
||
pub fn verify_release<S: ObjectSource>(src: &S, release_id: ObjectId) -> Verification<()> {
|
||
verify_release_inner(src, release_id, &mut ChainVerifier::new())
|
||
}
|
||
|
||
fn verify_release_inner<S: ObjectSource>(
|
||
src: &S,
|
||
release_id: ObjectId,
|
||
verifier: &mut ChainVerifier,
|
||
) -> Verification<()> {
|
||
let bytes = src.read_raw(release_id)?;
|
||
let actual = blake3::hash(&bytes);
|
||
if *actual.as_bytes() != release_id.0 {
|
||
return Err(VerifyError::Object {
|
||
hash: release_id.to_hex(),
|
||
kind: "stored bytes do not match expected hash".into(),
|
||
});
|
||
}
|
||
let signed = SignedObject::parse(&bytes).map_err(|e| VerifyError::Object {
|
||
hash: release_id.to_hex(),
|
||
kind: e.to_string(),
|
||
})?;
|
||
if signed.object_type != ObjectType::Release {
|
||
return Err(VerifyError::Object {
|
||
hash: release_id.to_hex(),
|
||
kind: format!("expected release, got {}", signed.object_type.name()),
|
||
});
|
||
}
|
||
if signed.signatures.is_empty() {
|
||
return Err(VerifyError::Object {
|
||
hash: release_id.to_hex(),
|
||
kind: "release has no signatures".into(),
|
||
});
|
||
}
|
||
verify_signed_object(&signed)?;
|
||
let release =
|
||
levcs_core::Release::parse_body(&signed.body).map_err(|e| VerifyError::Object {
|
||
hash: release_id.to_hex(),
|
||
kind: e.to_string(),
|
||
})?;
|
||
let _ = verifier.verify_chain(src, release.authority)?;
|
||
let auth_signed = read_signed(src, release.authority)?;
|
||
let auth_body = AuthorityBody::parse(&auth_signed.body)
|
||
.map_err(|e| VerifyError::Authority(e.to_string()))?;
|
||
for s in &signed.signatures {
|
||
let pk = PublicKey(s.public_key);
|
||
if auth_body.find_member(&pk).is_none() {
|
||
return Err(VerifyError::Object {
|
||
hash: release_id.to_hex(),
|
||
kind: "release signed by key not in authority".into(),
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn locate_new_authority<S: ObjectSource>(src: &S, tree_id: ObjectId) -> Verification<ObjectId> {
|
||
let raw = parse_raw(src, tree_id, ObjectType::Tree)?;
|
||
let tree = Tree::parse_body(&raw.body).map_err(|e| VerifyError::Object {
|
||
hash: tree_id.to_hex(),
|
||
kind: e.to_string(),
|
||
})?;
|
||
let levcs_dir = tree.find(".levcs").ok_or_else(|| VerifyError::Commit {
|
||
hash: tree_id.to_hex(),
|
||
reason: "tree has no .levcs directory".into(),
|
||
})?;
|
||
if levcs_dir.entry_type != levcs_core::EntryType::Tree {
|
||
return Err(VerifyError::Commit {
|
||
hash: tree_id.to_hex(),
|
||
reason: ".levcs is not a tree".into(),
|
||
});
|
||
}
|
||
let inner = parse_raw(src, levcs_dir.hash, ObjectType::Tree)?;
|
||
let inner_tree = Tree::parse_body(&inner.body).map_err(|e| VerifyError::Object {
|
||
hash: levcs_dir.hash.to_hex(),
|
||
kind: e.to_string(),
|
||
})?;
|
||
let auth = inner_tree
|
||
.find("authority")
|
||
.ok_or_else(|| VerifyError::Commit {
|
||
hash: levcs_dir.hash.to_hex(),
|
||
reason: "tree has no .levcs/authority entry".into(),
|
||
})?;
|
||
Ok(auth.hash)
|
||
}
|
||
|
||
fn parse_raw<S: ObjectSource>(
|
||
src: &S,
|
||
id: ObjectId,
|
||
expect: ObjectType,
|
||
) -> Verification<RawObject> {
|
||
let bytes = src.read_raw(id)?;
|
||
let actual = blake3::hash(&bytes);
|
||
if *actual.as_bytes() != id.0 {
|
||
return Err(VerifyError::Object {
|
||
hash: id.to_hex(),
|
||
kind: "hash mismatch".into(),
|
||
});
|
||
}
|
||
let raw = RawObject::parse(&bytes).map_err(|e| VerifyError::Object {
|
||
hash: id.to_hex(),
|
||
kind: e.to_string(),
|
||
})?;
|
||
if raw.object_type != expect {
|
||
return Err(VerifyError::Object {
|
||
hash: id.to_hex(),
|
||
kind: format!("expected {}, got {}", expect.name(), raw.object_type.name()),
|
||
});
|
||
}
|
||
Ok(raw)
|
||
}
|
||
|
||
/// Used by the trailer count check at object boundaries.
|
||
#[allow(dead_code)]
|
||
pub(crate) fn entry_eq(a: &SignatureEntry, b: &SignatureEntry) -> bool {
|
||
a.public_key == b.public_key && a.signature == b.signature
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::keys::SecretKey;
|
||
use crate::sign::sign_authority;
|
||
|
||
#[test]
|
||
fn genesis_verifies() {
|
||
let sk = SecretKey::generate();
|
||
let pk = sk.public();
|
||
let mut body = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: 1_700_000_000_000_000,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: pk,
|
||
handle: "alice".into(),
|
||
role: Role::Owner,
|
||
added_micros: 1_700_000_000_000_000,
|
||
added_by: pk,
|
||
}],
|
||
policy: vec![crate::authority::PolicyEntry {
|
||
key: "public_read".into(),
|
||
value: vec![0x01],
|
||
}],
|
||
};
|
||
body.assign_genesis_repo_id().unwrap();
|
||
let signed = sign_authority(&body, &sk).unwrap();
|
||
verify_signed_object(&signed).unwrap();
|
||
let body2 = verify_genesis(&signed).unwrap();
|
||
assert_eq!(body, body2);
|
||
}
|
||
|
||
#[test]
|
||
fn fork_against_public_source_succeeds() {
|
||
// Source authority: public_read = true, alice owns it.
|
||
let alice = SecretKey::generate();
|
||
let alice_pk = alice.public();
|
||
let now = 1_700_000_000_000_000;
|
||
let mut source = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: now,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: alice_pk,
|
||
handle: "alice".into(),
|
||
role: Role::Owner,
|
||
added_micros: now,
|
||
added_by: alice_pk,
|
||
}],
|
||
policy: vec![crate::authority::PolicyEntry {
|
||
key: "public_read".into(),
|
||
value: vec![0x01],
|
||
}],
|
||
};
|
||
source.normalize().unwrap();
|
||
source.assign_genesis_repo_id().unwrap();
|
||
|
||
// Bob (a stranger) creates a fork-genesis.
|
||
let bob = SecretKey::generate();
|
||
let bob_pk = bob.public();
|
||
let mut fork = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: now,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: bob_pk,
|
||
handle: "bob".into(),
|
||
role: Role::Owner,
|
||
added_micros: now,
|
||
added_by: bob_pk,
|
||
}],
|
||
policy: vec![],
|
||
};
|
||
fork.normalize().unwrap();
|
||
fork.assign_genesis_repo_id().unwrap();
|
||
assert_ne!(fork.repo_id, source.repo_id);
|
||
let fork_signed = crate::sign::sign_authority(&fork, &bob).unwrap();
|
||
verify_fork(bob_pk, &source, &fork_signed, &fork).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn fork_against_private_source_blocks_strangers() {
|
||
let alice = SecretKey::generate();
|
||
let alice_pk = alice.public();
|
||
let now = 1_700_000_000_000_000;
|
||
let mut source = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: now,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: alice_pk,
|
||
handle: "alice".into(),
|
||
role: Role::Owner,
|
||
added_micros: now,
|
||
added_by: alice_pk,
|
||
}],
|
||
policy: vec![crate::authority::PolicyEntry {
|
||
key: "public_read".into(),
|
||
value: vec![0x00],
|
||
}],
|
||
};
|
||
source.normalize().unwrap();
|
||
source.assign_genesis_repo_id().unwrap();
|
||
|
||
let bob = SecretKey::generate();
|
||
let bob_pk = bob.public();
|
||
let mut fork = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: now,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: bob_pk,
|
||
handle: "bob".into(),
|
||
role: Role::Owner,
|
||
added_micros: now,
|
||
added_by: bob_pk,
|
||
}],
|
||
policy: vec![],
|
||
};
|
||
fork.normalize().unwrap();
|
||
fork.assign_genesis_repo_id().unwrap();
|
||
let fork_signed = crate::sign::sign_authority(&fork, &bob).unwrap();
|
||
let res = verify_fork(bob_pk, &source, &fork_signed, &fork);
|
||
assert!(
|
||
res.is_err(),
|
||
"stranger should not be able to fork private source"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fork_with_colliding_repo_id_rejected() {
|
||
let alice = SecretKey::generate();
|
||
let alice_pk = alice.public();
|
||
let now = 1_700_000_000_000_000;
|
||
let mut source = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: now,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: alice_pk,
|
||
handle: "alice".into(),
|
||
role: Role::Owner,
|
||
added_micros: now,
|
||
added_by: alice_pk,
|
||
}],
|
||
policy: vec![crate::authority::PolicyEntry {
|
||
key: "public_read".into(),
|
||
value: vec![0x01],
|
||
}],
|
||
};
|
||
source.normalize().unwrap();
|
||
source.assign_genesis_repo_id().unwrap();
|
||
// Pretend the fork derived the same repo_id (impossible in practice
|
||
// without a BLAKE3 collision, but the verifier must still reject).
|
||
let mut fork = source.clone();
|
||
let fork_signed = crate::sign::sign_authority(&fork, &alice).unwrap();
|
||
let _ = fork.assign_genesis_repo_id();
|
||
let res = verify_fork(alice_pk, &source, &fork_signed, &fork);
|
||
assert!(res.is_err(), "colliding repo_id must be rejected");
|
||
}
|
||
|
||
#[test]
|
||
fn tampered_genesis_rejected() {
|
||
let sk = SecretKey::generate();
|
||
let pk = sk.public();
|
||
let mut body = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: 1_700_000_000_000_000,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: pk,
|
||
handle: "alice".into(),
|
||
role: Role::Owner,
|
||
added_micros: 1_700_000_000_000_000,
|
||
added_by: pk,
|
||
}],
|
||
policy: vec![],
|
||
};
|
||
body.assign_genesis_repo_id().unwrap();
|
||
let mut signed = sign_authority(&body, &sk).unwrap();
|
||
signed.body[0] ^= 0xFF; // tamper
|
||
assert!(verify_signed_object(&signed).is_err());
|
||
}
|
||
|
||
/// `ObjectSource` wrapper that counts each `read_raw` call. Lets us
|
||
/// assert exactly how many bytes the verifier actually fetched, which
|
||
/// is the only direct way to observe a chain-cache hit.
|
||
struct CountingSource<'a> {
|
||
inner: &'a MemorySource,
|
||
reads: std::cell::Cell<usize>,
|
||
}
|
||
impl<'a> CountingSource<'a> {
|
||
fn new(inner: &'a MemorySource) -> Self {
|
||
Self {
|
||
inner,
|
||
reads: std::cell::Cell::new(0),
|
||
}
|
||
}
|
||
fn reads(&self) -> usize {
|
||
self.reads.get()
|
||
}
|
||
}
|
||
impl<'a> ObjectSource for CountingSource<'a> {
|
||
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>> {
|
||
self.reads.set(self.reads.get() + 1);
|
||
self.inner.read_raw(id)
|
||
}
|
||
}
|
||
|
||
/// Build a chain of `length` authorities — genesis at index 0, each
|
||
/// later entry a properly-signed successor of the previous — backed
|
||
/// by `alice` (an Owner) for the whole walk. Returns the
|
||
/// `MemorySource` plus the list of authority ids in order.
|
||
fn build_chain(length: usize, alice: &SecretKey) -> (MemorySource, Vec<ObjectId>) {
|
||
assert!(length >= 1);
|
||
let alice_pk = alice.public();
|
||
let now = 1_700_000_000_000_000;
|
||
let mut genesis = AuthorityBody {
|
||
schema_version: 1,
|
||
repo_id: ObjectId([0u8; 32]),
|
||
previous_authority: ObjectId([0u8; 32]),
|
||
version: 1,
|
||
created_micros: now,
|
||
members: vec![crate::authority::MemberEntry {
|
||
key: alice_pk,
|
||
handle: "alice".into(),
|
||
role: Role::Owner,
|
||
added_micros: now,
|
||
added_by: alice_pk,
|
||
}],
|
||
policy: vec![crate::authority::PolicyEntry {
|
||
key: "public_read".into(),
|
||
value: vec![0x01],
|
||
}],
|
||
};
|
||
genesis.normalize().unwrap();
|
||
genesis.assign_genesis_repo_id().unwrap();
|
||
let genesis_signed = sign_authority(&genesis, alice).unwrap();
|
||
let genesis_id = ObjectId(*blake3::hash(&genesis_signed.serialize()).as_bytes());
|
||
|
||
let mut store: HashMap<ObjectId, Vec<u8>> = HashMap::new();
|
||
let mut ids = vec![genesis_id];
|
||
store.insert(genesis_id, genesis_signed.serialize());
|
||
|
||
let mut prev_id = genesis_id;
|
||
let mut prev_body = genesis;
|
||
for v in 2..=length as u32 {
|
||
let mut body = AuthorityBody {
|
||
schema_version: prev_body.schema_version,
|
||
repo_id: prev_body.repo_id,
|
||
previous_authority: prev_id,
|
||
version: v,
|
||
created_micros: now + v as i64 * 1_000_000,
|
||
members: prev_body.members.clone(),
|
||
policy: prev_body.policy.clone(),
|
||
};
|
||
body.normalize().unwrap();
|
||
let signed = sign_authority(&body, alice).unwrap();
|
||
let id = ObjectId(*blake3::hash(&signed.serialize()).as_bytes());
|
||
store.insert(id, signed.serialize());
|
||
ids.push(id);
|
||
prev_id = id;
|
||
prev_body = body;
|
||
}
|
||
(MemorySource(store), ids)
|
||
}
|
||
|
||
/// Verifying a chain of length N from the tip touches every
|
||
/// authority object exactly once. A second verification of the same
|
||
/// tip — using the same `ChainVerifier` — must perform zero reads.
|
||
/// This is the user-visible win: O(N²) → O(N) total work for N
|
||
/// commits citing the same chain.
|
||
#[test]
|
||
fn chain_verifier_caches_walked_authorities() {
|
||
let alice = SecretKey::generate();
|
||
let (mem, ids) = build_chain(5, &alice);
|
||
let src = CountingSource::new(&mem);
|
||
let mut verifier = ChainVerifier::new();
|
||
|
||
let _ = verifier.verify_chain(&src, *ids.last().unwrap()).unwrap();
|
||
let first_pass_reads = src.reads();
|
||
assert!(
|
||
first_pass_reads >= 5,
|
||
"first verification must read every authority at least once; got {first_pass_reads}"
|
||
);
|
||
// Every walked authority should now sit in the cache pointing
|
||
// at the same shared genesis body.
|
||
assert_eq!(verifier.cache_size(), ids.len());
|
||
|
||
let before = src.reads();
|
||
let _ = verifier.verify_chain(&src, *ids.last().unwrap()).unwrap();
|
||
assert_eq!(
|
||
src.reads(),
|
||
before,
|
||
"cached re-verification must perform zero reads"
|
||
);
|
||
}
|
||
|
||
/// Verifying an *ancestor* after the tip must also be a cache hit —
|
||
/// this is the practical case during a sync where one tip points at
|
||
/// `A_n` and a second tip points at `A_{n-1}`.
|
||
#[test]
|
||
fn chain_verifier_serves_ancestors_from_cache() {
|
||
let alice = SecretKey::generate();
|
||
let (mem, ids) = build_chain(4, &alice);
|
||
let src = CountingSource::new(&mem);
|
||
let mut verifier = ChainVerifier::new();
|
||
let _ = verifier.verify_chain(&src, *ids.last().unwrap()).unwrap();
|
||
let after_tip = src.reads();
|
||
// Ask for A_2 — should be a hit.
|
||
let _ = verifier.verify_chain(&src, ids[1]).unwrap();
|
||
assert_eq!(
|
||
src.reads(),
|
||
after_tip,
|
||
"ancestor verification must hit cache, not re-walk"
|
||
);
|
||
}
|
||
|
||
/// A failing verification must NOT pollute the cache. The test
|
||
/// rebuilds a chain, corrupts the bytes of the most recent
|
||
/// authority, and checks that (a) verification fails, (b) the
|
||
/// cache stays empty, and (c) a follow-up valid lookup still walks
|
||
/// the chain rather than serving a phantom hit.
|
||
#[test]
|
||
fn chain_verifier_does_not_cache_on_failure() {
|
||
let alice = SecretKey::generate();
|
||
let (mut mem, ids) = build_chain(3, &alice);
|
||
let tip_id = *ids.last().unwrap();
|
||
// Mutate the byte at offset 12 of the tip's signed bytes — that
|
||
// sits inside the authority body, so the recorded BLAKE3 hash
|
||
// will no longer match the id that names it. The verifier reads
|
||
// by id and re-hashes the bytes; mismatched bytes propagate as
|
||
// a `repo_id derivation invalid` or a parse-time error depending
|
||
// on the offset, but in either case the chain walk fails.
|
||
let bytes = mem.0.get(&tip_id).unwrap().clone();
|
||
let mut bad = bytes.clone();
|
||
bad[12] ^= 0xFF;
|
||
mem.0.insert(tip_id, bad);
|
||
|
||
let src = CountingSource::new(&mem);
|
||
let mut verifier = ChainVerifier::new();
|
||
assert!(
|
||
verifier.verify_chain(&src, tip_id).is_err(),
|
||
"corrupted tip must fail verification"
|
||
);
|
||
assert_eq!(
|
||
verifier.cache_size(),
|
||
0,
|
||
"failed verification must leave the cache empty"
|
||
);
|
||
}
|
||
}
|