Skip to main content

ai_memory/mcp/
server_identity.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Daemon-side Ed25519-signed `serverInfo` block published in the MCP
5//! initialize handshake response.
6//!
7//! Closes NSA CSI MCP Security concern (j) — Tool invocation path
8//! confusion — at the substrate boundary. See [issue #1154][1] for the
9//! full implementation specification and procurement context, and
10//! [`docs/compliance/nsa-csi-mcp.html`][2] for the public-facing
11//! coverage page.
12//!
13//! [1]: https://github.com/alphaonedev/ai-memory-mcp/issues/1154
14//! [2]: https://alphaonedev.github.io/ai-memory-mcp/compliance/nsa-csi-mcp.html
15//!
16//! # Threat model — what this defends against
17//!
18//! An MCP client (Claude Code, Cursor, Cline, Codex, OpenClaw, ...) can
19//! mount multiple MCP servers concurrently. The MCP protocol does not
20//! mandate cryptographic server attestation at handshake time, so a
21//! misconfigured or adversarial second server advertising the same tool
22//! names (e.g. `memory_recall`) can shadow the legitimate ai-memory
23//! daemon. ai-memory's defense at v0.7.0 captured `clientInfo.name`
24//! during the handshake (proving WHICH client made a call for audit
25//! purposes) but did not publish a cryptographic server identity the
26//! client could pin.
27//!
28//! This module closes the second half. When the daemon has an Ed25519
29//! keypair on disk (under `<key_dir>/<agent_id>.{pub,priv}` —
30//! `load_daemon_signing_key` at [`crate::governance::audit`]), the
31//! initialize response carries an `ai_memory_identity` block in
32//! `serverInfo`:
33//!
34//! ```json
35//! {
36//!   "serverInfo": {
37//!     "name": "ai-memory",
38//!     "version": "<binary>",         // populated from `CARGO_PKG_VERSION` (SSOT)
39//!     "ai_memory_identity": {
40//!       "schema_version": "v<current>",   // populated from `current_schema_version()` (SSOT)
41//!       "daemon_id": "ai:nhi@host",
42//!       "public_key": "<URL-safe base64 of 32-byte Ed25519 verifying key>",
43//!       "signed_at": "2026-05-23T16:30:22Z",
44//!       "signature": "<URL-safe base64 of 64-byte Ed25519 signature>"
45//!     }
46//!   }
47//! }
48//! ```
49//!
50//! Clients implement Trust On First Use (TOFU): on the first
51//! `initialize` response from a given daemon, the client captures the
52//! `ai_memory_identity` blob and stores its `signature`. On subsequent
53//! connects, the client re-verifies the daemon presents the same
54//! signed identity. A daemon swap with a different keypair on disk
55//! (operator key rotation OR adversary substitution) produces a
56//! distinguishable `signature`, allowing the client to refuse the
57//! mismatched server.
58//!
59//! # Backwards compatibility
60//!
61//! The `ai_memory_identity` block is OMITTED when the daemon has no
62//! keypair on disk. This preserves the v0.7.0 "continuing unsigned"
63//! posture documented at `src/main.rs:96-98`. Operators who do not
64//! enrol a daemon keypair see the same handshake shape v0.6.4 clients
65//! saw; the block appears once they generate a keypair via
66//! `ai-memory identity generate`.
67//!
68//! Per MCP protocol convention (JSON-RPC 2.0), clients MUST ignore
69//! unknown response fields. v0.6.4 / v0.7.0 clients that do not
70//! understand `ai_memory_identity` continue to function identically —
71//! the field is additive on the wire and zero-risk on the compat axis.
72//!
73//! # Canonical-bytes discipline
74//!
75//! The signed canonical bytes are the deterministic JSON serialisation
76//! of the four-field [`DaemonIdentityToSign`] struct (without the
77//! signature itself). This mirrors the existing canonical-bytes
78//! discipline established by [`crate::governance::rules_store::canonical_bytes_for_signing`]
79//! for governance rules: include exactly the load-bearing fields,
80//! exclude the signature, produce identical bytes on every re-sign.
81//!
82//! The signature is computed over the canonical bytes via
83//! [`ed25519_dalek::SigningKey::sign`].
84//!
85//! # Performance
86//!
87//! Initialize fires ONCE per MCP session — not on the recall hot
88//! path. A single Ed25519 sign over ~150 bytes of canonical identity
89//! takes ~10–50 µs on modern hardware. The cost is dwarfed by the
90//! JSON serialisation of the initialize response itself. The 50 ms
91//! recall p95 budget is untouched.
92
93use crate::models::field_names;
94use anyhow::{Context, Result};
95use base64::Engine as _;
96use base64::engine::general_purpose::URL_SAFE_NO_PAD;
97use ed25519_dalek::{Signature, Signer, Verifier, VerifyingKey};
98use serde_json::{Value, json};
99
100use crate::identity::keypair::AgentKeypair;
101use crate::storage::migrations::current_schema_version;
102
103/// Signed-block / response field for the daemon public key (#1558 batch 6).
104/// A signed-envelope field, NOT an MCP tool param — deliberately local.
105const PUBLIC_KEY_FIELD: &str = "public_key";
106
107/// Field set canonically serialised for the daemon-identity Ed25519
108/// signature. Mirrors the discipline established by
109/// [`crate::governance::rules_store::canonical_bytes_for_signing`]:
110/// the signed property is *what identity the daemon is presenting* —
111/// schema version, daemon id, public key, and the handshake timestamp.
112/// The signature itself is excluded from the signed bytes.
113#[derive(Debug, Clone)]
114pub struct DaemonIdentityToSign<'a> {
115    /// Substrate schema version the daemon is running. Stamped at
116    /// runtime from
117    /// [`crate::storage::migrations::current_schema_version()`] (the
118    /// SSOT — see also `CURRENT_SCHEMA_VERSION` in
119    /// `src/storage/migrations.rs`). Allows the TOFU-pinning client
120    /// to detect a schema rollback / rollforward separately from a
121    /// key rotation.
122    pub schema_version: &'a str,
123    /// Resolved daemon `agent_id` — the same identifier used for V-4
124    /// signed-events row attribution and outbound link signing.
125    pub daemon_id: &'a str,
126    /// URL-safe, no-padding base64 of the 32-byte Ed25519 verifying
127    /// key. Same format `AgentKeypair::public_base64` emits.
128    pub public_key: &'a str,
129    /// RFC3339 timestamp captured at handshake time. Pinned into the
130    /// signed bytes so a client can detect signature replay across
131    /// time-disjoint handshake windows.
132    pub signed_at: &'a str,
133}
134
135/// Produce the canonical byte representation of the daemon identity
136/// used as input to the Ed25519 sign + verify operations.
137///
138/// The canonical form is the deterministic JSON serialisation of the
139/// four-field identity object. The order is fixed at the call site
140/// (see [`canonical_bytes_for_identity`]); `serde_json::to_vec`
141/// preserves key order on serde-derived `Serialize` impls. Since this
142/// module owns both the signer and verifier code paths and both use
143/// this same function, deterministic byte equality is guaranteed by
144/// construction — no external canonicalisation library is required.
145///
146/// # Errors
147///
148/// Propagates `serde_json` encoding errors (unreachable in practice
149/// for the field set above, but surfaced for completeness).
150pub fn canonical_bytes_for_identity(identity: &DaemonIdentityToSign<'_>) -> Result<Vec<u8>> {
151    let canonical = json!({
152        (field_names::SCHEMA_VERSION): identity.schema_version,
153        "daemon_id": identity.daemon_id,
154        (PUBLIC_KEY_FIELD): identity.public_key,
155        "signed_at": identity.signed_at,
156    });
157    serde_json::to_vec(&canonical)
158        .context("server_identity::canonical_bytes_for_identity: serialize")
159}
160
161/// Build the signed `ai_memory_identity` block for the MCP initialize
162/// response. Returns `None` when `keypair` is `None` or its private
163/// half is missing — caller omits the block from the response in that
164/// case, preserving the v0.7.0 "continuing unsigned" posture.
165///
166/// On the happy path the returned [`Value`] is a JSON object with five
167/// fields: `schema_version`, `daemon_id`, `public_key`, `signed_at`,
168/// `signature`. The first four fields are the inputs to the canonical
169/// bytes; the fifth is the Ed25519 signature over those bytes.
170///
171/// `now_rfc3339` is injected as a parameter (rather than read from
172/// `chrono::Utc::now()` directly) so tests can pin the timestamp for
173/// reproducible assertions. Production callers pass the current UTC
174/// time formatted to RFC3339 with second precision.
175///
176/// # Errors
177///
178/// Propagates errors from [`canonical_bytes_for_identity`]. Returns
179/// `Ok(None)` (not `Err`) when the keypair cannot sign — refusing to
180/// sign is a normal posture, not an error condition.
181pub fn build_signed_identity(
182    keypair: Option<&AgentKeypair>,
183    now_rfc3339: &str,
184) -> Result<Option<Value>> {
185    let Some(kp) = keypair else {
186        return Ok(None);
187    };
188    let Some(signing_key) = kp.private.as_ref() else {
189        return Ok(None);
190    };
191
192    let schema_version = format!("v{}", current_schema_version());
193    let public_key = kp.public_base64();
194    let identity = DaemonIdentityToSign {
195        schema_version: &schema_version,
196        daemon_id: &kp.agent_id,
197        public_key: &public_key,
198        signed_at: now_rfc3339,
199    };
200    let canonical = canonical_bytes_for_identity(&identity)?;
201    let signature: Signature = signing_key.sign(&canonical);
202    let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
203
204    Ok(Some(json!({
205        (field_names::SCHEMA_VERSION): schema_version,
206        "daemon_id": kp.agent_id,
207        (PUBLIC_KEY_FIELD): public_key,
208        "signed_at": now_rfc3339,
209        "signature": sig_b64,
210    })))
211}
212
213/// Verify a previously-built `ai_memory_identity` block against the
214/// embedded public key. Returns `Ok(())` when the signature is
215/// well-formed, matches the canonical bytes of the four signed
216/// fields, and verifies against the embedded public key.
217///
218/// Clients use this on TOFU pin acquisition (first connect) and on
219/// every subsequent handshake. The verification is self-contained —
220/// no operator-side public key is required because the daemon
221/// publishes its own. A client wanting cross-deployment key custody
222/// can pair this with an out-of-band allowlist.
223///
224/// # Errors
225///
226/// - Returns a `SignatureError` when any required field is missing or
227///   not a string.
228/// - Returns a `SignatureError` when `public_key` or `signature` is
229///   not valid URL-safe base64.
230/// - Returns a `SignatureError` when `public_key` does not decode to
231///   32 bytes or `signature` does not decode to 64 bytes.
232/// - Returns a `SignatureError` when the Ed25519 verify call fails
233///   (tampered identity block, wrong key, or replay across a daemon
234///   keypair rotation — exactly the bypass attempts this catches).
235pub fn verify_signed_identity(block: &Value) -> Result<(), ed25519_dalek::SignatureError> {
236    let make_err = ed25519_dalek::SignatureError::new;
237
238    let obj = block.as_object().ok_or_else(make_err)?;
239    let schema_version = obj
240        .get(field_names::SCHEMA_VERSION)
241        .and_then(Value::as_str)
242        .ok_or_else(make_err)?;
243    let daemon_id = obj
244        .get("daemon_id")
245        .and_then(Value::as_str)
246        .ok_or_else(make_err)?;
247    let public_key_b64 = obj
248        .get(PUBLIC_KEY_FIELD)
249        .and_then(Value::as_str)
250        .ok_or_else(make_err)?;
251    let signed_at = obj
252        .get("signed_at")
253        .and_then(Value::as_str)
254        .ok_or_else(make_err)?;
255    let signature_b64 = obj
256        .get("signature")
257        .and_then(Value::as_str)
258        .ok_or_else(make_err)?;
259
260    let public_key_bytes = URL_SAFE_NO_PAD
261        .decode(public_key_b64)
262        .map_err(|_| make_err())?;
263    let signature_bytes = URL_SAFE_NO_PAD
264        .decode(signature_b64)
265        .map_err(|_| make_err())?;
266
267    if public_key_bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
268        return Err(make_err());
269    }
270    if signature_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
271        return Err(make_err());
272    }
273    let mut pk_arr = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
274    pk_arr.copy_from_slice(&public_key_bytes);
275    let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
276    sig_arr.copy_from_slice(&signature_bytes);
277
278    let verifying_key = VerifyingKey::from_bytes(&pk_arr).map_err(|_| make_err())?;
279    let signature = Signature::from_bytes(&sig_arr);
280
281    let identity = DaemonIdentityToSign {
282        schema_version,
283        daemon_id,
284        public_key: public_key_b64,
285        signed_at,
286    };
287    let canonical = canonical_bytes_for_identity(&identity).map_err(|_| make_err())?;
288    verifying_key.verify(&canonical, &signature)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use ed25519_dalek::SigningKey;
295
296    fn make_test_keypair(agent_id: &str) -> AgentKeypair {
297        // Deterministic seed so test signatures are byte-stable across runs.
298        let seed = [42u8; ed25519_dalek::SECRET_KEY_LENGTH];
299        let signing_key = SigningKey::from_bytes(&seed);
300        AgentKeypair {
301            agent_id: agent_id.to_string(),
302            public: signing_key.verifying_key(),
303            private: Some(signing_key),
304        }
305    }
306
307    fn make_public_only_keypair(agent_id: &str) -> AgentKeypair {
308        let kp = make_test_keypair(agent_id);
309        AgentKeypair {
310            agent_id: kp.agent_id,
311            public: kp.public,
312            private: None,
313        }
314    }
315
316    fn fixed_timestamp() -> &'static str {
317        "2026-05-23T16:30:22Z"
318    }
319
320    // --- canonical_bytes_for_identity tests -----------------------------------
321
322    // NOTE: schema-version values in this test module are synthetic
323    // fixtures (`vTEST_*`) — they exist only to exercise the canonical-
324    // bytes determinism + divergence properties and DO NOT track the
325    // real `CURRENT_SCHEMA_VERSION`. Hardcoded production schema
326    // literals are banned in this codebase; the runtime path consumes
327    // `crate::storage::migrations::current_schema_version()` as the
328    // single source of truth.
329
330    #[test]
331    fn canonical_bytes_are_deterministic() {
332        let id = DaemonIdentityToSign {
333            schema_version: "vTEST_BASE",
334            daemon_id: "ai:nhi@host",
335            public_key: "abc123",
336            signed_at: fixed_timestamp(),
337        };
338        let bytes_a = canonical_bytes_for_identity(&id).unwrap();
339        let bytes_b = canonical_bytes_for_identity(&id).unwrap();
340        assert_eq!(
341            bytes_a, bytes_b,
342            "canonical bytes must be deterministic across calls"
343        );
344    }
345
346    #[test]
347    fn canonical_bytes_diverge_on_any_field_change() {
348        let base = DaemonIdentityToSign {
349            schema_version: "vTEST_BASE",
350            daemon_id: "ai:nhi@host",
351            public_key: "abc123",
352            signed_at: fixed_timestamp(),
353        };
354        let base_bytes = canonical_bytes_for_identity(&base).unwrap();
355
356        let cases = [
357            DaemonIdentityToSign {
358                schema_version: "vTEST_CHANGED",
359                ..base.clone()
360            },
361            DaemonIdentityToSign {
362                daemon_id: "ai:other@host",
363                ..base.clone()
364            },
365            DaemonIdentityToSign {
366                public_key: "abc124",
367                ..base.clone()
368            },
369            DaemonIdentityToSign {
370                signed_at: "2026-05-24T00:00:00Z",
371                ..base.clone()
372            },
373        ];
374
375        for (i, mutated) in cases.iter().enumerate() {
376            let mutated_bytes = canonical_bytes_for_identity(mutated).unwrap();
377            assert_ne!(
378                base_bytes, mutated_bytes,
379                "canonical bytes must diverge when field {i} changes"
380            );
381        }
382    }
383
384    // --- build_signed_identity tests ------------------------------------------
385
386    #[test]
387    fn build_signed_identity_returns_none_when_keypair_absent() {
388        let result = build_signed_identity(None, fixed_timestamp()).unwrap();
389        assert!(result.is_none(), "absent keypair must yield None");
390    }
391
392    #[test]
393    fn build_signed_identity_returns_none_when_private_key_missing() {
394        let kp = make_public_only_keypair("ai:nhi@host");
395        let result = build_signed_identity(Some(&kp), fixed_timestamp()).unwrap();
396        assert!(result.is_none(), "public-only keypair must yield None");
397    }
398
399    #[test]
400    fn build_signed_identity_returns_well_formed_block_when_signing_key_present() {
401        let kp = make_test_keypair("ai:nhi@host");
402        let block = build_signed_identity(Some(&kp), fixed_timestamp())
403            .unwrap()
404            .expect("signing keypair must yield Some");
405
406        let obj = block.as_object().expect("block must be a JSON object");
407        assert!(obj.get("schema_version").and_then(Value::as_str).is_some());
408        assert!(obj.get("daemon_id").and_then(Value::as_str).is_some());
409        assert!(obj.get("public_key").and_then(Value::as_str).is_some());
410        assert!(obj.get("signed_at").and_then(Value::as_str).is_some());
411        assert!(obj.get("signature").and_then(Value::as_str).is_some());
412
413        assert_eq!(obj["daemon_id"], json!("ai:nhi@host"));
414        assert_eq!(obj["signed_at"], json!(fixed_timestamp()));
415    }
416
417    #[test]
418    fn build_signed_identity_carries_current_schema_version() {
419        let kp = make_test_keypair("ai:nhi@host");
420        let block = build_signed_identity(Some(&kp), fixed_timestamp())
421            .unwrap()
422            .expect("signing keypair must yield Some");
423        let schema = block["schema_version"].as_str().unwrap();
424        let expected = format!("v{}", current_schema_version());
425        assert_eq!(
426            schema, expected,
427            "schema_version must match CURRENT_SCHEMA_VERSION constant"
428        );
429    }
430
431    #[test]
432    fn build_signed_identity_carries_public_key_base64() {
433        let kp = make_test_keypair("ai:nhi@host");
434        let block = build_signed_identity(Some(&kp), fixed_timestamp())
435            .unwrap()
436            .expect("signing keypair must yield Some");
437        let pk_b64 = block["public_key"].as_str().unwrap();
438        assert_eq!(
439            pk_b64,
440            kp.public_base64(),
441            "public_key must round-trip kp.public_base64()"
442        );
443    }
444
445    // --- verify_signed_identity happy-path tests ------------------------------
446
447    #[test]
448    fn signed_identity_verifies_against_embedded_public_key() {
449        let kp = make_test_keypair("ai:nhi@host");
450        let block = build_signed_identity(Some(&kp), fixed_timestamp())
451            .unwrap()
452            .expect("signing keypair must yield Some");
453        verify_signed_identity(&block).expect("signature must verify");
454    }
455
456    #[test]
457    fn signed_identity_round_trips_across_many_signers() {
458        // 16 distinct seeds → 16 distinct keypairs → 16 distinct signatures
459        // that each individually verify against their own embedded pubkey.
460        for byte in 0u8..16 {
461            let seed = [byte; ed25519_dalek::SECRET_KEY_LENGTH];
462            let signing_key = SigningKey::from_bytes(&seed);
463            let kp = AgentKeypair {
464                agent_id: format!("ai:agent-{byte}@host"),
465                public: signing_key.verifying_key(),
466                private: Some(signing_key),
467            };
468            let block = build_signed_identity(Some(&kp), fixed_timestamp())
469                .unwrap()
470                .expect("signing keypair must yield Some");
471            verify_signed_identity(&block)
472                .unwrap_or_else(|_| panic!("signature {byte} must verify"));
473        }
474    }
475
476    // --- verify_signed_identity tampering tests -------------------------------
477
478    #[test]
479    fn tampered_daemon_id_fails_verification() {
480        let kp = make_test_keypair("ai:nhi@host");
481        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
482            .unwrap()
483            .expect("signing keypair must yield Some");
484        block["daemon_id"] = json!("ai:adversary@host");
485        assert!(
486            verify_signed_identity(&block).is_err(),
487            "tampered daemon_id must fail verification"
488        );
489    }
490
491    #[test]
492    fn tampered_schema_version_fails_verification() {
493        let kp = make_test_keypair("ai:nhi@host");
494        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
495            .unwrap()
496            .expect("signing keypair must yield Some");
497        block["schema_version"] = json!("v99");
498        assert!(
499            verify_signed_identity(&block).is_err(),
500            "tampered schema_version must fail verification"
501        );
502    }
503
504    #[test]
505    fn tampered_signed_at_fails_verification() {
506        let kp = make_test_keypair("ai:nhi@host");
507        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
508            .unwrap()
509            .expect("signing keypair must yield Some");
510        block["signed_at"] = json!("2099-12-31T23:59:59Z");
511        assert!(
512            verify_signed_identity(&block).is_err(),
513            "tampered signed_at must fail verification"
514        );
515    }
516
517    #[test]
518    fn tampered_signature_byte_fails_verification() {
519        let kp = make_test_keypair("ai:nhi@host");
520        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
521            .unwrap()
522            .expect("signing keypair must yield Some");
523        let original_sig = block["signature"].as_str().unwrap();
524        // Flip a single character mid-signature
525        let mut chars: Vec<char> = original_sig.chars().collect();
526        let mid = chars.len() / 2;
527        chars[mid] = if chars[mid] == 'A' { 'B' } else { 'A' };
528        let tampered: String = chars.into_iter().collect();
529        block["signature"] = json!(tampered);
530        assert!(
531            verify_signed_identity(&block).is_err(),
532            "tampered signature must fail verification"
533        );
534    }
535
536    #[test]
537    fn substituted_public_key_fails_verification() {
538        let kp_a = make_test_keypair("ai:nhi@host");
539        let mut block = build_signed_identity(Some(&kp_a), fixed_timestamp())
540            .unwrap()
541            .expect("signing keypair must yield Some");
542
543        // Build a different keypair and substitute its public key into the block
544        // without re-signing — exactly the substitution attack the canonical
545        // bytes discipline catches.
546        let seed_b = [99u8; ed25519_dalek::SECRET_KEY_LENGTH];
547        let kp_b_signing = SigningKey::from_bytes(&seed_b);
548        let kp_b_public_b64 = URL_SAFE_NO_PAD.encode(kp_b_signing.verifying_key().to_bytes());
549        block["public_key"] = json!(kp_b_public_b64);
550
551        assert!(
552            verify_signed_identity(&block).is_err(),
553            "substituted public key (without re-signing) must fail verification"
554        );
555    }
556
557    // --- verify_signed_identity malformed-input tests -------------------------
558
559    #[test]
560    fn verify_rejects_non_object_input() {
561        assert!(verify_signed_identity(&json!("not an object")).is_err());
562        assert!(verify_signed_identity(&json!(42)).is_err());
563        assert!(verify_signed_identity(&json!([1, 2, 3])).is_err());
564        assert!(verify_signed_identity(&json!(null)).is_err());
565    }
566
567    #[test]
568    fn verify_rejects_missing_required_field() {
569        let kp = make_test_keypair("ai:nhi@host");
570        let full_block = build_signed_identity(Some(&kp), fixed_timestamp())
571            .unwrap()
572            .expect("signing keypair must yield Some");
573
574        for field in &[
575            "schema_version",
576            "daemon_id",
577            "public_key",
578            "signed_at",
579            "signature",
580        ] {
581            let mut block = full_block.clone();
582            block.as_object_mut().unwrap().remove(*field);
583            assert!(
584                verify_signed_identity(&block).is_err(),
585                "missing field {field} must cause verification failure"
586            );
587        }
588    }
589
590    #[test]
591    fn verify_rejects_invalid_base64() {
592        let kp = make_test_keypair("ai:nhi@host");
593        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
594            .unwrap()
595            .expect("signing keypair must yield Some");
596        block["public_key"] = json!("@@@not-base64@@@");
597        assert!(verify_signed_identity(&block).is_err());
598
599        let mut block2 = build_signed_identity(Some(&kp), fixed_timestamp())
600            .unwrap()
601            .expect("signing keypair must yield Some");
602        block2["signature"] = json!("@@@not-base64@@@");
603        assert!(verify_signed_identity(&block2).is_err());
604    }
605
606    #[test]
607    fn verify_rejects_wrong_length_public_key() {
608        let kp = make_test_keypair("ai:nhi@host");
609        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
610            .unwrap()
611            .expect("signing keypair must yield Some");
612        // 16 bytes instead of 32
613        block["public_key"] = json!(URL_SAFE_NO_PAD.encode([0u8; 16]));
614        assert!(verify_signed_identity(&block).is_err());
615    }
616
617    #[test]
618    fn verify_rejects_wrong_length_signature() {
619        let kp = make_test_keypair("ai:nhi@host");
620        let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
621            .unwrap()
622            .expect("signing keypair must yield Some");
623        // 32 bytes instead of 64
624        block["signature"] = json!(URL_SAFE_NO_PAD.encode([0u8; 32]));
625        assert!(verify_signed_identity(&block).is_err());
626    }
627
628    // --- performance smoke test ----------------------------------------------
629
630    #[test]
631    fn build_signed_identity_completes_under_10ms_one_iteration() {
632        let kp = make_test_keypair("ai:nhi@host");
633        let start = std::time::Instant::now();
634        let _ = build_signed_identity(Some(&kp), fixed_timestamp()).unwrap();
635        let elapsed = start.elapsed();
636        // Ed25519 sign over ~150 bytes is ~10-50µs on modern hardware;
637        // 10ms is a 200-1000x margin to absorb CI noise. The test
638        // smoke-checks the order of magnitude is correct.
639        assert!(
640            elapsed.as_millis() < 10,
641            "single sign must be sub-10ms (was {elapsed:?})"
642        );
643    }
644}