use std::path::Path;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use crate::identity::keypair;
use crate::identity::sign::{SignableLink, SignableWrite, canonical_cbor, canonical_cbor_write};
pub const SIGNATURE_LEN: usize = ed25519_dalek::SIGNATURE_LENGTH;
#[derive(Debug, PartialEq, Eq)]
pub enum VerifyError {
Tampered,
NoPublicKey,
MalformedSignature,
}
impl std::fmt::Display for VerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Tampered => f.write_str(
"Ed25519 signature did not validate against the supplied public key — \
link content or signature bytes do not match what observed_by signed",
),
Self::NoPublicKey => {
f.write_str("no public key enrolled for observed_by — receiver cannot verify")
}
Self::MalformedSignature => f.write_str(
"signature is not exactly 64 bytes — not a well-formed Ed25519 signature",
),
}
}
}
impl std::error::Error for VerifyError {}
pub fn verify(
public: &VerifyingKey,
link: &SignableLink<'_>,
signature: &[u8],
) -> Result<(), VerifyError> {
if signature.len() != SIGNATURE_LEN {
return Err(VerifyError::MalformedSignature);
}
let mut sig_arr = [0u8; SIGNATURE_LEN];
sig_arr.copy_from_slice(signature);
let sig = Signature::from_bytes(&sig_arr);
let payload = canonical_cbor(link).map_err(|_| VerifyError::Tampered)?;
public
.verify(&payload, &sig)
.map_err(|_| VerifyError::Tampered)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestLevel {
Claimed,
AgentAttested,
}
impl AttestLevel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Claimed => "claimed",
Self::AgentAttested => "agent_attested",
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum AttestError {
Forged,
AttestationRequired,
BadBoundKey,
MalformedSignature,
}
impl std::fmt::Display for AttestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Forged => f.write_str(
"write signature did not verify against the agent's bound public key — \
payload or signature bytes do not match what the agent signed",
),
Self::AttestationRequired => f.write_str(
"agent attestation is required but this write is unsigned or the agent \
has no bound public key",
),
Self::BadBoundKey => f.write_str(
"the agent's bound public key is malformed and cannot be used to verify",
),
Self::MalformedSignature => f.write_str(
"signature is not exactly 64 bytes — not a well-formed Ed25519 signature",
),
}
}
}
impl std::error::Error for AttestError {}
pub fn verify_write(
public: &VerifyingKey,
write: &SignableWrite<'_>,
signature: &[u8],
) -> Result<(), VerifyError> {
if signature.len() != SIGNATURE_LEN {
return Err(VerifyError::MalformedSignature);
}
let mut sig_arr = [0u8; SIGNATURE_LEN];
sig_arr.copy_from_slice(signature);
let sig = Signature::from_bytes(&sig_arr);
let payload = canonical_cbor_write(write).map_err(|_| VerifyError::Tampered)?;
public
.verify(&payload, &sig)
.map_err(|_| VerifyError::Tampered)
}
pub fn attest_write(
write: &SignableWrite<'_>,
bound_pubkey_b64: Option<&str>,
signature: Option<&[u8]>,
require: bool,
) -> Result<AttestLevel, AttestError> {
match (signature, bound_pubkey_b64) {
(Some(sig), Some(pk_b64)) => {
let public =
keypair::decode_public_base64(pk_b64).map_err(|_| AttestError::BadBoundKey)?;
verify_write(&public, write, sig).map_err(|e| match e {
VerifyError::MalformedSignature => AttestError::MalformedSignature,
VerifyError::Tampered | VerifyError::NoPublicKey => AttestError::Forged,
})?;
Ok(AttestLevel::AgentAttested)
}
_ => {
if require {
Err(AttestError::AttestationRequired)
} else {
Ok(AttestLevel::Claimed)
}
}
}
}
#[must_use]
pub fn lookup_peer_public_key(observed_by: &str) -> Option<VerifyingKey> {
if observed_by.is_empty() {
return None;
}
let dir = keypair::default_key_dir().ok()?;
lookup_peer_public_key_in(observed_by, &dir)
}
#[must_use]
pub fn lookup_peer_public_key_in(observed_by: &str, dir: &Path) -> Option<VerifyingKey> {
if observed_by.is_empty() {
return None;
}
keypair::load(observed_by, dir).ok().map(|kp| kp.public)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::keypair as kp_mod;
use crate::identity::sign;
use tempfile::TempDir;
fn link_fixture() -> SignableLink<'static> {
SignableLink {
src_id: "src-001",
dst_id: "dst-002",
relation: "related_to",
observed_by: Some("alice"),
valid_from: Some("2026-05-05T00:00:00+00:00"),
valid_until: None,
}
}
#[test]
fn verify_accepts_valid_signature() {
let alice = kp_mod::generate("alice").unwrap();
let link = link_fixture();
let sig = sign::sign(&alice, &link).unwrap();
verify(&alice.public, &link, &sig).expect("happy-path verify must succeed");
}
#[test]
fn verify_rejects_flipped_signature_byte() {
let alice = kp_mod::generate("alice").unwrap();
let link = link_fixture();
let mut sig = sign::sign(&alice, &link).unwrap();
sig[0] ^= 0x01;
let err = verify(&alice.public, &link, &sig).unwrap_err();
assert_eq!(err, VerifyError::Tampered, "flipped sig byte must reject");
}
#[test]
fn verify_rejects_mutated_link_content() {
let alice = kp_mod::generate("alice").unwrap();
let original = link_fixture();
let sig = sign::sign(&alice, &original).unwrap();
let mut tampered = original.clone();
tampered.relation = "supersedes";
let err = verify(&alice.public, &tampered, &sig).unwrap_err();
assert_eq!(
err,
VerifyError::Tampered,
"mutated link content must reject"
);
}
#[test]
fn verify_rejects_wrong_pubkey() {
let alice = kp_mod::generate("alice").unwrap();
let bob = kp_mod::generate("bob").unwrap();
let link = link_fixture();
let sig = sign::sign(&alice, &link).unwrap();
let err = verify(&bob.public, &link, &sig).unwrap_err();
assert_eq!(err, VerifyError::Tampered);
}
#[test]
fn verify_rejects_short_signature() {
let alice = kp_mod::generate("alice").unwrap();
let link = link_fixture();
let short = vec![0u8; 32];
let err = verify(&alice.public, &link, &short).unwrap_err();
assert_eq!(err, VerifyError::MalformedSignature);
}
#[test]
fn verify_rejects_long_signature() {
let alice = kp_mod::generate("alice").unwrap();
let link = link_fixture();
let long = vec![0u8; 128];
let err = verify(&alice.public, &link, &long).unwrap_err();
assert_eq!(err, VerifyError::MalformedSignature);
}
#[test]
fn verify_rejects_empty_signature() {
let alice = kp_mod::generate("alice").unwrap();
let link = link_fixture();
let err = verify(&alice.public, &link, &[]).unwrap_err();
assert_eq!(err, VerifyError::MalformedSignature);
}
#[test]
fn lookup_peer_public_key_in_returns_none_for_unknown() {
let dir = TempDir::new().unwrap();
assert!(lookup_peer_public_key_in("alice", dir.path()).is_none());
}
#[test]
fn lookup_peer_public_key_in_returns_none_for_empty_id() {
let dir = TempDir::new().unwrap();
assert!(lookup_peer_public_key_in("", dir.path()).is_none());
}
#[test]
fn lookup_peer_public_key_in_finds_enrolled_pubkey() {
let dir = TempDir::new().unwrap();
let alice = kp_mod::generate("alice").unwrap();
let pub_only = kp_mod::AgentKeypair {
agent_id: "alice".to_string(),
public: alice.public,
private: None,
};
kp_mod::save_public_only(&pub_only, dir.path()).unwrap();
let found = lookup_peer_public_key_in("alice", dir.path()).expect("lookup hit");
assert_eq!(found.to_bytes(), alice.public.to_bytes());
}
#[test]
fn lookup_peer_public_key_in_finds_full_keypair_pub() {
let dir = TempDir::new().unwrap();
let alice = kp_mod::generate("alice").unwrap();
kp_mod::save(&alice, dir.path()).unwrap();
let found = lookup_peer_public_key_in("alice", dir.path()).expect("lookup hit");
assert_eq!(found.to_bytes(), alice.public.to_bytes());
}
#[test]
fn lookup_peer_public_key_in_skips_invalid_agent_id() {
let dir = TempDir::new().unwrap();
assert!(lookup_peer_public_key_in("has space", dir.path()).is_none());
assert!(lookup_peer_public_key_in("has\0null", dir.path()).is_none());
}
#[test]
fn end_to_end_peer_a_signs_peer_b_verifies() {
let host_b_keys = TempDir::new().unwrap();
let alice = kp_mod::generate("alice").unwrap();
let alice_pub_for_b = kp_mod::AgentKeypair {
agent_id: "alice".to_string(),
public: alice.public,
private: None,
};
kp_mod::save_public_only(&alice_pub_for_b, host_b_keys.path()).unwrap();
let link = link_fixture();
let sig = sign::sign(&alice, &link).unwrap();
let key_on_b =
lookup_peer_public_key_in("alice", host_b_keys.path()).expect("alice enrolled on B");
verify(&key_on_b, &link, &sig).expect("cross-host verify must succeed");
}
#[test]
fn end_to_end_no_pubkey_returns_none_for_caller_to_handle() {
let host_b_keys = TempDir::new().unwrap();
assert!(lookup_peer_public_key_in("alice", host_b_keys.path()).is_none());
}
#[test]
fn verify_error_display_messages_are_distinct() {
let m_t = format!("{}", VerifyError::Tampered);
let m_n = format!("{}", VerifyError::NoPublicKey);
let m_m = format!("{}", VerifyError::MalformedSignature);
assert!(!m_t.is_empty());
assert!(!m_n.is_empty());
assert!(!m_m.is_empty());
assert_ne!(m_t, m_n);
assert_ne!(m_n, m_m);
assert_ne!(m_t, m_m);
}
fn body_hash(seed: u8) -> [u8; 32] {
let mut h = [seed; 32];
h[0] ^= 0x5A;
h
}
fn write_fixture(body: &[u8; 32]) -> SignableWrite<'_> {
SignableWrite {
agent_id: "ai:curator",
namespace: "team/alpha",
title: "kubernetes deployment guide",
kind: "fact",
created_at: "2026-06-01T12:00:00+00:00",
content_sha256: body,
}
}
#[test]
fn verify_write_accepts_valid_signature() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x11);
let write = write_fixture(&body);
let sig = sign::sign_write(&kp, &write).unwrap();
verify_write(&kp.public, &write, &sig).expect("happy-path write verify must succeed");
}
#[test]
fn verify_write_rejects_flipped_signature_byte() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x12);
let write = write_fixture(&body);
let mut sig = sign::sign_write(&kp, &write).unwrap();
sig[0] ^= 0x01;
assert_eq!(
verify_write(&kp.public, &write, &sig).unwrap_err(),
VerifyError::Tampered
);
}
#[test]
fn verify_write_rejects_mutated_payload() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x13);
let original = write_fixture(&body);
let sig = sign::sign_write(&kp, &original).unwrap();
let mut tampered = original.clone();
tampered.agent_id = "ai:impostor";
assert_eq!(
verify_write(&kp.public, &tampered, &sig).unwrap_err(),
VerifyError::Tampered
);
}
#[test]
fn verify_write_rejects_short_signature() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x14);
let write = write_fixture(&body);
assert_eq!(
verify_write(&kp.public, &write, &[0u8; 32]).unwrap_err(),
VerifyError::MalformedSignature
);
}
#[test]
fn attest_write_signed_with_bound_key_is_attested() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x21);
let write = write_fixture(&body);
let sig = sign::sign_write(&kp, &write).unwrap();
let pk_b64 = kp.public_base64();
assert_eq!(
attest_write(&write, Some(&pk_b64), Some(&sig), false).unwrap(),
AttestLevel::AgentAttested
);
assert_eq!(
attest_write(&write, Some(&pk_b64), Some(&sig), true).unwrap(),
AttestLevel::AgentAttested
);
}
#[test]
fn attest_write_forged_signature_always_rejected() {
let kp = kp_mod::generate("ai:curator").unwrap();
let other = kp_mod::generate("ai:other").unwrap();
let body = body_hash(0x22);
let write = write_fixture(&body);
let sig = sign::sign_write(&other, &write).unwrap();
let pk_b64 = kp.public_base64();
assert_eq!(
attest_write(&write, Some(&pk_b64), Some(&sig), false).unwrap_err(),
AttestError::Forged
);
assert_eq!(
attest_write(&write, Some(&pk_b64), Some(&sig), true).unwrap_err(),
AttestError::Forged
);
}
#[test]
fn attest_write_unsigned_is_claimed_when_permissive_rejected_when_required() {
let body = body_hash(0x23);
let write = write_fixture(&body);
let kp = kp_mod::generate("ai:curator").unwrap();
let pk_b64 = kp.public_base64();
assert_eq!(
attest_write(&write, Some(&pk_b64), None, false).unwrap(),
AttestLevel::Claimed
);
assert_eq!(
attest_write(&write, Some(&pk_b64), None, true).unwrap_err(),
AttestError::AttestationRequired
);
}
#[test]
fn attest_write_signature_without_bound_key_cannot_attest() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x24);
let write = write_fixture(&body);
let sig = sign::sign_write(&kp, &write).unwrap();
assert_eq!(
attest_write(&write, None, Some(&sig), false).unwrap(),
AttestLevel::Claimed
);
assert_eq!(
attest_write(&write, None, Some(&sig), true).unwrap_err(),
AttestError::AttestationRequired
);
}
#[test]
fn attest_write_malformed_signature_is_reported() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x25);
let write = write_fixture(&body);
let pk_b64 = kp.public_base64();
assert_eq!(
attest_write(&write, Some(&pk_b64), Some(&[0u8; 10]), false).unwrap_err(),
AttestError::MalformedSignature
);
}
#[test]
fn attest_write_bad_bound_key_fails_closed() {
let kp = kp_mod::generate("ai:curator").unwrap();
let body = body_hash(0x26);
let write = write_fixture(&body);
let sig = sign::sign_write(&kp, &write).unwrap();
assert_eq!(
attest_write(&write, Some("!!!not-base64!!!"), Some(&sig), false).unwrap_err(),
AttestError::BadBoundKey
);
}
#[test]
fn attest_level_as_str_is_stable() {
assert_eq!(AttestLevel::Claimed.as_str(), "claimed");
assert_eq!(AttestLevel::AgentAttested.as_str(), "agent_attested");
}
}