use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use crate::keys::{KeyRef, Subject};
use crate::payload::Intent;
use crate::port::SignerError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum PolicyDecisionRecord {
Allow,
Deny {
reason: String,
},
}
impl From<&crate::guardrails::PolicyDecision> for PolicyDecisionRecord {
fn from(decision: &crate::guardrails::PolicyDecision) -> Self {
match decision {
crate::guardrails::PolicyDecision::Allow => Self::Allow,
crate::guardrails::PolicyDecision::Deny { reason } => Self::Deny {
reason: reason.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum SignOutcome {
Signed,
ProviderError {
error: String,
},
NotAttempted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignAuditRecord {
pub subject: Subject,
pub key_ref: KeyRef,
pub payload_hash: String,
pub intent: Option<Intent>,
pub decision: PolicyDecisionRecord,
pub outcome: SignOutcome,
pub at: String,
}
#[async_trait]
pub trait SignAudit: Send + Sync {
async fn record(&self, record: &SignAuditRecord) -> Result<(), SignerError>;
}
#[async_trait]
impl<A: SignAudit + ?Sized> SignAudit for &A {
async fn record(&self, record: &SignAuditRecord) -> Result<(), SignerError> {
(**self).record(record).await
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AuditAnchor {
pub entries: u64,
pub head_hash: String,
}
const GENESIS: [u8; 32] = [0_u8; 32];
const CHAIN_TAG: &[u8] = b"CRATEFIELD-SIGN-AUDIT-v1";
#[derive(Debug, Clone)]
struct Stored {
seq: u64,
prev_hash: [u8; 32],
hash: [u8; 32],
record: SignAuditRecord,
}
#[derive(Default)]
pub struct MemorySignAudit {
#[allow(clippy::disallowed_types)]
entries: std::sync::Mutex<Vec<Stored>>,
}
impl MemorySignAudit {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn records(&self) -> Vec<SignAuditRecord> {
self.entries
.lock()
.expect("an audit sink is never poisoned into use")
.iter()
.map(|stored| stored.record.clone())
.collect()
}
#[must_use]
pub fn record_at(&self, seq: u64) -> Option<SignAuditRecord> {
self.entries
.lock()
.expect("an audit sink is never poisoned into use")
.get(usize::try_from(seq).ok()?)
.map(|stored| stored.record.clone())
}
#[must_use]
pub fn hash_at(&self, seq: u64) -> Option<[u8; 32]> {
self.entries
.lock()
.expect("an audit sink is never poisoned into use")
.get(usize::try_from(seq).ok()?)
.map(|stored| stored.hash)
}
fn hash(seq: u64, prev_hash: &[u8; 32], record: &SignAuditRecord) -> [u8; 32] {
let serialised = serde_json::to_vec(record).unwrap_or_else(|_| {
Vec::new()
});
let seq_bytes = seq.to_be_bytes();
let mut hasher = sha2::Sha256::new();
hasher.update(CHAIN_TAG);
hasher.update(prev_hash);
hasher.update(
u32::try_from(seq_bytes.len())
.unwrap_or(u32::MAX)
.to_le_bytes(),
);
hasher.update(seq_bytes);
hasher.update(
u32::try_from(serialised.len())
.unwrap_or(u32::MAX)
.to_le_bytes(),
);
hasher.update(&serialised);
hasher.finalize().into()
}
}
#[async_trait]
impl SignAudit for MemorySignAudit {
async fn record(&self, record: &SignAuditRecord) -> Result<(), SignerError> {
let mut entries = self
.entries
.lock()
.expect("an audit sink is never poisoned into use");
let seq = entries.len() as u64;
let prev_hash = entries.last().map_or(GENESIS, |last| last.hash);
let hash = Self::hash(seq, &prev_hash, record);
entries.push(Stored {
seq,
prev_hash,
hash,
record: record.clone(),
});
Ok(())
}
}
impl MemorySignAudit {
pub fn verify(&self) -> Result<AuditAnchor, SignerError> {
let entries = self
.entries
.lock()
.expect("an audit sink is never poisoned into use");
let mut prev_hash = GENESIS;
for stored in entries.iter() {
let expected = Self::hash(stored.seq, &prev_hash, &stored.record);
if stored.prev_hash != prev_hash || stored.hash != expected {
return Err(SignerError::Audit(format!(
"the audit chain breaks at entry {}: the hash does not follow from its \
predecessor",
stored.seq
)));
}
prev_hash = stored.hash;
}
Ok(AuditAnchor {
entries: entries.len() as u64,
head_hash: hex::encode(prev_hash),
})
}
}
pub(crate) fn now() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn record(value: &str) -> SignAuditRecord {
SignAuditRecord {
subject: Subject::new("acme", None).expect("a venture"),
key_ref: KeyRef::new("session/secp256k1/acme/ops/0102030405060708")
.expect("a reference"),
payload_hash: format!("0x{}", hex::encode([0xab; 32])),
intent: None,
decision: PolicyDecisionRecord::Allow,
outcome: SignOutcome::Signed,
at: value.to_owned(),
}
}
#[test]
fn chain_verifies() {
let audit = MemorySignAudit::new();
for at in ["t1", "t2", "t3"] {
pollster::block_on(audit.record(&record(at))).expect("records");
}
let anchor = audit.verify().expect("the chain holds");
assert_eq!(anchor.entries, 3);
assert_ne!(
audit.hash_at(0),
audit.hash_at(1),
"distinct records must hash distinctly"
);
}
#[test]
fn verify_names_the_first_broken_entry() {
let audit = MemorySignAudit::new();
for at in ["t1", "t2", "t3"] {
pollster::block_on(audit.record(&record(at))).expect("records");
}
#[allow(clippy::disallowed_types)]
{
let mut entries = audit.entries.lock().expect("uncontended");
entries[1].record.at = "EDITED".to_owned();
}
let err = audit.verify().expect_err("the edit breaks the chain");
assert!(
err.to_string().contains("entry 1"),
"the error should name the first broken entry, got: {err}"
);
assert_eq!(
audit.hash_at(0),
Some(MemorySignAudit::hash(
0,
&GENESIS,
&audit.record_at(0).expect("entry 0")
))
);
}
#[test]
fn an_empty_chain_verifies_at_genesis() {
let audit = MemorySignAudit::new();
let anchor = audit.verify().expect("an empty chain holds");
assert_eq!(anchor.entries, 0);
assert_eq!(anchor.head_hash, hex::encode(GENESIS));
}
#[test]
fn records_round_trip_through_json() {
let audit = MemorySignAudit::new();
pollster::block_on(audit.record(&record("t1"))).expect("records");
let seen = audit.records();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].at, "t1");
let json = serde_json::to_string(&seen[0]).expect("serialises");
let back: SignAuditRecord = serde_json::from_str(&json).expect("deserialises");
assert_eq!(back, seen[0]);
}
}