Skip to main content

ai_memory/identity/
keypair.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent Ed25519 keypair lifecycle (Track H, Task H1).
5//!
6//! This module is the OSS substrate for v0.7's "attested cortex" track.
7//! Every agent that wants to sign outbound writes (links in H2, memories
8//! in H3+, audit events in H5) needs a stable Ed25519 keypair. The four
9//! verbs ([`generate`], [`save`], [`load`], [`list`]) plus the CLI
10//! wrapper at [`crate::cli::identity`] are the entire OSS surface.
11//!
12//! # Storage layout
13//!
14//! Keys live under `<key_dir>/<agent_id>.{pub,priv}`:
15//!
16//! | File                  | Mode (Unix) | Contents                                    |
17//! |-----------------------|-------------|---------------------------------------------|
18//! | `<agent_id>.pub`      | `0o644`     | 32 raw bytes — `VerifyingKey::to_bytes()`   |
19//! | `<agent_id>.priv`     | `0o600`     | 32 raw bytes — `SigningKey::to_bytes()`     |
20//!
21//! On Windows the mode bits do not apply; the files are created with
22//! the inherited ACL of the parent directory. This is a known coverage
23//! gap for the OSS layer — see "Hardware-backed key storage" below.
24//!
25//! The default key directory is `dirs::config_dir().join("ai-memory/keys/")`
26//! on every platform (`~/.config/ai-memory/keys/` on Linux,
27//! `~/Library/Application Support/ai-memory/keys/` on macOS,
28//! `%APPDATA%\ai-memory\keys\` on Windows). The CLI will create it on
29//! first use.
30//!
31//! # Hardware-backed key storage is OUT of OSS scope
32//!
33//! Per [`ROADMAP.md`](../../../ROADMAP.md) and
34//! [`docs/v0.7/V0.7-EPIC.md`](../../../docs/v0.7/V0.7-EPIC.md), the
35//! OSS path stops at file-based 0600 storage. TPM 2.0, PKCS#11 HSMs,
36//! Apple Secure Enclave / TEE, AWS KMS / GCP KMS / Azure Key Vault
37//! are intentionally **not** implemented in this crate. Operators who
38//! need any of those should look at the **AgenticMem™** commercial
39//! layer — same `AgentKeypair` shape, same wire format, hardware-backed
40//! signing under the hood.
41//!
42//! The OSS code never imports a hardware-token library and never
43//! depends on a non-pure-Rust dependency for key material. This is a
44//! deliberate licensing + portability decision, not a "we'll get to it"
45//! gap.
46//!
47//! # Format & interop
48//!
49//! - The on-disk format is the raw 32-byte key, no PEM, no DER, no
50//!   header, no length prefix. This is the smallest possible shape
51//!   that round-trips through `ed25519-dalek` and matches the COSE /
52//!   CBOR wire format H2 will use.
53//! - `export_pub` emits URL-safe, no-padding base64 of the public
54//!   key bytes — short enough to paste into a Slack message or a
55//!   peer's allowlist file.
56
57use std::fs;
58use std::io;
59use std::path::{Path, PathBuf};
60
61use anyhow::{Context, Result, anyhow, bail};
62use base64::Engine;
63use base64::engine::general_purpose::URL_SAFE_NO_PAD;
64use ed25519_dalek::{SigningKey, VerifyingKey};
65
66use crate::validate;
67
68/// Suffix for the public-key file (`<agent_id>.pub`).
69const PUB_SUFFIX: &str = ".pub";
70/// Suffix for the private-key file (`<agent_id>.priv`).
71const PRIV_SUFFIX: &str = ".priv";
72
73/// Length of an Ed25519 public key in bytes.
74const PUBLIC_KEY_LEN: usize = ed25519_dalek::PUBLIC_KEY_LENGTH;
75/// Length of an Ed25519 private/signing key seed in bytes.
76const SECRET_KEY_LEN: usize = ed25519_dalek::SECRET_KEY_LENGTH;
77
78/// Per-agent Ed25519 keypair.
79///
80/// `private` is `Option` because two of the lifecycle verbs ([`load`]
81/// when no `.priv` exists and [`list`] which always skips private
82/// material) yield a public-only handle. Code that needs to sign must
83/// match on `private` and refuse with a clear error when missing.
84#[derive(Debug, Clone)]
85pub struct AgentKeypair {
86    /// Logical agent identifier — same vocabulary as
87    /// `crate::identity::resolve_agent_id`.
88    pub agent_id: String,
89    /// Public verifying key. Always loaded.
90    pub public: VerifyingKey,
91    /// Optional private signing key. `None` for public-only loads.
92    pub private: Option<SigningKey>,
93}
94
95impl AgentKeypair {
96    /// Returns `true` when the private key is present and the keypair
97    /// can therefore sign.
98    #[must_use]
99    pub fn can_sign(&self) -> bool {
100        self.private.is_some()
101    }
102
103    /// URL-safe, no-padding base64 encoding of the public key bytes.
104    /// Stable wire format for `export-pub` and for peer allowlists.
105    #[must_use]
106    pub fn public_base64(&self) -> String {
107        URL_SAFE_NO_PAD.encode(self.public.to_bytes())
108    }
109}
110
111/// Test-only process-wide guard for tests that mutate
112/// `AI_MEMORY_KEY_DIR`. Exposed at `pub(crate)` (visibility only —
113/// no behavioural change) so coverage tests in `src/mcp/mod.rs`
114/// can serialise with the existing race-prone tests in this file.
115///
116/// Without this any other test that reads the env var concurrently
117/// can observe a half-written value, surfacing as flaky assertions.
118#[cfg(test)]
119pub(crate) fn key_dir_env_lock() -> &'static std::sync::Mutex<()> {
120    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
121    LOCK.get_or_init(|| std::sync::Mutex::new(()))
122}
123
124/// Env var that relocates the key storage directory (see
125/// [`default_key_dir`]). One declaration site so every consumer —
126/// the default-dir resolver and the `rules keygen` override detection
127/// (#1610) — reads the same name.
128pub const KEY_DIR_ENV: &str = "AI_MEMORY_KEY_DIR";
129
130/// Returns the explicit `AI_MEMORY_KEY_DIR` env override when set and
131/// non-empty, else `None`. Split out of [`default_key_dir`] so callers
132/// that must distinguish "operator explicitly relocated the key store"
133/// from "platform default" (the #1610 `rules keygen` write-path fix)
134/// share the same set-and-non-empty semantics.
135#[must_use]
136pub fn key_dir_env_override() -> Option<PathBuf> {
137    match std::env::var(KEY_DIR_ENV) {
138        Ok(v) if !v.is_empty() => Some(PathBuf::from(v)),
139        _ => None,
140    }
141}
142
143/// Returns the default key storage directory:
144/// `dirs::config_dir().join("ai-memory/keys/")`.
145///
146/// Errors when the OS does not advertise a config dir (extremely rare;
147/// every supported target — Linux, macOS, Windows — returns one).
148///
149/// `AI_MEMORY_KEY_DIR` env-var override: when set and non-empty, that
150/// path is returned verbatim. This mirrors the env-override pattern
151/// other paths in `ai-memory` use (`AI_MEMORY_DB`,
152/// `AI_MEMORY_AGENT_ID`) and lets H4's `memory_verify` integration
153/// tests stand up an isolated key dir per test without shelling out to
154/// the operator's real `~/.config/ai-memory/keys/`. Operators who want
155/// to relocate the key store in production can use the same override.
156pub fn default_key_dir() -> Result<PathBuf> {
157    if let Some(p) = key_dir_env_override() {
158        return Ok(p);
159    }
160    // COVERAGE: ok_or_else closure (line 131) reachable only on hosts
161    //           where dirs::config_dir() returns None — i.e. exotic
162    //           platforms with no HOME env var. Not deterministic to
163    //           trigger in tests because removing HOME breaks tempfile.
164    let base = dirs::config_dir()
165        .ok_or_else(|| anyhow!("OS did not advertise a config directory for key storage"))?;
166    Ok(base.join("ai-memory").join("keys"))
167}
168
169/// Generate a fresh Ed25519 keypair for `agent_id` using `OsRng`.
170///
171/// `agent_id` is validated against
172/// [`crate::validate::validate_agent_id_shape`] (shape-only — char
173/// class + length) so callers cannot smuggle invalid characters into
174/// the on-disk filename. The reserved-name reject lives at the WIRE
175/// boundary ([`crate::validate::validate_agent_id`]) so internal
176/// callers using reserved sentinels (e.g. the daemon's own
177/// [`DAEMON_KEYPAIR_LABEL`] self-signing keypair) can still
178/// load/generate cleanly. Wire
179/// entry points that route caller-supplied agent_ids into this
180/// function must validate FIRST via `validate_agent_id` before
181/// reaching here.
182/// The well-known stable label used by the daemon when auto-generating
183/// and loading its outbound link-signing keypair (`<label>.priv` /
184/// `<label>.pub` under the key directory).
185///
186/// This is a key-file LABEL, deliberately distinct from
187/// [`crate::identity::sentinels::DAEMON_PRINCIPAL`] (a caller
188/// identity) even though both are `"daemon"` today — they govern
189/// different mechanisms. Round-3 F12: the daemon's signing identity is
190/// process-wide (one daemon = one signing key) and decoupled from
191/// per-request `agent_id` resolution; a fixed label keeps `load` and
192/// `ensure_keypair` pointed at the same file across restarts.
193pub const DAEMON_KEYPAIR_LABEL: &str = "daemon";
194
195pub fn generate(agent_id: &str) -> Result<AgentKeypair> {
196    validate::validate_agent_id_shape(agent_id)?;
197    // ed25519-dalek 2.x consumes a `CryptoRngCore` (rand_core 0.6).
198    // `OsRng` is the platform CSPRNG; it never blocks on modern OSes.
199    let mut csprng = rand_core::OsRng;
200    let private = SigningKey::generate(&mut csprng);
201    let public = private.verifying_key();
202    Ok(AgentKeypair {
203        agent_id: agent_id.to_string(),
204        public,
205        private: Some(private),
206    })
207}
208
209/// Persist `keypair` to `dir`.
210///
211/// Creates the directory tree (recursive `mkdir`) on first use. On
212/// Unix the public file is written with mode `0o644` and the private
213/// file with mode `0o600`. Both files are written atomically by the
214/// underlying `fs::write` (single syscall on the modern OSes we
215/// target — no temp-file rename dance because the file shape is fixed
216/// 32 bytes and a partial write is recoverable by `generate` again).
217///
218/// Refuses if `keypair.private` is `None` — there is nothing to save
219/// beyond a public key, and saving a public-only file is the job of
220/// [`save_public_only`] (used by `import` when `--priv` is omitted).
221pub fn save(keypair: &AgentKeypair, dir: &Path) -> Result<()> {
222    let private = keypair.private.as_ref().ok_or_else(|| {
223        anyhow!(
224            "AgentKeypair for {} has no private key to save",
225            keypair.agent_id
226        )
227    })?;
228
229    let pub_path = dir.join(format!("{}{PUB_SUFFIX}", keypair.agent_id));
230    let priv_path = dir.join(format!("{}{PRIV_SUFFIX}", keypair.agent_id));
231
232    // #1514 — a SPIFFE-style slashed agent_id (e.g. `campaign/region/host`)
233    // nests the key files under sub-directories of `dir`; create the parent
234    // of each FILE, not just `dir`, or the nested write ENOENTs. For a plain
235    // (slash-free) agent_id the parent IS `dir`, so behaviour is unchanged.
236    ensure_parent(&pub_path)?;
237    ensure_parent(&priv_path)?;
238
239    // COVERAGE: with_context lazy-format closures (lines 178, 180)
240    //           reachable only when the underlying fs::write fails on
241    //           a successfully-created directory — same EACCES/ENOSPC
242    //           class as write_with_mode above. Not portable to tests.
243    write_with_mode(&pub_path, &keypair.public.to_bytes(), 0o644)
244        .with_context(|| format!("writing public key {}", pub_path.display()))?;
245    write_with_mode(&priv_path, &private.to_bytes(), 0o600)
246        .with_context(|| format!("writing private key {}", priv_path.display()))?;
247    Ok(())
248}
249
250/// Persist only the public-key file. Used by `identity import` when the
251/// caller supplies a public key without a private key (e.g., importing
252/// a peer's allowlist entry). The corresponding `.priv` is left absent;
253/// [`load`] will then return a public-only [`AgentKeypair`].
254pub fn save_public_only(keypair: &AgentKeypair, dir: &Path) -> Result<()> {
255    let pub_path = dir.join(format!("{}{PUB_SUFFIX}", keypair.agent_id));
256    // #1514 — create the parent of the FILE (nested for slashed agent_ids),
257    // not just `dir`; for a slash-free id the parent IS `dir`.
258    ensure_parent(&pub_path)?;
259    // COVERAGE: with_context closure (line 192) same class as save's
260    //           pub-write closure (line 178) — reachable on EACCES/
261    //           ENOSPC; not portable to unit tests on macOS/Linux.
262    write_with_mode(&pub_path, &keypair.public.to_bytes(), 0o644)
263        .with_context(|| format!("writing public key {}", pub_path.display()))?;
264    Ok(())
265}
266
267/// Load `agent_id`'s keypair from `dir`.
268///
269/// The public file must exist (errors otherwise). The private file is
270/// optional — if absent the returned `AgentKeypair.private` is `None`
271/// and the caller can verify but not sign.
272///
273/// # v0.7.0 S4-LOW1 — load-time mode-bits enforcement (Unix)
274///
275/// `save` writes the private file with mode `0o600`, but an operator
276/// (or a misconfigured restore-from-backup) can chmod-loosen the
277/// file on disk after the fact. Without a load-time check the
278/// daemon would happily sign with a world-readable key. On Unix we
279/// now stat the `.priv` file before reading and refuse to load
280/// when any group/other bit is set (`mode & 0o077 != 0`).
281///
282/// The error message names the path and the offending mode, and
283/// includes the `chmod` invocation that restores 0600 — so an
284/// operator hitting this in production has a copy-pasteable fix.
285///
286/// On non-Unix targets this check is a no-op (mode bits don't
287/// apply to NTFS ACLs; hardware-backed key storage is the
288/// commercial AgenticMem layer's responsibility — see the
289/// "Hardware-backed key storage" section above).
290pub fn load(agent_id: &str, dir: &Path) -> Result<AgentKeypair> {
291    // #977 — shape-only here; the daemon loads its own keypair under
292    // the reserved label `DAEMON_KEYPAIR_LABEL = "daemon"` and must
293    // continue to succeed. Wire-routed callers (CLI `identity load`,
294    // MCP `agent` tool) validate at their entry point via
295    // [`crate::validate::validate_agent_id`] (which rejects reserved
296    // names) before reaching here.
297    validate::validate_agent_id_shape(agent_id)?;
298    let pub_path = dir.join(format!("{agent_id}{PUB_SUFFIX}"));
299    let priv_path = dir.join(format!("{agent_id}{PRIV_SUFFIX}"));
300
301    let pub_bytes = fs::read(&pub_path)
302        .with_context(|| format!("reading public key {}", pub_path.display()))?;
303    if pub_bytes.len() != PUBLIC_KEY_LEN {
304        bail!(
305            "public key {} has {} bytes, expected {PUBLIC_KEY_LEN}",
306            pub_path.display(),
307            pub_bytes.len()
308        );
309    }
310    let mut pub_arr = [0u8; PUBLIC_KEY_LEN];
311    pub_arr.copy_from_slice(&pub_bytes);
312    // COVERAGE: with_context closure (line 218) reachable when the
313    //           32-byte file decodes into an invalid Edwards-curve
314    //           point. The load_returns_decode_context_for_corrupt_public_key
315    //           test exercises this with the all-FF input; whether
316    //           dalek 2.x accepts that input or not is version-bound.
317    let public = VerifyingKey::from_bytes(&pub_arr)
318        .with_context(|| format!("decoding public key {}", pub_path.display()))?;
319
320    // v0.7.0 S4-LOW1 — refuse to load a `.priv` whose Unix mode bits
321    // grant any group/other access. Only fire when the file exists;
322    // a missing `.priv` is a valid public-only load and the mode
323    // check is irrelevant there. Done as a pre-flight before
324    // `fs::read` so we never even map the bytes into memory for a
325    // world-readable key.
326    #[cfg(unix)]
327    {
328        use std::os::unix::fs::PermissionsExt;
329        match fs::metadata(&priv_path) {
330            Ok(meta) => {
331                let mode = meta.permissions().mode() & 0o777;
332                if mode & 0o077 != 0 {
333                    bail!(
334                        "private key {} has insecure mode {:o}; refusing to load. \
335                         Restore with: chmod 0600 {}",
336                        priv_path.display(),
337                        mode,
338                        priv_path.display()
339                    );
340                }
341            }
342            Err(e) if e.kind() == io::ErrorKind::NotFound => {
343                // Public-only load — fall through; the inner match
344                // below will surface the same NotFound path.
345            }
346            Err(e) => {
347                return Err(anyhow!(e))
348                    .with_context(|| format!("stat private key {}", priv_path.display()));
349            }
350        }
351    }
352
353    let private = match fs::read(&priv_path) {
354        Ok(mut priv_bytes) => {
355            if priv_bytes.len() != SECRET_KEY_LEN {
356                let actual_len = priv_bytes.len();
357                // #1258 — zeroize the (wrong-length) buffer before the
358                // bail; even a partial private key is a secret.
359                use zeroize::Zeroize;
360                priv_bytes.zeroize();
361                bail!(
362                    "private key {} has {} bytes, expected {SECRET_KEY_LEN}",
363                    priv_path.display(),
364                    actual_len
365                );
366            }
367            let mut priv_arr = [0u8; SECRET_KEY_LEN];
368            priv_arr.copy_from_slice(&priv_bytes);
369            let signing = SigningKey::from_bytes(&priv_arr);
370            // #1258 — zeroize both copies of the raw private-key bytes
371            // before they fall out of scope. `SigningKey` owns its own
372            // internal `secret` field which `ed25519-dalek` zeroizes on
373            // Drop; the two intermediate buffers here are ours to
374            // wipe.
375            {
376                use zeroize::Zeroize;
377                priv_bytes.zeroize();
378                priv_arr.zeroize();
379            }
380            // Cross-check: the private key must derive the same public
381            // key we just loaded. Mismatch means file tampering or a
382            // stale .pub — refuse loudly rather than sign with the
383            // wrong identity.
384            if signing.verifying_key().to_bytes() != public.to_bytes() {
385                bail!(
386                    "private key {} does not match public key {}",
387                    priv_path.display(),
388                    pub_path.display()
389                );
390            }
391            Some(signing)
392        }
393        Err(e) if e.kind() == io::ErrorKind::NotFound => None,
394        Err(e) => {
395            return Err(anyhow!(e))
396                .with_context(|| format!("reading private key {}", priv_path.display()));
397        }
398    };
399
400    Ok(AgentKeypair {
401        agent_id: agent_id.to_string(),
402        public,
403        private,
404    })
405}
406
407/// Enumerate every `<agent_id>.pub` under `dir` and return the
408/// public-only keypairs. Private keys are **not** loaded — `list` is
409/// the safe verb for ops dashboards and shell autocompletion.
410///
411/// Returns an empty `Vec` (not an error) when `dir` does not exist —
412/// "no keys generated yet" is the common first-run state.
413pub fn list(dir: &Path) -> Result<Vec<AgentKeypair>> {
414    if !dir.exists() {
415        return Ok(Vec::new());
416    }
417    let mut out = Vec::new();
418    for entry in
419        fs::read_dir(dir).with_context(|| format!("reading key directory {}", dir.display()))?
420    {
421        // COVERAGE: entry? Err-arm (line 273) reachable when a
422        //           specific dir entry fails to stat mid-iteration
423        //           — typically the file was deleted between
424        //           read_dir and entry materialisation. Not
425        //           deterministic to trigger.
426        let entry = entry?;
427        let name = entry.file_name();
428        // COVERAGE: name.to_str() None arm (line 276) reachable only
429        //           on Windows where filenames may contain non-UTF8
430        //           code units, or on Linux with weird filesystem
431        //           encoding. macOS NFD-normalises everything to
432        //           UTF-8 so the None arm doesn't fire on the dev
433        //           host. Exercised by GitHub Actions Windows CI.
434        let Some(name_str) = name.to_str() else {
435            continue;
436        };
437        let Some(stem) = name_str.strip_suffix(PUB_SUFFIX) else {
438            continue;
439        };
440        // Skip .pub files whose stem is not a valid agent_id — they
441        // can't have been written by this module's `save`. Shape-only
442        // check because on-disk keys can legitimately be labelled
443        // with reserved-sentinel names (e.g. the daemon's own
444        // `DAEMON_KEYPAIR_LABEL = "daemon"` pubkey).
445        if validate::validate_agent_id_shape(stem).is_err() {
446            continue;
447        }
448        let path = entry.path();
449        let pub_bytes = match fs::read(&path) {
450            Ok(b) => b,
451            Err(_) => continue,
452        };
453        if pub_bytes.len() != PUBLIC_KEY_LEN {
454            continue;
455        }
456        let mut pub_arr = [0u8; PUBLIC_KEY_LEN];
457        pub_arr.copy_from_slice(&pub_bytes);
458        let Ok(public) = VerifyingKey::from_bytes(&pub_arr) else {
459            continue;
460        };
461        out.push(AgentKeypair {
462            agent_id: stem.to_string(),
463            public,
464            private: None,
465        });
466    }
467    out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
468    Ok(out)
469}
470
471/// Decode a base64-encoded public key (URL-safe-no-pad **or** standard
472/// padded) into a [`VerifyingKey`]. Used by `identity import` so
473/// operators can paste either flavor of base64 they were sent.
474pub fn decode_public_base64(s: &str) -> Result<VerifyingKey> {
475    let trimmed = s.trim();
476    let bytes = URL_SAFE_NO_PAD
477        .decode(trimmed)
478        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(trimmed))
479        .with_context(|| "decoding base64 public key".to_string())?;
480    if bytes.len() != PUBLIC_KEY_LEN {
481        bail!(
482            "decoded public key has {} bytes, expected {PUBLIC_KEY_LEN}",
483            bytes.len()
484        );
485    }
486    let mut arr = [0u8; PUBLIC_KEY_LEN];
487    arr.copy_from_slice(&bytes);
488    // COVERAGE: with_context closure (line 326+) reachable when the
489    //           32-byte base64-decoded payload is an invalid Edwards-
490    //           curve point. Same class as load() line 218 — coverage
491    //           depends on the dalek 2.x decode policy for specific
492    //           inputs. Documented per L0.7 playbook §3c.
493    VerifyingKey::from_bytes(&arr).with_context(|| "decoding public key bytes".to_string())
494}
495
496/// Read a 32-byte raw key file and return the bytes. Used by
497/// `identity import` for `--pub <path> --priv <path>` when the operator
498/// hands us files instead of base64. Errors loudly on a length mismatch.
499pub fn read_raw_key_file(path: &Path) -> Result<[u8; SECRET_KEY_LEN]> {
500    let bytes = fs::read(path).with_context(|| format!("reading key file {}", path.display()))?;
501    if bytes.len() != SECRET_KEY_LEN {
502        bail!(
503            "key file {} has {} bytes, expected {SECRET_KEY_LEN}",
504            path.display(),
505            bytes.len()
506        );
507    }
508    let mut arr = [0u8; SECRET_KEY_LEN];
509    arr.copy_from_slice(&bytes);
510    Ok(arr)
511}
512
513// ---------------------------------------------------------------------------
514// Round-2 F12 — auto-generation of the daemon's signing keypair
515// ---------------------------------------------------------------------------
516//
517// Round-2 evidence: link signing was disabled by default at v0.7.0
518// because no Ed25519 keypair existed on a freshly-installed deployment
519// and the operator had to manually run `ai-memory identity generate`
520// before signed links would land. Default-secure says we should
521// auto-generate one at first `serve` startup unless the operator
522// explicitly opted out. The lifecycle is idempotent (re-runs are
523// no-ops) so a daemon restart never overwrites an existing keypair.
524
525/// Outcome of a single [`ensure_keypair`] call.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum EnsureOutcome {
528    /// Keypair already existed at the resolved path; no action taken.
529    AlreadyExists {
530        /// Path to the public-key file the existence check observed.
531        pub_path: PathBuf,
532    },
533    /// A fresh keypair was generated and persisted to `dir`.
534    Generated {
535        /// Path the public-key file was written to. The corresponding
536        /// `.priv` lives alongside.
537        pub_path: PathBuf,
538    },
539    /// Auto-generation was disabled — operator set
540    /// `[identity].disabled = true` (or equivalent) in config.
541    SkippedDisabled,
542}
543
544/// Round-2 F12 — auto-generate a signing keypair for `agent_id` under
545/// `dir` if one does not already exist.
546///
547/// `disabled` is the operator's opt-out flag (resolved from
548/// `[identity].disabled` in config). When `true` the helper returns
549/// [`EnsureOutcome::SkippedDisabled`] without touching the filesystem.
550///
551/// Idempotency: when the public-key file at
552/// `<dir>/<agent_id>.pub` already exists the helper returns
553/// [`EnsureOutcome::AlreadyExists`] without calling [`generate`] or
554/// [`save`]. This guarantees a daemon restart never overwrites a
555/// pre-existing keypair (which would silently invalidate every
556/// signed link the prior key produced).
557///
558/// On the [`EnsureOutcome::Generated`] path the helper logs at INFO
559/// level via `tracing` so the operator notices the new key in
560/// daemon logs. The same line is also surfaced by the F12 startup
561/// banner — see [`crate::cli::serve_banner`].
562pub fn ensure_keypair(agent_id: &str, dir: &Path, disabled: bool) -> Result<EnsureOutcome> {
563    if disabled {
564        tracing::info!(
565            "identity: auto-gen disabled by config; link signing will be skipped at boot"
566        );
567        return Ok(EnsureOutcome::SkippedDisabled);
568    }
569    // #977 — shape-only here: `ensure_keypair` is called from the
570    // daemon's own startup path (`src/daemon_runtime.rs:1760`) with
571    // `DAEMON_KEYPAIR_LABEL = "daemon"` (a reserved sentinel). The
572    // wire-routed callers (CLI `identity install`) validate at their
573    // entry point via the reserved-name-rejecting
574    // [`crate::validate::validate_agent_id`].
575    validate::validate_agent_id_shape(agent_id)?;
576
577    let pub_path = dir.join(format!("{agent_id}{PUB_SUFFIX}"));
578    if pub_path.exists() {
579        // Idempotent: do NOT regenerate. A daemon restart must keep
580        // the operator's existing key.
581        return Ok(EnsureOutcome::AlreadyExists { pub_path });
582    }
583
584    let kp = generate(agent_id)?;
585    save(&kp, dir)?;
586    // COVERAGE: tracing::info! lazy-format closure (lines 411-417)
587    //           — the format args are constructed lazily; the closure
588    //           body runs when the INFO subscriber is enabled. Coverage
589    //           depends on test subscriber config. Documented per L0.7
590    //           playbook §3c.
591    tracing::info!(
592        "auto-generated identity keypair at {} — consider backing up",
593        pub_path.display()
594    );
595    Ok(EnsureOutcome::Generated { pub_path })
596}
597
598/// Create the parent directory of `path` (recursive `mkdir`).
599///
600/// #1514 — a SPIFFE-style slashed `agent_id` (`campaign/region/host`)
601/// produces a key path nested several directories below `dir`; we must
602/// create the parent of the FILE, not just `dir`, or the subsequent
603/// write fails with `ENOENT`. For a plain (slash-free) `agent_id` the
604/// file parent IS `dir`, so this is behaviourally identical to the old
605/// `create_dir_all(dir)`.
606fn ensure_parent(path: &Path) -> Result<()> {
607    if let Some(parent) = path.parent() {
608        fs::create_dir_all(parent)
609            .with_context(|| format!("creating key directory {}", parent.display()))?;
610    }
611    Ok(())
612}
613
614/// Cross-platform `fs::write` with an explicit Unix mode. On non-Unix
615/// targets `mode` is ignored and the file inherits the parent ACL.
616// COVERAGE: the `?` Err-arm closures on `open`/`write_all`/`sync_all`
617//           (lines 432, 434, 435) are unreachable on the happy path
618//           because every test caller passes a tempdir-relative path
619//           with write permission. Triggering EACCES / ENOSPC / EIO
620//           in unit tests requires kernel-level fault injection.
621#[cfg(unix)]
622fn write_with_mode(path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> {
623    use std::os::unix::fs::OpenOptionsExt;
624    // Best-effort remove first so a previous, possibly stricter mode
625    // on the same name doesn't block an `open` with `create_new`.
626    let _ = fs::remove_file(path);
627    let mut file = fs::OpenOptions::new()
628        .write(true)
629        .create_new(true)
630        .mode(mode)
631        .open(path)?;
632    use std::io::Write;
633    file.write_all(bytes)?;
634    file.sync_all()?;
635    Ok(())
636}
637
638#[cfg(not(unix))]
639fn write_with_mode(path: &Path, bytes: &[u8], _mode: u32) -> io::Result<()> {
640    // Windows/non-Unix: mode bits don't apply. The file inherits the
641    // parent directory ACL. Hardware-backed key storage on Windows is
642    // out of OSS scope — see the AgenticMem commercial layer.
643    //
644    // v0.7.0 de-silencing: the requested restrictive `mode` cannot be
645    // honored here, so the private key lands with whatever the parent
646    // directory's ACL grants. Emit a once-per-process operator-visible
647    // warn so this weaker-than-Unix posture is observable rather than
648    // silent.
649    static NON_UNIX_KEY_PERM_WARN_ONCE: std::sync::Once = std::sync::Once::new();
650    NON_UNIX_KEY_PERM_WARN_ONCE.call_once(|| {
651        tracing::warn!(
652            target: "identity::keypair",
653            "writing key material on a non-Unix platform: restrictive file-mode \
654             bits are not applied, so the key file inherits the parent directory \
655             ACL. Restrict the key directory's ACL manually, or use hardware-backed \
656             key storage, to protect private keys."
657        );
658    });
659    fs::write(path, bytes)
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use ed25519_dalek::Signer;
666    use ed25519_dalek::Verifier;
667    use tempfile::TempDir;
668
669    fn tmp_dir() -> TempDir {
670        TempDir::new().expect("tempdir")
671    }
672
673    #[test]
674    fn generate_yields_signing_keypair() {
675        let kp = generate("alice").expect("generate");
676        assert_eq!(kp.agent_id, "alice");
677        assert!(
678            kp.can_sign(),
679            "freshly generated keypair must have private key"
680        );
681        // Public derives from private.
682        let priv_pub = kp.private.as_ref().unwrap().verifying_key().to_bytes();
683        assert_eq!(priv_pub, kp.public.to_bytes());
684    }
685
686    #[test]
687    fn generate_rejects_invalid_agent_id() {
688        assert!(generate("has space").is_err());
689        assert!(generate("has\0null").is_err());
690    }
691
692    #[test]
693    fn round_trip_save_then_load() {
694        let dir = tmp_dir();
695        let kp = generate("alice").unwrap();
696        save(&kp, dir.path()).expect("save");
697        let loaded = load("alice", dir.path()).expect("load");
698        assert_eq!(loaded.agent_id, "alice");
699        assert_eq!(loaded.public.to_bytes(), kp.public.to_bytes());
700        assert!(loaded.can_sign(), "private key should round-trip");
701        // Sign with loaded key, verify with original public.
702        let msg = b"hello world";
703        let sig = loaded.private.as_ref().unwrap().sign(msg);
704        assert!(kp.public.verify(msg, &sig).is_ok());
705    }
706
707    // #1514 — a SPIFFE-style slashed agent_id nests the key files under
708    // sub-directories of `dir`. `save` must create those parents (not just
709    // `dir`) or the write ENOENTs; `load` must then round-trip the nested
710    // files. Regression pin for the save/load asymmetry.
711    #[test]
712    fn round_trip_save_then_load_slashed_agent_id() {
713        let dir = tmp_dir();
714        let agent_id = "hive-1461/nyc3/hive-peer-nyc3-01";
715        let kp = generate(agent_id).expect("generate slashed id");
716        save(&kp, dir.path()).expect("save slashed id must create nested parents");
717
718        // The files really do live nested under dir.
719        let pub_path = dir.path().join(format!("{agent_id}.pub"));
720        let priv_path = dir.path().join(format!("{agent_id}.priv"));
721        assert!(pub_path.exists(), "nested .pub must exist at {pub_path:?}");
722        assert!(
723            priv_path.exists(),
724            "nested .priv must exist at {priv_path:?}"
725        );
726
727        let loaded = load(agent_id, dir.path()).expect("load slashed id");
728        assert_eq!(loaded.agent_id, agent_id);
729        assert_eq!(loaded.public.to_bytes(), kp.public.to_bytes());
730        assert!(loaded.can_sign(), "private key should round-trip");
731
732        // Modes survive the nested write on Unix.
733        #[cfg(unix)]
734        {
735            use std::os::unix::fs::PermissionsExt;
736            let pub_mode = fs::metadata(&pub_path).unwrap().permissions().mode() & 0o777;
737            let priv_mode = fs::metadata(&priv_path).unwrap().permissions().mode() & 0o777;
738            assert_eq!(pub_mode, 0o644, "nested public key must be 0644");
739            assert_eq!(priv_mode, 0o600, "nested private key must be 0600");
740        }
741    }
742
743    // #1514 — `save_public_only` must also create nested parents for a
744    // slashed agent_id (the allowlist-import path).
745    #[test]
746    fn save_public_only_slashed_agent_id_creates_nested_parent() {
747        let dir = tmp_dir();
748        let agent_id = "hive-1461/sfo2/hive-peer-sfo2-01";
749        let kp = generate(agent_id).expect("generate");
750        save_public_only(&kp, dir.path()).expect("save_public_only nested");
751
752        let pub_path = dir.path().join(format!("{agent_id}.pub"));
753        assert!(pub_path.exists(), "nested .pub must exist at {pub_path:?}");
754        let loaded = load(agent_id, dir.path()).expect("load");
755        assert!(!loaded.can_sign(), "public-only save must yield no private");
756        assert_eq!(loaded.public.to_bytes(), kp.public.to_bytes());
757    }
758
759    #[test]
760    fn load_without_private_yields_public_only() {
761        let dir = tmp_dir();
762        let kp = generate("alice").unwrap();
763        save(&kp, dir.path()).expect("save");
764        // Drop the private file.
765        let priv_path = dir.path().join("alice.priv");
766        fs::remove_file(&priv_path).expect("rm priv");
767        let loaded = load("alice", dir.path()).expect("load");
768        assert!(!loaded.can_sign(), "missing .priv must yield None private");
769        assert_eq!(loaded.public.to_bytes(), kp.public.to_bytes());
770    }
771
772    #[cfg(unix)]
773    #[test]
774    fn save_writes_unix_mode_0600_and_0644() {
775        use std::os::unix::fs::PermissionsExt;
776        let dir = tmp_dir();
777        let kp = generate("alice").unwrap();
778        save(&kp, dir.path()).expect("save");
779
780        let pub_meta = fs::metadata(dir.path().join("alice.pub")).unwrap();
781        let priv_meta = fs::metadata(dir.path().join("alice.priv")).unwrap();
782
783        // Mask off the file-type bits; we only care about the perm bits.
784        let pub_mode = pub_meta.permissions().mode() & 0o777;
785        let priv_mode = priv_meta.permissions().mode() & 0o777;
786        assert_eq!(
787            priv_mode, 0o600,
788            "private key must be 0600, got {priv_mode:o}"
789        );
790        assert_eq!(pub_mode, 0o644, "public key must be 0644, got {pub_mode:o}");
791    }
792
793    #[test]
794    fn list_enumerates_saved_keypairs() {
795        let dir = tmp_dir();
796        let alice = generate("alice").unwrap();
797        let bob = generate("bob").unwrap();
798        save(&alice, dir.path()).unwrap();
799        save(&bob, dir.path()).unwrap();
800
801        let listed = list(dir.path()).expect("list");
802        assert_eq!(listed.len(), 2);
803        // Sorted by agent_id.
804        assert_eq!(listed[0].agent_id, "alice");
805        assert_eq!(listed[1].agent_id, "bob");
806        // No private keys in list output.
807        for kp in &listed {
808            assert!(!kp.can_sign(), "list must not load private keys");
809        }
810        // Public bytes match.
811        assert_eq!(listed[0].public.to_bytes(), alice.public.to_bytes());
812        assert_eq!(listed[1].public.to_bytes(), bob.public.to_bytes());
813    }
814
815    #[test]
816    fn list_on_missing_dir_returns_empty() {
817        let dir = tmp_dir();
818        let nonexistent = dir.path().join("does-not-exist");
819        let listed = list(&nonexistent).expect("list");
820        assert!(listed.is_empty());
821    }
822
823    #[test]
824    fn list_skips_unrelated_files() {
825        let dir = tmp_dir();
826        let kp = generate("alice").unwrap();
827        save(&kp, dir.path()).unwrap();
828        // Drop noise that should be skipped.
829        fs::write(dir.path().join("README.txt"), b"ignore me").unwrap();
830        fs::write(dir.path().join("not-a-key.pub"), b"too short").unwrap();
831
832        let listed = list(dir.path()).expect("list");
833        assert_eq!(listed.len(), 1);
834        assert_eq!(listed[0].agent_id, "alice");
835    }
836
837    #[test]
838    fn load_rejects_truncated_public_key() {
839        let dir = tmp_dir();
840        fs::write(dir.path().join("alice.pub"), b"short").unwrap();
841        let err = load("alice", dir.path()).unwrap_err();
842        let msg = format!("{err:#}");
843        assert!(msg.contains("expected 32"), "got: {msg}");
844    }
845
846    #[test]
847    fn load_rejects_priv_pub_mismatch() {
848        let dir = tmp_dir();
849        let alice = generate("alice").unwrap();
850        let bob = generate("alice").unwrap();
851        save(&alice, dir.path()).unwrap();
852        // Overwrite .priv with a different keypair's private bytes.
853        fs::remove_file(dir.path().join("alice.priv")).unwrap();
854        // Use save_public_only path effectively: write a .priv that
855        // doesn't match alice's .pub.
856        let bob_priv = bob.private.as_ref().unwrap().to_bytes();
857        write_with_mode(&dir.path().join("alice.priv"), &bob_priv, 0o600).unwrap();
858        let err = load("alice", dir.path()).unwrap_err();
859        let msg = format!("{err:#}");
860        assert!(msg.contains("does not match"), "got: {msg}");
861    }
862
863    #[test]
864    fn export_pub_round_trips_through_base64() {
865        let kp = generate("alice").unwrap();
866        let b64 = kp.public_base64();
867        let decoded = decode_public_base64(&b64).expect("decode");
868        assert_eq!(decoded.to_bytes(), kp.public.to_bytes());
869    }
870
871    #[test]
872    fn decode_public_base64_accepts_padded_form() {
873        let kp = generate("alice").unwrap();
874        let padded = base64::engine::general_purpose::STANDARD.encode(kp.public.to_bytes());
875        let decoded = decode_public_base64(&padded).expect("decode padded");
876        assert_eq!(decoded.to_bytes(), kp.public.to_bytes());
877    }
878
879    #[test]
880    fn read_raw_key_file_validates_length() {
881        let dir = tmp_dir();
882        let p = dir.path().join("short.bin");
883        fs::write(&p, b"short").unwrap();
884        let err = read_raw_key_file(&p).unwrap_err();
885        let msg = format!("{err:#}");
886        assert!(msg.contains("expected 32"), "got: {msg}");
887    }
888
889    #[test]
890    fn save_refuses_public_only_keypair() {
891        let dir = tmp_dir();
892        let kp = AgentKeypair {
893            agent_id: "alice".to_string(),
894            public: generate("alice").unwrap().public,
895            private: None,
896        };
897        let err = save(&kp, dir.path()).unwrap_err();
898        let msg = format!("{err:#}");
899        assert!(msg.contains("no private key to save"), "got: {msg}");
900    }
901
902    #[test]
903    fn save_public_only_writes_pub_only() {
904        let dir = tmp_dir();
905        let kp = generate("alice").unwrap();
906        let pub_only = AgentKeypair {
907            agent_id: "alice".to_string(),
908            public: kp.public,
909            private: None,
910        };
911        save_public_only(&pub_only, dir.path()).expect("save_public_only");
912        assert!(dir.path().join("alice.pub").exists());
913        assert!(!dir.path().join("alice.priv").exists());
914        let loaded = load("alice", dir.path()).expect("load");
915        assert!(!loaded.can_sign());
916    }
917
918    #[test]
919    fn default_key_dir_ends_in_ai_memory_keys() {
920        // M9 — `default_key_dir_honours_env_override` flips the same
921        // `AI_MEMORY_KEY_DIR` key. Acquire the shared lock so the two
922        // tests cannot interleave under `cargo test --jobs N`.
923        let _g = key_dir_env_lock().lock().unwrap_or_else(|e| e.into_inner());
924        // SAFETY: env mutation serialised by `_g`. The H4 env-var
925        // override (`AI_MEMORY_KEY_DIR`) is scrubbed up-front so this
926        // test asserts the *fallback* path.
927        unsafe {
928            std::env::remove_var("AI_MEMORY_KEY_DIR");
929        }
930        let p = default_key_dir().expect("default dir");
931        let s = p.to_string_lossy();
932        assert!(s.ends_with("ai-memory/keys") || s.ends_with("ai-memory\\keys"));
933    }
934
935    /// Process-wide guard for tests that mutate `AI_MEMORY_KEY_DIR`.
936    /// Delegates to the module-level `pub(crate) key_dir_env_lock` so
937    /// sibling-crate test files (e.g. `src/mcp/mod.rs`'s H4 verify
938    /// coverage tests) can serialise against the keypair-module tests
939    /// that also mutate the env var. Local thin wrapper kept so the
940    /// existing call sites in this file do not change.
941    fn key_dir_env_lock() -> &'static std::sync::Mutex<()> {
942        super::key_dir_env_lock()
943    }
944
945    // ---- Round-2 F12 ensure_keypair --------------------------------------
946
947    #[test]
948    fn ensure_keypair_generates_when_missing() {
949        let dir = tmp_dir();
950        let outcome = ensure_keypair("alice", dir.path(), false).expect("ensure");
951        match outcome {
952            EnsureOutcome::Generated { pub_path } => {
953                assert!(pub_path.exists(), "pub key must be on disk");
954                let priv_path = dir.path().join("alice.priv");
955                assert!(priv_path.exists(), "priv key must be on disk");
956            }
957            other => panic!("expected Generated, got {other:?}"),
958        }
959    }
960
961    #[test]
962    fn ensure_keypair_idempotent_on_second_call() {
963        let dir = tmp_dir();
964        let first = ensure_keypair("alice", dir.path(), false).expect("first");
965        let pub_path = dir.path().join("alice.pub");
966        let priv_path = dir.path().join("alice.priv");
967        // Snapshot bytes to assert non-overwrite.
968        let pub_before = fs::read(&pub_path).unwrap();
969        let priv_before = fs::read(&priv_path).unwrap();
970
971        let second = ensure_keypair("alice", dir.path(), false).expect("second");
972        match second {
973            EnsureOutcome::AlreadyExists { pub_path: observed } => {
974                assert_eq!(observed, pub_path);
975            }
976            other => panic!("expected AlreadyExists on second call, got {other:?}"),
977        }
978        // Bytes must NOT have changed — overwrite would corrupt every
979        // prior signed link.
980        let pub_after = fs::read(&pub_path).unwrap();
981        let priv_after = fs::read(&priv_path).unwrap();
982        assert_eq!(pub_before, pub_after);
983        assert_eq!(priv_before, priv_after);
984        // First call's outcome must have been Generated.
985        assert!(matches!(first, EnsureOutcome::Generated { .. }));
986    }
987
988    #[test]
989    fn ensure_keypair_respects_disabled_flag() {
990        let dir = tmp_dir();
991        let outcome = ensure_keypair("alice", dir.path(), true).expect("ensure");
992        assert_eq!(outcome, EnsureOutcome::SkippedDisabled);
993        // Filesystem must be untouched.
994        assert!(!dir.path().join("alice.pub").exists());
995        assert!(!dir.path().join("alice.priv").exists());
996    }
997
998    #[test]
999    fn ensure_keypair_validates_agent_id() {
1000        let dir = tmp_dir();
1001        let res = ensure_keypair("has space", dir.path(), false);
1002        assert!(res.is_err(), "must reject invalid agent_id");
1003    }
1004
1005    // -----------------------------------------------------------------
1006    // L0.7-2 Tier A — error path + visibility closures
1007    // -----------------------------------------------------------------
1008
1009    #[test]
1010    fn save_returns_context_when_dir_is_a_file() {
1011        // Lines 172, 178: with_context closure for create_dir_all
1012        // when the parent component is a file.
1013        let dir = tmp_dir();
1014        let blocker = dir.path().join("blocker");
1015        fs::write(&blocker, b"file").unwrap();
1016        let kp = generate("alice").unwrap();
1017        // Treat the file as if it were a dir → mkdir of "blocker/sub"
1018        // fails because blocker is a file.
1019        let sub = blocker.join("sub");
1020        let err = save(&kp, &sub).unwrap_err();
1021        let msg = format!("{err:#}");
1022        assert!(
1023            msg.contains("creating key directory"),
1024            "expected wrapped context, got: {msg}"
1025        );
1026    }
1027
1028    #[test]
1029    fn save_public_only_returns_context_when_dir_is_a_file() {
1030        // Lines 189: with_context closure for create_dir_all.
1031        let dir = tmp_dir();
1032        let blocker = dir.path().join("blocker");
1033        fs::write(&blocker, b"file").unwrap();
1034        let kp = generate("alice").unwrap();
1035        let sub = blocker.join("sub");
1036        let err = save_public_only(&kp, &sub).unwrap_err();
1037        let msg = format!("{err:#}");
1038        assert!(
1039            msg.contains("creating key directory"),
1040            "expected wrapped context, got: {msg}"
1041        );
1042    }
1043
1044    #[test]
1045    fn load_returns_context_when_pub_file_missing() {
1046        // Line 207: with_context closure for fs::read of public.
1047        let dir = tmp_dir();
1048        let err = load("alice", dir.path()).unwrap_err();
1049        let msg = format!("{err:#}");
1050        assert!(msg.contains("reading public key"), "got: {msg}");
1051    }
1052
1053    #[test]
1054    fn load_returns_decode_context_for_corrupt_public_key() {
1055        // Line 218: with_context closure for VerifyingKey::from_bytes.
1056        // Construct 32 bytes that fail decode (an Ed25519 invariant
1057        // requires the encoded point to lie on the curve — most
1058        // arbitrary 32-byte sequences are valid, but certain
1059        // canonical points fail). Use 32 0xFF bytes to maximise the
1060        // chance of decode failure; if dalek accepts it, the test
1061        // falls back to asserting the length is the only check that
1062        // would fire. We trust the historical Ed25519 spec which
1063        // rejects all-1 encodings.
1064        let dir = tmp_dir();
1065        let bytes = [0xFFu8; PUBLIC_KEY_LEN];
1066        fs::write(dir.path().join("alice.pub"), bytes).unwrap();
1067        // The result may surface either a length-OK + decode error
1068        // OR a decode error directly. We only assert that LOAD errors
1069        // (not panics) — this pins the path even if dalek's decode
1070        // policy varies across versions.
1071        let res = load("alice", dir.path());
1072        if let Err(err) = res {
1073            let msg = format!("{err:#}");
1074            // Either path is acceptable; both go through with_context.
1075            assert!(
1076                msg.contains("decoding public key") || msg.contains("expected"),
1077                "got: {msg}"
1078            );
1079        } else {
1080            // If dalek accepted the all-FF point as a valid public
1081            // key, this test is a no-op (the spec edge differs from
1082            // our assumption). Document that we tolerate either
1083            // outcome via this branch.
1084        }
1085    }
1086
1087    #[test]
1088    fn load_with_truncated_priv_returns_length_error() {
1089        // Lines 222-226: bail! when private key bytes are wrong length.
1090        let dir = tmp_dir();
1091        let kp = generate("alice").unwrap();
1092        save(&kp, dir.path()).unwrap();
1093        // Truncate .priv to a non-32-byte length (e.g. 8 bytes).
1094        fs::write(dir.path().join("alice.priv"), b"shortie!").unwrap();
1095        let err = load("alice", dir.path()).unwrap_err();
1096        let msg = format!("{err:#}");
1097        assert!(msg.contains("expected 32"), "got: {msg}");
1098    }
1099
1100    #[test]
1101    fn list_returns_context_on_unreadable_directory() {
1102        // Line 271: with_context closure for read_dir failure. Hardest
1103        // to trigger portably — passing a regular file as `dir` makes
1104        // `dir.exists()` return true but read_dir fails with ENOTDIR.
1105        let dir = tmp_dir();
1106        let file = dir.path().join("not-a-dir");
1107        fs::write(&file, b"x").unwrap();
1108        let err = list(&file).unwrap_err();
1109        let msg = format!("{err:#}");
1110        assert!(msg.contains("reading key directory"), "got: {msg}");
1111    }
1112
1113    #[test]
1114    fn decode_public_base64_rejects_garbage() {
1115        // Line 317: with_context closure on base64 decode failure.
1116        let err = decode_public_base64("not-valid-base64!!!").unwrap_err();
1117        let msg = format!("{err:#}");
1118        assert!(msg.contains("decoding base64"), "got: {msg}");
1119    }
1120
1121    #[test]
1122    fn decode_public_base64_rejects_wrong_length() {
1123        // Line 318-322: bail! when decoded bytes are not 32.
1124        // 8 bytes encodes to 12 chars in base64 (no padding).
1125        let short = URL_SAFE_NO_PAD.encode([0u8; 8]);
1126        let err = decode_public_base64(&short).unwrap_err();
1127        let msg = format!("{err:#}");
1128        assert!(msg.contains("expected 32"), "got: {msg}");
1129    }
1130
1131    #[test]
1132    fn read_raw_key_file_returns_context_when_path_missing() {
1133        // Line 333: with_context closure on fs::read failure.
1134        let dir = tmp_dir();
1135        let missing = dir.path().join("nope.bin");
1136        let err = read_raw_key_file(&missing).unwrap_err();
1137        let msg = format!("{err:#}");
1138        assert!(msg.contains("reading key file"), "got: {msg}");
1139    }
1140
1141    #[test]
1142    fn ensure_keypair_rejects_invalid_agent_id_when_enabled() {
1143        // Line 402: validate_agent_id fires on the enabled branch.
1144        let dir = tmp_dir();
1145        let err = ensure_keypair("has space", dir.path(), false).unwrap_err();
1146        let msg = format!("{err:#}");
1147        assert!(msg.contains("invalid character"), "got: {msg}");
1148    }
1149
1150    // -----------------------------------------------------------------
1151    // L0.7-2 Tier A — list() iteration error closures + load() io error
1152    // branches not covered by the prior suite.
1153    // -----------------------------------------------------------------
1154
1155    #[test]
1156    fn list_skips_pub_file_with_invalid_agent_id_stem() {
1157        // Line 283-285: validate_agent_id(stem).is_err() => continue.
1158        // The stem must look like a .pub file (so the suffix strip
1159        // doesn't continue first) but must FAIL validate_agent_id.
1160        // "has space" violates the agent_id regex.
1161        let dir = tmp_dir();
1162        let kp = generate("alice").unwrap();
1163        save(&kp, dir.path()).unwrap();
1164        // 32-byte bytes so the length guard doesn't skip first.
1165        fs::write(dir.path().join("has space.pub"), [0u8; PUBLIC_KEY_LEN]).unwrap();
1166        let listed = list(dir.path()).expect("list");
1167        // The bogus stem is filtered out; only alice survives.
1168        assert_eq!(listed.len(), 1);
1169        assert_eq!(listed[0].agent_id, "alice");
1170    }
1171
1172    #[cfg(unix)]
1173    #[test]
1174    fn list_skips_unreadable_pub_file_continues_iteration() {
1175        // Lines 287-289: Err(_) => continue. Make a 0000-mode file
1176        // alongside a readable one — list must skip the unreadable
1177        // entry and still return the good one.
1178        use std::os::unix::fs::PermissionsExt;
1179        let dir = tmp_dir();
1180        let alice = generate("alice").unwrap();
1181        save(&alice, dir.path()).unwrap();
1182        let unreadable = dir.path().join("bob.pub");
1183        fs::write(&unreadable, [0u8; PUBLIC_KEY_LEN]).unwrap();
1184        fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap();
1185        let listed = list(dir.path()).expect("list");
1186        // Restore so tempdir cleanup works.
1187        fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o644)).unwrap();
1188        // The unreadable file is skipped — only alice survives. Bob
1189        // *may* survive if running as root (which bypasses 0000), so
1190        // we accept either 1 or 2 entries but require alice present.
1191        assert!(listed.iter().any(|k| k.agent_id == "alice"));
1192    }
1193
1194    #[test]
1195    fn list_skips_pub_file_with_invalid_curve_point() {
1196        // Lines 296-297: VerifyingKey::from_bytes Err => continue.
1197        // Search for a 32-byte sequence that ed25519-dalek rejects.
1198        // Many arbitrary inputs are valid points; some y-coordinates
1199        // off-curve are not. We probe a handful of candidates and
1200        // use the first one that errors. If none of them error on
1201        // this dalek version we fall back to asserting the iteration
1202        // doesn't panic — the COVERAGE note below records the cap.
1203        let dir = tmp_dir();
1204        let alice = generate("alice").unwrap();
1205        save(&alice, dir.path()).unwrap();
1206
1207        let mut bogus: Option<[u8; PUBLIC_KEY_LEN]> = None;
1208        for seed in 0u8..=255 {
1209            let mut bytes = [seed; PUBLIC_KEY_LEN];
1210            // Twiddle the high bits — Edwards curve y-coords are
1211            // 255-bit; setting bytes[31] = 0xFF often pushes the
1212            // decoded y above the field prime (2^255 - 19), which
1213            // dalek rejects.
1214            bytes[31] = 0xFF;
1215            if VerifyingKey::from_bytes(&bytes).is_err() {
1216                bogus = Some(bytes);
1217                break;
1218            }
1219        }
1220        if let Some(b) = bogus {
1221            fs::write(dir.path().join("bogus.pub"), b).unwrap();
1222            let listed = list(dir.path()).expect("list");
1223            // alice survives; bogus.pub is skipped because
1224            // VerifyingKey::from_bytes returned Err.
1225            assert!(
1226                listed.iter().any(|k| k.agent_id == "alice"),
1227                "alice must survive a sibling invalid-curve-point .pub file"
1228            );
1229            assert!(
1230                !listed.iter().any(|k| k.agent_id == "bogus"),
1231                "bogus.pub with invalid curve point must be filtered out"
1232            );
1233        }
1234        // COVERAGE: when no 32-byte sequence the search range rejects
1235        // (impossible on the dalek 2.x release pinned in Cargo.toml),
1236        // this test falls through without an assertion; the from_bytes
1237        // error closure stays uncovered. dalek versions <2 accepted
1238        // every 32-byte point; dalek 2.x rejects high-y wraps so the
1239        // search above terminates.
1240    }
1241
1242    #[cfg(unix)]
1243    #[test]
1244    fn load_propagates_non_notfound_io_error_on_private_key() {
1245        // Lines 246-249: Err(e) => return Err(anyhow!(e))
1246        //                     .with_context("reading private key ...")
1247        // Trigger by making the .priv file readable to nobody (mode
1248        // 0000) — fs::read returns EACCES, which is NOT NotFound.
1249        use std::os::unix::fs::PermissionsExt;
1250        let dir = tmp_dir();
1251        let kp = generate("alice").unwrap();
1252        save(&kp, dir.path()).unwrap();
1253        let priv_path = dir.path().join("alice.priv");
1254        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o000)).unwrap();
1255        let res = load("alice", dir.path());
1256        // Restore so tempdir cleanup works regardless of test outcome.
1257        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
1258        // On most CI hosts EACCES surfaces; if running as root the
1259        // permission is ignored and load succeeds — either way we
1260        // assert the function did not panic and returned a result.
1261        if let Err(err) = res {
1262            let msg = format!("{err:#}");
1263            assert!(msg.contains("reading private key"), "got: {msg}");
1264        }
1265    }
1266
1267    #[cfg(unix)]
1268    #[test]
1269    fn ensure_keypair_save_failure_propagates_context() {
1270        // Lines 412 + save chain: when save() fails (because the dir
1271        // is a regular file, not a directory), ensure_keypair must
1272        // propagate the error.
1273        let dir = tmp_dir();
1274        let blocker = dir.path().join("blocker");
1275        fs::write(&blocker, b"file").unwrap();
1276        let sub = blocker.join("sub");
1277        let res = ensure_keypair("alice", &sub, false);
1278        assert!(res.is_err(), "save under a file-blocked dir must fail");
1279    }
1280
1281    #[test]
1282    fn default_key_dir_honours_env_override() {
1283        // v0.7 H4 — the override exists so `memory_verify` integration
1284        // tests can populate a hermetic key dir per test process. Pin
1285        // the contract here so a future refactor doesn't quietly drop
1286        // the override.
1287        let _g = key_dir_env_lock().lock().unwrap_or_else(|e| e.into_inner());
1288        // Bind the override path once (OS-agnostic temp root) and assert
1289        // the same value round-trips, so the contract can't desync.
1290        let override_path = std::env::temp_dir().join("ai-memory-key-dir-override-probe");
1291        // SAFETY: env mutation serialised by `key_dir_env_lock` for
1292        // the duration of this test.
1293        unsafe {
1294            std::env::set_var("AI_MEMORY_KEY_DIR", &override_path);
1295        }
1296        let p = default_key_dir().expect("default dir");
1297        assert_eq!(p, override_path);
1298        // SAFETY: scoped cleanup so other tests see the unset value.
1299        unsafe {
1300            std::env::remove_var("AI_MEMORY_KEY_DIR");
1301        }
1302    }
1303
1304    // -----------------------------------------------------------------
1305    // v0.7.0 S4-LOW1 — load-time mode-bits enforcement
1306    // -----------------------------------------------------------------
1307
1308    #[cfg(unix)]
1309    #[test]
1310    fn test_keypair_load_refuses_world_readable_priv() {
1311        // 0o777 grants rwx to group + world. Loading must refuse.
1312        use std::os::unix::fs::PermissionsExt;
1313        let dir = tmp_dir();
1314        let kp = generate("alice").unwrap();
1315        save(&kp, dir.path()).unwrap();
1316        let priv_path = dir.path().join("alice.priv");
1317        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o777)).unwrap();
1318        let err = load("alice", dir.path()).unwrap_err();
1319        // Restore mode so tempdir cleanup works regardless of outcome.
1320        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
1321        let msg = format!("{err:#}");
1322        assert!(
1323            msg.contains("insecure mode"),
1324            "error must name the failure mode, got: {msg}"
1325        );
1326        assert!(
1327            msg.contains("chmod 0600"),
1328            "error must include the fix invocation, got: {msg}"
1329        );
1330    }
1331
1332    #[cfg(unix)]
1333    #[test]
1334    fn test_keypair_load_refuses_group_readable_priv() {
1335        // 0o640 grants read to group. Loading must refuse — any
1336        // group/other bit triggers the check (mode & 0o077 != 0).
1337        use std::os::unix::fs::PermissionsExt;
1338        let dir = tmp_dir();
1339        let kp = generate("alice").unwrap();
1340        save(&kp, dir.path()).unwrap();
1341        let priv_path = dir.path().join("alice.priv");
1342        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o640)).unwrap();
1343        let err = load("alice", dir.path()).unwrap_err();
1344        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
1345        let msg = format!("{err:#}");
1346        assert!(msg.contains("insecure mode"), "got: {msg}");
1347    }
1348
1349    #[cfg(unix)]
1350    #[test]
1351    fn test_keypair_load_accepts_0600() {
1352        // The canonical mode `save` writes. Must load cleanly.
1353        use std::os::unix::fs::PermissionsExt;
1354        let dir = tmp_dir();
1355        let kp = generate("alice").unwrap();
1356        save(&kp, dir.path()).unwrap();
1357        let priv_path = dir.path().join("alice.priv");
1358        // `save` already writes 0600; assert explicitly to catch a
1359        // future-self regression that loosens the save path.
1360        let mode = fs::metadata(&priv_path).unwrap().permissions().mode() & 0o777;
1361        assert_eq!(mode, 0o600, "save must write 0600, got {mode:o}");
1362
1363        let loaded = load("alice", dir.path()).expect("0600 must load");
1364        assert!(loaded.can_sign(), "0600 mode must yield a signing keypair");
1365    }
1366
1367    #[cfg(unix)]
1368    #[test]
1369    fn test_keypair_load_missing_priv_skips_mode_check() {
1370        // Public-only load (no .priv file) must NOT trip the mode
1371        // check. This is the documented "verify but not sign" path
1372        // for peer pubkey enrolment.
1373        let dir = tmp_dir();
1374        let kp = generate("alice").unwrap();
1375        save(&kp, dir.path()).unwrap();
1376        fs::remove_file(dir.path().join("alice.priv")).unwrap();
1377        let loaded = load("alice", dir.path()).expect("public-only load must succeed");
1378        assert!(!loaded.can_sign());
1379    }
1380}