use crate::models::field_names;
use anyhow::{Context, Result};
use ed25519_dalek::Signer;
use crate::identity::keypair::AgentKeypair;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableLink<'a> {
pub src_id: &'a str,
pub dst_id: &'a str,
pub relation: &'a str,
pub observed_by: Option<&'a str>,
pub valid_from: Option<&'a str>,
pub valid_until: Option<&'a str>,
}
pub fn canonical_cbor(link: &SignableLink<'_>) -> Result<Vec<u8>> {
use std::collections::BTreeMap;
let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
map.insert("src_id", ciborium::Value::Text(link.src_id.to_string()));
map.insert("dst_id", ciborium::Value::Text(link.dst_id.to_string()));
map.insert("relation", ciborium::Value::Text(link.relation.to_string()));
map.insert(field_names::OBSERVED_BY, text_or_null(link.observed_by));
map.insert(field_names::VALID_FROM, text_or_null(link.valid_from));
map.insert(field_names::VALID_UNTIL, text_or_null(link.valid_until));
let entries: Vec<(ciborium::Value, ciborium::Value)> = map
.into_iter()
.map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
.collect();
let value = ciborium::Value::Map(entries);
let mut out: Vec<u8> = Vec::with_capacity(128);
ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableLink")?;
Ok(out)
}
pub fn sign(keypair: &AgentKeypair, link: &SignableLink<'_>) -> Result<Vec<u8>> {
let signing = keypair.private.as_ref().with_context(|| {
format!(
"AgentKeypair for {} has no private key — cannot sign",
keypair.agent_id
)
})?;
let bytes = canonical_cbor(link)?;
let sig = signing.sign(&bytes);
Ok(sig.to_bytes().to_vec())
}
fn text_or_null(opt: Option<&str>) -> ciborium::Value {
match opt {
Some(s) => ciborium::Value::Text(s.to_string()),
None => ciborium::Value::Null,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignablePersona<'a> {
pub persona_id: &'a str,
pub entity_id: &'a str,
pub namespace: &'a str,
pub version: i32,
pub generated_at: &'a str,
pub sources: &'a [String],
pub body_md_sha256: &'a [u8; 32],
}
pub fn canonical_cbor_persona(p: &SignablePersona<'_>) -> Result<Vec<u8>> {
use std::collections::BTreeMap;
let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
map.insert(
"persona_id",
ciborium::Value::Text(p.persona_id.to_string()),
);
map.insert("entity_id", ciborium::Value::Text(p.entity_id.to_string()));
map.insert("namespace", ciborium::Value::Text(p.namespace.to_string()));
map.insert(
"version",
ciborium::Value::Integer(ciborium::value::Integer::from(p.version)),
);
map.insert(
field_names::GENERATED_AT,
ciborium::Value::Text(p.generated_at.to_string()),
);
let sources_val = ciborium::Value::Array(
p.sources
.iter()
.map(|s| ciborium::Value::Text(s.clone()))
.collect(),
);
map.insert("sources", sources_val);
map.insert(
"body_md_sha256",
ciborium::Value::Bytes(p.body_md_sha256.to_vec()),
);
let entries: Vec<(ciborium::Value, ciborium::Value)> = map
.into_iter()
.map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
.collect();
let value = ciborium::Value::Map(entries);
let mut out: Vec<u8> = Vec::with_capacity(256);
ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignablePersona")?;
Ok(out)
}
pub fn sign_persona(keypair: &AgentKeypair, persona: &SignablePersona<'_>) -> Result<Vec<u8>> {
let signing = keypair.private.as_ref().with_context(|| {
format!(
"AgentKeypair for {} has no private key — cannot sign persona",
keypair.agent_id
)
})?;
let bytes = canonical_cbor_persona(persona)?;
let sig = signing.sign(&bytes);
Ok(sig.to_bytes().to_vec())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableWrite<'a> {
pub agent_id: &'a str,
pub namespace: &'a str,
pub title: &'a str,
pub kind: &'a str,
pub created_at: &'a str,
pub content_sha256: &'a [u8; 32],
}
pub fn canonical_cbor_write(w: &SignableWrite<'_>) -> Result<Vec<u8>> {
use std::collections::BTreeMap;
let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
map.insert("agent_id", ciborium::Value::Text(w.agent_id.to_string()));
map.insert("namespace", ciborium::Value::Text(w.namespace.to_string()));
map.insert("title", ciborium::Value::Text(w.title.to_string()));
map.insert("kind", ciborium::Value::Text(w.kind.to_string()));
map.insert(
field_names::CREATED_AT,
ciborium::Value::Text(w.created_at.to_string()),
);
map.insert(
field_names::CONTENT_SHA256,
ciborium::Value::Bytes(w.content_sha256.to_vec()),
);
let entries: Vec<(ciborium::Value, ciborium::Value)> = map
.into_iter()
.map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
.collect();
let value = ciborium::Value::Map(entries);
let mut out: Vec<u8> = Vec::with_capacity(256);
ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableWrite")?;
Ok(out)
}
pub fn sign_write(keypair: &AgentKeypair, write: &SignableWrite<'_>) -> Result<Vec<u8>> {
let signing = keypair.private.as_ref().with_context(|| {
format!(
"AgentKeypair for {} has no private key — cannot sign write",
keypair.agent_id
)
})?;
let bytes = canonical_cbor_write(write)?;
let sig = signing.sign(&bytes);
Ok(sig.to_bytes().to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::keypair;
use ed25519_dalek::Verifier;
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 canonical_cbor_is_deterministic() {
let src_id = "src-001";
let dst_id = "dst-002";
let relation = "related_to";
let observed_by = Some("alice");
let valid_from = Some("2026-05-05T00:00:00+00:00");
let valid_until: Option<&str> = None;
let perm1 = SignableLink {
src_id,
dst_id,
relation,
observed_by,
valid_from,
valid_until,
};
let perm2 = SignableLink {
valid_until,
valid_from,
observed_by,
relation,
dst_id,
src_id,
};
let perm3 = SignableLink {
relation,
src_id,
valid_from,
dst_id,
valid_until,
observed_by,
};
let bytes1 = canonical_cbor(&perm1).expect("encode perm1");
let bytes2 = canonical_cbor(&perm2).expect("encode perm2");
let bytes3 = canonical_cbor(&perm3).expect("encode perm3");
assert_eq!(
bytes1, bytes2,
"field-order permutation 2 must produce identical CBOR (BTreeMap key sort)"
);
assert_eq!(
bytes2, bytes3,
"field-order permutation 3 must produce identical CBOR (BTreeMap key sort)"
);
let again = canonical_cbor(&perm1).expect("re-encode perm1");
assert_eq!(bytes1, again, "deterministic CBOR must be byte-stable");
}
#[test]
fn canonical_cbor_differs_on_field_change() {
let base = link_fixture();
let mut altered = base.clone();
altered.relation = "supersedes";
let a = canonical_cbor(&base).expect("encode base");
let b = canonical_cbor(&altered).expect("encode altered");
assert_ne!(a, b, "different relation must produce different bytes");
}
#[test]
fn canonical_cbor_handles_all_optionals_none() {
let link = SignableLink {
src_id: "s",
dst_id: "d",
relation: "r",
observed_by: None,
valid_from: None,
valid_until: None,
};
let bytes = canonical_cbor(&link).expect("encode");
assert!(!bytes.is_empty());
assert_eq!(bytes, canonical_cbor(&link).expect("re-encode"));
}
#[test]
fn sign_and_verify_round_trip() {
let kp = keypair::generate("alice").expect("generate");
let link = link_fixture();
let sig_bytes = sign(&kp, &link).expect("sign");
assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
let payload = canonical_cbor(&link).expect("encode");
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
kp.public.verify(&payload, &sig).expect("verify");
}
#[test]
fn sign_refuses_public_only_keypair() {
let kp = keypair::generate("alice").unwrap();
let pub_only = AgentKeypair {
agent_id: "alice".to_string(),
public: kp.public,
private: None,
};
let err = sign(&pub_only, &link_fixture()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no private key"), "got: {msg}");
}
#[test]
fn sign_differs_for_different_keys() {
let alice = keypair::generate("alice").unwrap();
let bob = keypair::generate("bob").unwrap();
let link = link_fixture();
let sig_a = sign(&alice, &link).unwrap();
let sig_b = sign(&bob, &link).unwrap();
assert_ne!(sig_a, sig_b);
}
#[test]
fn signature_does_not_verify_against_other_pub() {
let alice = keypair::generate("alice").unwrap();
let bob = keypair::generate("bob").unwrap();
let link = link_fixture();
let sig_bytes = sign(&alice, &link).unwrap();
let payload = canonical_cbor(&link).unwrap();
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
assert!(bob.public.verify(&payload, &sig).is_err());
}
fn body_hash_fixture(seed: u8) -> [u8; 32] {
let mut h = [seed; 32];
h[0] ^= 0xA5;
h
}
fn persona_fixture() -> ([u8; 32], Vec<String>) {
let body = body_hash_fixture(0x10);
let sources = vec!["src-1".to_string(), "src-2".to_string()];
(body, sources)
}
#[test]
fn canonical_cbor_persona_is_deterministic() {
let (body, sources) = persona_fixture();
let persona_id = "persona-001";
let entity_id = "alice";
let namespace = "team/alpha";
let version = 1_i32;
let generated_at = "2026-05-16T12:00:00+00:00";
let perm1 = SignablePersona {
persona_id,
entity_id,
namespace,
version,
generated_at,
sources: &sources,
body_md_sha256: &body,
};
let perm2 = SignablePersona {
body_md_sha256: &body,
sources: &sources,
generated_at,
version,
namespace,
entity_id,
persona_id,
};
let perm3 = SignablePersona {
namespace,
version,
sources: &sources,
entity_id,
body_md_sha256: &body,
generated_at,
persona_id,
};
let b1 = canonical_cbor_persona(&perm1).expect("encode perm1");
let b2 = canonical_cbor_persona(&perm2).expect("encode perm2");
let b3 = canonical_cbor_persona(&perm3).expect("encode perm3");
assert_eq!(b1, b2);
assert_eq!(b2, b3);
assert_eq!(b1, canonical_cbor_persona(&perm1).expect("re-encode"));
}
#[test]
fn canonical_cbor_persona_differs_on_field_change() {
let (body, sources) = persona_fixture();
let base = SignablePersona {
persona_id: "p",
entity_id: "alice",
namespace: "team/alpha",
version: 1,
generated_at: "2026-05-16T00:00:00+00:00",
sources: &sources,
body_md_sha256: &body,
};
let other_body = body_hash_fixture(0x99);
let altered = SignablePersona {
body_md_sha256: &other_body,
..base.clone()
};
let a = canonical_cbor_persona(&base).expect("encode base");
let b = canonical_cbor_persona(&altered).expect("encode altered");
assert_ne!(a, b, "different body hash must produce different bytes");
}
#[test]
fn canonical_cbor_persona_handles_empty_sources() {
let body = body_hash_fixture(0x01);
let sources: Vec<String> = Vec::new();
let persona = SignablePersona {
persona_id: "p",
entity_id: "alice",
namespace: "team/alpha",
version: 1,
generated_at: "2026-05-16T00:00:00+00:00",
sources: &sources,
body_md_sha256: &body,
};
let bytes = canonical_cbor_persona(&persona).expect("encode empty-sources");
assert!(!bytes.is_empty());
assert_eq!(bytes, canonical_cbor_persona(&persona).expect("re-encode"));
}
#[test]
fn sign_persona_round_trip() {
let kp = keypair::generate("ai:curator").expect("generate");
let (body, sources) = persona_fixture();
let persona = SignablePersona {
persona_id: "persona-xyz",
entity_id: "alice",
namespace: "team/alpha",
version: 1,
generated_at: "2026-05-16T12:00:00+00:00",
sources: &sources,
body_md_sha256: &body,
};
let sig_bytes = sign_persona(&kp, &persona).expect("sign");
assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
let payload = canonical_cbor_persona(&persona).expect("encode");
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
kp.public.verify(&payload, &sig).expect("verify");
}
#[test]
fn sign_persona_refuses_public_only_keypair() {
let kp = keypair::generate("ai:curator").unwrap();
let pub_only = AgentKeypair {
agent_id: "ai:curator".to_string(),
public: kp.public,
private: None,
};
let (body, sources) = persona_fixture();
let persona = SignablePersona {
persona_id: "p",
entity_id: "alice",
namespace: "team/alpha",
version: 1,
generated_at: "2026-05-16T00:00:00+00:00",
sources: &sources,
body_md_sha256: &body,
};
let err = sign_persona(&pub_only, &persona).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no private key"), "got: {msg}");
}
#[test]
fn sign_persona_does_not_verify_against_other_pub() {
let alice = keypair::generate("alice").unwrap();
let bob = keypair::generate("bob").unwrap();
let (body, sources) = persona_fixture();
let persona = SignablePersona {
persona_id: "p",
entity_id: "alice",
namespace: "team/alpha",
version: 1,
generated_at: "2026-05-16T00:00:00+00:00",
sources: &sources,
body_md_sha256: &body,
};
let sig_bytes = sign_persona(&alice, &persona).unwrap();
let payload = canonical_cbor_persona(&persona).unwrap();
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
assert!(bob.public.verify(&payload, &sig).is_err());
}
fn write_fixture<'a>(body: &'a [u8; 32]) -> SignableWrite<'a> {
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 canonical_cbor_write_is_deterministic() {
let body = body_hash_fixture(0x20);
let agent_id = "ai:curator";
let namespace = "team/alpha";
let title = "kubernetes deployment guide";
let kind = "fact";
let created_at = "2026-06-01T12:00:00+00:00";
let perm1 = SignableWrite {
agent_id,
namespace,
title,
kind,
created_at,
content_sha256: &body,
};
let perm2 = SignableWrite {
content_sha256: &body,
created_at,
kind,
title,
namespace,
agent_id,
};
let perm3 = SignableWrite {
title,
content_sha256: &body,
agent_id,
created_at,
namespace,
kind,
};
let b1 = canonical_cbor_write(&perm1).expect("encode perm1");
let b2 = canonical_cbor_write(&perm2).expect("encode perm2");
let b3 = canonical_cbor_write(&perm3).expect("encode perm3");
assert_eq!(b1, b2);
assert_eq!(b2, b3);
assert_eq!(b1, canonical_cbor_write(&perm1).expect("re-encode"));
}
#[test]
fn canonical_cbor_write_differs_on_field_change() {
let body = body_hash_fixture(0x21);
let base = write_fixture(&body);
let altered = SignableWrite {
agent_id: "ai:impostor",
..base.clone()
};
let a = canonical_cbor_write(&base).expect("encode base");
let b = canonical_cbor_write(&altered).expect("encode altered");
assert_ne!(a, b, "different agent_id must produce different bytes");
}
#[test]
fn canonical_cbor_write_differs_on_content_change() {
let body = body_hash_fixture(0x22);
let base = write_fixture(&body);
let other = body_hash_fixture(0x77);
let altered = SignableWrite {
content_sha256: &other,
..base.clone()
};
let a = canonical_cbor_write(&base).expect("encode base");
let b = canonical_cbor_write(&altered).expect("encode altered");
assert_ne!(a, b, "different content hash must produce different bytes");
}
#[test]
fn sign_write_round_trip() {
let kp = keypair::generate("ai:curator").expect("generate");
let body = body_hash_fixture(0x23);
let write = write_fixture(&body);
let sig_bytes = sign_write(&kp, &write).expect("sign");
assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
let payload = canonical_cbor_write(&write).expect("encode");
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
kp.public.verify(&payload, &sig).expect("verify");
}
#[test]
fn sign_write_refuses_public_only_keypair() {
let kp = keypair::generate("ai:curator").unwrap();
let pub_only = AgentKeypair {
agent_id: "ai:curator".to_string(),
public: kp.public,
private: None,
};
let body = body_hash_fixture(0x24);
let write = write_fixture(&body);
let err = sign_write(&pub_only, &write).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no private key"), "got: {msg}");
}
#[test]
fn sign_write_does_not_verify_against_other_pub() {
let alice = keypair::generate("alice").unwrap();
let bob = keypair::generate("bob").unwrap();
let body = body_hash_fixture(0x25);
let write = write_fixture(&body);
let sig_bytes = sign_write(&alice, &write).unwrap();
let payload = canonical_cbor_write(&write).unwrap();
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
assert!(bob.public.verify(&payload, &sig).is_err());
}
#[test]
fn sign_write_differs_for_different_keys() {
let alice = keypair::generate("alice").unwrap();
let bob = keypair::generate("bob").unwrap();
let body = body_hash_fixture(0x26);
let write = write_fixture(&body);
let sig_a = sign_write(&alice, &write).unwrap();
let sig_b = sign_write(&bob, &write).unwrap();
assert_ne!(sig_a, sig_b);
}
#[test]
fn canonical_cbor_write_kind_change_produces_different_bytes() {
let body = body_hash_fixture(0x27);
let as_fact = write_fixture(&body);
let as_plan = SignableWrite {
kind: "plan",
..as_fact.clone()
};
let a = canonical_cbor_write(&as_fact).expect("encode fact");
let b = canonical_cbor_write(&as_plan).expect("encode plan");
assert_ne!(a, b);
}
#[test]
fn canonical_cbor_persona_version_change_produces_different_bytes() {
let (body, sources) = persona_fixture();
let v1 = SignablePersona {
persona_id: "p",
entity_id: "alice",
namespace: "team/alpha",
version: 1,
generated_at: "2026-05-16T00:00:00+00:00",
sources: &sources,
body_md_sha256: &body,
};
let v2 = SignablePersona {
version: 2,
..v1.clone()
};
let a = canonical_cbor_persona(&v1).expect("encode v1");
let b = canonical_cbor_persona(&v2).expect("encode v2");
assert_ne!(a, b);
}
}