use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use super::identity::{IdentityContext, TenantId};
use super::tenant_isolation::{CrossTenantLeak, TenantBoundary, assert_same_tenant};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct EvidenceEntry {
pub timestamp: u64,
pub actor: IdentityContext,
pub action: String,
pub digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub signature: String,
pub previous_hash: Option<String>,
}
impl EvidenceEntry {
#[must_use]
pub fn new(
timestamp: u64,
actor: IdentityContext,
action: impl Into<String>,
digest: impl Into<String>,
signature: impl Into<String>,
previous_hash: Option<String>,
) -> Self {
let lineage = crate::core::task_spine::TaskSpine::current();
Self {
timestamp,
actor,
action: action.into(),
digest: digest.into(),
task_id: lineage
.as_ref()
.map(|task| task.task_id.as_str().to_owned()),
parent_id: lineage
.and_then(|task| task.parent_task_id.map(|parent| parent.as_str().to_owned())),
signature: signature.into(),
previous_hash,
}
}
#[must_use]
pub fn signed(
timestamp: u64,
actor: IdentityContext,
action: impl Into<String>,
digest: impl Into<String>,
previous_hash: Option<String>,
signing_key: &SigningKey,
) -> Self {
let mut entry = Self::new(
timestamp,
actor,
action,
digest,
String::new(),
previous_hash,
);
entry.sign_with(signing_key);
entry
}
pub fn sign_with(&mut self, signing_key: &SigningKey) {
let signature = signing_key.sign(&self.signing_bytes());
self.signature = format!(
"ed25519:{}:{}",
hex_encode(&signing_key.verifying_key().to_bytes()),
hex_encode(&signature.to_bytes())
);
}
#[must_use]
pub fn signing_bytes(&self) -> Vec<u8> {
let payload = SignedEvidencePayload {
timestamp: self.timestamp,
actor: &self.actor,
action: &self.action,
digest: &self.digest,
task_id: &self.task_id,
parent_id: &self.parent_id,
previous_hash: &self.previous_hash,
};
serde_json::to_vec(&payload).expect("evidence payload is serializable")
}
#[must_use]
pub fn entry_hash(&self) -> String {
let bytes = serde_json::to_vec(self).expect("evidence entry is serializable");
format!("blake3:{}", blake3::hash(&bytes).to_hex())
}
#[must_use]
pub fn has_valid_signature(&self) -> bool {
let Some((public_key, signature)) = parse_signature(&self.signature) else {
return false;
};
public_key.verify(&self.signing_bytes(), &signature).is_ok()
}
}
impl TenantBoundary for EvidenceEntry {
fn tenant_id(&self) -> &TenantId {
self.actor.tenant_id()
}
}
#[derive(Serialize)]
struct SignedEvidencePayload<'a> {
timestamp: u64,
actor: &'a IdentityContext,
action: &'a str,
digest: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: &'a Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: &'a Option<String>,
previous_hash: &'a Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct EvidenceChain {
#[serde(default)]
pub entries: Vec<EvidenceEntry>,
}
impl EvidenceChain {
#[must_use]
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
#[must_use]
pub fn from_entries(entries: Vec<EvidenceEntry>) -> Self {
Self { entries }
}
pub fn push(&mut self, entry: EvidenceEntry) {
self.entries.push(entry);
}
pub fn append_scoped(&mut self, entry: EvidenceEntry) -> Result<(), CrossTenantLeak> {
if let Some(previous) = self.entries.first() {
assert_same_tenant(previous, &entry)?;
}
self.entries.push(entry);
Ok(())
}
pub fn append_signed(
&mut self,
timestamp: u64,
actor: IdentityContext,
action: impl Into<String>,
digest: impl Into<String>,
signing_key: &SigningKey,
) -> Result<(), CrossTenantLeak> {
let previous_hash = self.entries.last().map(EvidenceEntry::entry_hash);
let entry =
EvidenceEntry::signed(timestamp, actor, action, digest, previous_hash, signing_key);
self.append_scoped(entry)
}
#[must_use]
pub fn tenant_id(&self) -> Option<&TenantId> {
self.entries.first().map(TenantBoundary::tenant_id)
}
#[must_use]
pub fn head_hash(&self) -> Option<String> {
self.entries.last().map(EvidenceEntry::entry_hash)
}
#[must_use]
pub fn verify(&self) -> ChainVerificationResult {
verify_chain(self)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ChainVerificationResult {
pub valid: bool,
pub broken_at: Option<usize>,
pub issues: Vec<String>,
}
impl ChainVerificationResult {
#[must_use]
pub const fn is_valid(&self) -> bool {
self.valid
}
}
#[must_use]
pub fn verify_chain(chain: &EvidenceChain) -> ChainVerificationResult {
let mut result = ChainVerificationResult {
valid: true,
broken_at: None,
issues: Vec::new(),
};
let mut previous_timestamp = None;
for (index, entry) in chain.entries.iter().enumerate() {
let mut report_issue = |issue: String| {
result.valid = false;
result.broken_at.get_or_insert(index);
result.issues.push(issue);
};
if entry.actor.tenant.is_empty() {
report_issue(format!("entry {index} has an empty tenant id"));
}
if entry.action.trim().is_empty() {
report_issue(format!("entry {index} has an empty action"));
}
if entry.digest.trim().is_empty() {
report_issue(format!("entry {index} has an empty digest"));
}
if let Some(timestamp) = previous_timestamp {
if entry.timestamp < timestamp {
report_issue(format!(
"entry {index} timestamp is earlier than its predecessor"
));
}
}
previous_timestamp = Some(entry.timestamp);
let expected_previous = if index == 0 {
None
} else {
Some(chain.entries[index - 1].entry_hash())
};
if entry.previous_hash != expected_previous {
report_issue(format!("entry {index} has an invalid previous_hash"));
}
if index > 0 {
if let Err(leak) = assert_same_tenant(&chain.entries[0], entry) {
report_issue(format!("entry {index} crosses tenant boundary: {leak}"));
}
}
if !entry.has_valid_signature() {
report_issue(format!("entry {index} has an invalid Ed25519 signature"));
}
}
result
}
fn parse_signature(value: &str) -> Option<(VerifyingKey, Signature)> {
let mut parts = value.split(':');
if parts.next()? != "ed25519" || parts.clone().count() != 2 {
return None;
}
let public_key = decode_fixed::<32>(parts.next()?)?;
let signature = decode_fixed::<64>(parts.next()?)?;
let public_key = VerifyingKey::from_bytes(&public_key).ok()?;
Some((public_key, Signature::from_bytes(&signature)))
}
fn decode_fixed<const N: usize>(value: &str) -> Option<[u8; N]> {
if value.len() != N * 2 {
return None;
}
let bytes = value.as_bytes();
let mut decoded = [0_u8; N];
for (index, byte) in decoded.iter_mut().enumerate() {
let high = hex_value(bytes[index * 2])?;
let low = hex_value(bytes[index * 2 + 1])?;
*byte = (high << 4) | low;
}
Some(decoded)
}
fn hex_value(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write;
let _ = write!(encoded, "{byte:02x}");
}
encoded
}