#![allow(clippy::too_long_first_doc_paragraph)]
use polyc_crypto::signing_role::JournalAttestationSigner;
use polyc_mmr::{SignedRoot, VerifiableLog, verify_root_signature_with_trust};
use crate::Event;
pub const MMR_SIGNED_ROOT_KIND: &str = "__mmr_signed_root__";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum IntegrityError {
#[error("mmr: {0}")]
Mmr(#[from] polyc_mmr::MmrError),
#[error("malformed signed-root marker at leaf count {leaf_count_hint}: {source}")]
MalformedRoot {
leaf_count_hint: u64,
source: serde_json::Error,
},
#[error("signed root at leaf count {leaf_count} does not verify under the expected signer")]
SignatureInvalid {
leaf_count: u64,
},
#[error(
"integrity violation: at leaf count {leaf_count}, replay computed root {computed_root_hex} \
but the signed marker recorded {expected_root_hex}"
)]
RootMismatch {
leaf_count: u64,
expected_root_hex: String,
computed_root_hex: String,
},
}
pub fn extend_and_sign(
log: &VerifiableLog,
new_events: &[Event],
signer: &JournalAttestationSigner,
) -> Result<Event, IntegrityError> {
for event in new_events {
log.append(&event.kind, &event.payload)?;
}
let root = log.sign_root(signer)?;
let payload = serde_json::to_vec(&root).expect("SignedRoot serializes");
Ok(Event::new(MMR_SIGNED_ROOT_KIND, payload))
}
pub fn rebuild_from_events(events: &[Event]) -> Result<VerifiableLog, IntegrityError> {
let log = VerifiableLog::rebuild(
events
.iter()
.filter(|e| e.kind != MMR_SIGNED_ROOT_KIND)
.map(|e| (e.kind.as_str(), e.payload.as_slice())),
)?;
Ok(log)
}
pub fn verify_replay(events: &[Event], expected_signer_pk_hex: &str) -> Result<(), IntegrityError> {
let public_key = hex::decode(expected_signer_pk_hex)
.map_err(|_| IntegrityError::SignatureInvalid { leaf_count: 0 })?;
let trust = polyc_crypto::signing_role::RoleTrustSet::<
polyc_crypto::signing_role::JournalAttestationRole,
>::from_public_keys(vec![public_key])
.map_err(|_| IntegrityError::SignatureInvalid { leaf_count: 0 })?;
verify_replay_with_trust(events, &trust)
}
pub fn verify_replay_with_trust(
events: &[Event],
trust: &polyc_crypto::signing_role::RoleTrustSet<
polyc_crypto::signing_role::JournalAttestationRole,
>,
) -> Result<(), IntegrityError> {
verify_extension_with_trust(&VerifiableLog::new(), events, trust)
}
pub fn verify_extension_with_trust(
log: &VerifiableLog,
events: &[Event],
trust: &polyc_crypto::signing_role::RoleTrustSet<
polyc_crypto::signing_role::JournalAttestationRole,
>,
) -> Result<(), IntegrityError> {
for event in events {
if event.kind == MMR_SIGNED_ROOT_KIND {
let leaf_count = log.leaf_count()?;
let root: SignedRoot = serde_json::from_slice(&event.payload).map_err(|source| {
IntegrityError::MalformedRoot {
leaf_count_hint: leaf_count,
source,
}
})?;
let sig_ok = verify_root_signature_with_trust(&root, trust).unwrap_or(false);
if !sig_ok {
return Err(IntegrityError::SignatureInvalid { leaf_count });
}
let computed_root = log.root()?;
let computed_root_hex = hex::encode(computed_root.as_ref());
if root.leaf_count != leaf_count || root.root_hex != computed_root_hex {
return Err(IntegrityError::RootMismatch {
leaf_count,
expected_root_hex: root.root_hex,
computed_root_hex,
});
}
} else {
log.append(&event.kind, &event.payload)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn signer() -> JournalAttestationSigner {
JournalAttestationSigner::from_seed(7)
}
fn pk_hex(signer: &JournalAttestationSigner) -> String {
hex::encode(signer.public_key_bytes())
}
#[test]
fn mmr_verify_replay_detects_tampered_event() {
let log = VerifiableLog::new();
let s = signer();
let turn_events = vec![
Event::new("user_msg", b"what is 2+2?".to_vec()),
Event::new("output_msg", b"4".to_vec()),
];
let marker = extend_and_sign(&log, &turn_events, &s).expect("sign");
let mut persisted = turn_events.clone();
persisted.push(marker);
verify_replay(&persisted, &pk_hex(&s)).expect("untampered replay must verify");
persisted[1].payload[0] ^= 0xFF;
let err = verify_replay(&persisted, &pk_hex(&s))
.expect_err("tampered replay must report an integrity violation");
assert!(
matches!(err, IntegrityError::RootMismatch { .. }),
"expected a root mismatch, got {err:?}"
);
}
#[test]
fn the_signed_root_is_blind_to_the_trust_tag() {
let s = signer();
let tagged = vec![
Event::trusted("user_msg", b"a".to_vec()),
Event::quarantined("tool_result", b"b".to_vec()),
];
let untagged = vec![
Event::new("user_msg", b"a".to_vec()),
Event::new("tool_result", b"b".to_vec()),
];
let tagged_tree = rebuild_from_events(&tagged).expect("rebuild tagged");
let untagged_tree = rebuild_from_events(&untagged).expect("rebuild untagged");
assert_eq!(
tagged_tree.root().expect("tagged root"),
untagged_tree.root().expect("untagged root"),
"the trust tag is not a leaf input, so it cannot move the root"
);
assert_eq!(
tagged_tree.leaf_count().expect("tagged leaves"),
untagged_tree.leaf_count().expect("untagged leaves")
);
let marker = extend_and_sign(&VerifiableLog::new(), &tagged, &s).expect("sign tagged");
let mut swapped = untagged;
swapped.push(marker);
verify_replay(&swapped, &pk_hex(&s))
.expect("a tag-only difference does not disturb verification");
}
#[test]
fn mmr_verify_replay_accepts_multi_turn_untampered_log() {
let log = VerifiableLog::new();
let s = signer();
let mut persisted = Vec::new();
for turn in 0..3u8 {
let events = vec![
Event::new("user_msg", vec![turn]),
Event::new("output_msg", vec![turn, turn]),
];
let marker = extend_and_sign(&log, &events, &s).expect("sign");
persisted.extend(events);
persisted.push(marker);
}
verify_replay(&persisted, &pk_hex(&s)).expect("three untampered turns must verify");
}
#[test]
fn replay_spans_attestation_rotation_only_with_explicit_history() {
use polyc_crypto::signing_role::{JournalAttestationRole, RoleTrustSet};
let first = JournalAttestationSigner::from_seed(71);
let second = JournalAttestationSigner::from_seed(72);
let log = VerifiableLog::new();
let first_events = vec![Event::new("user_msg", b"before".to_vec())];
let first_marker = extend_and_sign(&log, &first_events, &first).expect("first root");
let second_events = vec![Event::new("output_msg", b"after".to_vec())];
let second_marker = extend_and_sign(&log, &second_events, &second).expect("second root");
let persisted = [
first_events,
vec![first_marker],
second_events,
vec![second_marker],
]
.concat();
let current_only = RoleTrustSet::<JournalAttestationRole>::current(&second);
assert!(verify_replay_with_trust(&persisted, ¤t_only).is_err());
let history = RoleTrustSet::<JournalAttestationRole>::checked(vec![
second.identity(),
first.identity(),
])
.expect("valid history");
verify_replay_with_trust(&persisted, &history)
.expect("retired root remains verifiable during rotation overlap");
}
#[test]
fn mmr_verify_replay_rejects_root_signed_by_a_different_key() {
let log = VerifiableLog::new();
let s = signer();
let events = vec![Event::new("user_msg", b"hi".to_vec())];
let marker = extend_and_sign(&log, &events, &s).expect("sign");
let mut persisted = events;
persisted.push(marker);
let other = JournalAttestationSigner::from_seed(999);
let err = verify_replay(&persisted, &pk_hex(&other))
.expect_err("a root signed under a different key must not verify");
assert!(matches!(err, IntegrityError::SignatureInvalid { .. }));
}
#[test]
fn mmr_verify_replay_accepts_partition_with_no_signed_roots_yet() {
let events = vec![Event::new("user_msg", b"no marker yet".to_vec())];
verify_replay(&events, &pk_hex(&signer())).expect("no markers is trivially fine");
}
#[test]
fn verifying_a_tail_against_a_running_tree_matches_a_full_replay() {
use polyc_crypto::signing_role::{JournalAttestationRole, RoleTrustSet};
let s = signer();
let trust = RoleTrustSet::<JournalAttestationRole>::current(&s);
let writer = VerifiableLog::new();
let mut persisted = Vec::new();
let running = VerifiableLog::new();
for turn in 0..4u8 {
let events = vec![
Event::new("user_msg", vec![turn]),
Event::new("output_msg", vec![turn, turn]),
];
let marker = extend_and_sign(&writer, &events, &s).expect("sign");
let mut tail = events;
tail.push(marker);
verify_extension_with_trust(&running, &tail, &trust)
.expect("each tail verifies against the tree its predecessors built");
persisted.extend(tail);
verify_replay_with_trust(&persisted, &trust).expect("and so does the whole partition");
assert_eq!(running.root().unwrap(), writer.root().unwrap());
assert_eq!(running.leaf_count().unwrap(), writer.leaf_count().unwrap());
}
let events = vec![Event::new("user_msg", b"honest".to_vec())];
let marker = extend_and_sign(&writer, &events, &s).expect("sign");
let mut tampered = events;
tampered[0].payload[0] ^= 0xFF;
tampered.push(marker);
assert!(matches!(
verify_extension_with_trust(&running, &tampered, &trust)
.expect_err("a tampered tail must not verify"),
IntegrityError::RootMismatch { .. }
));
}
#[test]
fn mmr_rebuild_from_events_skips_marker_events() {
let log = VerifiableLog::new();
let s = signer();
let events = vec![
Event::new("user_msg", b"a".to_vec()),
Event::new("output_msg", b"b".to_vec()),
];
let marker = extend_and_sign(&log, &events, &s).expect("sign");
let mut persisted = events;
persisted.push(marker);
let rebuilt = rebuild_from_events(&persisted).expect("rebuild");
assert_eq!(rebuilt.leaf_count().unwrap(), 2, "markers are not leaves");
assert_eq!(rebuilt.root().unwrap(), log.root().unwrap());
}
}