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}