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}