use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::canonical::{
canonical_rotation_input, canonical_signing_input, AttestationPreimage,
SCHEMA_VERSION_ATTESTATION,
};
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum VerifyError {
#[error("unknown attestation schema_version: found {found}, expected {expected}")]
UnknownSchemaVersion {
found: u16,
expected: u16,
},
#[error("key_id mismatch: preimage says {preimage}, verifier expected {expected}")]
KeyIdMismatch {
preimage: String,
expected: String,
},
#[error("ed25519 signature verification failed")]
BadSignature,
#[error("malformed signature bytes")]
MalformedSignature,
}
pub trait Attestor: Send + Sync {
fn sign(&self, signing_input: &[u8]) -> Signature;
fn key_id(&self) -> &str;
fn verifying_key(&self) -> VerifyingKey;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
pub key_id: String,
#[serde(with = "signature_serde")]
pub signature: [u8; 64],
pub signed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RotationEnvelope {
pub schema_version: u16,
pub old_pubkey: [u8; 32],
pub new_pubkey: [u8; 32],
pub signed_at: DateTime<Utc>,
#[serde(with = "signature_serde")]
pub signature: [u8; 64],
}
#[must_use]
pub fn attest(preimage: &AttestationPreimage, attestor: &dyn Attestor) -> Attestation {
let bytes = canonical_signing_input(preimage);
let sig = attestor.sign(&bytes);
Attestation {
key_id: preimage.key_id.clone(),
signature: sig.to_bytes(),
signed_at: preimage.signed_at,
}
}
pub fn verify(
preimage: &AttestationPreimage,
attestation: &Attestation,
public_key: &VerifyingKey,
expected_key_id: &str,
) -> Result<(), VerifyError> {
if preimage.schema_version != SCHEMA_VERSION_ATTESTATION {
return Err(VerifyError::UnknownSchemaVersion {
found: preimage.schema_version,
expected: SCHEMA_VERSION_ATTESTATION,
});
}
if preimage.key_id != expected_key_id || attestation.key_id != expected_key_id {
return Err(VerifyError::KeyIdMismatch {
preimage: preimage.key_id.clone(),
expected: expected_key_id.to_string(),
});
}
if preimage.signed_at != attestation.signed_at {
return Err(VerifyError::BadSignature);
}
let signing_input = canonical_signing_input(preimage);
let sig = Signature::from_bytes(&attestation.signature);
public_key
.verify(&signing_input, &sig)
.map_err(|_| VerifyError::BadSignature)
}
#[must_use]
pub fn sign_rotation(
old_pubkey: &VerifyingKey,
new_pubkey: &VerifyingKey,
signed_at: DateTime<Utc>,
attestor: &dyn Attestor,
) -> RotationEnvelope {
let old_bytes = old_pubkey.to_bytes();
let new_bytes = new_pubkey.to_bytes();
let bytes = canonical_rotation_input(
SCHEMA_VERSION_ATTESTATION,
&old_bytes,
&new_bytes,
signed_at,
);
let sig = attestor.sign(&bytes);
RotationEnvelope {
schema_version: SCHEMA_VERSION_ATTESTATION,
old_pubkey: old_bytes,
new_pubkey: new_bytes,
signed_at,
signature: sig.to_bytes(),
}
}
pub fn verify_rotation(env: &RotationEnvelope) -> Result<(), VerifyError> {
if env.schema_version != SCHEMA_VERSION_ATTESTATION {
return Err(VerifyError::UnknownSchemaVersion {
found: env.schema_version,
expected: SCHEMA_VERSION_ATTESTATION,
});
}
let old_pk =
VerifyingKey::from_bytes(&env.old_pubkey).map_err(|_| VerifyError::MalformedSignature)?;
let bytes = canonical_rotation_input(
env.schema_version,
&env.old_pubkey,
&env.new_pubkey,
env.signed_at,
);
let sig = Signature::from_bytes(&env.signature);
old_pk
.verify(&bytes, &sig)
.map_err(|_| VerifyError::BadSignature)
}
pub struct InMemoryAttestor {
signing_key: SigningKey,
key_id: String,
}
impl std::fmt::Debug for InMemoryAttestor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InMemoryAttestor")
.field("key_id", &self.key_id)
.finish_non_exhaustive()
}
}
impl InMemoryAttestor {
pub fn from_signing_key(signing_key: SigningKey, key_id: impl Into<String>) -> Self {
Self {
signing_key,
key_id: key_id.into(),
}
}
#[must_use]
pub fn from_seed(seed: &[u8; 32]) -> Self {
let signing_key = SigningKey::from_bytes(seed);
let pk = signing_key.verifying_key();
let key_id = hex_lower(&pk.to_bytes());
Self {
signing_key,
key_id,
}
}
}
impl Attestor for InMemoryAttestor {
fn sign(&self, signing_input: &[u8]) -> Signature {
self.signing_key.sign(signing_input)
}
fn key_id(&self) -> &str {
&self.key_id
}
fn verifying_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
}
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
pub trait IdentityRotation: Attestor {
fn sign_rotation(&self, new_pubkey: &VerifyingKey, signed_at: DateTime<Utc>) -> RotationEnvelope
where
Self: Sized,
{
sign_rotation(&self.verifying_key(), new_pubkey, signed_at, self)
}
}
impl IdentityRotation for InMemoryAttestor {}
#[cfg(target_os = "macos")]
#[derive(Debug)]
pub struct KeychainAttestor {
key_id: String,
}
#[cfg(target_os = "macos")]
impl KeychainAttestor {
pub fn open(_key_id: impl Into<String>) -> Self {
unimplemented!(
"KeychainAttestor (macOS) not implemented in v0; use InMemoryAttestor (T-3.D.5/6)"
);
}
#[must_use]
pub fn key_id(&self) -> &str {
&self.key_id
}
}
#[cfg(target_os = "macos")]
impl Attestor for KeychainAttestor {
fn sign(&self, _signing_input: &[u8]) -> Signature {
unimplemented!("KeychainAttestor::sign (macOS) — see T-3.D.5/6")
}
fn key_id(&self) -> &str {
&self.key_id
}
fn verifying_key(&self) -> VerifyingKey {
unimplemented!("KeychainAttestor::verifying_key (macOS) — see T-3.D.5/6")
}
}
#[cfg(target_os = "linux")]
#[derive(Debug)]
pub struct KeychainAttestor {
key_id: String,
}
#[cfg(target_os = "linux")]
impl KeychainAttestor {
pub fn open(_key_id: impl Into<String>) -> Self {
unimplemented!(
"KeychainAttestor (Linux) not implemented in v0; use InMemoryAttestor (T-3.D.5/6)"
);
}
#[must_use]
pub fn key_id(&self) -> &str {
&self.key_id
}
}
#[cfg(target_os = "linux")]
impl Attestor for KeychainAttestor {
fn sign(&self, _signing_input: &[u8]) -> Signature {
unimplemented!("KeychainAttestor::sign (Linux) — see T-3.D.5/6")
}
fn key_id(&self) -> &str {
&self.key_id
}
fn verifying_key(&self) -> VerifyingKey {
unimplemented!("KeychainAttestor::verifying_key (Linux) — see T-3.D.5/6")
}
}
#[cfg(target_os = "windows")]
#[derive(Debug)]
pub struct KeychainAttestor {
key_id: String,
}
#[cfg(target_os = "windows")]
impl KeychainAttestor {
pub fn open(_key_id: impl Into<String>) -> Self {
unimplemented!(
"KeychainAttestor (Windows) not implemented in v0; use InMemoryAttestor (T-3.D.5/6)"
);
}
#[must_use]
pub fn key_id(&self) -> &str {
&self.key_id
}
}
#[cfg(target_os = "windows")]
impl Attestor for KeychainAttestor {
fn sign(&self, _signing_input: &[u8]) -> Signature {
unimplemented!("KeychainAttestor::sign (Windows) — see T-3.D.5/6")
}
fn key_id(&self) -> &str {
&self.key_id
}
fn verifying_key(&self) -> VerifyingKey {
unimplemented!("KeychainAttestor::verifying_key (Windows) — see T-3.D.5/6")
}
}
mod signature_serde {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
s.serialize_bytes(bytes)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
let v: Vec<u8> = Vec::deserialize(d)?;
if v.len() != 64 {
return Err(serde::de::Error::custom(format!(
"expected 64 signature bytes, got {}",
v.len()
)));
}
let mut out = [0u8; 64];
out.copy_from_slice(&v);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::{LineageBinding, SourceIdentity};
use chrono::TimeZone;
use ed25519_dalek::SigningKey;
use std::sync::atomic::{AtomicU8, Ordering};
static SEED_COUNTER: AtomicU8 = AtomicU8::new(1);
fn fresh_attestor() -> InMemoryAttestor {
let n = SEED_COUNTER.fetch_add(1, Ordering::Relaxed);
let seed = [n; 32];
InMemoryAttestor::from_seed(&seed)
}
fn fixture_preimage(attestor: &InMemoryAttestor) -> AttestationPreimage {
AttestationPreimage {
schema_version: SCHEMA_VERSION_ATTESTATION,
source: SourceIdentity::User,
event_id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
payload_hash: "deadbeef".into(),
session_id: "session-001".into(),
ledger_id: "ledger-main".into(),
lineage: LineageBinding::PreviousHash("aaaaaaaaaaaaaaaa".into()),
signed_at: Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
key_id: attestor.key_id().to_string(),
}
}
#[test]
fn unknown_schema_version_fails_closed() {
let attestor = fresh_attestor();
let mut p = fixture_preimage(&attestor);
let att = attest(&p, &attestor);
p.schema_version = 999;
let result = verify(&p, &att, &attestor.verifying_key(), attestor.key_id());
match result {
Err(VerifyError::UnknownSchemaVersion {
found: 999,
expected: SCHEMA_VERSION_ATTESTATION,
}) => {}
other => panic!("expected UnknownSchemaVersion, got {other:?}"),
}
}
#[test]
fn field_reorder_does_not_change_signed_semantics() {
let attestor = fresh_attestor();
let p1 = fixture_preimage(&attestor);
let att = attest(&p1, &attestor);
let p2 = AttestationPreimage {
key_id: attestor.key_id().to_string(),
signed_at: chrono::Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
lineage: LineageBinding::PreviousHash("aaaaaaaaaaaaaaaa".into()),
ledger_id: "ledger-main".into(),
session_id: "session-001".into(),
payload_hash: "deadbeef".into(),
event_id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
source: SourceIdentity::User,
schema_version: SCHEMA_VERSION_ATTESTATION,
};
verify(&p2, &att, &attestor.verifying_key(), attestor.key_id())
.expect("captured sig must verify under reordered preimage struct literal");
}
#[test]
fn wrong_prev_signature_fails() {
let attestor = fresh_attestor();
let p = fixture_preimage(&attestor);
let att = attest(&p, &attestor);
let mut tampered = p.clone();
tampered.lineage = LineageBinding::PreviousHash("bbbbbbbbbbbbbbbb".into());
let result = verify(
&tampered,
&att,
&attestor.verifying_key(),
attestor.key_id(),
);
assert_eq!(result, Err(VerifyError::BadSignature));
}
#[test]
fn replay_stale_row_after_chain_advance_fails() {
let attestor = fresh_attestor();
let mut p_old = fixture_preimage(&attestor);
p_old.lineage = LineageBinding::ChainPosition(10);
let att = attest(&p_old, &attestor);
let mut p_new = p_old.clone();
p_new.lineage = LineageBinding::ChainPosition(20);
let result = verify(&p_new, &att, &attestor.verifying_key(), attestor.key_id());
assert_eq!(result, Err(VerifyError::BadSignature));
}
#[test]
fn identity_rotate_accepted_when_envelope_verifies() {
let old = fresh_attestor();
let new = fresh_attestor();
let signed_at = Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap();
let env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
verify_rotation(&env).expect("envelope signed by old key must verify");
}
#[test]
fn identity_rotate_tampered_envelope_fails() {
let old = fresh_attestor();
let new = fresh_attestor();
let attacker_new = fresh_attestor();
let signed_at = Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap();
let mut env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
env.new_pubkey = attacker_new.verifying_key().to_bytes();
assert_eq!(verify_rotation(&env), Err(VerifyError::BadSignature));
}
#[test]
fn rotation_envelope_unknown_schema_version_fails_closed() {
let old = fresh_attestor();
let new = fresh_attestor();
let signed_at = Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap();
let mut env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
env.schema_version = 999;
match verify_rotation(&env) {
Err(VerifyError::UnknownSchemaVersion {
found: 999,
expected: SCHEMA_VERSION_ATTESTATION,
}) => {}
other => panic!("expected UnknownSchemaVersion, got {other:?}"),
}
}
#[test]
fn malformed_payload_fails_closed() {
let attestor = fresh_attestor();
let p = fixture_preimage(&attestor);
let mut att = attest(&p, &attestor);
for byte in att.signature.iter_mut().take(8) {
*byte = 0;
}
let result = verify(&p, &att, &attestor.verifying_key(), attestor.key_id());
assert_eq!(result, Err(VerifyError::BadSignature));
}
#[test]
fn key_id_mismatch_fails_closed() {
let attestor = fresh_attestor();
let p = fixture_preimage(&attestor);
let att = attest(&p, &attestor);
let result = verify(&p, &att, &attestor.verifying_key(), "fp:wrong-key");
match result {
Err(VerifyError::KeyIdMismatch { .. }) => {}
other => panic!("expected KeyIdMismatch, got {other:?}"),
}
}
#[test]
fn fresh_attestation_verifies() {
let attestor = fresh_attestor();
let p = fixture_preimage(&attestor);
let att = attest(&p, &attestor);
verify(&p, &att, &attestor.verifying_key(), attestor.key_id())
.expect("freshly-signed attestation must verify");
}
#[test]
fn cross_source_replay_fails() {
let attestor = fresh_attestor();
let p = fixture_preimage(&attestor);
let att = attest(&p, &attestor);
let mut p2 = p.clone();
p2.source = SourceIdentity::Tool {
name: "user".into(),
};
let result = verify(&p2, &att, &attestor.verifying_key(), attestor.key_id());
assert_eq!(result, Err(VerifyError::BadSignature));
}
#[test]
fn different_key_does_not_verify() {
let a1 = fresh_attestor();
let p = fixture_preimage(&a1);
let att = attest(&p, &a1);
let other = (2u8..=u8::MAX)
.map(|n| SigningKey::from_bytes(&[n; 32]).verifying_key())
.find(|candidate| candidate != &a1.verifying_key())
.expect("finite seed range must contain a distinct test key");
assert_eq!(
verify(&p, &att, &other, a1.key_id()),
Err(VerifyError::BadSignature)
);
}
}