use crate::supervisor::{AuditEntry, UrlOpenRequest};
use crate::trust;
use crate::undo::{
AuditAttestationSummary, AuditIntegritySummary, ContentHash, NetworkAuditEvent, SessionMetadata,
};
use crate::{NonoError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sigstore_verify::types::bundle::SignatureContent;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
pub const AUDIT_EVENTS_FILENAME: &str = "audit-events.ndjson";
pub const EVENT_DOMAIN_ALPHA: &[u8] = b"nono.audit.event.alpha\n";
pub const CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.chain.alpha\n";
pub const MERKLE_NODE_DOMAIN_ALPHA: &[u8] = b"nono.audit.merkle.alpha\n";
pub const MERKLE_SCHEME_ALPHA: &str = "alpha";
pub const AUDIT_HASH_ALGORITHM: &str = "sha256";
pub const SESSION_DIGEST_DOMAIN_ALPHA: &[u8] = b"nono.audit.session-digest.alpha\n";
pub const LEDGER_CHAIN_DOMAIN_ALPHA: &[u8] = b"nono.audit.ledger.chain.alpha\n";
pub const AUDIT_ATTESTATION_BUNDLE_FILENAME: &str = "audit-attestation.bundle";
pub const AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA: &str =
"https://nono.sh/attestation/audit-session/alpha";
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuditEventPayload {
SessionStarted {
started: String,
command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
redaction_policy: Option<crate::ScrubPolicyDiff>,
},
SessionEnded {
ended: String,
exit_code: i32,
},
CapabilityDecision {
entry: AuditEntry,
},
UrlOpen {
request: UrlOpenRequest,
success: bool,
error: Option<String>,
},
Network {
event: NetworkAuditEvent,
},
}
#[derive(Clone, Serialize, Deserialize)]
pub struct AuditEventRecord {
pub sequence: u64,
pub prev_chain: Option<ContentHash>,
pub leaf_hash: ContentHash,
pub chain_hash: ContentHash,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_json: Option<String>,
pub event: AuditEventPayload,
}
#[derive(Serialize)]
pub struct AuditVerificationResult {
pub hash_algorithm: String,
pub merkle_scheme: String,
pub event_count: u64,
pub computed_chain_head: Option<ContentHash>,
pub computed_merkle_root: Option<ContentHash>,
pub stored_event_count: Option<u64>,
pub stored_chain_head: Option<ContentHash>,
pub stored_merkle_root: Option<ContentHash>,
pub event_count_matches: bool,
pub records_verified: bool,
}
#[derive(Serialize)]
struct SessionDigestPayload<'a> {
session_id: &'a str,
started: &'a str,
ended: &'a Option<String>,
command: &'a [String],
executable_identity: Option<ExecutableIdentityDigestPayload>,
tracked_paths: Vec<Vec<u8>>,
snapshot_count: u32,
exit_code: &'a Option<i32>,
merkle_roots: &'a [ContentHash],
network_events: &'a [NetworkAuditEvent],
audit_event_count: u64,
audit_integrity: &'a Option<AuditIntegritySummary>,
audit_attestation: &'a Option<AuditAttestationSummary>,
}
#[derive(Serialize)]
struct ExecutableIdentityDigestPayload {
resolved_path: Vec<u8>,
sha256: ContentHash,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerRecord {
pub sequence: u64,
pub prev_chain: Option<ContentHash>,
pub session_id: String,
pub session_digest: ContentHash,
pub completed_at: String,
pub chain_hash: ContentHash,
}
#[derive(Serialize)]
struct LedgerLinkPayload<'a> {
sequence: u64,
session_id: &'a str,
session_digest: ContentHash,
completed_at: &'a str,
}
#[derive(Debug, Clone, Serialize)]
pub struct LedgerVerificationResult {
pub hash_algorithm: String,
pub entry_count: u64,
pub session_digest: ContentHash,
pub session_found: bool,
pub session_digest_matches: bool,
pub ledger_chain_verified: bool,
pub ledger_head: Option<ContentHash>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditAttestationVerificationResult {
pub present: bool,
pub predicate_type: Option<String>,
pub key_id: Option<String>,
pub key_id_matches: bool,
pub signature_verified: bool,
pub merkle_root_matches: bool,
pub session_id_matches: bool,
pub expected_public_key_matches: Option<bool>,
pub verification_error: Option<String>,
}
#[derive(Serialize)]
struct AuditAttestationPredicate<'a> {
version: u32,
session_id: &'a str,
started: &'a str,
ended: &'a Option<String>,
command: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
redaction_policy: Option<crate::ScrubPolicyDiff>,
audit_log: AuditLogPredicate<'a>,
signer: AuditSignerPredicate<'a>,
}
#[derive(Serialize)]
struct AuditLogPredicate<'a> {
hash_algorithm: &'a str,
event_count: u64,
chain_head: &'a ContentHash,
merkle_root: &'a ContentHash,
}
#[derive(Serialize)]
struct AuditSignerPredicate<'a> {
kind: &'static str,
key_id: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditProofDirection {
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditProofNode {
pub direction: AuditProofDirection,
pub hash: ContentHash,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditInclusionProof {
pub leaf_index: u64,
pub leaf_count: u64,
pub leaf_hash: ContentHash,
pub merkle_root: ContentHash,
pub siblings: Vec<AuditProofNode>,
}
pub struct AuditRecorder {
file: File,
next_sequence: u64,
previous_chain: Option<ContentHash>,
leaf_hashes: Vec<ContentHash>,
redaction_policy: crate::ScrubPolicy,
}
impl AuditRecorder {
pub fn new(session_dir: PathBuf) -> Result<Self> {
Self::new_with_policy(session_dir, crate::ScrubPolicy::secure_default())
}
pub fn new_with_policy(
session_dir: PathBuf,
redaction_policy: crate::ScrubPolicy,
) -> Result<Self> {
let path = session_dir.join(AUDIT_EVENTS_FILENAME);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| {
NonoError::Snapshot(format!(
"Failed to open audit event log {}: {e}",
path.display()
))
})?;
Ok(Self {
file,
next_sequence: 0,
previous_chain: None,
leaf_hashes: Vec::new(),
redaction_policy,
})
}
pub fn record_session_started(&mut self, started: String, command: Vec<String>) -> Result<()> {
self.append_event(AuditEventPayload::SessionStarted {
started,
command: crate::scrub_argv_with_policy(&command, &self.redaction_policy),
redaction_policy: self
.redaction_policy
.diff_from_secure_default()
.into_option(),
})
}
pub fn record_session_ended(&mut self, ended: String, exit_code: i32) -> Result<()> {
self.append_event(AuditEventPayload::SessionEnded { ended, exit_code })
}
pub fn record_capability_decision(&mut self, entry: AuditEntry) -> Result<()> {
self.append_event(AuditEventPayload::CapabilityDecision { entry })
}
pub fn record_open_url(
&mut self,
request: UrlOpenRequest,
success: bool,
error: Option<String>,
) -> Result<()> {
self.append_event(AuditEventPayload::UrlOpen {
request,
success,
error,
})
}
pub fn record_network_event(&mut self, event: NetworkAuditEvent) -> Result<()> {
self.append_event(AuditEventPayload::Network { event })
}
#[must_use]
pub fn event_count(&self) -> u64 {
self.leaf_hashes.len() as u64
}
#[must_use]
pub fn finalize(&self) -> Option<AuditIntegritySummary> {
let chain_head = self.previous_chain?;
let merkle_root = merkle_root(&self.leaf_hashes);
Some(AuditIntegritySummary {
hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
event_count: self.event_count(),
chain_head,
merkle_root,
})
}
fn append_event(&mut self, event: AuditEventPayload) -> Result<()> {
let event_bytes = serde_json::to_vec(&event)
.map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit event: {e}")))?;
let leaf_hash = hash_event(&event_bytes);
let chain_hash = hash_chain(self.previous_chain.as_ref(), &leaf_hash);
let record = AuditEventRecord {
sequence: self.next_sequence,
prev_chain: self.previous_chain,
leaf_hash,
chain_hash,
event_json: Some(String::from_utf8(event_bytes.clone()).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to encode canonical audit event JSON as UTF-8: {e}"
))
})?),
event,
};
let line = serde_json::to_vec(&record)
.map_err(|e| NonoError::Snapshot(format!("Failed to serialize audit record: {e}")))?;
self.file
.write_all(&line)
.and_then(|_| self.file.write_all(b"\n"))
.and_then(|_| self.file.flush())
.map_err(|e| NonoError::Snapshot(format!("Failed to append audit record: {e}")))?;
self.next_sequence = self.next_sequence.saturating_add(1);
self.previous_chain = Some(chain_hash);
self.leaf_hashes.push(leaf_hash);
Ok(())
}
}
#[must_use]
pub fn hash_event(event_bytes: &[u8]) -> ContentHash {
let mut hasher = Sha256::new();
hasher.update(EVENT_DOMAIN_ALPHA);
hasher.update(event_bytes);
ContentHash::from_bytes(hasher.finalize().into())
}
#[must_use]
pub fn hash_chain(previous: Option<&ContentHash>, leaf_hash: &ContentHash) -> ContentHash {
let mut hasher = Sha256::new();
hasher.update(CHAIN_DOMAIN_ALPHA);
if let Some(prev) = previous {
hasher.update(prev.as_bytes());
} else {
hasher.update([0u8; 32]);
}
hasher.update(leaf_hash.as_bytes());
ContentHash::from_bytes(hasher.finalize().into())
}
#[must_use]
pub fn merkle_root(leaves: &[ContentHash]) -> ContentHash {
if leaves.is_empty() {
return ContentHash::from_bytes(Sha256::digest(b"").into());
}
let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect();
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
for pair in level.chunks(2) {
let left = pair[0];
if pair.len() == 1 {
next.push(left);
continue;
}
let right = pair[1];
next.push(hash_merkle_node(left, right));
}
level = next;
}
ContentHash::from_bytes(level[0])
}
pub fn build_inclusion_proof(
leaves: &[ContentHash],
leaf_index: usize,
) -> Result<AuditInclusionProof> {
if leaves.is_empty() {
return Err(NonoError::Snapshot(
"Cannot build an audit inclusion proof for an empty log".to_string(),
));
}
if leaf_index >= leaves.len() {
return Err(NonoError::Snapshot(format!(
"Audit inclusion proof leaf index {} is out of range for {} leaves",
leaf_index,
leaves.len()
)));
}
let mut siblings = Vec::new();
let mut index = leaf_index;
let mut level: Vec<[u8; 32]> = leaves.iter().map(|leaf| *leaf.as_bytes()).collect();
while level.len() > 1 {
let sibling_index = if index.is_multiple_of(2) {
index.saturating_add(1)
} else {
index.saturating_sub(1)
};
if let Some(sibling) = level.get(sibling_index) {
siblings.push(AuditProofNode {
direction: if sibling_index < index {
AuditProofDirection::Left
} else {
AuditProofDirection::Right
},
hash: ContentHash::from_bytes(*sibling),
});
}
let mut next = Vec::with_capacity(level.len().div_ceil(2));
for pair in level.chunks(2) {
let left = pair[0];
if pair.len() == 1 {
next.push(left);
continue;
}
next.push(hash_merkle_node(left, pair[1]));
}
index /= 2;
level = next;
}
Ok(AuditInclusionProof {
leaf_index: leaf_index as u64,
leaf_count: leaves.len() as u64,
leaf_hash: leaves[leaf_index],
merkle_root: ContentHash::from_bytes(level[0]),
siblings,
})
}
#[must_use]
pub fn verify_inclusion_proof(proof: &AuditInclusionProof) -> bool {
if proof.leaf_count == 0 || proof.leaf_index >= proof.leaf_count {
return false;
}
let mut computed = *proof.leaf_hash.as_bytes();
let mut index = proof.leaf_index;
let mut width = proof.leaf_count;
let mut siblings = proof.siblings.iter();
while width > 1 {
let expected_direction = if index.is_multiple_of(2) {
if index.saturating_add(1) < width {
Some(AuditProofDirection::Right)
} else {
None
}
} else {
Some(AuditProofDirection::Left)
};
if let Some(direction) = expected_direction {
let Some(node) = siblings.next() else {
return false;
};
if node.direction != direction {
return false;
}
computed = match node.direction {
AuditProofDirection::Left => hash_merkle_node(*node.hash.as_bytes(), computed),
AuditProofDirection::Right => hash_merkle_node(computed, *node.hash.as_bytes()),
};
}
index /= 2;
width = width.div_ceil(2);
}
if siblings.next().is_some() {
return false;
}
computed == *proof.merkle_root.as_bytes()
}
fn hash_merkle_node(left: [u8; 32], right: [u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(MERKLE_NODE_DOMAIN_ALPHA);
hasher.update(left);
hasher.update(right);
hasher.finalize().into()
}
pub fn compute_session_digest(metadata: &SessionMetadata) -> Result<ContentHash> {
let payload = SessionDigestPayload {
session_id: &metadata.session_id,
started: &metadata.started,
ended: &metadata.ended,
command: &metadata.command,
executable_identity: metadata.executable_identity.as_ref().map(|identity| {
ExecutableIdentityDigestPayload {
resolved_path: path_bytes(&identity.resolved_path),
sha256: identity.sha256,
}
}),
tracked_paths: metadata
.tracked_paths
.iter()
.map(|path| path_bytes(path))
.collect(),
snapshot_count: metadata.snapshot_count,
exit_code: &metadata.exit_code,
merkle_roots: &metadata.merkle_roots,
network_events: &metadata.network_events,
audit_event_count: metadata.audit_event_count,
audit_integrity: &metadata.audit_integrity,
audit_attestation: &metadata.audit_attestation,
};
let bytes = serde_json::to_vec(&payload).map_err(|e| {
NonoError::Snapshot(format!("Failed to serialize session digest payload: {e}"))
})?;
let mut hasher = Sha256::new();
hasher.update(SESSION_DIGEST_DOMAIN_ALPHA);
hasher.update(bytes);
Ok(ContentHash::from_bytes(hasher.finalize().into()))
}
#[cfg(unix)]
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
path.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
path.to_string_lossy().into_owned().into_bytes()
}
pub fn validate_ledger_session_id(session_id: &str) -> Result<()> {
let valid = !session_id.is_empty()
&& session_id.len() <= 64
&& session_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'));
if valid {
Ok(())
} else {
Err(NonoError::ConfigParse(format!(
"invalid audit session id: {session_id}"
)))
}
}
pub fn append_session_to_ledger_file(
file: &mut std::fs::File,
metadata: &SessionMetadata,
) -> Result<LedgerRecord> {
validate_ledger_session_id(&metadata.session_id)?;
file.seek(SeekFrom::Start(0))
.map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger: {e}")))?;
let mut previous_chain = None;
let mut next_sequence = 0u64;
{
let reader = BufReader::new(&mut *file);
for (index, line) in reader.lines().enumerate() {
let line =
line.map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?;
if line.trim().is_empty() {
continue;
}
let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to parse audit ledger line {}: {e}",
index.saturating_add(1)
))
})?;
previous_chain = Some(record.chain_hash);
next_sequence = record.sequence.saturating_add(1);
}
}
let session_digest = compute_session_digest(metadata)?;
let completed_at = metadata
.ended
.clone()
.unwrap_or_else(|| metadata.started.clone());
let chain_hash = hash_ledger_link(
previous_chain.as_ref(),
next_sequence,
&metadata.session_id,
&session_digest,
&completed_at,
)?;
let record = LedgerRecord {
sequence: next_sequence,
prev_chain: previous_chain,
session_id: metadata.session_id.clone(),
session_digest,
completed_at,
chain_hash,
};
file.seek(SeekFrom::End(0))
.map_err(|e| NonoError::Snapshot(format!("Failed to seek audit ledger for append: {e}")))?;
let line = serde_json::to_vec(&record).map_err(|e| {
NonoError::Snapshot(format!("Failed to serialize audit ledger record: {e}"))
})?;
file.write_all(&line)
.and_then(|_| file.write_all(b"\n"))
.and_then(|_| file.sync_data())
.map_err(|e| NonoError::Snapshot(format!("Failed to append audit ledger record: {e}")))?;
Ok(record)
}
pub fn missing_ledger_verification_result(
metadata: &SessionMetadata,
) -> Result<LedgerVerificationResult> {
Ok(LedgerVerificationResult {
hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
entry_count: 0,
session_digest: compute_session_digest(metadata)?,
session_found: false,
session_digest_matches: false,
ledger_chain_verified: false,
ledger_head: None,
})
}
pub fn verify_session_in_ledger_reader<R: BufRead>(
reader: R,
metadata: &SessionMetadata,
) -> Result<LedgerVerificationResult> {
let expected_digest = compute_session_digest(metadata)?;
let mut previous_chain = None;
let mut entry_count = 0u64;
let mut ledger_head = None;
let mut session_found = false;
let mut session_digest_matches = false;
for (index, line) in reader.lines().enumerate() {
let line =
line.map_err(|e| NonoError::Snapshot(format!("Failed to read audit ledger: {e}")))?;
if line.trim().is_empty() {
continue;
}
let record: LedgerRecord = serde_json::from_str(&line).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to parse audit ledger line {}: {e}",
index.saturating_add(1)
))
})?;
if record.sequence != entry_count {
return Err(NonoError::Snapshot(format!(
"Audit ledger sequence mismatch at line {}",
index.saturating_add(1)
)));
}
if record.prev_chain != previous_chain {
return Err(NonoError::Snapshot(format!(
"Audit ledger prev_chain mismatch at line {}",
index.saturating_add(1)
)));
}
let chain_hash = hash_ledger_link(
previous_chain.as_ref(),
record.sequence,
&record.session_id,
&record.session_digest,
&record.completed_at,
)?;
if chain_hash != record.chain_hash {
return Err(NonoError::Snapshot(format!(
"Audit ledger chain hash mismatch at line {}",
index.saturating_add(1)
)));
}
if record.session_id == metadata.session_id {
session_found = true;
session_digest_matches = record.session_digest == expected_digest;
}
previous_chain = Some(record.chain_hash);
ledger_head = Some(record.chain_hash);
entry_count = entry_count.saturating_add(1);
}
Ok(LedgerVerificationResult {
hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
entry_count,
session_digest: expected_digest,
session_found,
session_digest_matches,
ledger_chain_verified: true,
ledger_head,
})
}
pub fn sign_audit_attestation_bundle(
metadata: &SessionMetadata,
key_pair: &trust::KeyPair,
key_id: &str,
public_key_b64: &str,
redaction_policy: &crate::ScrubPolicy,
) -> Result<(String, AuditAttestationSummary)> {
let integrity = metadata
.audit_integrity
.as_ref()
.ok_or_else(|| NonoError::TrustSigning {
path: metadata.session_id.clone(),
reason: "audit attestation requires audit integrity to be enabled".to_string(),
})?;
let scrubbed_command = crate::scrub_argv_with_policy(&metadata.command, redaction_policy);
let predicate = serde_json::to_value(AuditAttestationPredicate {
version: 1,
session_id: &metadata.session_id,
started: &metadata.started,
ended: &metadata.ended,
command: &scrubbed_command,
redaction_policy: redaction_policy.diff_from_secure_default().into_option(),
audit_log: AuditLogPredicate {
hash_algorithm: &integrity.hash_algorithm,
event_count: integrity.event_count,
chain_head: &integrity.chain_head,
merkle_root: &integrity.merkle_root,
},
signer: AuditSignerPredicate {
kind: "keyed",
key_id,
},
})
.map_err(|e| NonoError::TrustSigning {
path: metadata.session_id.clone(),
reason: format!("failed to serialize audit attestation predicate: {e}"),
})?;
let statement = trust::new_statement(
&format!("audit-session:{}", metadata.session_id),
&integrity.merkle_root.to_string(),
predicate,
AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA,
);
let bundle_json = trust::sign_statement_bundle(&statement, key_pair)?;
Ok((
bundle_json,
AuditAttestationSummary {
predicate_type: AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA.to_string(),
key_id: key_id.to_string(),
public_key: public_key_b64.to_string(),
bundle_filename: AUDIT_ATTESTATION_BUNDLE_FILENAME.to_string(),
},
))
}
pub fn verify_audit_attestation_bundle(
bundle: &trust::Bundle,
bundle_path: &Path,
metadata: &SessionMetadata,
expected_public_key: Option<&[u8]>,
) -> Result<AuditAttestationVerificationResult> {
let Some(summary) = metadata.audit_attestation.as_ref() else {
return Ok(AuditAttestationVerificationResult {
present: false,
predicate_type: None,
key_id: None,
key_id_matches: false,
signature_verified: false,
merkle_root_matches: false,
session_id_matches: false,
expected_public_key_matches: expected_public_key.map(|_| false),
verification_error: expected_public_key.map(|_| {
"session has no audit attestation to verify against provided public key".to_string()
}),
});
};
let mut expected_public_key_matches = None;
let Some(integrity) = metadata.audit_integrity.as_ref() else {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"session has audit attestation metadata but no audit integrity summary".to_string(),
));
};
let predicate_type = match trust::extract_predicate_type(bundle, bundle_path) {
Ok(predicate_type) => predicate_type,
Err(err) => {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
err.to_string(),
));
}
};
if predicate_type != AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
format!(
"wrong bundle type: expected {}, got {}",
AUDIT_ATTESTATION_PREDICATE_TYPE_ALPHA, predicate_type
),
));
}
let signer_identity = match trust::extract_signer_identity(bundle, bundle_path) {
Ok(identity) => identity,
Err(err) => {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
err.to_string(),
));
}
};
let signer_key_id = match signer_identity {
trust::SignerIdentity::Keyed { key_id } => key_id,
trust::SignerIdentity::Keyless { .. } => {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation must be keyed".to_string(),
));
}
};
let public_key_der = match trust::base64::base64_decode(&summary.public_key) {
Ok(public_key_der) => public_key_der,
Err(err) => {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
format!("invalid attested public key encoding: {err}"),
));
}
};
let recomputed_key_id = trust::public_key_id_hex(&public_key_der);
if recomputed_key_id != summary.key_id {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
format!(
"audit attestation metadata key mismatch: expected {}, got {}",
summary.key_id, recomputed_key_id
),
));
}
if signer_key_id != summary.key_id {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
format!(
"audit attestation signer key mismatch: expected {}, got {}",
summary.key_id, signer_key_id
),
));
}
if let Some(expected_public_key) = expected_public_key
&& expected_public_key != public_key_der.as_slice()
{
return Ok(attestation_failure(
summary,
Some(false),
"provided public key does not match the attested signer key".to_string(),
));
}
if expected_public_key.is_some() {
expected_public_key_matches = Some(true);
}
if let Err(err) = trust::verify_keyed_signature(bundle, &public_key_der, bundle_path) {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
err.to_string(),
));
}
let attested_root = match trust::extract_bundle_digest(bundle, bundle_path) {
Ok(attested_root) => attested_root,
Err(err) => {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
err.to_string(),
));
}
};
if attested_root != integrity.merkle_root.to_string() {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation Merkle root does not match session integrity summary".to_string(),
));
}
let statement = match extract_audit_attestation_statement(bundle) {
Ok(statement) => statement,
Err(err) => {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
err.to_string(),
));
}
};
let Some(statement_session_id) = statement
.predicate
.get("session_id")
.and_then(|value| value.as_str())
else {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation predicate missing session_id".to_string(),
));
};
if statement_session_id != metadata.session_id {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
format!(
"audit attestation session_id mismatch: expected {}, got {}",
metadata.session_id, statement_session_id
),
));
}
let Some(audit_log) = statement
.predicate
.get("audit_log")
.and_then(|value| value.as_object())
else {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation predicate missing audit_log".to_string(),
));
};
if audit_log
.get("hash_algorithm")
.and_then(|value| value.as_str())
!= Some(integrity.hash_algorithm.as_str())
{
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation hash_algorithm does not match session integrity summary".to_string(),
));
}
if audit_log
.get("event_count")
.and_then(|value| value.as_u64())
!= Some(integrity.event_count)
{
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation event_count does not match session integrity summary".to_string(),
));
}
let chain_head = integrity.chain_head.to_string();
if audit_log.get("chain_head").and_then(|value| value.as_str()) != Some(chain_head.as_str()) {
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation chain_head does not match session integrity summary".to_string(),
));
}
if statement
.predicate
.get("started")
.and_then(|value| value.as_str())
!= Some(metadata.started.as_str())
|| statement
.predicate
.get("ended")
.and_then(|value| value.as_str())
!= metadata.ended.as_deref()
{
return Ok(attestation_failure(
summary,
expected_public_key_matches,
"audit attestation timestamps do not match session metadata".to_string(),
));
}
Ok(AuditAttestationVerificationResult {
present: true,
predicate_type: Some(predicate_type),
key_id: Some(summary.key_id.clone()),
key_id_matches: true,
signature_verified: true,
merkle_root_matches: true,
session_id_matches: true,
expected_public_key_matches,
verification_error: None,
})
}
fn attestation_failure(
summary: &AuditAttestationSummary,
expected_public_key_matches: Option<bool>,
verification_error: String,
) -> AuditAttestationVerificationResult {
AuditAttestationVerificationResult {
present: true,
predicate_type: Some(summary.predicate_type.clone()),
key_id: Some(summary.key_id.clone()),
key_id_matches: false,
signature_verified: false,
merkle_root_matches: false,
session_id_matches: false,
expected_public_key_matches,
verification_error: Some(verification_error),
}
}
fn extract_audit_attestation_statement(bundle: &trust::Bundle) -> Result<trust::InTotoStatement> {
let envelope = match &bundle.content {
SignatureContent::DsseEnvelope(envelope) => envelope,
_ => {
return Err(NonoError::TrustVerification {
path: String::new(),
reason: "audit attestation bundle missing dsseEnvelope".to_string(),
});
}
};
serde_json::from_slice(envelope.payload.as_bytes()).map_err(|e| NonoError::TrustVerification {
path: String::new(),
reason: format!("invalid audit attestation statement JSON: {e}"),
})
}
fn hash_ledger_link(
previous: Option<&ContentHash>,
sequence: u64,
session_id: &str,
session_digest: &ContentHash,
completed_at: &str,
) -> Result<ContentHash> {
let payload = LedgerLinkPayload {
sequence,
session_id,
session_digest: *session_digest,
completed_at,
};
let payload_bytes = serde_json::to_vec(&payload).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to serialize audit ledger link payload: {e}"
))
})?;
let mut hasher = Sha256::new();
hasher.update(LEDGER_CHAIN_DOMAIN_ALPHA);
if let Some(prev) = previous {
hasher.update(prev.as_bytes());
} else {
hasher.update([0u8; 32]);
}
hasher.update(payload_bytes);
Ok(ContentHash::from_bytes(hasher.finalize().into()))
}
pub fn verify_audit_log(
session_dir: &Path,
stored: Option<&AuditIntegritySummary>,
) -> Result<AuditVerificationResult> {
let path = session_dir.join(AUDIT_EVENTS_FILENAME);
let file = File::open(&path).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to open audit event log {}: {e}",
path.display()
))
})?;
let reader = BufReader::new(file);
let mut previous_chain: Option<ContentHash> = None;
let mut leaf_hashes = Vec::new();
let mut computed_chain_head: Option<ContentHash> = None;
let mut missing_canonical_event_json = false;
for (index, line) in reader.lines().enumerate() {
let line = line.map_err(|e| {
NonoError::Snapshot(format!(
"Failed to read audit event log {}: {e}",
path.display()
))
})?;
if line.trim().is_empty() {
continue;
}
let record: AuditEventRecord = serde_json::from_str(&line).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to parse audit event record {} line {}: {e}",
path.display(),
index.saturating_add(1)
))
})?;
let expected_sequence = leaf_hashes.len() as u64;
if record.sequence != expected_sequence {
return Err(NonoError::Snapshot(format!(
"Audit event record sequence mismatch at line {}: expected {}, got {}",
index.saturating_add(1),
expected_sequence,
record.sequence
)));
}
if record.prev_chain != previous_chain {
return Err(NonoError::Snapshot(format!(
"Audit event record prev_chain mismatch at line {}",
index.saturating_add(1)
)));
}
let event_bytes = if let Some(raw) = record.event_json.as_ref() {
serde_json::from_str::<AuditEventPayload>(raw).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to parse canonical audit event JSON at line {}: {e}",
index.saturating_add(1)
))
})?;
let canonical_event_bytes = serde_json::to_vec(&record.event).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to serialize audit event payload at line {}: {e}",
index.saturating_add(1)
))
})?;
if raw.as_bytes() != canonical_event_bytes.as_slice() {
return Err(NonoError::Snapshot(format!(
"Audit event JSON mismatch at line {}",
index.saturating_add(1)
)));
}
raw.as_bytes().to_vec()
} else {
missing_canonical_event_json = true;
serde_json::to_vec(&record.event).map_err(|e| {
NonoError::Snapshot(format!(
"Failed to serialize audit event for verification at line {}: {e}",
index.saturating_add(1)
))
})?
};
let leaf_hash = hash_event(&event_bytes);
if record.leaf_hash != leaf_hash {
return Err(NonoError::Snapshot(format!(
"Audit event leaf hash mismatch at line {}",
index.saturating_add(1)
)));
}
let chain_hash = hash_chain(previous_chain.as_ref(), &leaf_hash);
if record.chain_hash != chain_hash {
return Err(NonoError::Snapshot(format!(
"Audit event chain hash mismatch at line {}",
index.saturating_add(1)
)));
}
previous_chain = Some(chain_hash);
computed_chain_head = Some(chain_hash);
leaf_hashes.push(leaf_hash);
}
let computed_merkle_root = if leaf_hashes.is_empty() {
None
} else {
Some(merkle_root(&leaf_hashes))
};
if stored.is_some() && !leaf_hashes.is_empty() && missing_canonical_event_json {
return Err(NonoError::Snapshot(
"Alpha audit log is missing canonical event_json bytes".to_string(),
));
}
let stored_event_count = stored.map(|s| s.event_count);
let stored_chain_head = stored.map(|s| s.chain_head);
let stored_merkle_root = stored.map(|s| s.merkle_root);
let event_count = leaf_hashes.len() as u64;
let event_count_matches = stored_event_count
.map(|count| count == event_count)
.unwrap_or(true);
if let Some(stored_head) = stored_chain_head
&& Some(stored_head) != computed_chain_head
{
return Err(NonoError::Snapshot(
"Alpha audit log chain head mismatch".to_string(),
));
}
if let Some(stored_root) = stored_merkle_root
&& Some(stored_root) != computed_merkle_root
{
return Err(NonoError::Snapshot(
"Alpha audit log Merkle root mismatch".to_string(),
));
}
Ok(AuditVerificationResult {
hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
merkle_scheme: MERKLE_SCHEME_ALPHA.to_string(),
event_count,
computed_chain_head,
computed_merkle_root,
stored_event_count,
stored_chain_head,
stored_merkle_root,
event_count_matches,
records_verified: true,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::AccessMode;
use crate::supervisor::{ApprovalDecision, CapabilityRequest};
use crate::undo::{ExecutableIdentity, NetworkAuditDecision, NetworkAuditMode};
use std::io::BufReader;
use std::time::{Duration, UNIX_EPOCH};
#[test]
fn recorder_produces_integrity_summary() {
let dir = tempfile::tempdir().unwrap();
let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
recorder
.record_session_started("2026-04-21T00:00:00Z".to_string(), vec!["pwd".to_string()])
.unwrap();
recorder
.record_session_ended("2026-04-21T00:00:01Z".to_string(), 0)
.unwrap();
let summary = recorder.finalize().unwrap();
assert_eq!(summary.event_count, 2);
assert_eq!(summary.hash_algorithm, AUDIT_HASH_ALGORITHM);
}
#[test]
fn record_session_started_scrubs_command_secrets() {
let dir = tempfile::tempdir().unwrap();
let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
recorder
.record_session_started(
"2026-04-21T00:00:00Z".to_string(),
vec![
"curl".to_string(),
"--password".to_string(),
"real-password".to_string(),
"-H".to_string(),
"Authorization: Bearer real-token".to_string(),
"https://example.com/api?token=query-secret".to_string(),
],
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join(AUDIT_EVENTS_FILENAME)).unwrap();
assert!(contents.contains("[REDACTED]"));
assert!(!contents.contains("real-password"));
assert!(!contents.contains("real-token"));
assert!(!contents.contains("query-secret"));
}
#[test]
fn verifier_round_trips_all_current_audit_event_payload_variants() {
let dir = tempfile::tempdir().unwrap();
let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
recorder
.record_session_started(
"2026-04-21T00:00:00Z".to_string(),
vec!["claude".to_string(), "--debug".to_string()],
)
.unwrap();
recorder
.record_capability_decision(AuditEntry {
timestamp: UNIX_EPOCH + Duration::from_secs(5),
request: CapabilityRequest {
request_id: "req-1".to_string(),
path: PathBuf::from("/tmp/example"),
access: AccessMode::ReadWrite,
reason: Some("need scratch space".to_string()),
child_pid: 42,
session_id: "sess-1".to_string(),
},
decision: ApprovalDecision::Denied {
reason: "outside policy".to_string(),
},
backend: "terminal".to_string(),
duration_ms: 12,
})
.unwrap();
recorder
.record_open_url(
UrlOpenRequest {
request_id: "open-1".to_string(),
url: "https://example.com/callback".to_string(),
child_pid: 42,
session_id: "sess-1".to_string(),
},
false,
Some("blocked".to_string()),
)
.unwrap();
recorder
.record_network_event(NetworkAuditEvent {
timestamp_unix_ms: 123,
mode: NetworkAuditMode::Reverse,
decision: NetworkAuditDecision::Deny,
route_id: None,
auth_mechanism: None,
auth_outcome: None,
managed_credential_active: None,
injection_mode: None,
denial_category: None,
target: "api.example.com".to_string(),
port: Some(443),
method: Some("POST".to_string()),
path: Some("/v1/chat".to_string()),
status: Some(403),
reason: Some("policy".to_string()),
})
.unwrap();
recorder
.record_session_ended("2026-04-21T00:00:01Z".to_string(), 7)
.unwrap();
let summary = recorder.finalize().unwrap();
let verified = verify_audit_log(dir.path(), Some(&summary)).unwrap();
assert_eq!(verified.event_count, 5);
assert_eq!(verified.merkle_scheme, "alpha");
assert!(verified.records_verified);
}
#[test]
fn verifier_rejects_alpha_records_missing_event_json() {
let dir = tempfile::tempdir().unwrap();
let mut recorder = AuditRecorder::new(dir.path().to_path_buf()).unwrap();
recorder
.record_session_started("2026-04-21T00:00:00Z".to_string(), vec!["pwd".to_string()])
.unwrap();
recorder
.record_session_ended("2026-04-21T00:00:01Z".to_string(), 0)
.unwrap();
let path = dir.path().join(AUDIT_EVENTS_FILENAME);
let contents = std::fs::read_to_string(&path).unwrap();
let rewritten = contents
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut record: AuditEventRecord = serde_json::from_str(line).unwrap();
record.event_json = None;
serde_json::to_string(&record).unwrap()
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, format!("{rewritten}\n")).unwrap();
let summary = recorder.finalize().unwrap();
let err = match verify_audit_log(dir.path(), Some(&summary)) {
Ok(_) => panic!("alpha verification should reject records missing event_json"),
Err(err) => err,
};
assert!(
err.to_string()
.contains("missing canonical event_json bytes")
);
}
#[test]
fn inclusion_proof_round_trips_each_leaf() {
let leaves = vec![
ContentHash::from_bytes([1; 32]),
ContentHash::from_bytes([2; 32]),
ContentHash::from_bytes([3; 32]),
ContentHash::from_bytes([4; 32]),
ContentHash::from_bytes([5; 32]),
];
let root = merkle_root(&leaves);
for index in 0..leaves.len() {
let proof = build_inclusion_proof(&leaves, index).unwrap();
assert_eq!(proof.merkle_root, root);
assert_eq!(proof.leaf_hash, leaves[index]);
assert!(verify_inclusion_proof(&proof));
}
}
#[test]
fn inclusion_proof_rejects_tampered_leaf() {
let leaves = vec![
ContentHash::from_bytes([1; 32]),
ContentHash::from_bytes([2; 32]),
ContentHash::from_bytes([3; 32]),
];
let mut proof = build_inclusion_proof(&leaves, 1).unwrap();
proof.leaf_hash = ContentHash::from_bytes([9; 32]);
assert!(!verify_inclusion_proof(&proof));
}
fn sample_metadata(id: &str) -> SessionMetadata {
SessionMetadata {
session_id: id.to_string(),
started: "2026-04-21T20:00:00Z".to_string(),
ended: Some("2026-04-21T20:00:01Z".to_string()),
command: vec!["/bin/pwd".to_string()],
executable_identity: None,
tracked_paths: vec![PathBuf::from("/tmp/work")],
snapshot_count: 0,
exit_code: Some(0),
merkle_roots: Vec::new(),
network_events: Vec::new(),
audit_event_count: 2,
audit_integrity: None,
audit_attestation: None,
}
}
#[test]
fn ledger_appends_and_verifies_session_digest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.ndjson");
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.unwrap();
let meta = sample_metadata("20260421-200000-11111");
append_session_to_ledger_file(&mut file, &meta).unwrap();
let reader = BufReader::new(std::fs::File::open(&path).unwrap());
let verified = verify_session_in_ledger_reader(reader, &meta).unwrap();
assert!(verified.session_found);
assert!(verified.session_digest_matches);
assert!(verified.ledger_chain_verified);
assert_eq!(verified.entry_count, 1);
}
#[test]
fn ledger_rejects_malformed_session_id() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.ndjson");
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.unwrap();
let meta = sample_metadata("real-token\\|real-key");
let err = match append_session_to_ledger_file(&mut file, &meta) {
Ok(_) => panic!("malformed session id should be rejected"),
Err(err) => err,
};
assert!(err.to_string().contains("invalid audit session id"));
}
#[test]
fn session_digest_changes_when_protected_fields_change() {
let base = SessionMetadata {
session_id: "20260421-200000-11111".to_string(),
started: "2026-04-21T20:00:00Z".to_string(),
ended: Some("2026-04-21T20:00:01Z".to_string()),
command: vec!["/bin/pwd".to_string()],
executable_identity: Some(ExecutableIdentity {
resolved_path: PathBuf::from("/bin/pwd"),
sha256: ContentHash::from_bytes([9; 32]),
}),
tracked_paths: vec![PathBuf::from("/tmp/work")],
snapshot_count: 3,
exit_code: Some(7),
merkle_roots: vec![ContentHash::from_bytes([1; 32])],
network_events: vec![NetworkAuditEvent {
timestamp_unix_ms: 5,
mode: NetworkAuditMode::Connect,
decision: NetworkAuditDecision::Allow,
route_id: None,
auth_mechanism: None,
auth_outcome: None,
managed_credential_active: None,
injection_mode: None,
denial_category: None,
target: "example.com".to_string(),
port: Some(443),
method: Some("GET".to_string()),
path: Some("/".to_string()),
status: Some(200),
reason: None,
}],
audit_event_count: 9,
audit_integrity: Some(AuditIntegritySummary {
hash_algorithm: "sha256".to_string(),
event_count: 9,
chain_head: ContentHash::from_bytes([2; 32]),
merkle_root: ContentHash::from_bytes([3; 32]),
}),
audit_attestation: None,
};
let base_digest = compute_session_digest(&base).unwrap();
let mut changed = base.clone();
changed.session_id.push('x');
assert_ne!(base_digest, compute_session_digest(&changed).unwrap());
let mut changed = base.clone();
changed.network_events[0].target = "other.example.com".to_string();
assert_ne!(base_digest, compute_session_digest(&changed).unwrap());
let mut changed = base.clone();
changed.audit_integrity = Some(AuditIntegritySummary {
hash_algorithm: "sha256".to_string(),
event_count: 9,
chain_head: ContentHash::from_bytes([8; 32]),
merkle_root: ContentHash::from_bytes([3; 32]),
});
assert_ne!(base_digest, compute_session_digest(&changed).unwrap());
}
#[test]
fn audit_attestation_bundle_round_trips_in_core() {
let key_pair = crate::trust::generate_signing_key().unwrap();
let key_id = crate::trust::key_id_hex(&key_pair).unwrap();
let public_key = crate::trust::export_public_key(&key_pair).unwrap();
let public_key_b64 = crate::trust::base64::base64_encode(public_key.as_bytes());
let mut meta = sample_metadata("20260421-200000-11111");
meta.audit_integrity = Some(AuditIntegritySummary {
hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
event_count: 2,
chain_head: ContentHash::from_bytes([0x11; 32]),
merkle_root: ContentHash::from_bytes([0x22; 32]),
});
let (bundle_json, summary) = sign_audit_attestation_bundle(
&meta,
&key_pair,
&key_id,
&public_key_b64,
&crate::ScrubPolicy::secure_default(),
)
.unwrap();
meta.audit_attestation = Some(summary);
let bundle_path = Path::new("audit-attestation.bundle");
let bundle = crate::trust::load_bundle_from_str(&bundle_json, bundle_path).unwrap();
let verified = verify_audit_attestation_bundle(
&bundle,
bundle_path,
&meta,
Some(public_key.as_bytes()),
)
.unwrap();
assert!(verified.present);
assert!(verified.key_id_matches);
assert!(verified.signature_verified);
assert!(verified.merkle_root_matches);
assert!(verified.session_id_matches);
assert_eq!(verified.expected_public_key_matches, Some(true));
assert!(verified.verification_error.is_none());
let mut tampered_bundle_value: serde_json::Value =
serde_json::from_str(&bundle_json).unwrap();
tampered_bundle_value["dsseEnvelope"]["payload"] =
serde_json::Value::String(crate::trust::base64::base64_encode(b"tampered"));
let tampered_bundle = crate::trust::load_bundle_from_str(
&serde_json::to_string(&tampered_bundle_value).unwrap(),
bundle_path,
)
.unwrap();
let verified = verify_audit_attestation_bundle(
&tampered_bundle,
bundle_path,
&meta,
Some(public_key.as_bytes()),
)
.unwrap();
assert!(!verified.signature_verified);
assert_eq!(verified.expected_public_key_matches, None);
let mut changed = meta.clone();
changed.audit_integrity = Some(AuditIntegritySummary {
hash_algorithm: AUDIT_HASH_ALGORITHM.to_string(),
event_count: 3,
chain_head: ContentHash::from_bytes([0x11; 32]),
merkle_root: ContentHash::from_bytes([0x22; 32]),
});
let verified = verify_audit_attestation_bundle(
&bundle,
bundle_path,
&changed,
Some(public_key.as_bytes()),
)
.unwrap();
assert!(!verified.signature_verified);
assert_eq!(verified.expected_public_key_matches, Some(true));
assert!(
verified
.verification_error
.as_deref()
.is_some_and(|err| err.contains("event_count"))
);
}
#[test]
fn rust_compatibility_golden_vectors() {
let meta = sample_metadata("20260421-200000-11111");
assert_eq!(
compute_session_digest(&meta).unwrap().to_string(),
"3a1ed53d426d6ea2544cec6cf6b95ccdc31fda4570d86931239ee0f7d7d39012"
);
let dir = tempfile::tempdir().unwrap();
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(dir.path().join("ledger.ndjson"))
.unwrap();
let record = append_session_to_ledger_file(&mut file, &meta).unwrap();
assert_eq!(
record.chain_hash.to_string(),
"8b6dbc155d44df05e6b5e9948fb8fff142222b4b41fb37284fb0d1217000e9bb"
);
let leaves = vec![
ContentHash::from_bytes([1; 32]),
ContentHash::from_bytes([2; 32]),
ContentHash::from_bytes([3; 32]),
ContentHash::from_bytes([4; 32]),
ContentHash::from_bytes([5; 32]),
];
let proof = build_inclusion_proof(&leaves, 2).unwrap();
assert_eq!(
serde_json::to_string(&proof).unwrap(),
concat!(
r#"{"leaf_index":2,"leaf_count":5,"#,
r#""leaf_hash":"0303030303030303030303030303030303030303030303030303030303030303","#,
r#""merkle_root":"87f9319b8dbb3d3fd55d419aabf3c218aafd2dfd82d5e30fb22e8e89c10c0160","#,
r#""siblings":[{"direction":"right","hash":"0404040404040404040404040404040404040404040404040404040404040404"},"#,
r#"{"direction":"left","hash":"85fb11ff61817c3aa118af30f054a3ea63c042902722cf8ae35e704fff9624fe"},"#,
r#"{"direction":"right","hash":"0505050505050505050505050505050505050505050505050505050505050505"}]}"#
)
);
}
}