ai_memory/identity/verify.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Inbound Ed25519 verification for federated `memory_links` (Track H,
5//! Task H3).
6//!
7//! Builds on H1 ([`crate::identity::keypair`]) and H2
8//! ([`crate::identity::sign`]). H2 sealed the canonical CBOR encoding and
9//! the outbound signing path; this module is the mirror — when a link
10//! arrives from a peer over `sync_push`, we re-derive the same canonical
11//! CBOR bytes and verify the 64-byte signature against the public key
12//! associated with the link's `observed_by` claim.
13//!
14//! # Trust model
15//!
16//! - The peer's public key is read from the *receiver's* on-disk key
17//! directory ([`crate::identity::keypair::default_key_dir`]) — i.e. the
18//! peer was previously enrolled (`identity import` or `identity
19//! generate` for a peer agent_id) by this host's operator. This keeps
20//! the trust root local: a peer cannot upgrade its own attest_level by
21//! sending us a fresh public key.
22//! - If `observed_by` has no enrolled key on this host, the link is still
23//! accepted (`attest_level = "unsigned"`) so federation back-compat
24//! holds for peers that haven't enrolled yet. This degraded posture is
25//! intentional — H3 brings opt-in attestation, not a hard cutover.
26//! - If the peer *is* enrolled and the signature does not verify, the
27//! link is rejected with a `tracing::warn!` log line. Tampered or
28//! forged inbound links never land in the receiver's `memory_links`
29//! table.
30//!
31//! # Out of scope here
32//!
33//! - `attest_level` enum + `memory_verify` MCP tool (H4). H3 stays on
34//! the existing TEXT column with the literal `"peer_attested"` /
35//! `"unsigned"` strings already documented in [`crate::db`].
36//! - `signed_events` audit table (H5).
37//! - End-to-end federation integration test (H6).
38
39use std::path::Path;
40
41use ed25519_dalek::{Signature, Verifier, VerifyingKey};
42
43use crate::identity::keypair;
44use crate::identity::sign::{SignableLink, SignableWrite, canonical_cbor, canonical_cbor_write};
45
46/// Length of an Ed25519 signature in bytes. Mirrors the constant
47/// [`ed25519_dalek::SIGNATURE_LENGTH`] but pinned locally so the verify
48/// path doesn't pull a pub-use dependency on the crate's surface.
49pub const SIGNATURE_LEN: usize = ed25519_dalek::SIGNATURE_LENGTH;
50
51/// Outcome of an inbound verify attempt.
52///
53/// Hand-rolled `Display` + `Error` (no `thiserror`) per repo convention:
54/// the OSS substrate keeps its dependency surface deliberately small so
55/// the AgenticMem commercial layer can lift the same error shape without
56/// re-vendoring proc-macros.
57#[derive(Debug, PartialEq, Eq)]
58pub enum VerifyError {
59 /// Signature did not validate against the supplied public key over
60 /// the link's canonical CBOR. Either the link content was tampered
61 /// with in flight, the signature bytes themselves were flipped, or
62 /// the wrong public key was supplied for `observed_by`.
63 Tampered,
64 /// `lookup_peer_public_key` returned `None` — the receiver has no
65 /// enrolled key for `observed_by`. Callers may choose to treat this
66 /// as accept-and-flag-as-unsigned (the federation inbound path) or
67 /// as a hard reject (a future strict-mode operator opt-in).
68 NoPublicKey,
69 /// The supplied signature blob was not exactly 64 bytes — Ed25519
70 /// signatures are fixed-length, so any other length is structurally
71 /// invalid before we even try the verify.
72 MalformedSignature,
73}
74
75impl std::fmt::Display for VerifyError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::Tampered => f.write_str(
79 "Ed25519 signature did not validate against the supplied public key — \
80 link content or signature bytes do not match what observed_by signed",
81 ),
82 Self::NoPublicKey => {
83 f.write_str("no public key enrolled for observed_by — receiver cannot verify")
84 }
85 Self::MalformedSignature => f.write_str(
86 "signature is not exactly 64 bytes — not a well-formed Ed25519 signature",
87 ),
88 }
89 }
90}
91
92impl std::error::Error for VerifyError {}
93
94/// Verify `signature` over the canonical CBOR encoding of `link` using
95/// `public`.
96///
97/// The verifier re-derives the exact byte sequence H2's
98/// [`crate::identity::sign::sign`] hashed before signing. Any divergence
99/// between the inbound link content and what the peer originally signed
100/// — even a single byte flip in `relation`, `observed_by`, etc. —
101/// changes the CBOR output and makes Ed25519 reject the signature.
102///
103/// # Errors
104///
105/// - [`VerifyError::MalformedSignature`] — `signature.len() != 64`.
106/// - [`VerifyError::Tampered`] — signature does not validate against
107/// `public` over the canonical CBOR. Same variant covers all
108/// "validation failed" cases (wrong key, flipped sig byte, mutated
109/// link field) on purpose: the inbound posture is "reject" regardless
110/// of *which* of those happened, and surfacing the distinction would
111/// leak verification-side timing/structure to a misbehaving peer.
112pub fn verify(
113 public: &VerifyingKey,
114 link: &SignableLink<'_>,
115 signature: &[u8],
116) -> Result<(), VerifyError> {
117 if signature.len() != SIGNATURE_LEN {
118 return Err(VerifyError::MalformedSignature);
119 }
120 let mut sig_arr = [0u8; SIGNATURE_LEN];
121 sig_arr.copy_from_slice(signature);
122 let sig = Signature::from_bytes(&sig_arr);
123
124 // CBOR encode failures are surfaced as Tampered too — the only way
125 // canonical_cbor errors today is a serialization bug, which from the
126 // verifier's perspective is functionally equivalent to "we cannot
127 // re-derive the bytes the peer signed, so we cannot trust this link".
128 let payload = canonical_cbor(link).map_err(|_| VerifyError::Tampered)?;
129
130 public
131 .verify(&payload, &sig)
132 .map_err(|_| VerifyError::Tampered)
133}
134
135// ---------------------------------------------------------------------------
136// #626 Layer-3 (Task 1.3 / C4) — store-path write attestation
137// ---------------------------------------------------------------------------
138//
139// The link verifier above reads the peer key from the on-disk key store
140// (federation trust root). The WRITE attestation path is different: the
141// trust anchor is the key BOUND into the agent's registration row by C3
142// (`db::bind_agent_pubkey` / `MemoryStore::agent_pubkey`). A write that
143// claims `agent_id` is upgraded from *claimed* to *attested* only when its
144// Ed25519 signature verifies under that bound key.
145
146/// Attestation level resolved for a store-path write.
147///
148/// Stamped into the stored row's metadata so downstream readers can tell a
149/// bare `agent_id` claim apart from one cryptographically attested by a
150/// holder of the agent's bound private key.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum AttestLevel {
153 /// The write asserted an `agent_id` with no verified signature — a
154 /// bare claim. The permissive default for unsigned writes (or writes
155 /// whose agent has no bound key) when attestation is not required.
156 Claimed,
157 /// The write carried an Ed25519 signature that verified against the
158 /// `agent_id`'s bound public key — the `agent_id` is attested.
159 AgentAttested,
160}
161
162impl AttestLevel {
163 /// Stable wire string for the `metadata.attest_level` field.
164 #[must_use]
165 pub fn as_str(self) -> &'static str {
166 match self {
167 Self::Claimed => "claimed",
168 Self::AgentAttested => "agent_attested",
169 }
170 }
171}
172
173/// Reason a store-path write was refused (or could not be attested) by
174/// [`attest_write`].
175#[derive(Debug, PartialEq, Eq)]
176pub enum AttestError {
177 /// A signature was presented and a bound key exists, but the signature
178 /// did not verify — tampered payload, flipped signature byte, or a
179 /// signature minted under a different key. ALWAYS a hard reject,
180 /// regardless of the require-attestation posture: a presented-but-bad
181 /// signature is never silently downgraded to a claim.
182 Forged,
183 /// Attestation is required (`AI_MEMORY_REQUIRE_AGENT_ATTESTATION`) but
184 /// the write could not be attested — no signature was presented, or
185 /// the agent has no bound key to verify against.
186 AttestationRequired,
187 /// The agent's bound public key could not be decoded — the
188 /// registration metadata holds a corrupt `agent_pubkey`. Fail-closed
189 /// (we cannot attest against a key we cannot parse).
190 BadBoundKey,
191 /// The presented signature blob was not exactly 64 bytes.
192 MalformedSignature,
193}
194
195impl std::fmt::Display for AttestError {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 Self::Forged => f.write_str(
199 "write signature did not verify against the agent's bound public key — \
200 payload or signature bytes do not match what the agent signed",
201 ),
202 Self::AttestationRequired => f.write_str(
203 "agent attestation is required but this write is unsigned or the agent \
204 has no bound public key",
205 ),
206 Self::BadBoundKey => f.write_str(
207 "the agent's bound public key is malformed and cannot be used to verify",
208 ),
209 Self::MalformedSignature => f.write_str(
210 "signature is not exactly 64 bytes — not a well-formed Ed25519 signature",
211 ),
212 }
213 }
214}
215
216impl std::error::Error for AttestError {}
217
218/// Verify `signature` over the canonical CBOR encoding of `write` using
219/// `public`.
220///
221/// Mirror of [`verify`] for the store path: re-derives the exact bytes
222/// [`crate::identity::sign::sign_write`] signed and checks the 64-byte
223/// Ed25519 signature. Any divergence between the stored write fields and
224/// what the agent signed makes the verify fail.
225///
226/// # Errors
227///
228/// - [`VerifyError::MalformedSignature`] — `signature.len() != 64`.
229/// - [`VerifyError::Tampered`] — signature does not validate against
230/// `public` over the canonical CBOR (wrong key, flipped byte, or mutated
231/// write field).
232pub fn verify_write(
233 public: &VerifyingKey,
234 write: &SignableWrite<'_>,
235 signature: &[u8],
236) -> Result<(), VerifyError> {
237 if signature.len() != SIGNATURE_LEN {
238 return Err(VerifyError::MalformedSignature);
239 }
240 let mut sig_arr = [0u8; SIGNATURE_LEN];
241 sig_arr.copy_from_slice(signature);
242 let sig = Signature::from_bytes(&sig_arr);
243
244 let payload = canonical_cbor_write(write).map_err(|_| VerifyError::Tampered)?;
245 public
246 .verify(&payload, &sig)
247 .map_err(|_| VerifyError::Tampered)
248}
249
250/// Resolve the [`AttestLevel`] for a store-path write — the C4 gate.
251///
252/// Decision table (`require` = `AI_MEMORY_REQUIRE_AGENT_ATTESTATION`):
253///
254/// | signature | bound key | verify | require=false | require=true |
255/// |-----------|-----------|--------|---------------|--------------|
256/// | present | present | ok | `AgentAttested` | `AgentAttested` |
257/// | present | present | fail | `Err(Forged)` | `Err(Forged)` |
258/// | present | absent | — | `Claimed` | `Err(Required)` |
259/// | absent | any | — | `Claimed` | `Err(Required)` |
260///
261/// The load-bearing invariant: a *presented* signature that fails to
262/// verify is ALWAYS rejected (`Forged`), never downgraded to `Claimed` —
263/// so an attacker cannot strip attestation by sending a deliberately bad
264/// signature. Only the *absence* of a signature (or of a bound key) maps
265/// to the permissive `Claimed` posture, and only when `require` is unset.
266///
267/// # Errors
268///
269/// See the table — [`AttestError::Forged`], [`AttestError::AttestationRequired`],
270/// [`AttestError::BadBoundKey`], or [`AttestError::MalformedSignature`].
271pub fn attest_write(
272 write: &SignableWrite<'_>,
273 bound_pubkey_b64: Option<&str>,
274 signature: Option<&[u8]>,
275 require: bool,
276) -> Result<AttestLevel, AttestError> {
277 match (signature, bound_pubkey_b64) {
278 (Some(sig), Some(pk_b64)) => {
279 let public =
280 keypair::decode_public_base64(pk_b64).map_err(|_| AttestError::BadBoundKey)?;
281 verify_write(&public, write, sig).map_err(|e| match e {
282 VerifyError::MalformedSignature => AttestError::MalformedSignature,
283 // Tampered / wrong-key both collapse to Forged on the
284 // write path — same fail-closed posture as the link verifier.
285 VerifyError::Tampered | VerifyError::NoPublicKey => AttestError::Forged,
286 })?;
287 Ok(AttestLevel::AgentAttested)
288 }
289 // Either no signature, or a signature with no key to check it
290 // against. Both are "cannot attest": permissive → Claimed,
291 // strict → reject.
292 _ => {
293 if require {
294 Err(AttestError::AttestationRequired)
295 } else {
296 Ok(AttestLevel::Claimed)
297 }
298 }
299 }
300}
301
302/// Look up the public key associated with `observed_by` on this host's
303/// on-disk key store.
304///
305/// Reuses the H1 [`keypair::load`] loader (same path layout: `<key_dir>/
306/// <agent_id>.pub`). The loader will succeed for any `agent_id` whose
307/// public-key file is present — it does not require the `.priv` file
308/// (this host has no reason to hold a peer's private key, only the
309/// matching public key it received via `identity import`).
310///
311/// Returns `None` when:
312/// - `observed_by` is the empty string,
313/// - the key directory cannot be resolved (extremely rare; only when the
314/// OS does not advertise a config dir),
315/// - no `<observed_by>.pub` file exists under the key directory,
316/// - the on-disk file is malformed (length mismatch, etc.).
317///
318/// In every `None` case the caller should fall back to the
319/// accept-and-flag-as-unsigned posture rather than rejecting the link.
320#[must_use]
321pub fn lookup_peer_public_key(observed_by: &str) -> Option<VerifyingKey> {
322 if observed_by.is_empty() {
323 return None;
324 }
325 let dir = keypair::default_key_dir().ok()?;
326 lookup_peer_public_key_in(observed_by, &dir)
327}
328
329/// Variant of [`lookup_peer_public_key`] that takes an explicit key
330/// directory. Used by tests so we can populate a tempdir with peer
331/// public keys without touching the operator's real `~/.config/ai-memory`.
332/// Callers in production code should prefer [`lookup_peer_public_key`]
333/// so the storage location stays uniform across `keypair`, `sign`, and
334/// `verify`.
335#[must_use]
336pub fn lookup_peer_public_key_in(observed_by: &str, dir: &Path) -> Option<VerifyingKey> {
337 if observed_by.is_empty() {
338 return None;
339 }
340 keypair::load(observed_by, dir).ok().map(|kp| kp.public)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::identity::keypair as kp_mod;
347 use crate::identity::sign;
348 use tempfile::TempDir;
349
350 fn link_fixture() -> SignableLink<'static> {
351 SignableLink {
352 src_id: "src-001",
353 dst_id: "dst-002",
354 relation: "related_to",
355 observed_by: Some("alice"),
356 valid_from: Some("2026-05-05T00:00:00+00:00"),
357 valid_until: None,
358 }
359 }
360
361 #[test]
362 fn verify_accepts_valid_signature() {
363 // Happy path: alice signs, verifier holds alice.pub → accept.
364 let alice = kp_mod::generate("alice").unwrap();
365 let link = link_fixture();
366 let sig = sign::sign(&alice, &link).unwrap();
367 verify(&alice.public, &link, &sig).expect("happy-path verify must succeed");
368 }
369
370 #[test]
371 fn verify_rejects_flipped_signature_byte() {
372 // Single bit flip in the signature → Tampered. Ed25519 has no
373 // malleability window — any altered byte invalidates.
374 let alice = kp_mod::generate("alice").unwrap();
375 let link = link_fixture();
376 let mut sig = sign::sign(&alice, &link).unwrap();
377 sig[0] ^= 0x01;
378 let err = verify(&alice.public, &link, &sig).unwrap_err();
379 assert_eq!(err, VerifyError::Tampered, "flipped sig byte must reject");
380 }
381
382 #[test]
383 fn verify_rejects_mutated_link_content() {
384 // Re-sign with relation=related_to, but verifier sees relation=
385 // supersedes (same other fields). CBOR re-encoding produces a
386 // different byte stream → Ed25519 rejects.
387 let alice = kp_mod::generate("alice").unwrap();
388 let original = link_fixture();
389 let sig = sign::sign(&alice, &original).unwrap();
390
391 let mut tampered = original.clone();
392 tampered.relation = "supersedes";
393 let err = verify(&alice.public, &tampered, &sig).unwrap_err();
394 assert_eq!(
395 err,
396 VerifyError::Tampered,
397 "mutated link content must reject"
398 );
399 }
400
401 #[test]
402 fn verify_rejects_wrong_pubkey() {
403 // Signed by alice, attempted-verified with bob's pubkey →
404 // Tampered. The variant deliberately doesn't distinguish "wrong
405 // key" from "tampered content" so a misbehaving peer can't
406 // probe which fields the verifier touched.
407 let alice = kp_mod::generate("alice").unwrap();
408 let bob = kp_mod::generate("bob").unwrap();
409 let link = link_fixture();
410 let sig = sign::sign(&alice, &link).unwrap();
411 let err = verify(&bob.public, &link, &sig).unwrap_err();
412 assert_eq!(err, VerifyError::Tampered);
413 }
414
415 #[test]
416 fn verify_rejects_short_signature() {
417 let alice = kp_mod::generate("alice").unwrap();
418 let link = link_fixture();
419 // 32 bytes is wrong (Ed25519 wants 64).
420 let short = vec![0u8; 32];
421 let err = verify(&alice.public, &link, &short).unwrap_err();
422 assert_eq!(err, VerifyError::MalformedSignature);
423 }
424
425 #[test]
426 fn verify_rejects_long_signature() {
427 let alice = kp_mod::generate("alice").unwrap();
428 let link = link_fixture();
429 // 128 bytes is wrong (Ed25519 wants 64).
430 let long = vec![0u8; 128];
431 let err = verify(&alice.public, &link, &long).unwrap_err();
432 assert_eq!(err, VerifyError::MalformedSignature);
433 }
434
435 #[test]
436 fn verify_rejects_empty_signature() {
437 let alice = kp_mod::generate("alice").unwrap();
438 let link = link_fixture();
439 let err = verify(&alice.public, &link, &[]).unwrap_err();
440 assert_eq!(err, VerifyError::MalformedSignature);
441 }
442
443 #[test]
444 fn lookup_peer_public_key_in_returns_none_for_unknown() {
445 let dir = TempDir::new().unwrap();
446 // Empty key dir → no enrolled peer.
447 assert!(lookup_peer_public_key_in("alice", dir.path()).is_none());
448 }
449
450 #[test]
451 fn lookup_peer_public_key_in_returns_none_for_empty_id() {
452 let dir = TempDir::new().unwrap();
453 assert!(lookup_peer_public_key_in("", dir.path()).is_none());
454 }
455
456 #[test]
457 fn lookup_peer_public_key_in_finds_enrolled_pubkey() {
458 // Mirror an `identity import` for a peer: write only the .pub
459 // file under the key dir. lookup must return the same key.
460 let dir = TempDir::new().unwrap();
461 let alice = kp_mod::generate("alice").unwrap();
462 let pub_only = kp_mod::AgentKeypair {
463 agent_id: "alice".to_string(),
464 public: alice.public,
465 private: None,
466 };
467 kp_mod::save_public_only(&pub_only, dir.path()).unwrap();
468 let found = lookup_peer_public_key_in("alice", dir.path()).expect("lookup hit");
469 assert_eq!(found.to_bytes(), alice.public.to_bytes());
470 }
471
472 #[test]
473 fn lookup_peer_public_key_in_finds_full_keypair_pub() {
474 // A self-generated agent (with both .pub and .priv on disk) is
475 // also a valid lookup target — useful in single-host loopback
476 // tests where the same agent both signs and verifies.
477 let dir = TempDir::new().unwrap();
478 let alice = kp_mod::generate("alice").unwrap();
479 kp_mod::save(&alice, dir.path()).unwrap();
480 let found = lookup_peer_public_key_in("alice", dir.path()).expect("lookup hit");
481 assert_eq!(found.to_bytes(), alice.public.to_bytes());
482 }
483
484 #[test]
485 fn lookup_peer_public_key_in_skips_invalid_agent_id() {
486 // `keypair::load` validates the agent_id; lookup should not
487 // panic and should report `None` for invalid input.
488 let dir = TempDir::new().unwrap();
489 assert!(lookup_peer_public_key_in("has space", dir.path()).is_none());
490 assert!(lookup_peer_public_key_in("has\0null", dir.path()).is_none());
491 }
492
493 #[test]
494 fn end_to_end_peer_a_signs_peer_b_verifies() {
495 // Two-host simulation: alice signs on host A; host B has only
496 // alice.pub enrolled (no .priv). Host B looks up alice's pubkey
497 // and verifies — passes.
498 let host_b_keys = TempDir::new().unwrap();
499 let alice = kp_mod::generate("alice").unwrap();
500
501 // Host B operator imports alice's public key.
502 let alice_pub_for_b = kp_mod::AgentKeypair {
503 agent_id: "alice".to_string(),
504 public: alice.public,
505 private: None,
506 };
507 kp_mod::save_public_only(&alice_pub_for_b, host_b_keys.path()).unwrap();
508
509 // Alice signs a link on host A.
510 let link = link_fixture();
511 let sig = sign::sign(&alice, &link).unwrap();
512
513 // Host B receives the link, looks up alice's pubkey, verifies.
514 let key_on_b =
515 lookup_peer_public_key_in("alice", host_b_keys.path()).expect("alice enrolled on B");
516 verify(&key_on_b, &link, &sig).expect("cross-host verify must succeed");
517 }
518
519 #[test]
520 fn end_to_end_no_pubkey_returns_none_for_caller_to_handle() {
521 // Host B has no key enrolled for alice → lookup returns None.
522 // The caller (federation inbound) is responsible for the
523 // accept-and-flag-as-unsigned posture; verify() is not invoked.
524 let host_b_keys = TempDir::new().unwrap();
525 assert!(lookup_peer_public_key_in("alice", host_b_keys.path()).is_none());
526 }
527
528 #[test]
529 fn verify_error_display_messages_are_distinct() {
530 // Sanity: each variant has a non-empty, distinct human message.
531 let m_t = format!("{}", VerifyError::Tampered);
532 let m_n = format!("{}", VerifyError::NoPublicKey);
533 let m_m = format!("{}", VerifyError::MalformedSignature);
534 assert!(!m_t.is_empty());
535 assert!(!m_n.is_empty());
536 assert!(!m_m.is_empty());
537 assert_ne!(m_t, m_n);
538 assert_ne!(m_n, m_m);
539 assert_ne!(m_t, m_m);
540 }
541
542 // -----------------------------------------------------------------
543 // #626 Layer-3 (Task 1.3 / C4) — verify_write + attest_write gate
544 // -----------------------------------------------------------------
545
546 fn body_hash(seed: u8) -> [u8; 32] {
547 let mut h = [seed; 32];
548 h[0] ^= 0x5A;
549 h
550 }
551
552 fn write_fixture(body: &[u8; 32]) -> SignableWrite<'_> {
553 SignableWrite {
554 agent_id: "ai:curator",
555 namespace: "team/alpha",
556 title: "kubernetes deployment guide",
557 kind: "fact",
558 created_at: "2026-06-01T12:00:00+00:00",
559 content_sha256: body,
560 }
561 }
562
563 #[test]
564 fn verify_write_accepts_valid_signature() {
565 let kp = kp_mod::generate("ai:curator").unwrap();
566 let body = body_hash(0x11);
567 let write = write_fixture(&body);
568 let sig = sign::sign_write(&kp, &write).unwrap();
569 verify_write(&kp.public, &write, &sig).expect("happy-path write verify must succeed");
570 }
571
572 #[test]
573 fn verify_write_rejects_flipped_signature_byte() {
574 let kp = kp_mod::generate("ai:curator").unwrap();
575 let body = body_hash(0x12);
576 let write = write_fixture(&body);
577 let mut sig = sign::sign_write(&kp, &write).unwrap();
578 sig[0] ^= 0x01;
579 assert_eq!(
580 verify_write(&kp.public, &write, &sig).unwrap_err(),
581 VerifyError::Tampered
582 );
583 }
584
585 #[test]
586 fn verify_write_rejects_mutated_payload() {
587 let kp = kp_mod::generate("ai:curator").unwrap();
588 let body = body_hash(0x13);
589 let original = write_fixture(&body);
590 let sig = sign::sign_write(&kp, &original).unwrap();
591 let mut tampered = original.clone();
592 tampered.agent_id = "ai:impostor";
593 assert_eq!(
594 verify_write(&kp.public, &tampered, &sig).unwrap_err(),
595 VerifyError::Tampered
596 );
597 }
598
599 #[test]
600 fn verify_write_rejects_short_signature() {
601 let kp = kp_mod::generate("ai:curator").unwrap();
602 let body = body_hash(0x14);
603 let write = write_fixture(&body);
604 assert_eq!(
605 verify_write(&kp.public, &write, &[0u8; 32]).unwrap_err(),
606 VerifyError::MalformedSignature
607 );
608 }
609
610 #[test]
611 fn attest_write_signed_with_bound_key_is_attested() {
612 let kp = kp_mod::generate("ai:curator").unwrap();
613 let body = body_hash(0x21);
614 let write = write_fixture(&body);
615 let sig = sign::sign_write(&kp, &write).unwrap();
616 let pk_b64 = kp.public_base64();
617 // Permissive and strict both attest a valid signature.
618 assert_eq!(
619 attest_write(&write, Some(&pk_b64), Some(&sig), false).unwrap(),
620 AttestLevel::AgentAttested
621 );
622 assert_eq!(
623 attest_write(&write, Some(&pk_b64), Some(&sig), true).unwrap(),
624 AttestLevel::AgentAttested
625 );
626 }
627
628 #[test]
629 fn attest_write_forged_signature_always_rejected() {
630 let kp = kp_mod::generate("ai:curator").unwrap();
631 let other = kp_mod::generate("ai:other").unwrap();
632 let body = body_hash(0x22);
633 let write = write_fixture(&body);
634 // Sign with `other` but present `kp` as the bound key → forged.
635 let sig = sign::sign_write(&other, &write).unwrap();
636 let pk_b64 = kp.public_base64();
637 // Forged rejects in BOTH postures — never downgraded to Claimed.
638 assert_eq!(
639 attest_write(&write, Some(&pk_b64), Some(&sig), false).unwrap_err(),
640 AttestError::Forged
641 );
642 assert_eq!(
643 attest_write(&write, Some(&pk_b64), Some(&sig), true).unwrap_err(),
644 AttestError::Forged
645 );
646 }
647
648 #[test]
649 fn attest_write_unsigned_is_claimed_when_permissive_rejected_when_required() {
650 let body = body_hash(0x23);
651 let write = write_fixture(&body);
652 let kp = kp_mod::generate("ai:curator").unwrap();
653 let pk_b64 = kp.public_base64();
654 // No signature → permissive Claimed.
655 assert_eq!(
656 attest_write(&write, Some(&pk_b64), None, false).unwrap(),
657 AttestLevel::Claimed
658 );
659 // No signature, attestation required → reject.
660 assert_eq!(
661 attest_write(&write, Some(&pk_b64), None, true).unwrap_err(),
662 AttestError::AttestationRequired
663 );
664 }
665
666 #[test]
667 fn attest_write_signature_without_bound_key_cannot_attest() {
668 let kp = kp_mod::generate("ai:curator").unwrap();
669 let body = body_hash(0x24);
670 let write = write_fixture(&body);
671 let sig = sign::sign_write(&kp, &write).unwrap();
672 // Signature presented but agent has no bound key → cannot verify.
673 // Permissive → Claimed; strict → reject.
674 assert_eq!(
675 attest_write(&write, None, Some(&sig), false).unwrap(),
676 AttestLevel::Claimed
677 );
678 assert_eq!(
679 attest_write(&write, None, Some(&sig), true).unwrap_err(),
680 AttestError::AttestationRequired
681 );
682 }
683
684 #[test]
685 fn attest_write_malformed_signature_is_reported() {
686 let kp = kp_mod::generate("ai:curator").unwrap();
687 let body = body_hash(0x25);
688 let write = write_fixture(&body);
689 let pk_b64 = kp.public_base64();
690 assert_eq!(
691 attest_write(&write, Some(&pk_b64), Some(&[0u8; 10]), false).unwrap_err(),
692 AttestError::MalformedSignature
693 );
694 }
695
696 #[test]
697 fn attest_write_bad_bound_key_fails_closed() {
698 let kp = kp_mod::generate("ai:curator").unwrap();
699 let body = body_hash(0x26);
700 let write = write_fixture(&body);
701 let sig = sign::sign_write(&kp, &write).unwrap();
702 // Corrupt bound key (not decodable) → fail-closed.
703 assert_eq!(
704 attest_write(&write, Some("!!!not-base64!!!"), Some(&sig), false).unwrap_err(),
705 AttestError::BadBoundKey
706 );
707 }
708
709 #[test]
710 fn attest_level_as_str_is_stable() {
711 assert_eq!(AttestLevel::Claimed.as_str(), "claimed");
712 assert_eq!(AttestLevel::AgentAttested.as_str(), "agent_attested");
713 }
714}