Skip to main content

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}