Skip to main content

ai_memory/identity/
sign.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Outbound Ed25519 signing for `memory_links` (Track H, Task H2).
5//!
6//! Builds on H1 ([`crate::identity::keypair`]) — the per-agent
7//! [`AgentKeypair`] is the signing key. This module provides the two
8//! pieces H2 ships:
9//!
10//! 1. [`canonical_cbor`] — RFC 8949 §4.2.1 deterministic CBOR encoding
11//!    of the six link fields the signature commits to:
12//!    `src_id`, `dst_id`, `relation`, `observed_by`, `valid_from`,
13//!    `valid_until`. Same bytes on every host, every architecture,
14//!    every endianness — the precondition for round-tripping a
15//!    signature through the federation wire.
16//! 2. [`sign`] — wraps `canonical_cbor` + Ed25519 over the resulting
17//!    bytes. Returns the 64-byte signature ready to drop into the
18//!    `signature` BLOB column on `memory_links`.
19//!
20//! H3 will mirror [`canonical_cbor`] on the inbound path so verification
21//! re-derives the same bytes from the inbound row before checking the
22//! signature against the peer's public key.
23//!
24//! # Why CBOR?
25//!
26//! CBOR is the RustCrypto / IETF default for signed payloads (COSE
27//! lives on top of CBOR). RFC 8949 §4.2.1 defines a *deterministic*
28//! encoding: map keys sort lexicographically, integers use the smallest
29//! length, no indefinite-length items, no semantic tags we don't need.
30//! That gives us byte-stable input to Ed25519 without writing a custom
31//! binary format and without depending on `serde_json`'s key-ordering
32//! quirks (which are not part of its public contract).
33//!
34//! # Out of scope here
35//!
36//! - Inbound verification (H3).
37//! - `attest_level` enum + `memory_verify` MCP tool (H4).
38//! - `signed_events` audit table (H5).
39
40use crate::models::field_names;
41use anyhow::{Context, Result};
42use ed25519_dalek::Signer;
43
44use crate::identity::keypair::AgentKeypair;
45
46/// The six fields the link signature commits to.
47///
48/// Decoupled from [`crate::models::MemoryLink`] on purpose: that struct
49/// is the public wire shape for `get_links` (4 columns), while the
50/// signed bundle includes the temporal-validity columns (`valid_from`,
51/// `valid_until`, `observed_by`) added in v0.6.3 schema v15. Keeping
52/// `SignableLink` separate means H3's verifier can deserialize directly
53/// from a row without dragging the entire `MemoryLink` shape — and it
54/// gives the canonical encoder a single, audited shape to commit to.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SignableLink<'a> {
57    pub src_id: &'a str,
58    pub dst_id: &'a str,
59    pub relation: &'a str,
60    /// Agent that observed / asserted this link. `None` when the link
61    /// was created by an unidentified caller (rare on the signing path
62    /// — the keypair's owner is normally the observer).
63    pub observed_by: Option<&'a str>,
64    /// RFC3339 instant the link became true. Always present on writes
65    /// produced by `db::create_link` (set to "now" at insert time).
66    pub valid_from: Option<&'a str>,
67    /// RFC3339 instant the link was invalidated, or `None` if still
68    /// valid. Almost always `None` at insert time; set later by
69    /// `db::invalidate_link`.
70    pub valid_until: Option<&'a str>,
71}
72
73/// RFC 8949 §4.2.1 deterministic CBOR encoding of the six signable
74/// link fields.
75///
76/// The encoded shape is a CBOR map with 6 entries keyed by the field
77/// names below. Map keys are emitted in sort order (per RFC 8949 §4.2.1
78/// "Core Deterministic Encoding"), integers use the shortest form, and
79/// `Option::None` is encoded as CBOR `null`. Encoding the same
80/// `SignableLink` twice (or on a different host) produces identical
81/// bytes — the precondition Ed25519 needs.
82///
83/// Field order matters at the *byte* level even though `ciborium`
84/// canonicalises map keys for us — we still pass a deterministic
85/// `Vec<(&str, ...)>` shape to keep this function's intent reviewable
86/// without leaning on a non-obvious property of the encoder.
87///
88/// # Errors
89///
90/// Returns an error only when CBOR serialization fails — in practice
91/// unreachable for the fixed-shape input above, but surfaced as a
92/// `Result` so callers don't have to choose between panicking and
93/// silently signing a truncated payload.
94pub fn canonical_cbor(link: &SignableLink<'_>) -> Result<Vec<u8>> {
95    // We model the payload as a plain `BTreeMap<&str, ciborium::Value>`
96    // so map-key ordering is enforced at construction time — the encoder
97    // walks the BTreeMap in iteration order, which matches lexicographic
98    // sort. ciborium's `into_writer` emits canonical (smallest-int,
99    // definite-length) representations by default.
100    use std::collections::BTreeMap;
101    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
102    map.insert("src_id", ciborium::Value::Text(link.src_id.to_string()));
103    map.insert("dst_id", ciborium::Value::Text(link.dst_id.to_string()));
104    map.insert("relation", ciborium::Value::Text(link.relation.to_string()));
105    map.insert(field_names::OBSERVED_BY, text_or_null(link.observed_by));
106    map.insert(field_names::VALID_FROM, text_or_null(link.valid_from));
107    map.insert(field_names::VALID_UNTIL, text_or_null(link.valid_until));
108
109    // Convert the BTreeMap to a `ciborium::Value::Map` whose entries are
110    // already in lexicographic key order. ciborium will preserve that
111    // order on the wire — the documented default for `into_writer`.
112    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
113        .into_iter()
114        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
115        .collect();
116    let value = ciborium::Value::Map(entries);
117
118    let mut out: Vec<u8> = Vec::with_capacity(128);
119    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableLink")?;
120    Ok(out)
121}
122
123/// Sign `link` with `keypair`'s private key.
124///
125/// Encodes the link via [`canonical_cbor`], then runs Ed25519 over the
126/// resulting bytes. Returns the 64-byte signature, ready to drop into
127/// the `signature` BLOB column on `memory_links`.
128///
129/// # Errors
130///
131/// - `keypair.private` is `None` (public-only handle — verification
132///   only).
133/// - The CBOR encoding step fails (in practice unreachable; surfaced
134///   for completeness).
135pub fn sign(keypair: &AgentKeypair, link: &SignableLink<'_>) -> Result<Vec<u8>> {
136    let signing = keypair.private.as_ref().with_context(|| {
137        format!(
138            "AgentKeypair for {} has no private key — cannot sign",
139            keypair.agent_id
140        )
141    })?;
142    let bytes = canonical_cbor(link)?;
143    let sig = signing.sign(&bytes);
144    Ok(sig.to_bytes().to_vec())
145}
146
147/// Helper: lift `Option<&str>` into a CBOR `Text` or `Null`. Encoding
148/// `None` as `null` (rather than dropping the key) keeps the map's key
149/// set fixed across rows — H3's verifier can re-derive the bytes
150/// without branching on which optional fields were present.
151fn text_or_null(opt: Option<&str>) -> ciborium::Value {
152    match opt {
153        Some(s) => ciborium::Value::Text(s.to_string()),
154        None => ciborium::Value::Null,
155    }
156}
157
158// ---------------------------------------------------------------------------
159// v0.7.0 issue #812 / #813 — SignablePersona + sign_persona
160// ---------------------------------------------------------------------------
161//
162// Mirrors the `SignableLink` shape: a single, audited surface for the
163// seven fields the persona signature commits to, encoded via RFC 8949
164// §4.2.1 deterministic CBOR. The body of the persona Markdown is
165// hashed (SHA-256) BEFORE entering the signed envelope so the payload
166// stays bounded (32 bytes) regardless of body length — Ed25519 over
167// kilobytes of prose would still work, but the bounded shape lets the
168// `signed_events` row carry the same `payload_hash` cheaply.
169
170/// The seven fields the persona signature commits to.
171///
172/// `body_md_sha256` is the SHA-256 of the UTF-8 bytes of the rendered
173/// persona Markdown body (the same string that lands in
174/// `memories.content`). Hashing it before signing keeps the canonical
175/// payload bounded at ~200 bytes regardless of body length — a 300-500
176/// word persona body would otherwise dominate the signed envelope and
177/// inflate every `signed_events.payload_hash` recomputation.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct SignablePersona<'a> {
180    /// The Persona memory's id (UUIDv4). Stable per (entity_id,
181    /// namespace, version) tuple — `PersonaGenerator::generate` mints
182    /// it before computing the signature.
183    pub persona_id: &'a str,
184    /// Subject the persona distils. Mirrors `Persona::entity_id`.
185    pub entity_id: &'a str,
186    /// Namespace the persona was minted under.
187    pub namespace: &'a str,
188    /// Monotonic version counter — `1` on the first generation, then
189    /// `prev + 1` per regeneration. Pinned in the signature so a
190    /// regeneration cannot replay an earlier version's signed bytes.
191    pub version: i32,
192    /// RFC3339 generation timestamp pinned in `metadata.persona.generated_at`.
193    pub generated_at: &'a str,
194    /// Source reflection ids — one `derives_from` edge per element.
195    /// Order matters at the byte level (the CBOR encoder preserves the
196    /// slice order); the writer pins the order to match
197    /// `metadata.persona.sources`.
198    pub sources: &'a [String],
199    /// SHA-256 (32 bytes) over the rendered persona Markdown body's
200    /// UTF-8 bytes. Bounds the signed payload size.
201    pub body_md_sha256: &'a [u8; 32],
202}
203
204/// RFC 8949 §4.2.1 deterministic CBOR encoding of the seven signable
205/// persona fields.
206///
207/// The encoded shape is a CBOR map with seven entries keyed by the
208/// field names below. Map keys are emitted in sort order (per RFC 8949
209/// §4.2.1 "Core Deterministic Encoding"), integers use the shortest
210/// form, the body hash is encoded as CBOR `bytes`, and the source-id
211/// list is encoded as an ordered CBOR array (slice order preserved).
212/// Encoding the same `SignablePersona` twice (or on a different host)
213/// produces identical bytes — the precondition Ed25519 needs.
214///
215/// # Errors
216///
217/// Returns an error only when CBOR serialization fails — in practice
218/// unreachable for the fixed-shape input above, but surfaced as a
219/// `Result` so callers don't have to choose between panicking and
220/// silently signing a truncated payload.
221pub fn canonical_cbor_persona(p: &SignablePersona<'_>) -> Result<Vec<u8>> {
222    use std::collections::BTreeMap;
223    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
224    map.insert(
225        "persona_id",
226        ciborium::Value::Text(p.persona_id.to_string()),
227    );
228    map.insert("entity_id", ciborium::Value::Text(p.entity_id.to_string()));
229    map.insert("namespace", ciborium::Value::Text(p.namespace.to_string()));
230    map.insert(
231        "version",
232        ciborium::Value::Integer(ciborium::value::Integer::from(p.version)),
233    );
234    map.insert(
235        field_names::GENERATED_AT,
236        ciborium::Value::Text(p.generated_at.to_string()),
237    );
238    let sources_val = ciborium::Value::Array(
239        p.sources
240            .iter()
241            .map(|s| ciborium::Value::Text(s.clone()))
242            .collect(),
243    );
244    map.insert("sources", sources_val);
245    map.insert(
246        "body_md_sha256",
247        ciborium::Value::Bytes(p.body_md_sha256.to_vec()),
248    );
249
250    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
251        .into_iter()
252        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
253        .collect();
254    let value = ciborium::Value::Map(entries);
255
256    let mut out: Vec<u8> = Vec::with_capacity(256);
257    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignablePersona")?;
258    Ok(out)
259}
260
261/// Sign `persona` with `keypair`'s private key.
262///
263/// Encodes the persona via [`canonical_cbor_persona`], then runs
264/// Ed25519 over the resulting bytes. Returns the 64-byte signature,
265/// ready to drop into the `metadata.persona.signature` base64 field on
266/// the persona memory and into the `signature` BLOB column on the
267/// corresponding `signed_events` row.
268///
269/// # Errors
270///
271/// - `keypair.private` is `None` (public-only handle — verification
272///   only).
273/// - The CBOR encoding step fails (in practice unreachable; surfaced
274///   for completeness).
275pub fn sign_persona(keypair: &AgentKeypair, persona: &SignablePersona<'_>) -> Result<Vec<u8>> {
276    let signing = keypair.private.as_ref().with_context(|| {
277        format!(
278            "AgentKeypair for {} has no private key — cannot sign persona",
279            keypair.agent_id
280        )
281    })?;
282    let bytes = canonical_cbor_persona(persona)?;
283    let sig = signing.sign(&bytes);
284    Ok(sig.to_bytes().to_vec())
285}
286
287// ---------------------------------------------------------------------------
288// v0.7.0 #626 Layer-3 (Task 1.3) — SignableWrite + sign_write
289// ---------------------------------------------------------------------------
290//
291// Closes the claimed→attested agent_id gap on the *store* path. A bare
292// `store` request asserts `agent_id` as a free-text claim — anyone can
293// type any id. Layer-3 lets a holder of the agent's private key sign the
294// write so the verifier can re-derive these bytes from the stored row and
295// confirm the `agent_id` was *attested* (the signer held the key bound to
296// that id), not merely claimed.
297//
298// Mirrors `SignableLink` / `SignablePersona`: a single audited surface for
299// the six fields the write signature commits to, encoded via RFC 8949
300// §4.2.1 deterministic CBOR. The memory body is hashed (SHA-256) BEFORE
301// entering the envelope so the signed payload stays bounded (~200 bytes)
302// regardless of content length — the same bound `SignablePersona` uses.
303
304/// The six fields the store-path write signature commits to.
305///
306/// Decoupled from [`crate::models::Memory`] on purpose: the signed bundle
307/// pins exactly the identity-bearing surface of a write (who, where, what
308/// title, what content, what kind, when) without dragging the full
309/// `Memory` shape — so the verifier can re-derive the bytes directly from
310/// the persisted row, and the canonical encoder has a single, audited
311/// shape to commit to.
312///
313/// `content_sha256` is the SHA-256 of the UTF-8 bytes of the memory
314/// content (the same string that lands in `memories.content`). Hashing it
315/// before signing keeps the canonical payload bounded regardless of body
316/// length — a multi-kilobyte memory would otherwise dominate the signed
317/// envelope and inflate every `signed_events.payload_hash` recomputation.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct SignableWrite<'a> {
320    /// The claiming agent's id. This is the field the attestation gate
321    /// exists to bind: the signature proves the signer held the keypair
322    /// registered to this id, upgrading the write from *claimed* to
323    /// *attested*.
324    pub agent_id: &'a str,
325    /// Namespace the write targets.
326    pub namespace: &'a str,
327    /// Memory title (the `(title, namespace)` pair is the upsert key, so
328    /// it is identity-bearing and must be inside the signed surface).
329    pub title: &'a str,
330    /// Memory kind discriminant (e.g. `"fact"`, `"plan"`). Pinned so a
331    /// signature minted for one kind cannot be replayed onto another.
332    pub kind: &'a str,
333    /// RFC3339 creation timestamp pinned at insert time. Inside the
334    /// signed surface so a captured signature cannot be replayed to
335    /// back- or forward-date a write.
336    pub created_at: &'a str,
337    /// SHA-256 (32 bytes) over the rendered memory content's UTF-8 bytes.
338    /// Bounds the signed payload size.
339    pub content_sha256: &'a [u8; 32],
340}
341
342/// RFC 8949 §4.2.1 deterministic CBOR encoding of the six signable write
343/// fields.
344///
345/// The encoded shape is a CBOR map with six entries keyed by the field
346/// names below. Map keys are emitted in sort order (per RFC 8949 §4.2.1
347/// "Core Deterministic Encoding"), the content hash is encoded as CBOR
348/// `bytes`, and all other fields as CBOR `text`. Encoding the same
349/// `SignableWrite` twice (or on a different host) produces identical
350/// bytes — the precondition Ed25519 needs.
351///
352/// # Errors
353///
354/// Returns an error only when CBOR serialization fails — in practice
355/// unreachable for the fixed-shape input above, but surfaced as a
356/// `Result` so callers don't have to choose between panicking and
357/// silently signing a truncated payload.
358pub fn canonical_cbor_write(w: &SignableWrite<'_>) -> Result<Vec<u8>> {
359    use std::collections::BTreeMap;
360    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
361    map.insert("agent_id", ciborium::Value::Text(w.agent_id.to_string()));
362    map.insert("namespace", ciborium::Value::Text(w.namespace.to_string()));
363    map.insert("title", ciborium::Value::Text(w.title.to_string()));
364    map.insert("kind", ciborium::Value::Text(w.kind.to_string()));
365    map.insert(
366        field_names::CREATED_AT,
367        ciborium::Value::Text(w.created_at.to_string()),
368    );
369    map.insert(
370        field_names::CONTENT_SHA256,
371        ciborium::Value::Bytes(w.content_sha256.to_vec()),
372    );
373
374    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
375        .into_iter()
376        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
377        .collect();
378    let value = ciborium::Value::Map(entries);
379
380    let mut out: Vec<u8> = Vec::with_capacity(256);
381    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableWrite")?;
382    Ok(out)
383}
384
385/// Sign `write` with `keypair`'s private key.
386///
387/// Encodes the write via [`canonical_cbor_write`], then runs Ed25519 over
388/// the resulting bytes. Returns the 64-byte signature, ready to drop into
389/// the store-path signature wire field and the `signed_events` row.
390///
391/// # Errors
392///
393/// - `keypair.private` is `None` (public-only handle — verification
394///   only).
395/// - The CBOR encoding step fails (in practice unreachable; surfaced for
396///   completeness).
397pub fn sign_write(keypair: &AgentKeypair, write: &SignableWrite<'_>) -> Result<Vec<u8>> {
398    let signing = keypair.private.as_ref().with_context(|| {
399        format!(
400            "AgentKeypair for {} has no private key — cannot sign write",
401            keypair.agent_id
402        )
403    })?;
404    let bytes = canonical_cbor_write(write)?;
405    let sig = signing.sign(&bytes);
406    Ok(sig.to_bytes().to_vec())
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::identity::keypair;
413    use ed25519_dalek::Verifier;
414
415    fn link_fixture() -> SignableLink<'static> {
416        SignableLink {
417            src_id: "src-001",
418            dst_id: "dst-002",
419            relation: "related_to",
420            observed_by: Some("alice"),
421            valid_from: Some("2026-05-05T00:00:00+00:00"),
422            valid_until: None,
423        }
424    }
425
426    #[test]
427    fn canonical_cbor_is_deterministic() {
428        // RFC 8949 §4.2.1 — encoding the same logical input three times
429        // (in three *different* logical map-key orderings) must produce
430        // identical bytes. This is the round-trip precondition for
431        // Ed25519 signing AND a regression guard against an encoder
432        // upgrade silently switching iteration order.
433        //
434        // M2 (v0.7.0 round-2): the encoder reads from a `BTreeMap<&str,
435        // ...>` which is sorted by construction, so the bytes only ever
436        // come out one way regardless of insertion order. We exercise
437        // that property explicitly by inserting the six fields in three
438        // distinct permutations and asserting all three encodes match.
439        // If a future ciborium upgrade changes ordering semantics (or
440        // someone swaps the `BTreeMap` for a `HashMap`), this test
441        // fires and the maintainer revisits the canonicalisation
442        // surface before signatures silently break across versions.
443
444        // The shared field values — same payload, different insertion
445        // orders below.
446        let src_id = "src-001";
447        let dst_id = "dst-002";
448        let relation = "related_to";
449        let observed_by = Some("alice");
450        let valid_from = Some("2026-05-05T00:00:00+00:00");
451        let valid_until: Option<&str> = None;
452
453        // Helper: encode by inserting into a *non*-canonical map first
454        // (`HashMap`) in a chosen visit order, then producing a
455        // canonical `BTreeMap` and round-tripping through
456        // `canonical_cbor`.  We can't easily inject our own non-canonical
457        // CBOR here without re-writing `canonical_cbor`'s body, but we
458        // CAN prove that constructing the same logical input via three
459        // distinct intermediate orderings collapses to identical bytes
460        // because `canonical_cbor` itself enforces the sort.
461
462        // Permutation 1: declared order (alphabetic-by-construction).
463        let perm1 = SignableLink {
464            src_id,
465            dst_id,
466            relation,
467            observed_by,
468            valid_from,
469            valid_until,
470        };
471
472        // Permutation 2: same logical link, constructed via field
473        // reassignment in a different visual order. Rust struct literal
474        // field order is purely syntactic; the binary representation
475        // is the same. The encoder must still sort by name.
476        let perm2 = SignableLink {
477            valid_until,
478            valid_from,
479            observed_by,
480            relation,
481            dst_id,
482            src_id,
483        };
484
485        // Permutation 3: interleaved order.
486        let perm3 = SignableLink {
487            relation,
488            src_id,
489            valid_from,
490            dst_id,
491            valid_until,
492            observed_by,
493        };
494
495        let bytes1 = canonical_cbor(&perm1).expect("encode perm1");
496        let bytes2 = canonical_cbor(&perm2).expect("encode perm2");
497        let bytes3 = canonical_cbor(&perm3).expect("encode perm3");
498
499        assert_eq!(
500            bytes1, bytes2,
501            "field-order permutation 2 must produce identical CBOR (BTreeMap key sort)"
502        );
503        assert_eq!(
504            bytes2, bytes3,
505            "field-order permutation 3 must produce identical CBOR (BTreeMap key sort)"
506        );
507
508        // Also exercise byte-stability across repeated encodes of the
509        // same instance — the property that's load-bearing for sign +
510        // verify across hosts.
511        let again = canonical_cbor(&perm1).expect("re-encode perm1");
512        assert_eq!(bytes1, again, "deterministic CBOR must be byte-stable");
513    }
514
515    #[test]
516    fn canonical_cbor_differs_on_field_change() {
517        // Sanity-check that the encoder isn't flattening fields. Any
518        // change in the signed surface should change the byte output.
519        let base = link_fixture();
520        let mut altered = base.clone();
521        altered.relation = "supersedes";
522        let a = canonical_cbor(&base).expect("encode base");
523        let b = canonical_cbor(&altered).expect("encode altered");
524        assert_ne!(a, b, "different relation must produce different bytes");
525    }
526
527    #[test]
528    fn canonical_cbor_handles_all_optionals_none() {
529        let link = SignableLink {
530            src_id: "s",
531            dst_id: "d",
532            relation: "r",
533            observed_by: None,
534            valid_from: None,
535            valid_until: None,
536        };
537        let bytes = canonical_cbor(&link).expect("encode");
538        assert!(!bytes.is_empty());
539        // Two encodes still match.
540        assert_eq!(bytes, canonical_cbor(&link).expect("re-encode"));
541    }
542
543    #[test]
544    fn sign_and_verify_round_trip() {
545        let kp = keypair::generate("alice").expect("generate");
546        let link = link_fixture();
547        let sig_bytes = sign(&kp, &link).expect("sign");
548        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
549
550        // Re-derive the canonical bytes and verify with the public key.
551        let payload = canonical_cbor(&link).expect("encode");
552        let mut sig_arr = [0u8; 64];
553        sig_arr.copy_from_slice(&sig_bytes);
554        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
555        kp.public.verify(&payload, &sig).expect("verify");
556    }
557
558    #[test]
559    fn sign_refuses_public_only_keypair() {
560        // Public-only handles (load() with no .priv on disk, or list())
561        // must not be silently treated as zero-byte signatures — the
562        // caller has to fall back to the unsigned path explicitly.
563        let kp = keypair::generate("alice").unwrap();
564        let pub_only = AgentKeypair {
565            agent_id: "alice".to_string(),
566            public: kp.public,
567            private: None,
568        };
569        let err = sign(&pub_only, &link_fixture()).unwrap_err();
570        let msg = format!("{err:#}");
571        assert!(msg.contains("no private key"), "got: {msg}");
572    }
573
574    #[test]
575    fn sign_differs_for_different_keys() {
576        // Two keypairs over the same link produce different signatures
577        // (nondeterministic randomness, plus distinct keys).
578        let alice = keypair::generate("alice").unwrap();
579        let bob = keypair::generate("bob").unwrap();
580        let link = link_fixture();
581        let sig_a = sign(&alice, &link).unwrap();
582        let sig_b = sign(&bob, &link).unwrap();
583        assert_ne!(sig_a, sig_b);
584    }
585
586    #[test]
587    fn signature_does_not_verify_against_other_pub() {
588        let alice = keypair::generate("alice").unwrap();
589        let bob = keypair::generate("bob").unwrap();
590        let link = link_fixture();
591        let sig_bytes = sign(&alice, &link).unwrap();
592        let payload = canonical_cbor(&link).unwrap();
593        let mut sig_arr = [0u8; 64];
594        sig_arr.copy_from_slice(&sig_bytes);
595        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
596        // Alice's signature must not verify under Bob's public key.
597        assert!(bob.public.verify(&payload, &sig).is_err());
598    }
599
600    // -----------------------------------------------------------------
601    // v0.7.0 issue #812 / #813 — SignablePersona + sign_persona
602    // -----------------------------------------------------------------
603
604    fn body_hash_fixture(seed: u8) -> [u8; 32] {
605        let mut h = [seed; 32];
606        h[0] ^= 0xA5;
607        h
608    }
609
610    fn persona_fixture() -> ([u8; 32], Vec<String>) {
611        let body = body_hash_fixture(0x10);
612        let sources = vec!["src-1".to_string(), "src-2".to_string()];
613        (body, sources)
614    }
615
616    #[test]
617    fn canonical_cbor_persona_is_deterministic() {
618        // Mirrors the link-side determinism test: three distinct
619        // permutations of the SignablePersona literal must collapse
620        // to identical bytes because the BTreeMap key-sort runs at
621        // encode time. Catches a regression where a future refactor
622        // swaps the BTreeMap for a HashMap or drops the explicit sort.
623        let (body, sources) = persona_fixture();
624        let persona_id = "persona-001";
625        let entity_id = "alice";
626        let namespace = "team/alpha";
627        let version = 1_i32;
628        let generated_at = "2026-05-16T12:00:00+00:00";
629
630        let perm1 = SignablePersona {
631            persona_id,
632            entity_id,
633            namespace,
634            version,
635            generated_at,
636            sources: &sources,
637            body_md_sha256: &body,
638        };
639        let perm2 = SignablePersona {
640            body_md_sha256: &body,
641            sources: &sources,
642            generated_at,
643            version,
644            namespace,
645            entity_id,
646            persona_id,
647        };
648        let perm3 = SignablePersona {
649            namespace,
650            version,
651            sources: &sources,
652            entity_id,
653            body_md_sha256: &body,
654            generated_at,
655            persona_id,
656        };
657
658        let b1 = canonical_cbor_persona(&perm1).expect("encode perm1");
659        let b2 = canonical_cbor_persona(&perm2).expect("encode perm2");
660        let b3 = canonical_cbor_persona(&perm3).expect("encode perm3");
661        assert_eq!(b1, b2);
662        assert_eq!(b2, b3);
663        // Stable across repeated encodes of the same instance.
664        assert_eq!(b1, canonical_cbor_persona(&perm1).expect("re-encode"));
665    }
666
667    #[test]
668    fn canonical_cbor_persona_differs_on_field_change() {
669        let (body, sources) = persona_fixture();
670        let base = SignablePersona {
671            persona_id: "p",
672            entity_id: "alice",
673            namespace: "team/alpha",
674            version: 1,
675            generated_at: "2026-05-16T00:00:00+00:00",
676            sources: &sources,
677            body_md_sha256: &body,
678        };
679        // Flip the body hash — different bytes must result.
680        let other_body = body_hash_fixture(0x99);
681        let altered = SignablePersona {
682            body_md_sha256: &other_body,
683            ..base.clone()
684        };
685        let a = canonical_cbor_persona(&base).expect("encode base");
686        let b = canonical_cbor_persona(&altered).expect("encode altered");
687        assert_ne!(a, b, "different body hash must produce different bytes");
688    }
689
690    #[test]
691    fn canonical_cbor_persona_handles_empty_sources() {
692        let body = body_hash_fixture(0x01);
693        let sources: Vec<String> = Vec::new();
694        let persona = SignablePersona {
695            persona_id: "p",
696            entity_id: "alice",
697            namespace: "team/alpha",
698            version: 1,
699            generated_at: "2026-05-16T00:00:00+00:00",
700            sources: &sources,
701            body_md_sha256: &body,
702        };
703        // Encoding must not panic on an empty source list. Two
704        // encodes still match (determinism over empty array).
705        let bytes = canonical_cbor_persona(&persona).expect("encode empty-sources");
706        assert!(!bytes.is_empty());
707        assert_eq!(bytes, canonical_cbor_persona(&persona).expect("re-encode"));
708    }
709
710    #[test]
711    fn sign_persona_round_trip() {
712        let kp = keypair::generate("ai:curator").expect("generate");
713        let (body, sources) = persona_fixture();
714        let persona = SignablePersona {
715            persona_id: "persona-xyz",
716            entity_id: "alice",
717            namespace: "team/alpha",
718            version: 1,
719            generated_at: "2026-05-16T12:00:00+00:00",
720            sources: &sources,
721            body_md_sha256: &body,
722        };
723        let sig_bytes = sign_persona(&kp, &persona).expect("sign");
724        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
725
726        let payload = canonical_cbor_persona(&persona).expect("encode");
727        let mut sig_arr = [0u8; 64];
728        sig_arr.copy_from_slice(&sig_bytes);
729        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
730        kp.public.verify(&payload, &sig).expect("verify");
731    }
732
733    #[test]
734    fn sign_persona_refuses_public_only_keypair() {
735        let kp = keypair::generate("ai:curator").unwrap();
736        let pub_only = AgentKeypair {
737            agent_id: "ai:curator".to_string(),
738            public: kp.public,
739            private: None,
740        };
741        let (body, sources) = persona_fixture();
742        let persona = SignablePersona {
743            persona_id: "p",
744            entity_id: "alice",
745            namespace: "team/alpha",
746            version: 1,
747            generated_at: "2026-05-16T00:00:00+00:00",
748            sources: &sources,
749            body_md_sha256: &body,
750        };
751        let err = sign_persona(&pub_only, &persona).unwrap_err();
752        let msg = format!("{err:#}");
753        assert!(msg.contains("no private key"), "got: {msg}");
754    }
755
756    #[test]
757    fn sign_persona_does_not_verify_against_other_pub() {
758        // Cross-key non-replayability — Alice's signature must not
759        // verify under Bob's public key.
760        let alice = keypair::generate("alice").unwrap();
761        let bob = keypair::generate("bob").unwrap();
762        let (body, sources) = persona_fixture();
763        let persona = SignablePersona {
764            persona_id: "p",
765            entity_id: "alice",
766            namespace: "team/alpha",
767            version: 1,
768            generated_at: "2026-05-16T00:00:00+00:00",
769            sources: &sources,
770            body_md_sha256: &body,
771        };
772        let sig_bytes = sign_persona(&alice, &persona).unwrap();
773        let payload = canonical_cbor_persona(&persona).unwrap();
774        let mut sig_arr = [0u8; 64];
775        sig_arr.copy_from_slice(&sig_bytes);
776        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
777        assert!(bob.public.verify(&payload, &sig).is_err());
778    }
779
780    // -----------------------------------------------------------------
781    // v0.7.0 #626 Layer-3 (Task 1.3) — SignableWrite + sign_write
782    // -----------------------------------------------------------------
783
784    fn write_fixture<'a>(body: &'a [u8; 32]) -> SignableWrite<'a> {
785        SignableWrite {
786            agent_id: "ai:curator",
787            namespace: "team/alpha",
788            title: "kubernetes deployment guide",
789            kind: "fact",
790            created_at: "2026-06-01T12:00:00+00:00",
791            content_sha256: body,
792        }
793    }
794
795    #[test]
796    fn canonical_cbor_write_is_deterministic() {
797        // Three distinct permutations of the SignableWrite literal must
798        // collapse to identical bytes because the BTreeMap key-sort runs
799        // at encode time. Catches a regression where a future refactor
800        // swaps the BTreeMap for a HashMap or drops the explicit sort.
801        let body = body_hash_fixture(0x20);
802        let agent_id = "ai:curator";
803        let namespace = "team/alpha";
804        let title = "kubernetes deployment guide";
805        let kind = "fact";
806        let created_at = "2026-06-01T12:00:00+00:00";
807
808        let perm1 = SignableWrite {
809            agent_id,
810            namespace,
811            title,
812            kind,
813            created_at,
814            content_sha256: &body,
815        };
816        let perm2 = SignableWrite {
817            content_sha256: &body,
818            created_at,
819            kind,
820            title,
821            namespace,
822            agent_id,
823        };
824        let perm3 = SignableWrite {
825            title,
826            content_sha256: &body,
827            agent_id,
828            created_at,
829            namespace,
830            kind,
831        };
832
833        let b1 = canonical_cbor_write(&perm1).expect("encode perm1");
834        let b2 = canonical_cbor_write(&perm2).expect("encode perm2");
835        let b3 = canonical_cbor_write(&perm3).expect("encode perm3");
836        assert_eq!(b1, b2);
837        assert_eq!(b2, b3);
838        assert_eq!(b1, canonical_cbor_write(&perm1).expect("re-encode"));
839    }
840
841    #[test]
842    fn canonical_cbor_write_differs_on_field_change() {
843        let body = body_hash_fixture(0x21);
844        let base = write_fixture(&body);
845        // Flip the agent_id — the field the attestation gate binds. A
846        // different claimer must produce different signed bytes.
847        let altered = SignableWrite {
848            agent_id: "ai:impostor",
849            ..base.clone()
850        };
851        let a = canonical_cbor_write(&base).expect("encode base");
852        let b = canonical_cbor_write(&altered).expect("encode altered");
853        assert_ne!(a, b, "different agent_id must produce different bytes");
854    }
855
856    #[test]
857    fn canonical_cbor_write_differs_on_content_change() {
858        let body = body_hash_fixture(0x22);
859        let base = write_fixture(&body);
860        let other = body_hash_fixture(0x77);
861        let altered = SignableWrite {
862            content_sha256: &other,
863            ..base.clone()
864        };
865        let a = canonical_cbor_write(&base).expect("encode base");
866        let b = canonical_cbor_write(&altered).expect("encode altered");
867        assert_ne!(a, b, "different content hash must produce different bytes");
868    }
869
870    #[test]
871    fn sign_write_round_trip() {
872        let kp = keypair::generate("ai:curator").expect("generate");
873        let body = body_hash_fixture(0x23);
874        let write = write_fixture(&body);
875        let sig_bytes = sign_write(&kp, &write).expect("sign");
876        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");
877
878        let payload = canonical_cbor_write(&write).expect("encode");
879        let mut sig_arr = [0u8; 64];
880        sig_arr.copy_from_slice(&sig_bytes);
881        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
882        kp.public.verify(&payload, &sig).expect("verify");
883    }
884
885    #[test]
886    fn sign_write_refuses_public_only_keypair() {
887        let kp = keypair::generate("ai:curator").unwrap();
888        let pub_only = AgentKeypair {
889            agent_id: "ai:curator".to_string(),
890            public: kp.public,
891            private: None,
892        };
893        let body = body_hash_fixture(0x24);
894        let write = write_fixture(&body);
895        let err = sign_write(&pub_only, &write).unwrap_err();
896        let msg = format!("{err:#}");
897        assert!(msg.contains("no private key"), "got: {msg}");
898    }
899
900    #[test]
901    fn sign_write_does_not_verify_against_other_pub() {
902        // Cross-key non-replayability — Alice's signature must not verify
903        // under Bob's public key. This is the property the attestation
904        // gate leans on: a write signed by a non-bound key is rejected.
905        let alice = keypair::generate("alice").unwrap();
906        let bob = keypair::generate("bob").unwrap();
907        let body = body_hash_fixture(0x25);
908        let write = write_fixture(&body);
909        let sig_bytes = sign_write(&alice, &write).unwrap();
910        let payload = canonical_cbor_write(&write).unwrap();
911        let mut sig_arr = [0u8; 64];
912        sig_arr.copy_from_slice(&sig_bytes);
913        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
914        assert!(bob.public.verify(&payload, &sig).is_err());
915    }
916
917    #[test]
918    fn sign_write_differs_for_different_keys() {
919        let alice = keypair::generate("alice").unwrap();
920        let bob = keypair::generate("bob").unwrap();
921        let body = body_hash_fixture(0x26);
922        let write = write_fixture(&body);
923        let sig_a = sign_write(&alice, &write).unwrap();
924        let sig_b = sign_write(&bob, &write).unwrap();
925        assert_ne!(sig_a, sig_b);
926    }
927
928    #[test]
929    fn canonical_cbor_write_kind_change_produces_different_bytes() {
930        // Kind is inside the signed payload so a signature minted for a
931        // "fact" write cannot be replayed onto a "plan" write.
932        let body = body_hash_fixture(0x27);
933        let as_fact = write_fixture(&body);
934        let as_plan = SignableWrite {
935            kind: "plan",
936            ..as_fact.clone()
937        };
938        let a = canonical_cbor_write(&as_fact).expect("encode fact");
939        let b = canonical_cbor_write(&as_plan).expect("encode plan");
940        assert_ne!(a, b);
941    }
942
943    #[test]
944    fn canonical_cbor_persona_version_change_produces_different_bytes() {
945        // Version is part of the signed payload so a v1 signature
946        // cannot be replayed as a v2 signature — pin that.
947        let (body, sources) = persona_fixture();
948        let v1 = SignablePersona {
949            persona_id: "p",
950            entity_id: "alice",
951            namespace: "team/alpha",
952            version: 1,
953            generated_at: "2026-05-16T00:00:00+00:00",
954            sources: &sources,
955            body_md_sha256: &body,
956        };
957        let v2 = SignablePersona {
958            version: 2,
959            ..v1.clone()
960        };
961        let a = canonical_cbor_persona(&v1).expect("encode v1");
962        let b = canonical_cbor_persona(&v2).expect("encode v2");
963        assert_ne!(a, b);
964    }
965}