use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use clap::{Args, Subcommand, ValueEnum};
use cortex_core::{
compose_policy_outcomes, Attestor, AuthorityClass, ClaimCeiling, ClaimProofState, FailingEdge,
InMemoryAttestor, PolicyContribution, PolicyDecision, PolicyOutcome, ProofClosureReport,
ProofEdge, ProofEdgeFailure, ProofEdgeKind, RuntimeMode,
};
use cortex_ledger::{
anchor::{verify_anchor_history, AnchorHistoryVerifyError},
audit::verify_schema_migration_v1_to_v2_boundary,
current_anchor, enforce_disjoint_authority_quorum, parse_anchor, rekor_submit,
rekor_verify_receipt, verify_anchor, verify_chain, verify_signed_chain, AnchorVerifyError,
ExternalReceipt, ExternalReceiptVerifyError, ExternalSink, JsonlError, JsonlLog, OtsWitness,
RekorError, Report, TrustRootStalenessAnchor, TrustedRoot, ANCHOR_TEXT_HASH_MISMATCH_INVARIANT,
OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT, PARSED_ONLY_VERIFICATION_STATUS,
REKOR_DEFAULT_ENDPOINT, REKOR_EXTERNAL_AUTHORITY_STATUS, REKOR_SUBMIT_FAILED_INVARIANT,
REKOR_TRUSTED_ROOT_STALE_INVARIANT, REKOR_VERIFY_FAILED_INVARIANT,
REKOR_VERIFY_SIGNATURE_MISMATCH_INVARIANT,
};
use cortex_runtime::{
development_ledger_use_decision, runtime_claim_preflight, runtime_claim_preflight_with_policy,
DevelopmentLedgerUse, RuntimeClaimKind,
};
use cortex_verifier::SelfSignedKeyRegistry;
use ed25519_dalek::VerifyingKey;
use serde::Serialize;
pub const VERIFY_HASH_CHAIN_CLOSURE_RULE_ID: &str = "audit.verify.hash_chain_closure";
pub const VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID: &str = "audit.verify.signed_chain_closure";
pub const VERIFY_V1_TO_V2_BOUNDARY_RULE_ID: &str = "audit.verify.v1_to_v2_boundary";
pub const VERIFY_PROOF_CLOSURE_COMPOSED_REPORT_INVARIANT: &str =
"audit.verify.proof_closure.composed_report";
pub const VERIFY_TEMPORAL_AUTHORITY_RULE_ID: &str = "audit.verify.temporal_authority";
pub const ANCHOR_SINK_AUTHORITY_RULE_ID: &str = "audit.anchor.sink_authority";
pub const ANCHOR_TEMPORAL_AUTHORITY_RULE_ID: &str = "audit.anchor.temporal_authority";
pub const EXPORT_DEVELOPMENT_LEDGER_RULE_ID: &str = "audit.export.development_ledger";
pub const EXPORT_SIGNED_LOCAL_CLASS_RULE_ID: &str = "audit.export.signed_local_class";
pub const EXPORT_PROOF_CLOSURE_RULE_ID: &str = "audit.export.proof_closure";
pub const EXPORT_TEMPORAL_AUTHORITY_RULE_ID: &str = "audit.export.temporal_authority";
use crate::cmd::open_default_store;
use crate::cmd::temporal::{revalidate_operator_temporal_authority, revalidation_failed_invariant};
use crate::exit::Exit;
use crate::output::{self, Envelope};
use crate::paths::DataLayout;
use cortex_core::TrustTier;
#[derive(Debug, Subcommand)]
pub enum AuditSub {
Verify(VerifyArgs),
Anchor(AnchorArgs),
Export(ExportArgs),
#[command(name = "refresh-trust")]
RefreshTrust(RefreshTrustArgs),
}
#[derive(Debug, Args)]
pub struct VerifyArgs {
#[arg(long = "event-log", value_name = "PATH")]
pub event_log: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub db: Option<PathBuf>,
#[arg(long)]
pub signed: bool,
#[arg(long = "verification-key", value_name = "KEY_PATH")]
pub verification_key: Option<PathBuf>,
#[arg(long, value_name = "KEY_PATH")]
pub attestation: Option<PathBuf>,
#[arg(long = "require-v1-to-v2-boundary")]
pub require_v1_to_v2_boundary: bool,
#[arg(long = "against", value_name = "ANCHOR_PATH")]
pub against: Option<PathBuf>,
#[arg(long = "against-history", value_name = "ANCHOR_HISTORY_PATH")]
pub against_history: Option<PathBuf>,
#[arg(long = "against-external", value_name = "RECEIPTS_PATH")]
pub against_external: Option<PathBuf>,
#[arg(long = "witness-key-registry", value_name = "PATH")]
pub witness_key_registry: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct AnchorArgs {
#[arg(long = "event-log", value_name = "PATH")]
pub event_log: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub db: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub output: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub history: Option<PathBuf>,
#[arg(long, value_enum, default_value = "local-only")]
pub sink: AnchorSink,
#[arg(long = "sink-path", value_name = "PATH")]
pub sink_path: Option<PathBuf>,
#[arg(long = "sink-endpoint", value_name = "URL")]
pub sink_endpoint: Option<String>,
#[arg(long = "sink-receipt", value_name = "PATH")]
pub sink_receipt: Option<PathBuf>,
#[arg(long = "sink-receipt-history", value_name = "PATH")]
pub sink_receipt_history: Option<PathBuf>,
#[arg(long = "offline")]
pub offline: bool,
#[arg(long = "require-non-pending", conflicts_with = "allow_pending")]
pub require_non_pending: bool,
#[arg(long = "allow-pending")]
pub allow_pending: bool,
#[arg(long = "sink-receipt-in", value_name = "PATH")]
pub sink_receipt_in: Option<PathBuf>,
#[arg(long = "bitcoin-header", value_name = "PATH")]
pub bitcoin_header: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum AnchorSink {
LocalOnly,
ExternalAppendOnly,
Rekor,
#[value(name = "opentimestamps", alias = "open-timestamps")]
OpenTimestamps,
}
impl AnchorSink {
const fn authority_label(self) -> &'static str {
match self {
Self::LocalOnly => "local_only",
Self::Rekor => "external_authority_rekor",
Self::ExternalAppendOnly | Self::OpenTimestamps => "external_unconfigured",
}
}
const fn external_configured(self) -> bool {
match self {
Self::LocalOnly | Self::ExternalAppendOnly | Self::OpenTimestamps => false,
Self::Rekor => true,
}
}
const fn external_authority(self) -> bool {
match self {
Self::LocalOnly | Self::ExternalAppendOnly | Self::OpenTimestamps => false,
Self::Rekor => true,
}
}
const fn wire_str(self) -> &'static str {
match self {
Self::LocalOnly => "local-only",
Self::ExternalAppendOnly => "external-append-only",
Self::Rekor => "rekor",
Self::OpenTimestamps => "opentimestamps",
}
}
}
pub const EXTERNAL_SINK_ADAPTER_NOT_YET_IMPLEMENTED_REASON: &str =
"external_sink_adapter_not_yet_implemented_pending_design_specs";
pub const EXTERNAL_AUTHORITY_OPENTIMESTAMPS: &str = "external_authority_opentimestamps";
pub const EXTERNAL_AUTHORITY_OPENTIMESTAMPS_PENDING: &str =
"external_authority_opentimestamps_pending";
pub const OTS_PENDING_REJECTED_UNDER_REQUIRE_NON_PENDING_REASON: &str =
"ots_pending_rejected_under_require_non_pending";
pub const OTS_PENDING_ACCEPTED_UNDER_ALLOW_PENDING_REASON: &str =
"ots_pending_accepted_under_allow_pending";
pub const OTS_BITCOIN_CONFIRMED_VERIFIED_REASON: &str = "ots_bitcoin_confirmed_verified";
struct AnchorSinkDecision {
authority_label: &'static str,
external_configured: bool,
external_authority: bool,
reason: &'static str,
policy_outcome: PolicyOutcome,
}
fn anchor_sink_decision(sink: AnchorSink, sink_path: Option<&PathBuf>) -> AnchorSinkDecision {
match (sink, sink_path) {
(AnchorSink::LocalOnly, None) => AnchorSinkDecision {
authority_label: sink.authority_label(),
external_configured: sink.external_configured(),
external_authority: sink.external_authority(),
reason: "local_only",
policy_outcome: PolicyOutcome::Allow,
},
(AnchorSink::LocalOnly, Some(_)) => AnchorSinkDecision {
authority_label: "local_only",
external_configured: true,
external_authority: false,
reason: "sink_path_requires_external_append_only_sink",
policy_outcome: PolicyOutcome::Reject,
},
(AnchorSink::ExternalAppendOnly, None) => AnchorSinkDecision {
authority_label: "external_unconfigured",
external_configured: false,
external_authority: false,
reason: "missing_external_sink_path",
policy_outcome: PolicyOutcome::Reject,
},
(AnchorSink::ExternalAppendOnly, Some(_)) => AnchorSinkDecision {
authority_label: "external_path_unproven",
external_configured: true,
external_authority: false,
reason: "local_path_cannot_prove_disjoint_append_only_semantics",
policy_outcome: PolicyOutcome::Quarantine,
},
(AnchorSink::Rekor, _) => AnchorSinkDecision {
authority_label: sink.authority_label(),
external_configured: true,
external_authority: false,
reason: "rekor_live_adapter_pending_submit",
policy_outcome: PolicyOutcome::Quarantine,
},
(AnchorSink::OpenTimestamps, _) => AnchorSinkDecision {
authority_label: "external_unconfigured",
external_configured: false,
external_authority: false,
reason: "ots_live_adapter_invoked_pending_real_outcome",
policy_outcome: PolicyOutcome::Quarantine,
},
}
}
fn ots_anchor_sink_decision(
outcome: &cortex_ledger::OtsVerificationOutcome,
allow_pending: bool,
) -> AnchorSinkDecision {
use cortex_ledger::OtsVerificationOutcome;
match outcome {
OtsVerificationOutcome::FullChainVerified { .. } => AnchorSinkDecision {
authority_label: EXTERNAL_AUTHORITY_OPENTIMESTAMPS,
external_configured: true,
external_authority: true,
reason: OTS_BITCOIN_CONFIRMED_VERIFIED_REASON,
policy_outcome: PolicyOutcome::Allow,
},
OtsVerificationOutcome::Partial { reasons, .. } => {
let is_pending = reasons
.iter()
.any(|r| r == cortex_ledger::OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT);
if is_pending {
if allow_pending {
AnchorSinkDecision {
authority_label: EXTERNAL_AUTHORITY_OPENTIMESTAMPS_PENDING,
external_configured: true,
external_authority: false,
reason: OTS_PENDING_ACCEPTED_UNDER_ALLOW_PENDING_REASON,
policy_outcome: PolicyOutcome::Quarantine,
}
} else {
AnchorSinkDecision {
authority_label: "external_unconfigured",
external_configured: true,
external_authority: false,
reason: OTS_PENDING_REJECTED_UNDER_REQUIRE_NON_PENDING_REASON,
policy_outcome: PolicyOutcome::Reject,
}
}
} else {
AnchorSinkDecision {
authority_label: EXTERNAL_AUTHORITY_OPENTIMESTAMPS_PENDING,
external_configured: true,
external_authority: false,
reason: cortex_ledger::OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT,
policy_outcome: PolicyOutcome::Quarantine,
}
}
}
OtsVerificationOutcome::Broken { edge, .. } => AnchorSinkDecision {
authority_label: "external_unconfigured",
external_configured: true,
external_authority: false,
reason: edge.invariant,
policy_outcome: PolicyOutcome::Reject,
},
}
}
fn rekor_anchor_sink_decision(outcome: &RekorAdapterOutcome) -> AnchorSinkDecision {
match outcome {
RekorAdapterOutcome::Allowed => AnchorSinkDecision {
authority_label: "external_authority_rekor",
external_configured: true,
external_authority: true,
reason: REKOR_EXTERNAL_AUTHORITY_STATUS,
policy_outcome: PolicyOutcome::Allow,
},
RekorAdapterOutcome::QuarantineTrustedRootStale => AnchorSinkDecision {
authority_label: "external_authority_rekor_quarantined",
external_configured: true,
external_authority: false,
reason: REKOR_TRUSTED_ROOT_STALE_INVARIANT,
policy_outcome: PolicyOutcome::Quarantine,
},
RekorAdapterOutcome::Rejected { invariant, .. } => AnchorSinkDecision {
authority_label: "external_authority_rekor_rejected",
external_configured: true,
external_authority: false,
reason: invariant,
policy_outcome: PolicyOutcome::Reject,
},
}
}
#[derive(Debug)]
enum RekorAdapterOutcome {
Allowed,
QuarantineTrustedRootStale,
Rejected {
invariant: &'static str,
#[allow(dead_code)]
reason: String,
},
}
#[derive(Debug, Args)]
pub struct ExportArgs {
#[arg(long = "event-log", value_name = "PATH")]
pub event_log: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub db: Option<PathBuf>,
#[arg(long = "local-diagnostic")]
pub local_diagnostic: bool,
#[arg(long, value_enum, default_value = "audit-export")]
pub surface: AuditExportSurface,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum AuditExportSurface {
AuditExport,
ComplianceEvidence,
CrossSystemTrustDecision,
ExternalReporting,
}
impl AuditExportSurface {
const fn label(self) -> &'static str {
match self {
Self::AuditExport => "audit export",
Self::ComplianceEvidence => "compliance evidence",
Self::CrossSystemTrustDecision => "cross-system trust decision",
Self::ExternalReporting => "external reporting",
}
}
const fn artifact_kind(self) -> &'static str {
match self {
Self::AuditExport => "audit_summary",
Self::ComplianceEvidence => "compliance_evidence",
Self::CrossSystemTrustDecision => "cross_system_trust_decision",
Self::ExternalReporting => "external_reporting",
}
}
const fn development_ledger_use(self) -> DevelopmentLedgerUse {
match self {
Self::AuditExport => DevelopmentLedgerUse::AuditExport,
Self::ComplianceEvidence => DevelopmentLedgerUse::ComplianceEvidence,
Self::CrossSystemTrustDecision => DevelopmentLedgerUse::CrossSystemTrustDecision,
Self::ExternalReporting => DevelopmentLedgerUse::ExternalReporting,
}
}
const fn runtime_claim_kind(self) -> RuntimeClaimKind {
match self {
Self::AuditExport | Self::ExternalReporting => RuntimeClaimKind::Export,
Self::ComplianceEvidence => RuntimeClaimKind::ComplianceEvidence,
Self::CrossSystemTrustDecision => RuntimeClaimKind::CrossSystemTrust,
}
}
const fn requested_ceiling(self) -> ClaimCeiling {
match self {
Self::AuditExport | Self::ExternalReporting => ClaimCeiling::ExternallyAnchored,
Self::ComplianceEvidence | Self::CrossSystemTrustDecision => {
ClaimCeiling::AuthorityGrade
}
}
}
const fn is_default_audit_export(self) -> bool {
matches!(self, Self::AuditExport)
}
}
pub fn run(sub: AuditSub) -> Exit {
match sub {
AuditSub::Verify(args) => run_verify(args),
AuditSub::Anchor(args) => run_anchor(args),
AuditSub::Export(args) => run_export(args),
AuditSub::RefreshTrust(args) => run_refresh_trust(args),
}
}
pub fn run_verify(args: VerifyArgs) -> Exit {
VERIFY_CONTRIBUTIONS.with(|cell| cell.borrow_mut().clear());
let inner_exit = run_verify_inner(args);
let (policy, proof_closure) = compose_verify_policy_decision();
record_verify_policy_decision(&policy);
record_verify_proof_closure(&proof_closure);
print_verify_policy_outcome(&policy);
print_verify_proof_closure(&proof_closure);
let exit = match policy.final_outcome {
PolicyOutcome::Reject | PolicyOutcome::Quarantine if inner_exit == Exit::Ok => {
Exit::IntegrityFailure
}
_ => inner_exit,
};
if output::json_enabled() {
let mut report = VERIFY_REPORT.with(VerifyReport::take_or_default);
let (runtime_mode, proof_state) = verify_report_runtime_mode_and_proof_state(&report, exit);
let authority_class = AuthorityClass::Observed;
let requested_ceiling = ClaimCeiling::LocalUnsigned;
let claim_ceiling = cortex_core::effective_ceiling(
runtime_mode,
authority_class,
proof_state,
requested_ceiling,
);
report.runtime_mode = runtime_mode;
report.proof_state = proof_state;
report.claim_ceiling = claim_ceiling;
report.authority_class = authority_class;
let policy_summary = policy_outcome_summary(&policy);
let envelope =
Envelope::new("cortex.audit.verify", exit, report).with_policy_outcome(policy_summary);
output::emit(&envelope, exit)
} else {
exit
}
}
fn verify_report_runtime_mode_and_proof_state(
report: &VerifyReport,
exit: Exit,
) -> (RuntimeMode, ClaimProofState) {
match exit {
Exit::Ok => {
let runtime_mode = if report.signed_verification.is_some() {
RuntimeMode::SignedLocalLedger
} else {
RuntimeMode::LocalUnsigned
};
(runtime_mode, ClaimProofState::FullChainVerified)
}
Exit::IntegrityFailure | Exit::ChainCorruption => {
(RuntimeMode::LocalUnsigned, ClaimProofState::Broken)
}
_ => (RuntimeMode::LocalUnsigned, ClaimProofState::Unknown),
}
}
fn run_verify_inner(args: VerifyArgs) -> Exit {
let _witness_key_registry: Option<SelfSignedKeyRegistry> =
match args.witness_key_registry.as_deref() {
None => None,
Some(path) => match SelfSignedKeyRegistry::load(path) {
Ok(registry) => {
eprintln!(
"audit verify: witness-key-registry loaded {} key(s) from `{}`",
registry.len(),
path.display()
);
Some(registry)
}
Err(e) => {
eprintln!("audit verify: {e}");
return Exit::PreconditionUnmet;
}
},
};
let layout = match DataLayout::resolve(args.db, args.event_log) {
Ok(l) => l,
Err(e) => return e,
};
if !layout.event_log_path.exists() {
eprintln!(
"audit verify: event-log path does not exist: {}",
layout.event_log_path.display()
);
return Exit::PreconditionUnmet;
}
if args.signed {
let key_inputs =
usize::from(args.verification_key.is_some()) + usize::from(args.attestation.is_some());
if key_inputs == 0 {
eprintln!(
"audit verify: --signed requires --verification-key <KEY_PATH> or local-development --attestation <KEY_PATH>; no trusted verification was performed"
);
return Exit::PreconditionUnmet;
}
if key_inputs > 1 {
eprintln!("audit verify: use only one of --verification-key or --attestation");
return Exit::Usage;
}
let (verifying_key, key_id) =
if let Some(verification_key_path) = args.verification_key.as_ref() {
match load_verifying_key_from_file(verification_key_path) {
Ok(key) => key,
Err(exit) => return exit,
}
} else {
let attestation_path = args
.attestation
.as_ref()
.expect("attestation is present when verification key is absent");
let attestor = match load_attestor_from_key_file(attestation_path) {
Ok(attestor) => attestor,
Err(exit) => return exit,
};
(attestor.verifying_key(), attestor.key_id().to_string())
};
match verify_signed_chain(&layout.event_log_path, &verifying_key, &key_id) {
Ok(outcome) => {
print_report(&outcome.report);
if output::json_enabled() {
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.signed_verification = Some(SignedVerification {
key_id: key_id.clone(),
});
});
}
if outcome.report.ok() {
record_verify_contribution(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Allow,
"hash chain verified end to end with no per-row failures",
);
record_verify_contribution(
VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Allow,
"signed chain verified against the supplied verification key",
);
record_temporal_authority_contribution_for_audit_verify(&key_id);
verify_post_chain_requirements(
&layout.event_log_path,
args.require_v1_to_v2_boundary,
args.against.as_ref(),
args.against_history.as_ref(),
args.against_external.as_ref(),
)
} else {
record_verify_contribution(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"hash chain verification produced per-row failures",
);
record_verify_contribution(
VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"signed chain verification produced per-row failures",
);
record_temporal_authority_contribution_for_audit_verify(&key_id);
Exit::IntegrityFailure
}
}
Err(e) => {
eprintln!(
"audit verify: signed chain corruption ({}): {e}",
layout.event_log_path.display(),
);
record_verify_contribution(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"hash chain could not be scanned (JSONL corruption)",
);
record_verify_contribution(
VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"signed chain could not be verified (JSONL corruption)",
);
record_temporal_authority_contribution_for_audit_verify(&key_id);
map_open_err(&e)
}
}
} else if args.attestation.is_some() || args.verification_key.is_some() {
eprintln!(
"audit verify: --attestation and --verification-key are only valid with --signed"
);
Exit::Usage
} else {
match verify_chain(&layout.event_log_path) {
Ok(report) => {
print_report(&report);
if report.ok() {
record_verify_contribution(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Allow,
"hash chain verified end to end with no per-row failures",
);
verify_post_chain_requirements(
&layout.event_log_path,
args.require_v1_to_v2_boundary,
args.against.as_ref(),
args.against_history.as_ref(),
args.against_external.as_ref(),
)
} else {
record_verify_contribution(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"hash chain verification produced per-row failures",
);
Exit::IntegrityFailure
}
}
Err(e) => {
eprintln!(
"audit verify: chain corruption ({}): {e}",
layout.event_log_path.display(),
);
record_verify_contribution(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"hash chain could not be scanned (JSONL corruption)",
);
map_open_err(&e)
}
}
}
}
fn verify_post_chain_requirements(
path: &PathBuf,
require_v1_to_v2_boundary: bool,
against: Option<&PathBuf>,
against_history: Option<&PathBuf>,
against_external: Option<&PathBuf>,
) -> Exit {
let boundary_exit = verify_required_v1_to_v2_boundary(path, require_v1_to_v2_boundary);
if boundary_exit != Exit::Ok {
return boundary_exit;
}
let anchor_exit = verify_against_anchor(path, against);
if anchor_exit != Exit::Ok {
return anchor_exit;
}
let history_exit = verify_against_anchor_history(path, against_history);
if history_exit != Exit::Ok {
return history_exit;
}
verify_against_external_receipts(path, against_external)
}
fn verify_required_v1_to_v2_boundary(path: &PathBuf, required: bool) -> Exit {
if !required {
record_boundary(BoundaryReport {
required: false,
ok: true,
failures: Vec::new(),
});
record_verify_contribution(
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID,
PolicyOutcome::Allow,
"schema v1 to v2 boundary check was not requested",
);
return Exit::Ok;
}
match verify_schema_migration_v1_to_v2_boundary(path, true) {
Ok(report) if report.ok() => {
record_boundary(BoundaryReport {
required: true,
ok: true,
failures: Vec::new(),
});
record_verify_contribution(
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID,
PolicyOutcome::Allow,
"schema v1 to v2 boundary verified",
);
Exit::Ok
}
Ok(report) => {
let mut failures = Vec::new();
for failure in &report.failures {
let line = format!("{}: {:?}", failure.invariant, failure.detail);
eprintln!("audit verify: {line}");
failures.push(line);
}
record_boundary(BoundaryReport {
required: true,
ok: false,
failures,
});
record_verify_contribution(
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID,
PolicyOutcome::Reject,
"schema v1 to v2 boundary failed required invariants",
);
eprintln!(
"audit verify: hint: if this is a fresh v2 store, the boundary check may be a \
false positive — run `cortex doctor --repair` to confirm; for a migrated store \
run `cortex migrate v2 --backup-manifest <path>` to complete the upgrade"
);
Exit::SchemaMismatch
}
Err(e) => {
eprintln!(
"audit verify: failed to verify schema boundary events ({}): {e}",
path.display()
);
record_boundary(BoundaryReport {
required: true,
ok: false,
failures: vec![format!("failed to verify schema boundary events: {e}")],
});
record_verify_contribution(
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID,
PolicyOutcome::Reject,
"schema v1 to v2 boundary could not be scanned",
);
map_open_err(&e)
}
}
}
fn record_boundary(boundary: BoundaryReport) {
if !output::json_enabled() {
return;
}
VERIFY_REPORT.with(|cell| {
let mut report_ref = cell.borrow_mut();
let entry = report_ref.get_or_insert_with(VerifyReport::default);
entry.boundary = Some(boundary);
});
}
fn verify_against_anchor(path: &PathBuf, against: Option<&PathBuf>) -> Exit {
let Some(anchor_path) = against else {
return Exit::Ok;
};
let text = match std::fs::read_to_string(anchor_path) {
Ok(text) => text,
Err(err) => {
eprintln!(
"audit verify: cannot read --against anchor file `{}`: {err}",
anchor_path.display()
);
return Exit::PreconditionUnmet;
}
};
let anchor = match parse_anchor(&text) {
Ok(anchor) => anchor,
Err(err) => {
eprintln!(
"audit verify: invalid --against anchor file `{}`: {err}",
anchor_path.display()
);
return Exit::PreconditionUnmet;
}
};
match verify_anchor(path, &anchor) {
Ok(verified) => {
if !output::json_enabled() {
println!(
"audit verify: anchor {} matched event_count {} ({} rows scanned)",
anchor_path.display(),
verified.anchor.event_count,
verified.db_count
);
}
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.anchor = Some(AnchorMatch {
anchor_path: anchor_path.display().to_string(),
event_count: verified.anchor.event_count,
db_count: verified.db_count,
});
});
Exit::Ok
}
Err(AnchorVerifyError::Jsonl(err)) => {
eprintln!(
"audit verify: anchor verification could not scan ledger ({}): {err}",
path.display()
);
map_open_err(&err)
}
Err(err) => {
eprintln!(
"audit verify: anchor verification failed for `{}` against `{}`: {err}",
anchor_path.display(),
path.display()
);
Exit::IntegrityFailure
}
}
}
fn verify_against_anchor_history(path: &PathBuf, against_history: Option<&PathBuf>) -> Exit {
let Some(history_path) = against_history else {
return Exit::Ok;
};
match verify_anchor_history(path, history_path) {
Ok(verified) => {
if !output::json_enabled() {
println!(
"audit verify: anchor history {} verified {} anchors through event_count {} ({} rows scanned)",
history_path.display(),
verified.anchors_verified,
verified.latest_anchor.event_count,
verified.db_count
);
}
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.anchor_history = Some(AnchorHistoryMatch {
history_path: history_path.display().to_string(),
anchors_verified: verified.anchors_verified,
latest_event_count: verified.latest_anchor.event_count,
db_count: verified.db_count,
});
});
Exit::Ok
}
Err(AnchorHistoryVerifyError::ReadHistory { source, .. }) => {
eprintln!(
"audit verify: cannot read --against-history anchor history `{}`: {source}",
history_path.display()
);
Exit::PreconditionUnmet
}
Err(AnchorHistoryVerifyError::Parse { source, .. }) => {
eprintln!(
"audit verify: invalid --against-history anchor history `{}`: {source}",
history_path.display()
);
Exit::PreconditionUnmet
}
Err(AnchorHistoryVerifyError::Anchor { source, .. })
if matches!(source.as_ref(), AnchorVerifyError::Jsonl(_)) =>
{
let AnchorVerifyError::Jsonl(err) = *source else {
unreachable!("guard ensures boxed anchor source is a JSONL error")
};
eprintln!(
"audit verify: anchor history verification could not scan ledger ({}): {err}",
path.display()
);
map_open_err(&err)
}
Err(err @ AnchorHistoryVerifyError::NonMonotonic { .. })
| Err(err @ AnchorHistoryVerifyError::Anchor { .. }) => {
eprintln!(
"audit verify: anchor history verification failed for `{}` against `{}`: {err}",
history_path.display(),
path.display()
);
Exit::IntegrityFailure
}
}
}
fn verify_against_external_receipts(path: &PathBuf, against_external: Option<&PathBuf>) -> Exit {
let Some(receipts_path) = against_external else {
return Exit::Ok;
};
let trust_root_cache = trust_root_cache_path();
let now = Utc::now();
let result = cortex_ledger::external_sink::verify_external_receipts_with_options(
path,
receipts_path.clone(),
trust_root_cache.as_deref(),
now,
cortex_ledger::external_sink::DEFAULT_MAX_TRUST_ROOT_AGE,
);
match result {
Ok(verified) => {
if !output::json_enabled() {
println!(
"audit verify: external anchor receipts {} parsed and verified {} receipts through event_count {} ({} rows scanned)",
receipts_path.display(),
verified.receipts_verified,
verified.latest_receipt.anchor_event_count,
verified.db_count
);
}
let ots_exit = verify_ots_receipts_in_history(receipts_path);
if ots_exit != Exit::Ok {
return ots_exit;
}
let live_status =
match run_live_rekor_verification(receipts_path, &verified.latest_receipt) {
Ok(status) => status,
Err(exit) => return exit,
};
eprintln!("audit verify: external_anchor_authority={live_status}");
eprintln!(
"audit verify: external_anchor_receipts_verified={}",
verified.receipts_verified
);
eprintln!("audit verify: external_anchor_status={live_status}");
eprintln!(
"audit verify: trust_root_status={}",
verified.trust_root_status
);
if let Some(signed_at) = verified.trust_root_signed_at {
eprintln!("audit verify: trust_root_signed_at={signed_at}");
}
if verified.trust_root_status == cortex_ledger::external_sink::EMBEDDED_ROOT_STATUS {
eprintln!(
"audit verify: warning: embedded trusted_root.json snapshot is in force; run `cortex audit refresh-trust` to install a fresh cache"
);
let embedded_stale = TrustedRoot::embedded()
.ok()
.and_then(|root| {
root.is_stale(Utc::now(), TrustRootStalenessAnchor::embedded_snapshot())
.ok()
})
.unwrap_or(false);
if embedded_stale {
eprintln!(
"audit verify: invariant={}",
cortex_ledger::TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT
);
eprintln!(
"audit verify: invariant={}",
cortex_ledger::TRUSTED_ROOT_STALE_INVARIANT
);
}
}
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.external_receipts = Some(ExternalReceiptsMatch {
receipts_path: receipts_path.display().to_string(),
receipts_verified: verified.receipts_verified,
latest_anchor_event_count: verified.latest_receipt.anchor_event_count,
db_count: verified.db_count,
external_anchor_authority: live_status,
status: live_status.to_string(),
trust_root_status: verified.trust_root_status,
trust_root_signed_at: verified.trust_root_signed_at.map(|ts| ts.to_rfc3339()),
});
});
Exit::Ok
}
Err(ExternalReceiptVerifyError::ReadHistory { source, .. }) => {
eprintln!(
"audit verify: cannot read --against-external receipts `{}`: {source}",
receipts_path.display()
);
Exit::PreconditionUnmet
}
Err(ExternalReceiptVerifyError::Parse { source, .. }) => {
eprintln!(
"audit verify: invalid --against-external receipts `{}`: {source}",
receipts_path.display()
);
match source {
cortex_ledger::ExternalReceiptParseError::NonMonotonic { .. } => {
Exit::IntegrityFailure
}
_ => Exit::PreconditionUnmet,
}
}
Err(ExternalReceiptVerifyError::Anchor { source, .. }) => {
eprintln!(
"audit verify: external anchor receipts `{}` carry malformed anchor metadata: {source}",
receipts_path.display()
);
Exit::PreconditionUnmet
}
Err(ExternalReceiptVerifyError::AnchorVerify { source, .. })
if matches!(source.as_ref(), AnchorVerifyError::Jsonl(_)) =>
{
let AnchorVerifyError::Jsonl(err) = *source else {
unreachable!("guard ensures boxed anchor source is a JSONL error")
};
eprintln!(
"audit verify: external receipt verification could not scan ledger ({}): {err}",
path.display()
);
map_open_err(&err)
}
Err(err @ ExternalReceiptVerifyError::AnchorVerify { .. })
| Err(err @ ExternalReceiptVerifyError::AnchorTextHashMismatch { .. }) => {
if let ExternalReceiptVerifyError::AnchorTextHashMismatch { invariant, .. } = &err {
eprintln!("audit verify: invariant={invariant}");
debug_assert_eq!(*invariant, ANCHOR_TEXT_HASH_MISMATCH_INVARIANT);
}
eprintln!(
"audit verify: external receipt verification failed for `{}` against `{}`: {err}",
receipts_path.display(),
path.display()
);
Exit::IntegrityFailure
}
Err(ExternalReceiptVerifyError::TrustedRootStale {
invariant,
trust_root_status,
cache_path,
signed_at,
now,
max_age,
}) => {
eprintln!("audit verify: invariant={invariant}");
eprintln!(
"audit verify: invariant={}",
cortex_ledger::TRUSTED_ROOT_STALE_INVARIANT
);
eprintln!(
"audit verify: external_anchor_authority=fail_closed_trusted_root_stale_>30d"
);
eprintln!("audit verify: trust_root_status={trust_root_status}");
if let Some(signed_at) = signed_at {
eprintln!("audit verify: trust_root_signed_at={signed_at}");
}
if let Some(cache_path) = cache_path.as_ref() {
eprintln!(
"audit verify: trust_root_cache_path={}",
cache_path.display()
);
}
eprintln!(
"audit verify: cached trusted_root.json is stale at {now} (max_age={max_age:?}); run `cortex audit refresh-trust`"
);
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.external_receipts = Some(ExternalReceiptsMatch {
receipts_path: receipts_path.display().to_string(),
receipts_verified: 0,
latest_anchor_event_count: 0,
db_count: 0,
external_anchor_authority: "fail_closed_trusted_root_stale_>30d",
status: invariant.to_string(),
trust_root_status,
trust_root_signed_at: signed_at.map(|ts| ts.to_rfc3339()),
});
});
Exit::PreconditionUnmet
}
Err(ExternalReceiptVerifyError::TrustedRootIo {
invariant,
path: trust_root_path,
source,
}) => {
eprintln!("audit verify: invariant={invariant}");
eprintln!(
"audit verify: failed to load trusted_root.json at `{}`: {source}",
trust_root_path.display()
);
Exit::PreconditionUnmet
}
}
}
fn verify_ots_receipts_in_history(receipts_path: &PathBuf) -> Exit {
let receipts = match cortex_ledger::read_external_receipt_history(receipts_path) {
Ok(receipts) => receipts,
Err(err) => {
eprintln!(
"audit verify: cannot reread --against-external receipts `{}` for OTS pass: {err}",
receipts_path.display()
);
return Exit::PreconditionUnmet;
}
};
let mut pending_count = 0usize;
let mut bitcoin_partial_count = 0usize;
let mut bitcoin_verified_count = 0usize;
let mut quorum_partial_count = 0usize;
let live_https_source: Option<cortex_ledger::HttpsHeadersBitcoinHeaderSource> =
if ots_live_mode_enabled() {
Some(cortex_ledger::HttpsHeadersBitcoinHeaderSource::new(
live_bitcoin_header_providers(),
))
} else {
None
};
let bitcoin_dyn: Option<&dyn cortex_ledger::BitcoinHeaderSource> = live_https_source
.as_ref()
.map(|s| s as &dyn cortex_ledger::BitcoinHeaderSource);
struct PerReceipt {
record_index: usize,
outcome: cortex_ledger::OtsVerificationOutcome,
}
let mut per_receipt: Vec<PerReceipt> = Vec::new();
let mut history_witnesses: Vec<OtsWitness> = Vec::new();
for (index, receipt) in receipts.iter().enumerate() {
if receipt.sink != cortex_ledger::ExternalSink::OpenTimestamps {
continue;
}
if receipt.receipt.get("ots_proof_base64").is_none() {
continue;
}
let record_index = index + 1;
let outcome = match cortex_ledger::verify_ots_receipt_with_defaults(receipt, bitcoin_dyn) {
Ok(outcome) => outcome,
Err(err) => {
eprintln!(
"audit verify: OTS receipt {} in `{}` parser rejected the proof: {err}",
record_index,
receipts_path.display()
);
let exit = match err {
cortex_ledger::OtsError::UnknownTag { .. } => Exit::IntegrityFailure,
_ => Exit::PreconditionUnmet,
};
return exit;
}
};
match &outcome {
cortex_ledger::OtsVerificationOutcome::FullChainVerified { witnesses } => {
history_witnesses.extend(witnesses.iter().cloned());
}
cortex_ledger::OtsVerificationOutcome::Partial { witnesses, .. } => {
history_witnesses.extend(witnesses.iter().cloned());
}
cortex_ledger::OtsVerificationOutcome::Broken { witnesses, .. } => {
history_witnesses.extend(witnesses.iter().cloned());
}
}
per_receipt.push(PerReceipt {
record_index,
outcome,
});
}
for entry in per_receipt {
let PerReceipt {
record_index,
outcome,
} = entry;
let gated = enforce_disjoint_authority_quorum(outcome, &history_witnesses);
match gated {
cortex_ledger::OtsVerificationOutcome::FullChainVerified { .. } => {
bitcoin_verified_count += 1;
}
cortex_ledger::OtsVerificationOutcome::Partial { reasons, .. } => {
let is_pending = reasons
.iter()
.any(|r| r == cortex_ledger::OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT);
let is_quorum_not_met = reasons
.iter()
.any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT);
if is_pending {
pending_count += 1;
eprintln!(
"audit verify: invariant={}",
cortex_ledger::OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT
);
} else if is_quorum_not_met {
quorum_partial_count += 1;
eprintln!(
"audit verify: invariant={OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT}"
);
eprintln!(
"audit verify: OTS receipt {} in `{}` held at Partial: quorum of N>=2 disjoint operators not met across receipt history. add a witness from a non-Todd calendar (e.g. eternitywall.com) to reach FullChainVerified.",
record_index,
receipts_path.display()
);
} else {
bitcoin_partial_count += 1;
eprintln!(
"audit verify: invariant={}",
cortex_ledger::OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT
);
}
}
cortex_ledger::OtsVerificationOutcome::Broken { edge, .. } => {
eprintln!("audit verify: invariant={}", edge.invariant);
eprintln!(
"audit verify: OTS receipt {} in `{}` failed closed: {}",
record_index,
receipts_path.display(),
edge.detail
);
return Exit::IntegrityFailure;
}
}
}
if pending_count > 0
|| bitcoin_partial_count > 0
|| bitcoin_verified_count > 0
|| quorum_partial_count > 0
{
eprintln!(
"audit verify: ots_receipts_pending={pending_count} \
ots_receipts_bitcoin_partial={bitcoin_partial_count} \
ots_receipts_bitcoin_full_chain_verified={bitcoin_verified_count} \
ots_receipts_quorum_not_met={quorum_partial_count}"
);
}
Exit::Ok
}
fn run_live_rekor_verification(
receipts_path: &std::path::Path,
latest: &ExternalReceipt,
) -> Result<&'static str, Exit> {
if latest.sink != ExternalSink::Rekor {
return Ok(PARSED_ONLY_VERIFICATION_STATUS);
}
let live_fields_present = latest.receipt.get("body").is_some()
&& latest.receipt.get("inclusionProof").is_some()
&& latest.receipt.get("signedEntryTimestamp").is_some();
if !live_fields_present {
return Ok(PARSED_ONLY_VERIFICATION_STATUS);
}
let trusted_root = match TrustedRoot::from_embedded() {
Ok(root) => root,
Err(err) => {
eprintln!(
"audit verify: failed to load embedded trusted root for live Rekor verification of `{}`: {err}",
receipts_path.display()
);
return Err(Exit::Internal);
}
};
match rekor_verify_receipt(latest, &trusted_root) {
Ok(verification) => {
eprintln!(
"audit verify: rekor entry uuid={} log_index={}",
verification.uuid, verification.log_index
);
Ok(REKOR_EXTERNAL_AUTHORITY_STATUS)
}
Err(err) => {
let (invariant, reason) = rekor_error_invariant(&err);
if matches!(err, RekorError::SetSignatureInvalid { .. }) {
eprintln!("audit verify: invariant={REKOR_VERIFY_SIGNATURE_MISMATCH_INVARIANT}");
}
eprintln!("audit verify: invariant={invariant}");
eprintln!(
"audit verify: rekor live verification failed for receipts `{}`: {reason}",
receipts_path.display()
);
Err(Exit::IntegrityFailure)
}
}
}
pub fn run_anchor(args: AnchorArgs) -> Exit {
ANCHOR_POLICY_OUTCOME.with(|cell| cell.borrow_mut().take());
let exit = run_anchor_inner(args);
if output::json_enabled() {
let report = ANCHOR_REPORT.with(|cell| cell.borrow_mut().take().unwrap_or_default());
let envelope = match ANCHOR_POLICY_OUTCOME.with(|cell| cell.borrow().clone()) {
Some(policy) => Envelope::new("cortex.audit.anchor", exit, report)
.with_policy_outcome(policy_outcome_summary(&policy)),
None => Envelope::new("cortex.audit.anchor", exit, report),
};
output::emit(&envelope, exit)
} else {
exit
}
}
thread_local! {
static ANCHOR_REPORT: std::cell::RefCell<Option<AnchorReport>> =
const { std::cell::RefCell::new(None) };
static ANCHOR_POLICY_OUTCOME: std::cell::RefCell<Option<PolicyDecision>> =
const { std::cell::RefCell::new(None) };
}
#[derive(Debug, Serialize)]
struct AnchorReport {
sink: Option<String>,
anchor_authority: Option<String>,
external_anchor_sink_configured: bool,
external_anchor_authority: bool,
#[serde(skip_serializing_if = "Option::is_none")]
external_anchor_authority_status: Option<&'static str>,
anchor_sink_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
anchor_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
event_count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
chain_head_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
output_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
history_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
forbidden_uses: Option<Vec<&'static str>>,
runtime_mode: RuntimeMode,
proof_state: ClaimProofState,
claim_ceiling: ClaimCeiling,
authority_class: AuthorityClass,
#[serde(skip_serializing_if = "Option::is_none")]
policy_outcome: Option<AnchorPolicyOutcome>,
#[serde(skip_serializing_if = "Option::is_none")]
rekor_log_index: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
rekor_uuid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rekor_set_signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
trusted_root_signed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rekor_receipt_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rekor_receipt_history_path: Option<String>,
}
impl Default for AnchorReport {
fn default() -> Self {
Self {
sink: None,
anchor_authority: None,
external_anchor_sink_configured: false,
external_anchor_authority: false,
external_anchor_authority_status: None,
anchor_sink_reason: None,
anchor_text: None,
event_count: None,
chain_head_hash: None,
output_path: None,
history_path: None,
forbidden_uses: None,
runtime_mode: RuntimeMode::LocalUnsigned,
proof_state: ClaimProofState::Unknown,
claim_ceiling: ClaimCeiling::DevOnly,
authority_class: AuthorityClass::Observed,
policy_outcome: None,
rekor_log_index: None,
rekor_uuid: None,
rekor_set_signature: None,
trusted_root_signed_at: None,
rekor_receipt_path: None,
rekor_receipt_history_path: None,
}
}
}
#[derive(Debug, Serialize)]
struct AnchorPolicyOutcome {
final_outcome: PolicyOutcome,
contributing: Vec<PolicyContribution>,
discarded: Vec<PolicyContribution>,
}
impl AnchorPolicyOutcome {
fn from_decision(policy: &PolicyDecision) -> Self {
Self {
final_outcome: policy.final_outcome,
contributing: policy.contributing.clone(),
discarded: policy.discarded.clone(),
}
}
}
fn run_anchor_inner(args: AnchorArgs) -> Exit {
let sink_decision = anchor_sink_decision(args.sink, args.sink_path.as_ref());
if output::json_enabled() {
ANCHOR_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(AnchorReport::default);
entry.sink = Some(args.sink.wire_str().to_string());
});
}
if args.sink == AnchorSink::LocalOnly && args.sink_path.is_some() {
print_anchor_authority_report(&sink_decision, None);
eprintln!(
"audit anchor: --sink-path is only valid with --sink external-append-only; no anchor was emitted or written"
);
return Exit::Usage;
}
if args.sink == AnchorSink::LocalOnly
&& (args.sink_endpoint.is_some()
|| args.sink_receipt.is_some()
|| args.sink_receipt_history.is_some()
|| args.offline)
{
print_anchor_authority_report(&sink_decision, None);
eprintln!(
"audit anchor: --sink-endpoint/--sink-receipt/--sink-receipt-history/--offline are only valid with an --sink external-* selector; no anchor was emitted or written"
);
return Exit::Usage;
}
if args.sink == AnchorSink::ExternalAppendOnly {
print_anchor_authority_report(&sink_decision, None);
eprintln!(
"audit anchor: --sink external-append-only requires a configured disjoint append-only sink; no anchor was emitted or written"
);
return Exit::PreconditionUnmet;
}
if args.sink == AnchorSink::Rekor {
return run_anchor_rekor(&args);
}
if args.sink == AnchorSink::OpenTimestamps {
return run_anchor_opentimestamps(args);
}
let layout = match DataLayout::resolve(args.db, args.event_log) {
Ok(l) => l,
Err(e) => return e,
};
let anchor = match current_anchor(&layout.event_log_path, Utc::now()) {
Ok(anchor) => anchor,
Err(AnchorVerifyError::Jsonl(err)) => {
eprintln!(
"audit anchor: cannot scan ledger ({}): {err}",
layout.event_log_path.display()
);
return map_open_err(&err);
}
Err(err) => {
eprintln!(
"audit anchor: cannot publish anchor for `{}`: {err}",
layout.event_log_path.display()
);
return Exit::IntegrityFailure;
}
};
let text = anchor.to_anchor_text();
if let Some(history) = args.history.as_ref() {
let exit = validate_anchor_history_before_append(&layout.event_log_path, history);
if exit != Exit::Ok {
return exit;
}
}
if let Some(output) = args.output.as_ref() {
let mut file = match OpenOptions::new().write(true).create_new(true).open(output) {
Ok(file) => file,
Err(err) => {
eprintln!(
"audit anchor: failed to create anchor output `{}` without replacement: {err}",
output.display()
);
return Exit::PreconditionUnmet;
}
};
if let Err(err) = write_and_sync(&mut file, text.as_bytes()) {
eprintln!(
"audit anchor: failed to write anchor output `{}`: {err}",
output.display()
);
return Exit::Internal;
}
}
if let Some(history) = args.history.as_ref() {
let mut file = match OpenOptions::new().append(true).create(true).open(history) {
Ok(file) => file,
Err(err) => {
eprintln!(
"audit anchor: failed to open anchor history `{}` for append: {err}",
history.display()
);
return Exit::PreconditionUnmet;
}
};
if let Err(err) = write_and_sync(&mut file, text.as_bytes()) {
eprintln!(
"audit anchor: failed to append anchor history `{}`: {err}",
history.display()
);
return Exit::Internal;
}
}
if !output::json_enabled() {
print!("{text}");
}
print_anchor_authority_report(&sink_decision, Some(&layout.event_log_path));
if let Some(history) = args.history.as_ref() {
eprintln!(
"audit anchor: appended local anchor history `{}` through event_count {}",
history.display(),
anchor.event_count
);
} else if let Some(output) = args.output.as_ref() {
eprintln!(
"audit anchor: wrote local anchor `{}` at event_count {}",
output.display(),
anchor.event_count
);
} else {
eprintln!(
"audit anchor: emitted local anchor at event_count {}; publish to a disjoint append-only sink for stronger authority",
anchor.event_count
);
}
if output::json_enabled() {
ANCHOR_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(AnchorReport::default);
entry.anchor_text = Some(text);
entry.event_count = Some(anchor.event_count);
entry.chain_head_hash = Some(anchor.chain_head_hash.clone());
entry.output_path = args.output.as_ref().map(|p| p.display().to_string());
entry.history_path = args.history.as_ref().map(|p| p.display().to_string());
});
}
Exit::Ok
}
pub const CORTEX_OTS_LIVE_ENV: &str = "CORTEX_OTS_LIVE";
pub const CORTEX_OTS_BTC_HEADER_PROVIDERS_ENV: &str = "CORTEX_OTS_BTC_HEADER_PROVIDERS";
fn ots_live_mode_enabled() -> bool {
std::env::var(CORTEX_OTS_LIVE_ENV)
.map(|v| v.trim() == "1")
.unwrap_or(false)
}
fn live_bitcoin_header_providers() -> Vec<String> {
if let Ok(raw) = std::env::var(CORTEX_OTS_BTC_HEADER_PROVIDERS_ENV) {
let providers: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if !providers.is_empty() {
return providers;
}
}
cortex_ledger::DEFAULT_HTTPS_HEADER_PROVIDERS
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn run_anchor_opentimestamps(args: AnchorArgs) -> Exit {
use cortex_ledger::{
submit_ots, verify_ots_receipt_with_defaults, BitcoinHeaderSource,
HttpsHeadersBitcoinHeaderSource, NoopCalendarClient, OtsError, UreqCalendarClient,
DEFAULT_OTS_CALENDAR_URL, DEFAULT_OTS_CALENDAR_URLS,
};
if args.allow_pending && args.require_non_pending {
eprintln!(
"audit anchor: --allow-pending and --require-non-pending are mutually exclusive; \
pick at most one"
);
return Exit::Usage;
}
let allow_pending = args.allow_pending;
let layout = match DataLayout::resolve(args.db, args.event_log) {
Ok(l) => l,
Err(e) => return e,
};
let anchor = match current_anchor(&layout.event_log_path, Utc::now()) {
Ok(anchor) => anchor,
Err(AnchorVerifyError::Jsonl(err)) => {
eprintln!(
"audit anchor: cannot scan ledger ({}): {err}",
layout.event_log_path.display()
);
return map_open_err(&err);
}
Err(err) => {
eprintln!(
"audit anchor: cannot publish anchor for `{}`: {err}",
layout.event_log_path.display()
);
return Exit::IntegrityFailure;
}
};
let live_mode = ots_live_mode_enabled();
let calendar_urls: Vec<String> = if let Some(endpoint) = args.sink_endpoint.as_deref() {
vec![endpoint.to_string()]
} else if live_mode {
DEFAULT_OTS_CALENDAR_URLS
.iter()
.map(|s| (*s).to_string())
.collect()
} else {
vec![DEFAULT_OTS_CALENDAR_URL.to_string()]
};
let submitted_at = Utc::now();
let mut receipts: Vec<cortex_ledger::ExternalReceipt> = Vec::with_capacity(calendar_urls.len());
match args.sink_receipt_in.as_ref() {
Some(path) => {
for url in &calendar_urls {
match build_receipt_from_pre_fetched_bytes(&anchor, url, submitted_at, path) {
Ok(receipt) => receipts.push(receipt),
Err(exit) => return exit,
}
}
}
None => {
let live_client = UreqCalendarClient::new();
let noop_client = NoopCalendarClient;
for url in &calendar_urls {
let submission = if live_mode {
submit_ots(&anchor, url, submitted_at, &live_client)
} else {
submit_ots(&anchor, url, submitted_at, &noop_client)
};
match submission {
Ok(receipt) => receipts.push(receipt),
Err(OtsError::OtsCrateError(reason))
if reason.contains("NoopCalendarClient") =>
{
eprintln!(
"audit anchor: --sink opentimestamps requires either \
--sink-receipt-in, `CORTEX_OTS_LIVE=1` (live UreqCalendarClient), \
or an operator-supplied CalendarClient build; default \
NoopCalendarClient refused the submission ({reason})"
);
let decision = AnchorSinkDecision {
authority_label: "external_unconfigured",
external_configured: false,
external_authority: false,
reason: EXTERNAL_SINK_ADAPTER_NOT_YET_IMPLEMENTED_REASON,
policy_outcome: PolicyOutcome::Reject,
};
print_anchor_authority_report(&decision, Some(&layout.event_log_path));
return Exit::PreconditionUnmet;
}
Err(err) => {
eprintln!("audit anchor: OTS submit to {url} failed: {err}");
return Exit::PreconditionUnmet;
}
}
}
}
}
enum AnchorBitcoinSource {
Static(cortex_ledger::StaticBitcoinHeaderSource),
Https(HttpsHeadersBitcoinHeaderSource),
}
let bitcoin_source: Option<AnchorBitcoinSource> = match args.bitcoin_header.as_ref() {
Some(path) => match load_bitcoin_header(path) {
Ok(source) => Some(AnchorBitcoinSource::Static(source)),
Err(exit) => return exit,
},
None if live_mode => Some(AnchorBitcoinSource::Https(
HttpsHeadersBitcoinHeaderSource::new(live_bitcoin_header_providers()),
)),
None => None,
};
let bitcoin_dyn: Option<&dyn BitcoinHeaderSource> = match bitcoin_source.as_ref() {
Some(AnchorBitcoinSource::Static(s)) => Some(s as &dyn BitcoinHeaderSource),
Some(AnchorBitcoinSource::Https(s)) => Some(s as &dyn BitcoinHeaderSource),
None => None,
};
let mut per_receipt_outcomes: Vec<cortex_ledger::OtsVerificationOutcome> =
Vec::with_capacity(receipts.len());
let mut history_witnesses: Vec<OtsWitness> = Vec::new();
for receipt in &receipts {
let outcome = match verify_ots_receipt_with_defaults(receipt, bitcoin_dyn) {
Ok(outcome) => outcome,
Err(err) => {
eprintln!("audit anchor: OTS receipt verification failed: {err}");
let exit = match err {
OtsError::UnknownTag { .. } => Exit::IntegrityFailure,
OtsError::EmptyProof | OtsError::MalformedHeader { .. } => {
Exit::PreconditionUnmet
}
_ => Exit::PreconditionUnmet,
};
return exit;
}
};
match &outcome {
cortex_ledger::OtsVerificationOutcome::FullChainVerified { witnesses } => {
history_witnesses.extend(witnesses.iter().cloned());
}
cortex_ledger::OtsVerificationOutcome::Partial { witnesses, .. } => {
history_witnesses.extend(witnesses.iter().cloned());
}
cortex_ledger::OtsVerificationOutcome::Broken { witnesses, .. } => {
history_witnesses.extend(witnesses.iter().cloned());
}
}
per_receipt_outcomes.push(outcome);
}
let mut quorum_not_met_count: usize = 0;
let mut gated_outcomes: Vec<cortex_ledger::OtsVerificationOutcome> =
Vec::with_capacity(per_receipt_outcomes.len());
for raw_outcome in per_receipt_outcomes {
let gated = enforce_disjoint_authority_quorum(raw_outcome, &history_witnesses);
if let cortex_ledger::OtsVerificationOutcome::Partial { reasons, .. } = &gated {
if reasons
.iter()
.any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT)
{
quorum_not_met_count += 1;
}
}
gated_outcomes.push(gated);
}
let representative_index = pick_representative_anchor_outcome(&gated_outcomes);
let outcome = gated_outcomes[representative_index].clone();
let receipt = receipts[representative_index].clone();
if quorum_not_met_count > 0 {
eprintln!("audit anchor: invariant={OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT}");
eprintln!(
"audit anchor: OTS receipt quorum gate downgraded {quorum_not_met_count} of {} receipt(s) — disjoint-authority quorum of N>=2 distinct operators not met. Add a witness from a non-Todd calendar (e.g. finney.calendar.eternitywall.com) to reach FullChainVerified.",
receipts.len()
);
}
let decision = ots_anchor_sink_decision(&outcome, allow_pending);
let representative_text = match receipt.to_record_text() {
Ok(text) => text,
Err(err) => {
eprintln!("audit anchor: failed to serialize OTS receipt: {err}");
return Exit::Internal;
}
};
if matches!(
decision.policy_outcome,
PolicyOutcome::Allow | PolicyOutcome::Quarantine | PolicyOutcome::Warn
) {
if let Some(output) = args.sink_receipt.as_ref() {
let mut file = match OpenOptions::new().write(true).create_new(true).open(output) {
Ok(file) => file,
Err(err) => {
eprintln!(
"audit anchor: failed to create OTS receipt output `{}`: {err}",
output.display()
);
return Exit::PreconditionUnmet;
}
};
if let Err(err) = write_and_sync(&mut file, representative_text.as_bytes()) {
eprintln!(
"audit anchor: failed to write OTS receipt output `{}`: {err}",
output.display()
);
return Exit::Internal;
}
}
if let Some(history) = args.sink_receipt_history.as_ref() {
let mut file = match OpenOptions::new().append(true).create(true).open(history) {
Ok(file) => file,
Err(err) => {
eprintln!(
"audit anchor: failed to open OTS receipt history `{}`: {err}",
history.display()
);
return Exit::PreconditionUnmet;
}
};
for r in &receipts {
let text = match r.to_record_text() {
Ok(text) => text,
Err(err) => {
eprintln!("audit anchor: failed to serialize OTS receipt: {err}");
return Exit::Internal;
}
};
if let Err(err) = write_and_sync(&mut file, text.as_bytes()) {
eprintln!(
"audit anchor: failed to append OTS receipt history `{}`: {err}",
history.display()
);
return Exit::Internal;
}
}
}
}
if !output::json_enabled() {
print!("{}", anchor.to_anchor_text());
}
print_anchor_authority_report(&decision, Some(&layout.event_log_path));
eprintln!(
"audit anchor: ots_outcome={} (sink_endpoint={})",
outcome.wire_str(),
receipt.sink_endpoint
);
if output::json_enabled() {
ANCHOR_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(AnchorReport::default);
entry.anchor_text = Some(anchor.to_anchor_text());
entry.event_count = Some(anchor.event_count);
entry.chain_head_hash = Some(anchor.chain_head_hash.clone());
entry.output_path = args.sink_receipt.as_ref().map(|p| p.display().to_string());
entry.history_path = args
.sink_receipt_history
.as_ref()
.map(|p| p.display().to_string());
});
}
match decision.policy_outcome {
PolicyOutcome::Reject => Exit::PreconditionUnmet,
_ => Exit::Ok,
}
}
fn run_anchor_rekor(args: &AnchorArgs) -> Exit {
if args.offline {
let outcome = RekorAdapterOutcome::Rejected {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "--offline forbids live Rekor submission".to_string(),
};
let decision = rekor_anchor_sink_decision(&outcome);
print_anchor_authority_report(&decision, None);
eprintln!(
"audit anchor: --sink rekor refused with --offline; live submission requires network reachability"
);
return Exit::PreconditionUnmet;
}
let layout = match DataLayout::resolve(args.db.clone(), args.event_log.clone()) {
Ok(layout) => layout,
Err(exit) => return exit,
};
let anchor = match current_anchor(&layout.event_log_path, Utc::now()) {
Ok(anchor) => anchor,
Err(AnchorVerifyError::Jsonl(err)) => {
eprintln!(
"audit anchor: cannot scan ledger ({}): {err}",
layout.event_log_path.display()
);
return map_open_err(&err);
}
Err(err) => {
eprintln!(
"audit anchor: cannot publish anchor for `{}`: {err}",
layout.event_log_path.display()
);
return Exit::IntegrityFailure;
}
};
let trusted_root = match TrustedRoot::from_embedded() {
Ok(root) => root,
Err(err) => {
let outcome = RekorAdapterOutcome::Rejected {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
reason: format!("failed to load embedded trusted root: {err}"),
};
let decision = rekor_anchor_sink_decision(&outcome);
print_anchor_authority_report(&decision, Some(&layout.event_log_path));
eprintln!("audit anchor: rekor adapter: {err}");
return Exit::Internal;
}
};
let trusted_root_signed_at = trusted_root.signed_at();
let trusted_root_stale = trusted_root
.is_stale(Utc::now(), TrustRootStalenessAnchor::embedded_snapshot())
.unwrap_or(true);
let endpoint = args
.sink_endpoint
.as_deref()
.unwrap_or(REKOR_DEFAULT_ENDPOINT);
let receipt = match rekor_submit(&anchor, endpoint) {
Ok(receipt) => receipt,
Err(err) => {
let (invariant, reason) = rekor_error_invariant(&err);
let outcome = RekorAdapterOutcome::Rejected { invariant, reason };
let decision = rekor_anchor_sink_decision(&outcome);
print_anchor_authority_report(&decision, Some(&layout.event_log_path));
eprintln!("audit anchor: rekor submission failed: {err}");
return Exit::Internal;
}
};
let verification = match rekor_verify_receipt(&receipt, &trusted_root) {
Ok(v) => v,
Err(err) => {
let (invariant, reason) = rekor_error_invariant(&err);
let outcome = RekorAdapterOutcome::Rejected { invariant, reason };
let decision = rekor_anchor_sink_decision(&outcome);
print_anchor_authority_report(&decision, Some(&layout.event_log_path));
eprintln!("audit anchor: rekor verification failed: {err}");
return Exit::IntegrityFailure;
}
};
let outcome = if trusted_root_stale {
RekorAdapterOutcome::QuarantineTrustedRootStale
} else {
RekorAdapterOutcome::Allowed
};
let decision = rekor_anchor_sink_decision(&outcome);
let receipt_text = match receipt.to_record_text() {
Ok(text) => text,
Err(err) => {
eprintln!("audit anchor: failed to render rekor receipt: {err}");
return Exit::Internal;
}
};
if let Some(receipt_path) = args.sink_receipt.as_ref() {
let mut file = match OpenOptions::new()
.write(true)
.create_new(true)
.open(receipt_path)
{
Ok(file) => file,
Err(err) => {
eprintln!(
"audit anchor: failed to create rekor receipt output `{}` without replacement: {err}",
receipt_path.display()
);
return Exit::PreconditionUnmet;
}
};
if let Err(err) = write_and_sync(&mut file, receipt_text.as_bytes()) {
eprintln!(
"audit anchor: failed to write rekor receipt `{}`: {err}",
receipt_path.display()
);
return Exit::Internal;
}
}
if let Some(history_path) = args.sink_receipt_history.as_ref() {
let mut file = match OpenOptions::new()
.append(true)
.create(true)
.open(history_path)
{
Ok(file) => file,
Err(err) => {
eprintln!(
"audit anchor: failed to open rekor receipt history `{}` for append: {err}",
history_path.display()
);
return Exit::PreconditionUnmet;
}
};
if let Err(err) = write_and_sync(&mut file, receipt_text.as_bytes()) {
eprintln!(
"audit anchor: failed to append rekor receipt history `{}`: {err}",
history_path.display()
);
return Exit::Internal;
}
}
print_anchor_authority_report(&decision, Some(&layout.event_log_path));
eprintln!(
"audit anchor: rekor entry uuid={} log_index={} endpoint={endpoint}",
verification.uuid, verification.log_index,
);
eprintln!(
"audit anchor: rekor trusted_root_signed_at={}",
trusted_root_signed_at.to_rfc3339()
);
if trusted_root_stale {
eprintln!(
"audit anchor: rekor trusted root is older than 30 days; receipt is quarantined (invariant={REKOR_TRUSTED_ROOT_STALE_INVARIANT})"
);
}
if output::json_enabled() {
ANCHOR_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(AnchorReport::default);
entry.anchor_text = Some(anchor.to_anchor_text());
entry.event_count = Some(anchor.event_count);
entry.chain_head_hash = Some(anchor.chain_head_hash.clone());
entry.rekor_log_index = Some(verification.log_index);
entry.rekor_uuid = Some(verification.uuid.clone());
entry.rekor_set_signature = Some(verification.set_signature.clone());
entry.trusted_root_signed_at = Some(trusted_root_signed_at.to_rfc3339());
entry.rekor_receipt_path = args.sink_receipt.as_ref().map(|p| p.display().to_string());
entry.rekor_receipt_history_path = args
.sink_receipt_history
.as_ref()
.map(|p| p.display().to_string());
if matches!(outcome, RekorAdapterOutcome::Allowed) {
entry.external_anchor_authority_status = Some(REKOR_EXTERNAL_AUTHORITY_STATUS);
}
});
}
match outcome {
RekorAdapterOutcome::Allowed => Exit::Ok,
RekorAdapterOutcome::QuarantineTrustedRootStale => Exit::Ok,
RekorAdapterOutcome::Rejected { .. } => unreachable!("rejected branches early-returned"),
}
}
fn rekor_error_invariant(err: &RekorError) -> (&'static str, String) {
match err {
RekorError::SubmitHttp { invariant, reason }
| RekorError::SubmitBody { invariant, reason } => (*invariant, reason.clone()),
RekorError::WrongSink {
invariant,
observed,
} => (*invariant, format!("wrong sink: {observed}")),
RekorError::MalformedReceipt { invariant, reason } => (*invariant, reason.clone()),
RekorError::SetSignatureInvalid { invariant, reason } => (*invariant, reason.clone()),
RekorError::InclusionProofInvalid { invariant, reason } => (*invariant, reason.clone()),
RekorError::TrustedRootStale {
invariant,
signed_at_rfc3339,
} => (
*invariant,
format!("trusted root stale (signed_at={signed_at_rfc3339})"),
),
RekorError::TlogLogIdUnknown {
invariant,
receipt_log_id,
tlog_log_ids,
} => (
*invariant,
format!(
"rekor receipt logID `{receipt_log_id}` did not bind to any tlog in trusted_root (declared: {})",
tlog_log_ids.join(", ")
),
),
}
}
fn build_receipt_from_pre_fetched_bytes(
anchor: &cortex_ledger::LedgerAnchor,
calendar_url: &str,
submitted_at: chrono::DateTime<chrono::Utc>,
path: &PathBuf,
) -> Result<cortex_ledger::ExternalReceipt, Exit> {
let bytes = std::fs::read(path).map_err(|err| {
eprintln!(
"audit anchor: cannot read --sink-receipt-in `{}`: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
let ots_bytes = bytes.clone();
use cortex_ledger::OtsParser as _;
match cortex_ledger::DefaultOtsParser.parse(&ots_bytes) {
Ok(_) => {}
Err(err) => {
eprintln!(
"audit anchor: --sink-receipt-in `{}` did not parse as a valid OTS proof: {err}",
path.display()
);
return Err(Exit::PreconditionUnmet);
}
}
Ok(cortex_ledger::ExternalReceipt {
sink: cortex_ledger::ExternalSink::OpenTimestamps,
anchor_text_sha256: cortex_ledger::anchor_text_sha256(anchor),
anchor_event_count: anchor.event_count,
anchor_chain_head_hash: anchor.chain_head_hash.clone(),
submitted_at,
sink_endpoint: calendar_url.to_string(),
receipt: serde_json::json!({
"ots_proof_base64": ots_proof_base64(&ots_bytes),
"calendar_url": calendar_url,
"submitted_digest_hex": cortex_ledger::anchor_text_sha256(anchor),
}),
})
}
fn pick_representative_anchor_outcome(outcomes: &[cortex_ledger::OtsVerificationOutcome]) -> usize {
debug_assert!(
!outcomes.is_empty(),
"audit anchor: outcomes vector must be non-empty"
);
if let Some((idx, _)) = outcomes.iter().enumerate().find(|(_, o)| {
matches!(
o,
cortex_ledger::OtsVerificationOutcome::FullChainVerified { .. }
)
}) {
return idx;
}
if let Some((idx, _)) = outcomes
.iter()
.enumerate()
.find(|(_, o)| matches!(o, cortex_ledger::OtsVerificationOutcome::Partial { .. }))
{
return idx;
}
0
}
fn load_bitcoin_header(path: &PathBuf) -> Result<cortex_ledger::StaticBitcoinHeaderSource, Exit> {
let bytes = std::fs::read(path).map_err(|err| {
eprintln!(
"audit anchor: cannot read --bitcoin-header `{}`: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
if bytes.len() != 80 {
eprintln!(
"audit anchor: --bitcoin-header `{}` must be exactly 80 raw bytes; got {}",
path.display(),
bytes.len()
);
return Err(Exit::PreconditionUnmet);
}
let height_path = path.with_extension("height");
let height_text = std::fs::read_to_string(&height_path).map_err(|err| {
eprintln!(
"audit anchor: --bitcoin-header `{}` requires a neighbor `{}` carrying the decimal block height: {err}",
path.display(),
height_path.display()
);
Exit::PreconditionUnmet
})?;
let height: u64 = height_text.trim().parse().map_err(|err| {
eprintln!(
"audit anchor: --bitcoin-header height file `{}` is not a decimal u64: {err}",
height_path.display()
);
Exit::PreconditionUnmet
})?;
Ok(cortex_ledger::StaticBitcoinHeaderSource::new().with_header(height, bytes))
}
fn ots_proof_base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
let mut i = 0;
while i + 3 <= bytes.len() {
let triple = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | bytes[i + 2] as u32;
out.push(TABLE[((triple >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((triple >> 12) & 0x3f) as usize] as char);
out.push(TABLE[((triple >> 6) & 0x3f) as usize] as char);
out.push(TABLE[(triple & 0x3f) as usize] as char);
i += 3;
}
let remaining = bytes.len() - i;
match remaining {
1 => {
let single = (bytes[i] as u32) << 16;
out.push(TABLE[((single >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((single >> 12) & 0x3f) as usize] as char);
out.push('=');
out.push('=');
}
2 => {
let pair = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
out.push(TABLE[((pair >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((pair >> 12) & 0x3f) as usize] as char);
out.push(TABLE[((pair >> 6) & 0x3f) as usize] as char);
out.push('=');
}
_ => {}
}
out
}
fn print_anchor_authority_report(decision: &AnchorSinkDecision, event_log_path: Option<&PathBuf>) {
eprintln!(
"audit anchor: anchor_authority={}",
decision.authority_label
);
eprintln!(
"audit anchor: external_anchor_sink_configured={}",
decision.external_configured
);
eprintln!(
"audit anchor: external_anchor_authority={}",
decision.external_authority
);
eprintln!("audit anchor: anchor_sink_reason={}", decision.reason);
let temporal_authority = match event_log_path {
Some(path) => build_temporal_authority_contribution_for_row_set(
path,
"audit.anchor",
ANCHOR_TEMPORAL_AUTHORITY_RULE_ID,
),
None => {
let invariant = revalidation_failed_invariant("audit.anchor");
PolicyContribution::new(
ANCHOR_TEMPORAL_AUTHORITY_RULE_ID,
PolicyOutcome::Reject,
format!(
"{invariant}: anchor surface bailed before resolving event log; temporal authority axis cannot be observed",
),
)
.expect("anchor temporal authority placeholder contribution is statically valid")
}
};
let policy = compose_anchor_policy_decision(decision, temporal_authority);
let final_outcome = policy.final_outcome;
eprintln!("audit anchor: policy_outcome={final_outcome:?}");
for contribution in &policy.contributing {
let outcome = contribution.outcome;
let rule_id = contribution.rule_id.as_str();
let reason = contribution.reason.as_str();
eprintln!(
"audit anchor: policy_contributing={rule_id} outcome={outcome:?} reason={reason}"
);
}
ANCHOR_POLICY_OUTCOME.with(|cell| *cell.borrow_mut() = Some(policy.clone()));
if output::json_enabled() {
ANCHOR_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(AnchorReport::default);
entry.anchor_authority = Some(decision.authority_label.to_string());
entry.external_anchor_sink_configured = decision.external_configured;
entry.external_anchor_authority = decision.external_authority;
entry.anchor_sink_reason = Some(decision.reason.to_string());
if !decision.external_authority {
entry.forbidden_uses = Some(vec![
"compliance_evidence",
"cross_system_trust_decision",
"external_reporting",
]);
}
entry.policy_outcome = Some(AnchorPolicyOutcome::from_decision(&policy));
let (runtime_mode, proof_state) = if decision.external_authority {
(
RuntimeMode::ExternallyAnchored,
ClaimProofState::FullChainVerified,
)
} else {
(RuntimeMode::LocalUnsigned, ClaimProofState::Unknown)
};
let authority_class = if decision.external_authority {
AuthorityClass::Verified
} else {
AuthorityClass::Observed
};
let requested_ceiling = if decision.external_authority {
ClaimCeiling::ExternallyAnchored
} else {
ClaimCeiling::LocalUnsigned
};
let claim_ceiling = cortex_core::effective_ceiling(
runtime_mode,
authority_class,
proof_state,
requested_ceiling,
);
entry.runtime_mode = runtime_mode;
entry.proof_state = proof_state;
entry.claim_ceiling = claim_ceiling;
entry.authority_class = authority_class;
});
}
}
fn compose_anchor_policy_decision(
decision: &AnchorSinkDecision,
temporal_authority: PolicyContribution,
) -> PolicyDecision {
let reason = format!(
"anchor sink `{}` resolved with reason `{}`",
decision.authority_label, decision.reason,
);
let sink_contribution = PolicyContribution::new(
ANCHOR_SINK_AUTHORITY_RULE_ID,
decision.policy_outcome,
reason,
)
.expect("anchor sink contribution is statically valid");
compose_policy_outcomes(vec![sink_contribution, temporal_authority], None)
}
fn validate_anchor_history_before_append(ledger_path: &PathBuf, history: &PathBuf) -> Exit {
if !history.exists() {
return Exit::Ok;
}
match std::fs::metadata(history) {
Ok(metadata) if metadata.len() > 0 => {
let exit = verify_against_anchor_history(ledger_path, Some(history));
if exit != Exit::Ok {
eprintln!(
"audit anchor: existing anchor history `{}` did not verify; new anchor was not appended",
history.display()
);
return exit;
}
Exit::Ok
}
Ok(_) => Exit::Ok,
Err(err) => {
eprintln!(
"audit anchor: cannot inspect anchor history `{}`: {err}",
history.display()
);
Exit::PreconditionUnmet
}
}
}
fn write_and_sync(file: &mut std::fs::File, bytes: &[u8]) -> std::io::Result<()> {
file.write_all(bytes)?;
file.sync_all()
}
pub fn run_export(args: ExportArgs) -> Exit {
let layout = match DataLayout::resolve(args.db, args.event_log) {
Ok(l) => l,
Err(e) => {
return export_failure_envelope(e, "failed to resolve data layout", None);
}
};
let report = match verify_chain(&layout.event_log_path) {
Ok(report) if report.ok() => report,
Ok(report) => {
print_report(&report);
return export_failure_envelope(
Exit::IntegrityFailure,
"chain verification reported failures",
None,
);
}
Err(e) => {
eprintln!(
"audit export: chain corruption ({}): {e}",
layout.event_log_path.display(),
);
return export_failure_envelope(map_open_err(&e), "chain corruption", None);
}
};
let scan = match scan_development_ledger_rows(
&layout.event_log_path,
args.surface.development_ledger_use(),
) {
Ok(scan) => scan,
Err(e) => {
eprintln!(
"audit export: failed to scan ledger authority ({}): {e}",
layout.event_log_path.display(),
);
return export_failure_envelope(
map_open_err(&e),
"failed to scan ledger authority",
None,
);
}
};
let temporal_authority = build_temporal_authority_contribution_for_row_set(
&layout.event_log_path,
"audit.export",
EXPORT_TEMPORAL_AUTHORITY_RULE_ID,
);
let policy = compose_export_policy_decision(
&scan,
args.surface,
args.local_diagnostic,
temporal_authority,
);
let authority_claim = runtime_claim_preflight_with_policy(
format!("{} artifact", args.surface.label()),
args.surface.runtime_claim_kind(),
RuntimeMode::LocalUnsigned,
AuthorityClass::Observed,
ClaimProofState::Partial,
args.surface.requested_ceiling(),
&policy,
);
if scan.blocked_event_ids.is_empty() || args.local_diagnostic {
if !args.local_diagnostic
&& !args.surface.is_default_audit_export()
&& !authority_claim.allowed
{
eprintln!(
"audit export: {} preflight failed: {}; no trusted artifact emitted",
args.surface.label(),
authority_claim.reason
);
return export_failure_envelope(
Exit::PreconditionUnmet,
&format!(
"{} preflight failed: {}",
args.surface.label(),
authority_claim.reason
),
Some(&policy),
);
}
let artifact_kind = if !scan.blocked_event_ids.is_empty() {
"development_ledger_diagnostic"
} else if args.local_diagnostic {
"local_diagnostic"
} else {
args.surface.artifact_kind()
};
let claim = runtime_claim_preflight(
"local audit export artifact",
RuntimeClaimKind::Advisory,
RuntimeMode::LocalUnsigned,
AuthorityClass::Observed,
ClaimProofState::Partial,
ClaimCeiling::LocalUnsigned,
);
let artifact = serde_json::json!({
"artifact_schema": "cortex.audit_export.v1",
"artifact_kind": artifact_kind,
"runtime_mode": claim.claim.runtime_mode,
"proof_state": claim.claim.proof_state,
"claim_ceiling": claim.claim.effective_ceiling,
"claim_allowed": claim.allowed,
"downgrade_reasons": claim.claim.reasons,
"requested_surface": args.surface.development_ledger_use().wire_str(),
"authority_preflight_allowed": authority_claim.allowed,
"authority_preflight_reason": authority_claim.reason,
"policy_outcome": policy.final_outcome,
"policy_contributing": policy.contributing,
"policy_discarded": policy.discarded,
"path": report.path,
"rows_scanned": report.rows_scanned,
"trusted_run_history": false,
"development_ledger_rows": scan.blocked_event_ids.len(),
"development_event_ids": scan.blocked_event_ids,
"forbidden_uses_enforced": [
"audit_export",
"compliance_evidence",
"cross_system_trust_decision",
"external_reporting"
],
});
if output::json_enabled() {
let envelope = Envelope::new("cortex.audit.export", Exit::Ok, artifact)
.with_policy_outcome(policy_outcome_summary(&policy));
output::emit(&envelope, Exit::Ok)
} else {
match serde_json::to_string_pretty(&artifact) {
Ok(serialized) => {
println!("{serialized}");
Exit::Ok
}
Err(err) => {
eprintln!("audit export: failed to serialize artifact: {err}");
Exit::Internal
}
}
}
} else {
eprintln!(
"audit export: ledger rows cannot be used for {}; \
rerun with --local-diagnostic for local diagnostics only",
args.surface.label()
);
export_failure_envelope(
Exit::PreconditionUnmet,
&format!(
"ledger rows cannot be used for {}; rerun with --local-diagnostic",
args.surface.label()
),
Some(&policy),
)
}
}
fn compose_export_policy_decision(
scan: &LedgerAuthorityScan,
surface: AuditExportSurface,
local_diagnostic: bool,
temporal_authority: PolicyContribution,
) -> PolicyDecision {
let development_outcome = if scan.blocked_event_ids.is_empty() {
PolicyOutcome::Allow
} else if local_diagnostic {
PolicyOutcome::Warn
} else {
PolicyOutcome::Reject
};
let development_reason = match development_outcome {
PolicyOutcome::Allow => {
"no development-ledger rows are blocked for the requested surface".to_string()
}
PolicyOutcome::Warn => format!(
"{} development-ledger rows are blocked but local diagnostic mode tolerates them",
scan.blocked_event_ids.len()
),
_ => format!(
"{} development-ledger rows are forbidden for {}",
scan.blocked_event_ids.len(),
surface.label()
),
};
let signed_local_outcome = if surface.is_default_audit_export() || local_diagnostic {
PolicyOutcome::Warn
} else {
PolicyOutcome::Reject
};
let signed_local_reason = match signed_local_outcome {
PolicyOutcome::Warn => {
"local-unsigned ledger is diagnostic-only and cannot promote authority".to_string()
}
_ => format!(
"{} requires external authority class which is not available from local-unsigned ledger",
surface.label()
),
};
let proof_outcome = PolicyOutcome::Quarantine;
let proof_reason =
"proof state is Partial; ADR 0036 forbids promoting partial-proof artifacts".to_string();
let contributions = vec![
PolicyContribution::new(
EXPORT_DEVELOPMENT_LEDGER_RULE_ID,
development_outcome,
development_reason,
)
.expect("export development-ledger contribution is statically valid"),
PolicyContribution::new(
EXPORT_SIGNED_LOCAL_CLASS_RULE_ID,
signed_local_outcome,
signed_local_reason,
)
.expect("export signed-local class contribution is statically valid"),
PolicyContribution::new(EXPORT_PROOF_CLOSURE_RULE_ID, proof_outcome, proof_reason)
.expect("export proof-closure contribution is statically valid"),
temporal_authority,
];
compose_policy_outcomes(contributions, None)
}
fn export_failure_envelope(exit: Exit, detail: &str, policy: Option<&PolicyDecision>) -> Exit {
if !output::json_enabled() {
return exit;
}
let payload = match policy {
Some(policy) => serde_json::json!({
"status": "error",
"detail": detail,
"policy_outcome": policy.final_outcome,
"policy_contributing": policy.contributing,
"policy_discarded": policy.discarded,
}),
None => serde_json::json!({
"status": "error",
"detail": detail,
}),
};
let envelope = match policy {
Some(policy) => Envelope::new("cortex.audit.export", exit, payload)
.with_policy_outcome(policy_outcome_summary(policy)),
None => Envelope::new("cortex.audit.export", exit, payload),
};
output::emit(&envelope, exit)
}
fn print_report(report: &Report) {
let json = output::json_enabled();
if !json {
println!(
"audit verify: {} ({} rows scanned, {} failures)",
report.path.display(),
report.rows_scanned,
report.failures.len(),
);
}
let mut failures = Vec::new();
for f in &report.failures {
let line = f.line;
let reason = &f.reason;
let invariant = reason
.invariant()
.map(|name| format!(" invariant={name}"))
.unwrap_or_default();
if !json {
match &f.event_id {
Some(id) => eprintln!(" line {line} event_id={id}{invariant}: {reason:?}"),
None => eprintln!(" line {line}{invariant}: {reason:?}"),
}
}
failures.push(VerifyFailure {
line,
event_id: f.event_id.as_ref().map(ToString::to_string),
invariant: reason.invariant().map(str::to_string),
reason: format!("{reason:?}"),
});
}
if json {
VERIFY_REPORT.with(|cell| {
let mut report_ref = cell.borrow_mut();
let entry = report_ref.get_or_insert_with(VerifyReport::default);
entry.path = Some(report.path.display().to_string());
entry.rows_scanned = report.rows_scanned;
entry.chain_ok = report.ok();
entry.failures = failures;
});
}
}
thread_local! {
static VERIFY_REPORT: std::cell::RefCell<Option<VerifyReport>> =
const { std::cell::RefCell::new(None) };
static VERIFY_CONTRIBUTIONS: std::cell::RefCell<Vec<PolicyContribution>> =
const { std::cell::RefCell::new(Vec::new()) };
}
fn record_temporal_authority_contribution_for_audit_verify(key_id: &str) {
let invariant = revalidation_failed_invariant("audit.verify");
let pool = match open_default_store("audit verify") {
Ok(pool) => pool,
Err(_) => {
record_verify_contribution(
VERIFY_TEMPORAL_AUTHORITY_RULE_ID,
PolicyOutcome::Reject,
&format!(
"{invariant}: cannot open default store to revalidate operator key {key_id}"
),
);
return;
}
};
let now = chrono::Utc::now();
match revalidate_operator_temporal_authority(
&pool,
VERIFY_TEMPORAL_AUTHORITY_RULE_ID,
key_id,
now,
TrustTier::Verified,
) {
Ok(contribution) => {
let outcome = contribution.outcome();
let reason = if contribution.report.valid_now {
format!(
"operator temporal authority valid for current use (key {})",
contribution.report.key_id,
)
} else {
let reasons = contribution
.report
.reasons
.iter()
.map(|reason| reason.wire_str())
.collect::<Vec<_>>()
.join(",");
format!(
"{invariant}: operator temporal authority current use blocked for key {} (reasons: {reasons})",
contribution.report.key_id,
)
};
record_verify_contribution(VERIFY_TEMPORAL_AUTHORITY_RULE_ID, outcome, &reason);
}
Err(err) => {
record_verify_contribution(
VERIFY_TEMPORAL_AUTHORITY_RULE_ID,
PolicyOutcome::Reject,
&format!("{invariant}: failed to read authority timeline for key {key_id}: {err}"),
);
}
}
}
fn record_verify_contribution(rule_id: &'static str, outcome: PolicyOutcome, reason: &str) {
let Ok(contribution) = PolicyContribution::new(rule_id, outcome, reason) else {
return;
};
VERIFY_CONTRIBUTIONS.with(|cell| cell.borrow_mut().push(contribution));
}
fn scan_signed_row_key_ids(path: &PathBuf) -> Result<Vec<String>, JsonlError> {
let log = JsonlLog::open(path)?;
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for item in log.iter_signed()? {
let row = match item {
Ok(r) => r,
Err(_) => continue,
};
if let Some(sig) = row.signature.as_ref() {
seen.insert(sig.key_id.clone());
}
}
Ok(seen.into_iter().collect())
}
fn build_temporal_authority_contribution_for_row_set(
path: &PathBuf,
surface: &str,
rule_id: &'static str,
) -> PolicyContribution {
let invariant = revalidation_failed_invariant(surface);
let key_ids = match scan_signed_row_key_ids(path) {
Ok(keys) => keys,
Err(err) => {
return PolicyContribution::new(
rule_id,
PolicyOutcome::Reject,
format!("{invariant}: failed to scan signed rows for key_ids: {err}"),
)
.expect("static rule id + non-empty reason is valid");
}
};
if key_ids.is_empty() {
return PolicyContribution::new(
rule_id,
PolicyOutcome::Warn,
"no signed rows present in row set; no temporal authority axis to revalidate",
)
.expect("static rule id + non-empty reason is valid");
}
let pool = match open_default_store(surface) {
Ok(pool) => pool,
Err(_) => {
return PolicyContribution::new(
rule_id,
PolicyOutcome::Reject,
format!(
"{invariant}: cannot open default store to revalidate {} operator key(s)",
key_ids.len(),
),
)
.expect("static rule id + non-empty reason is valid");
}
};
let now = chrono::Utc::now();
let mut worst_outcome = PolicyOutcome::Allow;
let mut failing_reasons: Vec<String> = Vec::new();
let mut allowed_keys: Vec<String> = Vec::new();
for key_id in &key_ids {
match revalidate_operator_temporal_authority(
&pool,
rule_id,
key_id,
now,
TrustTier::Verified,
) {
Ok(contribution) => {
let outcome = contribution.outcome();
if contribution.report.valid_now {
allowed_keys.push(contribution.report.key_id.clone());
} else {
let reasons = contribution
.report
.reasons
.iter()
.map(|reason| reason.wire_str())
.collect::<Vec<_>>()
.join(",");
failing_reasons.push(format!(
"key {} -> {outcome:?} (reasons: {reasons})",
contribution.report.key_id,
));
}
if temporal_outcome_rank(outcome) > temporal_outcome_rank(worst_outcome) {
worst_outcome = outcome;
}
}
Err(err) => {
failing_reasons.push(format!("key {key_id} -> Reject (store error: {err})"));
worst_outcome = PolicyOutcome::Reject;
}
}
}
let reason = if matches!(worst_outcome, PolicyOutcome::Allow) {
format!(
"operator temporal authority valid for current use across {} observed key(s): {}",
allowed_keys.len(),
allowed_keys.join(","),
)
} else {
format!(
"{invariant}: operator temporal authority current use blocked for {} of {} observed key(s); {}",
failing_reasons.len(),
key_ids.len(),
failing_reasons.join("; "),
)
};
PolicyContribution::new(rule_id, worst_outcome, reason)
.expect("static rule id + non-empty reason is valid")
}
fn temporal_outcome_rank(outcome: PolicyOutcome) -> u8 {
match outcome {
PolicyOutcome::Allow => 0,
PolicyOutcome::Warn => 1,
PolicyOutcome::Quarantine => 2,
PolicyOutcome::Reject => 3,
PolicyOutcome::BreakGlass => 1,
}
}
fn compose_verify_policy_decision() -> (PolicyDecision, ProofClosureReport) {
let contributions = VERIFY_CONTRIBUTIONS.with(|cell| std::mem::take(&mut *cell.borrow_mut()));
if contributions.is_empty() {
let fallback = PolicyContribution::new(
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
PolicyOutcome::Reject,
"audit verify bailed out before recording any contributor",
)
.expect("static fallback contribution is valid");
let decision = compose_policy_outcomes(vec![fallback.clone()], None);
let report = compose_verify_proof_closure_report(std::slice::from_ref(&fallback));
return (decision, report);
}
let report = compose_verify_proof_closure_report(&contributions);
let decision = compose_policy_outcomes(contributions, None);
(decision, report)
}
fn compose_verify_proof_closure_report(contributions: &[PolicyContribution]) -> ProofClosureReport {
let mut verified_edges: Vec<ProofEdge> = Vec::new();
let mut failing_edges: Vec<FailingEdge> = Vec::new();
let mut hash_chain_rejected = false;
for contribution in contributions {
let rule_id = contribution.rule_id.as_str();
let (kind, axis_label) = match rule_id {
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID => (ProofEdgeKind::HashChain, "hash_chain_closure"),
VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID => {
(ProofEdgeKind::Signature, "signed_chain_closure")
}
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID => (ProofEdgeKind::HashChain, "v1_to_v2_boundary"),
_ => continue,
};
match contribution.outcome {
PolicyOutcome::Allow => {
verified_edges.push(
ProofEdge::new(kind, "audit.verify", axis_label)
.with_evidence_ref(rule_id.to_string()),
);
}
PolicyOutcome::Reject => {
if matches!(rule_id, VERIFY_HASH_CHAIN_CLOSURE_RULE_ID) {
hash_chain_rejected = true;
}
failing_edges.push(FailingEdge::broken(
kind,
"audit.verify",
axis_label,
ProofEdgeFailure::Mismatch,
contribution.reason.as_str(),
));
}
PolicyOutcome::Quarantine => failing_edges.push(FailingEdge::unresolved(
kind,
"audit.verify",
contribution.reason.as_str(),
)),
PolicyOutcome::Warn | PolicyOutcome::BreakGlass => failing_edges.push(
FailingEdge::unresolved(kind, "audit.verify", contribution.reason.as_str()),
),
}
}
let _ = hash_chain_rejected;
ProofClosureReport::from_edges(verified_edges, failing_edges)
}
fn policy_outcome_summary(policy: &PolicyDecision) -> serde_json::Value {
serde_json::json!({
"final_outcome": policy.final_outcome,
"contributing": policy.contributing,
"discarded": policy.discarded,
})
}
fn record_verify_policy_decision(policy: &PolicyDecision) {
if !output::json_enabled() {
return;
}
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.policy_outcome = Some(VerifyPolicyOutcome::from_decision(policy));
});
}
fn record_verify_proof_closure(report: &ProofClosureReport) {
if !output::json_enabled() {
return;
}
VERIFY_REPORT.with(|cell| {
let mut r = cell.borrow_mut();
let entry = r.get_or_insert_with(VerifyReport::default);
entry.proof_closure = Some(report.clone());
});
}
fn print_verify_proof_closure(report: &ProofClosureReport) {
if output::json_enabled() {
return;
}
let state = report.state();
let invariant = VERIFY_PROOF_CLOSURE_COMPOSED_REPORT_INVARIANT;
eprintln!("audit verify: invariant={invariant} proof_state={state:?}");
for failing in report.failing_edges() {
let kind = failing.kind;
let from_ref = &failing.from_ref;
let failure = failing.failure;
let reason = &failing.reason;
eprintln!(
"audit verify: invariant={invariant} failing_edge kind={kind:?} from={from_ref} failure={failure:?} reason={reason}"
);
}
}
fn print_verify_policy_outcome(policy: &PolicyDecision) {
if output::json_enabled() {
return;
}
let final_outcome = policy.final_outcome;
eprintln!("audit verify: policy_outcome={final_outcome:?}");
for contribution in &policy.contributing {
let outcome = contribution.outcome;
let rule_id = contribution.rule_id.as_str();
let reason = contribution.reason.as_str();
eprintln!(
"audit verify: policy_contributing={rule_id} outcome={outcome:?} reason={reason}"
);
}
for contribution in &policy.discarded {
let outcome = contribution.outcome;
let rule_id = contribution.rule_id.as_str();
let reason = contribution.reason.as_str();
eprintln!("audit verify: policy_discarded={rule_id} outcome={outcome:?} reason={reason}");
}
}
#[derive(Debug, Serialize)]
struct VerifyReport {
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
rows_scanned: usize,
chain_ok: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
failures: Vec<VerifyFailure>,
#[serde(skip_serializing_if = "Option::is_none")]
boundary: Option<BoundaryReport>,
#[serde(skip_serializing_if = "Option::is_none")]
anchor: Option<AnchorMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
anchor_history: Option<AnchorHistoryMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
external_receipts: Option<ExternalReceiptsMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
signed_verification: Option<SignedVerification>,
#[serde(skip_serializing_if = "Option::is_none")]
policy_outcome: Option<VerifyPolicyOutcome>,
#[serde(skip_serializing_if = "Option::is_none")]
proof_closure: Option<ProofClosureReport>,
runtime_mode: RuntimeMode,
proof_state: ClaimProofState,
claim_ceiling: ClaimCeiling,
authority_class: AuthorityClass,
}
impl Default for VerifyReport {
fn default() -> Self {
Self {
path: None,
rows_scanned: 0,
chain_ok: false,
failures: Vec::new(),
boundary: None,
anchor: None,
anchor_history: None,
external_receipts: None,
signed_verification: None,
policy_outcome: None,
proof_closure: None,
runtime_mode: RuntimeMode::LocalUnsigned,
proof_state: ClaimProofState::Unknown,
claim_ceiling: ClaimCeiling::DevOnly,
authority_class: AuthorityClass::Observed,
}
}
}
#[derive(Debug, Serialize)]
struct VerifyPolicyOutcome {
final_outcome: PolicyOutcome,
contributing: Vec<PolicyContribution>,
discarded: Vec<PolicyContribution>,
}
impl VerifyPolicyOutcome {
fn from_decision(policy: &PolicyDecision) -> Self {
Self {
final_outcome: policy.final_outcome,
contributing: policy.contributing.clone(),
discarded: policy.discarded.clone(),
}
}
}
impl VerifyReport {
fn take_or_default(cell: &std::cell::RefCell<Option<Self>>) -> Self {
cell.borrow_mut().take().unwrap_or_default()
}
}
#[derive(Debug, Serialize)]
struct VerifyFailure {
line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
event_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
invariant: Option<String>,
reason: String,
}
#[derive(Debug, Serialize)]
struct BoundaryReport {
required: bool,
ok: bool,
failures: Vec<String>,
}
#[derive(Debug, Serialize)]
struct AnchorMatch {
anchor_path: String,
event_count: u64,
db_count: u64,
}
#[derive(Debug, Serialize)]
struct AnchorHistoryMatch {
history_path: String,
anchors_verified: usize,
latest_event_count: u64,
db_count: u64,
}
#[derive(Debug, Serialize)]
struct ExternalReceiptsMatch {
receipts_path: String,
receipts_verified: usize,
latest_anchor_event_count: u64,
db_count: u64,
external_anchor_authority: &'static str,
status: String,
trust_root_status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
trust_root_signed_at: Option<String>,
}
pub(crate) fn trust_root_cache_path() -> Option<PathBuf> {
crate::paths::default_data_dir().map(|d| d.join("trusted_root.json"))
}
#[derive(Debug, Serialize)]
struct SignedVerification {
key_id: String,
}
#[derive(Debug, Default)]
struct LedgerAuthorityScan {
blocked_event_ids: Vec<String>,
}
fn scan_development_ledger_rows(
path: &PathBuf,
requested_use: DevelopmentLedgerUse,
) -> Result<LedgerAuthorityScan, JsonlError> {
let log = JsonlLog::open(path)?;
let mut scan = LedgerAuthorityScan::default();
for event in log.iter()? {
let event = event?;
let decision = development_ledger_use_decision(&event.payload, requested_use);
if !decision.allowed {
scan.blocked_event_ids.push(event.id.to_string());
}
}
Ok(scan)
}
fn load_attestor_from_key_file(path: &PathBuf) -> Result<InMemoryAttestor, Exit> {
let bytes = std::fs::read(path).map_err(|err| {
eprintln!(
"audit verify: cannot read --attestation key file `{}`: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
if bytes.len() != 32 {
eprintln!(
"audit verify: --attestation key file `{}` must be exactly 32 raw bytes (Ed25519 seed); got {} bytes",
path.display(),
bytes.len()
);
return Err(Exit::PreconditionUnmet);
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
Ok(InMemoryAttestor::from_seed(&seed))
}
fn load_verifying_key_from_file(path: &PathBuf) -> Result<(VerifyingKey, String), Exit> {
let bytes = std::fs::read(path).map_err(|err| {
eprintln!(
"audit verify: cannot read --verification-key file `{}`: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
let key_bytes: [u8; 32] = match bytes.as_slice().try_into() {
Ok(key_bytes) => key_bytes,
Err(_) => {
eprintln!(
"audit verify: --verification-key file `{}` must be exactly 32 raw bytes (Ed25519 public key); got {} bytes",
path.display(),
bytes.len()
);
return Err(Exit::PreconditionUnmet);
}
};
let verifying_key = VerifyingKey::from_bytes(&key_bytes).map_err(|err| {
eprintln!(
"audit verify: --verification-key file `{}` is not a valid Ed25519 public key: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
Ok((verifying_key, hex_lower(&key_bytes)))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
pub const DEFAULT_TRUSTED_ROOT_URL: &str =
"https://tuf-repo-cdn.sigstore.dev/targets/trusted_root.json";
pub const REFRESH_TRUST_COMMAND_NAME: &str = "cortex.audit.anchor.refresh_trust";
pub const REFRESH_TRUST_OUTCOME_OK: &str = "ok";
pub const REFRESH_TRUST_OUTCOME_HTTP_ERROR: &str = "http_error";
pub const REFRESH_TRUST_OUTCOME_PARSE_ERROR: &str = "parse_error";
pub const REFRESH_TRUST_OUTCOME_SIGNATURE_INVALID: &str = "signature_invalid";
pub const REFRESH_TRUST_OUTCOME_STALE_TO_STALE: &str = "stale_to_stale";
pub const REFRESH_TRUST_INVARIANT_OK: &str = "audit.anchor.refresh_trust.ok";
pub const REFRESH_TRUST_INVARIANT_HTTP_ERROR: &str = "audit.anchor.refresh_trust.http_error";
pub const REFRESH_TRUST_INVARIANT_PARSE_ERROR: &str = "audit.anchor.refresh_trust.parse_error";
pub const REFRESH_TRUST_INVARIANT_SIGNATURE_INVALID: &str =
"audit.anchor.refresh_trust.signature_invalid";
pub const REFRESH_TRUST_INVARIANT_STALE_TO_STALE: &str =
"audit.anchor.refresh_trust.stale_to_stale";
const fn invariant_for_outcome(outcome: &str) -> &'static str {
match outcome.as_bytes() {
b"ok" => REFRESH_TRUST_INVARIANT_OK,
b"http_error" => REFRESH_TRUST_INVARIANT_HTTP_ERROR,
b"parse_error" => REFRESH_TRUST_INVARIANT_PARSE_ERROR,
b"signature_invalid" => REFRESH_TRUST_INVARIANT_SIGNATURE_INVALID,
b"stale_to_stale" => REFRESH_TRUST_INVARIANT_STALE_TO_STALE,
_ => "audit.anchor.refresh_trust.unknown",
}
}
#[derive(Debug, Args)]
pub struct RefreshTrustArgs {
#[arg(long = "url", value_name = "URL")]
pub url: Option<String>,
#[arg(long = "cache-path", value_name = "PATH")]
pub cache_path: Option<PathBuf>,
}
#[derive(Debug, Default, Serialize)]
struct RefreshTrustReport {
command: &'static str,
outcome: &'static str,
invariant: &'static str,
url: String,
cache_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
previous_signed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
new_signed_at: Option<DateTime<Utc>>,
cache_written: bool,
#[serde(skip_serializing_if = "Option::is_none")]
failure_reason: Option<String>,
}
pub fn run_refresh_trust(args: RefreshTrustArgs) -> Exit {
let mut report = RefreshTrustReport {
command: REFRESH_TRUST_COMMAND_NAME,
outcome: REFRESH_TRUST_OUTCOME_OK,
invariant: REFRESH_TRUST_INVARIANT_OK,
..Default::default()
};
let exit = run_refresh_trust_inner(args, &mut report);
report.invariant = invariant_for_outcome(report.outcome);
if !output::json_enabled() {
eprintln!("audit refresh-trust: invariant={}", report.invariant);
}
if output::json_enabled() {
let outcome_token = match exit {
Exit::Ok => crate::output::Outcome::Ok,
Exit::PreconditionUnmet => crate::output::Outcome::PreconditionUnmet,
Exit::Internal => crate::output::Outcome::Internal,
other => crate::output::Outcome::from_exit(other),
};
let envelope =
Envelope::new(REFRESH_TRUST_COMMAND_NAME, exit, report).with_outcome(outcome_token);
output::emit(&envelope, exit)
} else {
exit
}
}
fn run_refresh_trust_inner(args: RefreshTrustArgs, report: &mut RefreshTrustReport) -> Exit {
let url = args
.url
.unwrap_or_else(|| DEFAULT_TRUSTED_ROOT_URL.to_string());
let cache_path = match args.cache_path {
Some(path) => path,
None => match crate::paths::default_data_dir() {
Some(dir) => dir.join("trusted_root.json"),
None => {
eprintln!(
"audit refresh-trust: no data directory could be resolved; pass --cache-path to override"
);
report.outcome = REFRESH_TRUST_OUTCOME_HTTP_ERROR;
report.url = url;
report.cache_path = String::from("<unresolved>");
report.failure_reason =
Some("default data directory unresolved; supply --cache-path".into());
return Exit::PreconditionUnmet;
}
},
};
report.url = url.clone();
report.cache_path = cache_path.display().to_string();
let previous_signed_at = match TrustedRoot::load_cached(&cache_path) {
Ok(Some(previous)) => previous.metadata_signed_at(),
Ok(None) => None,
Err(err) => {
eprintln!(
"audit refresh-trust: existing cache `{}` did not parse and will be replaced: {err}",
cache_path.display()
);
None
}
};
report.previous_signed_at = previous_signed_at;
if cache_path.exists() {
if let Ok(Some(parsed)) = TrustedRoot::load_cached(&cache_path) {
let pre_refresh_stale = parsed
.is_stale(
Utc::now(),
TrustRootStalenessAnchor::cache_file_mtime(&cache_path),
)
.unwrap_or(false);
if pre_refresh_stale {
eprintln!(
"audit refresh-trust: invariant={}",
cortex_ledger::TRUSTED_ROOT_CACHE_STALE_INVARIANT
);
eprintln!(
"audit refresh-trust: pre-refresh cache `{}` is stale by mtime; refreshing",
cache_path.display()
);
}
}
}
let body = match fetch_trusted_root_bytes(&url) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("audit refresh-trust: failed to fetch trusted_root.json from {url}: {err}");
report.outcome = REFRESH_TRUST_OUTCOME_HTTP_ERROR;
report.failure_reason = Some(format!("{err}"));
return Exit::PreconditionUnmet;
}
};
let parsed = match TrustedRoot::parse_bytes(&body) {
Ok(root) => root,
Err(err) => {
eprintln!("audit refresh-trust: fetched payload from {url} did not parse: {err}");
report.outcome = REFRESH_TRUST_OUTCOME_PARSE_ERROR;
report.failure_reason = Some(format!("{err}"));
return Exit::PreconditionUnmet;
}
};
let new_signed_at = parsed.metadata_signed_at();
report.new_signed_at = new_signed_at;
if let (Some(new), Some(embedded)) = (
new_signed_at,
TrustedRoot::embedded()
.ok()
.and_then(|r| r.metadata_signed_at()),
) {
if new < embedded {
eprintln!(
"audit refresh-trust: fetched trusted_root.json from {url} is older ({new}) than embedded snapshot ({embedded}); refusing to install"
);
report.outcome = REFRESH_TRUST_OUTCOME_SIGNATURE_INVALID;
report.failure_reason = Some(format!(
"fetched activation {new} predates embedded snapshot {embedded}"
));
return Exit::PreconditionUnmet;
}
}
if let (Some(prev), Some(new)) = (previous_signed_at, new_signed_at) {
if new <= prev {
eprintln!(
"audit refresh-trust: fetched root from {url} is not newer than the cached root ({new} <= {prev}); cache not rewritten"
);
report.outcome = REFRESH_TRUST_OUTCOME_STALE_TO_STALE;
report.cache_written = false;
return Exit::Ok;
}
}
if let Err(err) = parsed.write_atomic(&cache_path) {
eprintln!(
"audit refresh-trust: failed to write trusted_root.json cache `{}`: {err}",
cache_path.display()
);
report.outcome = REFRESH_TRUST_OUTCOME_HTTP_ERROR;
report.failure_reason = Some(format!("{err}"));
return Exit::Internal;
}
report.cache_written = true;
report.outcome = REFRESH_TRUST_OUTCOME_OK;
if !output::json_enabled() {
match (previous_signed_at, new_signed_at) {
(Some(prev), Some(new)) => println!(
"audit refresh-trust: refreshed trusted_root.json (previous={prev}, new={new}) -> {}",
cache_path.display()
),
(None, Some(new)) => println!(
"audit refresh-trust: installed trusted_root.json (new={new}) -> {}",
cache_path.display()
),
_ => println!(
"audit refresh-trust: installed trusted_root.json -> {}",
cache_path.display()
),
}
}
Exit::Ok
}
#[derive(Debug)]
enum FetchError {
UnsupportedScheme { url: String },
Spawn { source: std::io::Error },
CurlNonZero { status: String, stderr: String },
FileRead {
path: String,
source: std::io::Error,
},
}
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedScheme { url } => write!(
f,
"unsupported URL scheme for `{url}` (expected http, https, or file)"
),
Self::Spawn { source } => write!(f, "failed to spawn curl: {source}"),
Self::CurlNonZero { status, stderr } => {
write!(f, "curl exited with status {status}: {stderr}")
}
Self::FileRead { path, source } => {
write!(f, "failed to read file `{path}`: {source}")
}
}
}
}
impl std::error::Error for FetchError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Spawn { source } | Self::FileRead { source, .. } => Some(source),
_ => None,
}
}
}
fn fetch_trusted_root_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
if let Some(rest) = url.strip_prefix("file://") {
let trimmed = rest.trim_start_matches('/');
let path: PathBuf = if cfg!(windows) {
PathBuf::from(trimmed.replace('/', "\\"))
} else {
PathBuf::from(format!("/{trimmed}"))
};
return std::fs::read(&path).map_err(|source| FetchError::FileRead {
path: path.display().to_string(),
source,
});
}
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err(FetchError::UnsupportedScheme {
url: url.to_string(),
});
}
let output = std::process::Command::new("curl")
.args(["-sSfL", "--max-time", "30", "--", url])
.output()
.map_err(|source| FetchError::Spawn { source })?;
if !output.status.success() {
return Err(FetchError::CurlNonZero {
status: output
.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string()),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(output.stdout)
}
fn map_open_err(e: &JsonlError) -> Exit {
match e {
JsonlError::Decode { .. } | JsonlError::ChainBroken(_) => Exit::ChainCorruption,
JsonlError::Validation(_) => Exit::PreconditionUnmet,
JsonlError::Io { .. } => Exit::PreconditionUnmet,
JsonlError::Encode(_) => Exit::Internal,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use cortex_core::{
Event, EventId, EventSource, EventType, PolicyDecision, ProofState, SCHEMA_VERSION,
};
use cortex_ledger::{append_policy_decision_test_allow, JsonlLog};
use std::io::Write;
use tempfile::tempdir;
fn allow_policy() -> PolicyDecision {
append_policy_decision_test_allow()
}
fn fixture_event(seq: u64) -> Event {
Event {
id: EventId::new(),
schema_version: SCHEMA_VERSION,
observed_at: Utc::now(),
recorded_at: Utc::now(),
source: EventSource::User,
event_type: EventType::UserMessage,
trace_id: None,
session_id: None,
domain_tags: vec![],
payload: serde_json::json!({"seq": seq}),
payload_hash: String::new(),
prev_event_hash: None,
event_hash: String::new(),
}
}
#[test]
fn clean_chain_returns_ok() {
let tmp = tempdir().unwrap();
let log_path = tmp.path().join("events.jsonl");
let mut log = JsonlLog::open(&log_path).unwrap();
for i in 0..3u64 {
log.append(fixture_event(i), &allow_policy()).unwrap();
}
let exit = run_verify(VerifyArgs {
event_log: Some(log_path),
db: Some(tmp.path().join("cortex.db")),
signed: false,
verification_key: None,
attestation: None,
require_v1_to_v2_boundary: false,
against: None,
against_history: None,
against_external: None,
witness_key_registry: None,
});
assert_eq!(exit, Exit::Ok);
}
#[test]
fn bytewise_corruption_returns_chain_corruption() {
let tmp = tempdir().unwrap();
let log_path = tmp.path().join("events.jsonl");
let mut log = JsonlLog::open(&log_path).unwrap();
log.append(fixture_event(0), &allow_policy()).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&log_path)
.unwrap();
writeln!(f, "{{not valid json").unwrap();
f.sync_all().unwrap();
drop(f);
let exit = run_verify(VerifyArgs {
event_log: Some(log_path),
db: Some(tmp.path().join("cortex.db")),
signed: false,
verification_key: None,
attestation: None,
require_v1_to_v2_boundary: false,
against: None,
against_history: None,
against_external: None,
witness_key_registry: None,
});
assert_eq!(exit, Exit::ChainCorruption);
}
#[test]
fn ordinal_gap_returns_integrity_failure() {
let tmp = tempdir().unwrap();
let log_path = tmp.path().join("events.jsonl");
let mut log = JsonlLog::open(&log_path).unwrap();
for i in 0..3u64 {
log.append(fixture_event(i), &allow_policy()).unwrap();
}
let raw = std::fs::read_to_string(&log_path).unwrap();
let mut rows: Vec<Event> = raw
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str::<Event>(l).unwrap())
.collect();
rows[1].prev_event_hash = Some("0".repeat(64));
cortex_ledger::seal(&mut rows[1]);
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
.open(&log_path)
.unwrap();
for r in &rows {
writeln!(f, "{}", serde_json::to_string(r).unwrap()).unwrap();
}
f.sync_all().unwrap();
drop(f);
let exit = run_verify(VerifyArgs {
event_log: Some(log_path),
db: Some(tmp.path().join("cortex.db")),
signed: false,
verification_key: None,
attestation: None,
require_v1_to_v2_boundary: false,
against: None,
against_history: None,
against_external: None,
witness_key_registry: None,
});
assert_eq!(exit, Exit::IntegrityFailure);
}
fn allow_contribution(rule_id: &'static str) -> PolicyContribution {
PolicyContribution::new(rule_id, PolicyOutcome::Allow, "ok").expect("static contribution")
}
fn reject_contribution(rule_id: &'static str) -> PolicyContribution {
PolicyContribution::new(rule_id, PolicyOutcome::Reject, "broken")
.expect("static contribution")
}
fn quarantine_contribution(rule_id: &'static str) -> PolicyContribution {
PolicyContribution::new(rule_id, PolicyOutcome::Quarantine, "partial")
.expect("static contribution")
}
#[test]
fn verify_proof_closure_fold_composes_three_axes_into_one_report() {
let report = compose_verify_proof_closure_report(&[
allow_contribution(VERIFY_HASH_CHAIN_CLOSURE_RULE_ID),
allow_contribution(VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID),
allow_contribution(VERIFY_V1_TO_V2_BOUNDARY_RULE_ID),
]);
assert_eq!(report.state(), ProofState::FullChainVerified);
assert_eq!(report.verified_edges().len(), 3);
assert!(report.failing_edges().is_empty());
}
#[test]
fn verify_proof_closure_fold_rejects_when_hash_chain_contributor_rejects() {
let report = compose_verify_proof_closure_report(&[
reject_contribution(VERIFY_HASH_CHAIN_CLOSURE_RULE_ID),
allow_contribution(VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID),
allow_contribution(VERIFY_V1_TO_V2_BOUNDARY_RULE_ID),
]);
assert_eq!(report.state(), ProofState::Broken);
assert!(report.is_broken());
assert!(report.failing_edges().iter().any(|edge| matches!(
edge.kind,
ProofEdgeKind::HashChain
) && matches!(
edge.failure,
ProofEdgeFailure::Mismatch
)));
}
#[test]
fn verify_proof_closure_fold_rejects_when_any_single_contributor_rejects() {
for reject_rule in [
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID,
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID,
] {
let contributions: Vec<PolicyContribution> = [
VERIFY_HASH_CHAIN_CLOSURE_RULE_ID,
VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID,
VERIFY_V1_TO_V2_BOUNDARY_RULE_ID,
]
.into_iter()
.map(|rule| {
if rule == reject_rule {
reject_contribution(rule)
} else {
allow_contribution(rule)
}
})
.collect();
let report = compose_verify_proof_closure_report(&contributions);
assert_eq!(
report.state(),
ProofState::Broken,
"rejecting {reject_rule} must collapse fold to Broken"
);
}
}
#[test]
fn verify_proof_closure_fold_downgrades_to_partial_on_quarantine() {
let report = compose_verify_proof_closure_report(&[
allow_contribution(VERIFY_HASH_CHAIN_CLOSURE_RULE_ID),
allow_contribution(VERIFY_SIGNED_CHAIN_CLOSURE_RULE_ID),
quarantine_contribution(VERIFY_V1_TO_V2_BOUNDARY_RULE_ID),
]);
assert_eq!(report.state(), ProofState::Partial);
assert!(!report.is_broken());
assert!(!report.is_full_chain_verified());
}
#[test]
fn verify_proof_closure_invariant_key_is_stable() {
assert_eq!(
VERIFY_PROOF_CLOSURE_COMPOSED_REPORT_INVARIANT,
"audit.verify.proof_closure.composed_report"
);
}
}