Skip to main content

mur_common/
identity.rs

1//! Per-agent Ed25519 identity keypair.
2//!
3//! Loaded from `<agent_home>/identity.key` (private, 0600) and
4//! `<agent_home>/identity.pub` (public, multibase-encoded text).
5
6use ed25519_dalek::{SECRET_KEY_LENGTH, SigningKey, VerifyingKey};
7use rand_core::OsRng;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15#[derive(Debug, thiserror::Error)]
16pub enum IdentityError {
17    #[error("identity files not found")]
18    NotFound,
19    /// Refused to overwrite a key that is already there. `save` is
20    /// write-if-absent by contract: a private key has no `.prev` and no
21    /// rotation attestation behind it, so clobbering one is unrecoverable.
22    /// Callers that legitimately replace a key (`mur agent rekey`) write to a
23    /// scratch directory and rename.
24    #[error("refusing to overwrite an existing identity key at {0}")]
25    Exists(String),
26    /// The key is there but this process may not read it — a sandbox deny, or
27    /// wrong ownership. Distinct from `NotFound` on purpose: callers that treat
28    /// an absent key as "not signed yet" must NOT treat an unreadable one the
29    /// same way, or a deny silently downgrades signing to unsigned.
30    #[error("identity key exists but is not readable: {0}")]
31    Denied(String),
32    #[error("io error: {0}")]
33    Io(#[from] io::Error),
34    #[error("invalid key material: {0}")]
35    InvalidKey(String),
36    #[error("multibase decode error: {0}")]
37    Multibase(#[from] multibase::Error),
38}
39
40#[derive(Clone)]
41pub struct AgentIdentity {
42    signing: SigningKey,
43}
44
45impl std::fmt::Debug for AgentIdentity {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("AgentIdentity")
48            .field("verifying_key", &self.signing.verifying_key())
49            .finish()
50    }
51}
52
53/// Where an agent's PRIVATE key lives: `<mur_home>/keys/<name>/identity.key`
54/// for a directory of the form `<mur_home>/agents/<name>`, and `dir` itself for
55/// anything else.
56///
57/// #850 option (c). Both sandbox rules that protect or expose key material work
58/// by ENUMERATING `agents/*` — the sibling-key deny (#975) and the peer-public
59/// grant (#1006) — so both miss an agent created after the policy sealed.
60/// Neither backend can express "a file with this name under any agent home", so
61/// the rule has to become a subtree, and that means the private half cannot
62/// share a directory with the public half.
63///
64/// The narrow mapping is deliberate. Three callers pass a directory that is NOT
65/// under `agents/` and must be left alone, each already covered as a fixed path
66/// by `credential_paths()`:
67///
68/// - `<mur_home>/commander` (commander signing identity)
69/// - `<mur_home>/publisher` (skill publisher identity, #1013)
70/// - `<mur_home>` itself (the host key)
71///
72/// Keying off "parent is literally `agents`" is what keeps those out.
73pub fn private_key_dir(dir: &Path) -> PathBuf {
74    let is_agent_home = dir
75        .parent()
76        .and_then(|p| p.file_name())
77        .is_some_and(|n| n == "agents");
78    if !is_agent_home {
79        return dir.to_path_buf();
80    }
81    let (Some(name), Some(mur_home)) = (dir.file_name(), dir.parent().and_then(|p| p.parent()))
82    else {
83        return dir.to_path_buf();
84    };
85    mur_home.join("keys").join(name)
86}
87
88/// Move one agent's private key out of the agents tree (#850 option (c), step 2).
89///
90/// Scoped to a SINGLE agent on purpose. Every agent runs this for its own key at
91/// startup, so 27 agents starting together never contend — no two of them touch
92/// the same file. A sweep that migrated everyone from whichever process got
93/// there first would have that race for no gain.
94///
95/// Runs at startup rather than from `mur update` because `mur update` is not the
96/// only upgrade path: `build.sh --install` + `mur agent restart --stale` never
97/// invokes it, and neither does `brew upgrade`. A migration hooked there simply
98/// would not run on those machines.
99///
100/// Refuses rather than overwrites when the destination already holds a
101/// DIFFERENT key. Silently picking one would change the agent's identity, which
102/// forges attribution on every channel it has ever written to, and there is no
103/// `.prev` and no rotation attestation to undo it with.
104///
105/// Returns `Ok(true)` when a key was moved. Idempotent: a second call finds
106/// nothing. Never an error for "nothing to migrate".
107pub fn migrate_private_key(agent_dir: &Path) -> Result<bool, IdentityError> {
108    let key_dir = private_key_dir(agent_dir);
109    if key_dir == agent_dir {
110        return Ok(false); // not an agent home — commander, publisher, host key
111    }
112    let legacy = agent_dir.join("identity.key");
113    let target = key_dir.join("identity.key");
114
115    let legacy_bytes = match fs::read(&legacy) {
116        Ok(b) => b,
117        // Nothing to move. Note this deliberately does NOT distinguish denied
118        // from absent: a migration that cannot read the source has nothing to
119        // do either way, and the load path (which does distinguish) is what
120        // reports the problem.
121        Err(_) => return Ok(false),
122    };
123
124    if let Ok(existing) = fs::read(&target) {
125        if existing == legacy_bytes {
126            // Already migrated, with a leftover copy behind. Remove the copy —
127            // leaving a private key in the agents tree is the exposure this
128            // whole change exists to remove.
129            let _ = fs::remove_file(&legacy);
130            return Ok(false);
131        }
132        return Err(IdentityError::Exists(format!(
133            "{} already holds a DIFFERENT key than {}; refusing to migrate —              resolve by hand, because picking one silently changes this agent's              identity",
134            target.display(),
135            legacy.display()
136        )));
137    }
138
139    fs::create_dir_all(&key_dir)?;
140    // Copy-then-remove rather than rename: `keys/` and `agents/` are both under
141    // mur_home so a rename would normally work, but a bind-mounted or symlinked
142    // agents dir would make it cross-device, and a failed rename there would
143    // leave the key nowhere.
144    fs::write(&target, &legacy_bytes)?;
145    #[cfg(unix)]
146    {
147        use std::os::unix::fs::PermissionsExt;
148        fs::set_permissions(&target, fs::Permissions::from_mode(0o600))?;
149    }
150    // Only now is it safe to drop the original.
151    fs::remove_file(&legacy)?;
152
153    // `.prev` follows its key if present; rekey writes it beside the private
154    // half, so leaving it behind would strand a usable old key in the tree.
155    let legacy_prev = agent_dir.join("identity.key.prev");
156    if let Ok(prev) = fs::read(&legacy_prev) {
157        let target_prev = key_dir.join("identity.key.prev");
158        if fs::metadata(&target_prev).is_err() {
159            fs::write(&target_prev, &prev)?;
160            #[cfg(unix)]
161            {
162                use std::os::unix::fs::PermissionsExt;
163                fs::set_permissions(&target_prev, fs::Permissions::from_mode(0o600))?;
164            }
165        }
166        let _ = fs::remove_file(&legacy_prev);
167    }
168    Ok(true)
169}
170
171/// Environment flag telling a spawned `mur` that its parent handed it a
172/// signing capability on stdin. Set only by `fleet_run`, only alongside the
173/// pipe that carries it.
174pub const SIGNING_HANDOFF_ENV: &str = "MUR_CHANNEL_SIGNING_STDIN";
175
176/// The signing capability a runtime hands to a child it sealed inside its own
177/// sandbox.
178///
179/// `<mur_home>/keys` is kernel-denied to every sandboxed process on purpose —
180/// a prompt-injected agent must not be able to exfiltrate its own signing key.
181/// The runtime escapes that only because it loads the key BEFORE the sandbox
182/// seals; a child spawned afterwards cannot, so every channel event such a
183/// child wrote was unsigned.
184///
185/// This travels on the child's **stdin pipe** — never argv, never the
186/// environment. `ps eww` shows another same-uid process's environment, so an
187/// env var here would hand the key straight back to the bash tool the sandbox
188/// exists to keep it away from. A pipe is readable only by the process holding
189/// the descriptor.
190///
191/// Design: `docs/superpowers/specs/2026-09-09-in-sandbox-channel-signing-design.md`.
192#[derive(serde::Serialize, serde::Deserialize)]
193pub struct SigningHandoff {
194    /// The agent this key signs as. The receiver uses it only when the event
195    /// it is writing names this same writer.
196    pub agent: String,
197    pub key_version: u32,
198    /// Raw Ed25519 secret, as a JSON array of bytes — no encoding dependency
199    /// stands between the two ends of the pipe.
200    pub secret: [u8; SECRET_KEY_LENGTH],
201}
202
203impl AgentIdentity {
204    /// Rebuild an identity from raw secret bytes.
205    ///
206    /// The only legitimate caller is the receiving end of a `SigningHandoff`.
207    /// Anything that can reach the key file uses `load`.
208    pub fn from_secret_bytes(secret: &[u8; SECRET_KEY_LENGTH]) -> Self {
209        Self {
210            signing: SigningKey::from_bytes(secret),
211        }
212    }
213
214    /// The raw secret, for building a `SigningHandoff` and nothing else.
215    ///
216    /// Named for its one use so a second one has to argue for itself: putting
217    /// these bytes anywhere they can be read back — a file, argv, an
218    /// environment variable — undoes the reason `keys/` is denied at all.
219    pub fn secret_bytes_for_handoff(&self) -> [u8; SECRET_KEY_LENGTH] {
220        self.signing.to_bytes()
221    }
222
223    /// Generate a fresh Ed25519 keypair using OS CSPRNG.
224    pub fn generate() -> Self {
225        Self {
226            signing: SigningKey::generate(&mut OsRng),
227        }
228    }
229
230    /// Write both halves of the keypair to the given directory.
231    /// Private key is mode 0600 on Unix.
232    ///
233    /// **Write-if-absent.** Refuses when `identity.key` already exists, because
234    /// `fs::write` truncates and a private key has nothing behind it to restore
235    /// from — no `.prev`, and no rotation attestation to bridge the swap, so
236    /// every event the old key signed silently stops attributing. A caller that
237    /// means to replace a key writes to a scratch directory and renames, which
238    /// is what `mur agent rekey` does.
239    ///
240    /// This is not hypothetical caution: `mur skill publish` guarded on
241    /// `publisher-identity.key` and wrote `identity.key`, so it overwrote the
242    /// host key on every machine that had one (#1011).
243    pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
244        fs::create_dir_all(dir)?;
245        // Private half goes to `keys/<name>/` for an agent home, `dir` itself
246        // otherwise (#850 option (c), step 1). Public half never moves — it is
247        // what peers read to verify, and `agents/` staying public-only is the
248        // whole point.
249        let key_dir = private_key_dir(dir);
250        fs::create_dir_all(&key_dir)?;
251        let priv_path = key_dir.join("identity.key");
252        let pub_path = dir.join("identity.pub");
253
254        // `exists()` is the right call here despite #1010: a false from a
255        // denied stat means we are about to fail the write anyway, and the
256        // conservative reading (treat unknown as "might exist") would block
257        // legitimate first-time saves under a sandbox.
258        if priv_path.exists() {
259            return Err(IdentityError::Exists(priv_path.display().to_string()));
260        }
261
262        fs::write(&priv_path, self.signing.to_bytes())?;
263        #[cfg(unix)]
264        {
265            let mut perms = fs::metadata(&priv_path)?.permissions();
266            perms.set_mode(0o600);
267            fs::set_permissions(&priv_path, perms)?;
268        }
269
270        let pub_text = encode_pubkey(&self.signing.verifying_key());
271        fs::write(&pub_path, pub_text)?;
272        Ok(())
273    }
274
275    /// Load both halves from the given directory. Prefers the private key
276    /// (since we can derive pubkey from it); but also validates that a
277    /// present `identity.pub` matches.
278    pub fn load(dir: &Path) -> Result<Self, IdentityError> {
279        // One location only (#850 option (c) step 3). The legacy fallback is
280        // gone because the migration runs at startup, in the same binary,
281        // BEFORE this load — so a key still in `agents/` gets moved and then
282        // found here. A machine upgrading straight past step 2 is covered for
283        // the same reason: the migration is cumulative, not a one-release
284        // window.
285        //
286        // What this does change: if the migration could not move the key, the
287        // load now fails instead of quietly reading the old path. That is the
288        // intent — an unmigrated key is one that is still readable to every
289        // sibling, and it should be loud.
290        let priv_path = private_key_dir(dir).join("identity.key");
291        // `Path::exists()` cannot be used here: it answers false for ANY stat
292        // failure, so a sandbox deny is indistinguishable from a missing file
293        // and the caller's "no key yet" branch runs when the truth is "you may
294        // not read this key". Ask for the metadata and keep the error kind.
295        if let Err(e) = fs::metadata(&priv_path) {
296            return Err(match e.kind() {
297                io::ErrorKind::NotFound => IdentityError::NotFound,
298                _ => IdentityError::Denied(format!("{}: {e}", priv_path.display())),
299            });
300        }
301        let bytes = fs::read(&priv_path).map_err(|e| match e.kind() {
302            io::ErrorKind::NotFound => IdentityError::NotFound,
303            io::ErrorKind::PermissionDenied => {
304                IdentityError::Denied(format!("{}: {e}", priv_path.display()))
305            }
306            _ => IdentityError::Io(e),
307        })?;
308        if bytes.len() != SECRET_KEY_LENGTH {
309            return Err(IdentityError::InvalidKey(format!(
310                "expected {SECRET_KEY_LENGTH} bytes, got {}",
311                bytes.len()
312            )));
313        }
314        let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
315        let signing = SigningKey::from_bytes(&arr);
316
317        let pub_path = dir.join("identity.pub");
318        if pub_path.exists() {
319            let text = fs::read_to_string(&pub_path)?;
320            let loaded_pub = decode_pubkey(text.trim())?;
321            if loaded_pub != *signing.verifying_key().as_bytes() {
322                return Err(IdentityError::InvalidKey(
323                    "identity.pub does not match identity.key".into(),
324                ));
325            }
326        }
327
328        Ok(Self { signing })
329    }
330
331    /// Load ONLY the public key from `<dir>/identity.pub` — for verifiers
332    /// (inbox ingest, proposal review) that must not require the private key.
333    pub fn load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
334        let path = dir.join("identity.pub");
335        if !path.exists() {
336            return Err(IdentityError::NotFound);
337        }
338        decode_pubkey(fs::read_to_string(&path)?.trim())
339    }
340
341    pub fn signing_key(&self) -> &SigningKey {
342        &self.signing
343    }
344
345    /// Sign `msg` with the Ed25519 private key and return the raw 64-byte
346    /// signature. Callers that only have a `&AgentIdentity` (and therefore
347    /// cannot import `ed25519_dalek::Signer` themselves) should use this
348    /// instead of calling `signing_key().sign()` directly.
349    pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
350        use ed25519_dalek::Signer;
351        self.signing.sign(msg).to_bytes()
352    }
353
354    /// Sign `msg` and encode the signature as multibase Base58Btc — the exact
355    /// encoding `verify_bytes` decodes (mirrors mur-channel/src/sign.rs).
356    pub fn sign_multibase(&self, msg: &[u8]) -> String {
357        multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
358    }
359
360    pub fn verifying_key(&self) -> VerifyingKey {
361        self.signing.verifying_key()
362    }
363
364    pub fn verifying_key_bytes(&self) -> [u8; 32] {
365        *self.signing.verifying_key().as_bytes()
366    }
367
368    pub fn pubkey_text(&self) -> String {
369        encode_pubkey(&self.signing.verifying_key())
370    }
371
372    /// Alias for `pubkey_text()` — returns the verifying key as multibase
373    /// base58btc (`z`-prefixed string), matching the `bridge_pubkey_multibase`
374    /// field used in signed envelopes.
375    pub fn public_key_multibase(&self) -> String {
376        encode_pubkey(&self.signing.verifying_key())
377    }
378
379    /// Derive the X25519 static secret usable by Noise XK.
380    ///
381    /// Ed25519 and X25519 both use Curve25519 underneath; the Ed25519
382    /// SigningKey scalar maps directly to an X25519 StaticSecret.
383    /// ed25519-dalek 2.x exposes `to_scalar_bytes()` for exactly this.
384    pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
385        let scalar_bytes = self.signing.to_scalar_bytes();
386        x25519_dalek::StaticSecret::from(scalar_bytes)
387    }
388}
389
390/// Verify a multibase-encoded Ed25519 signature over `msg` against `pubkey`.
391/// Fail-closed: any decode/length/verify error returns false.
392pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
393    let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
394        return false;
395    };
396    let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
397        return false;
398    };
399    let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
400        return false;
401    };
402    vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
403        .is_ok()
404}
405
406/// True iff `bytes` is a valid Ed25519 verifying key (on-curve), not just 32 bytes.
407pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
408    VerifyingKey::from_bytes(bytes).is_ok()
409}
410
411/// Encode an Ed25519 public key to multibase base58btc (`z` prefix).
412pub fn encode_pubkey(key: &VerifyingKey) -> String {
413    multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
414}
415
416/// Decode a multibase-encoded pubkey. Accepts any multibase variant.
417pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
418    let (_base, bytes) = multibase::decode(text)?;
419    if bytes.len() != 32 {
420        return Err(IdentityError::InvalidKey(format!(
421            "pubkey must be 32 bytes, got {}",
422            bytes.len()
423        )));
424    }
425    let mut out = [0u8; 32];
426    out.copy_from_slice(&bytes);
427    Ok(out)
428}
429
430/// Convert an Ed25519 public key to its X25519 (Montgomery `u`) public key.
431///
432/// Ed25519 and X25519 share Curve25519; an Ed25519 verifying key is an Edwards
433/// point whose Montgomery form is the corresponding X25519 public key. This is
434/// the public-key analogue of [`AgentIdentity::to_x25519_static_secret`], and
435/// lets us match a Noise-XK peer's authenticated static key against a peer's
436/// Ed25519 identity. Returns `None` if `ed_pub` is not a valid Edwards point.
437pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
438    let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
439    let point = compressed.decompress()?;
440    Some(point.to_montgomery().to_bytes())
441}
442
443/// Decode a multibase Ed25519 pubkey and convert it to its X25519 public key.
444pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
445    let ed = decode_pubkey(text)?;
446    ed25519_pub_to_x25519(&ed)
447        .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
448}
449
450/// Default location: `<agent_home>/identity.{key,pub}`.
451pub fn default_dir(agent_home: &Path) -> PathBuf {
452    agent_home.to_path_buf()
453}
454
455// ---------------------------------------------------------------------------
456// RotationAttestation — proof that a key rotation was authorized by the
457// holder of the prior identity key.
458// ---------------------------------------------------------------------------
459
460use serde::{Deserialize, Serialize};
461
462/// Why a rotation happened. Free-form audit hint; does not affect verification
463/// rules other than `Emergency`, which permits an empty signature.
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(rename_all = "snake_case")]
466pub enum RotationReason {
467    Scheduled,
468    SuspectCompromise,
469    OwnerChange,
470    Emergency,
471}
472
473/// Cryptographic proof of an identity-key rotation.
474///
475/// `signature` is multibase base58btc Ed25519 over `canonical_bytes()`
476/// (which serializes every field except `signature` itself).
477///
478/// For `reason = Emergency`, signature MAY be empty — those rotations
479/// require out-of-band admin approval to take effect.
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481pub struct RotationAttestation {
482    /// Schema version. Always 1 for now.
483    pub schema: u32,
484    /// Agent UUIDv7 — stable across rotations.
485    pub uuid: String,
486    /// Signing algorithm. "ed25519" for now.
487    pub algorithm: String,
488    /// Outgoing pubkey (multibase). Empty string for the bootstrap entry only.
489    pub old_pubkey: String,
490    /// Incoming pubkey (multibase). Always present.
491    pub new_pubkey: String,
492    pub old_key_version: u32,
493    /// = old_key_version + 1 for non-emergency rotations.
494    pub new_key_version: u32,
495    /// RFC3339 timestamp.
496    pub rotated_at: String,
497    pub reason: RotationReason,
498    /// Multibase Ed25519 signature over canonical_bytes(). Empty for
499    /// Emergency reason or for the bootstrap entry.
500    #[serde(default, skip_serializing_if = "String::is_empty")]
501    pub signature: String,
502    /// True only for the create-time entry (no prior key existed).
503    #[serde(default, skip_serializing_if = "is_false")]
504    pub bootstrap: bool,
505}
506
507fn is_false(b: &bool) -> bool {
508    !*b
509}
510
511impl RotationAttestation {
512    /// Build a new (unsigned) attestation.
513    pub fn new(
514        uuid: impl Into<String>,
515        old_pubkey: impl Into<String>,
516        new_pubkey: impl Into<String>,
517        old_key_version: u32,
518        new_key_version: u32,
519        rotated_at: impl Into<String>,
520        reason: RotationReason,
521    ) -> Self {
522        Self {
523            schema: 1,
524            uuid: uuid.into(),
525            algorithm: "ed25519".into(),
526            old_pubkey: old_pubkey.into(),
527            new_pubkey: new_pubkey.into(),
528            old_key_version,
529            new_key_version,
530            rotated_at: rotated_at.into(),
531            reason,
532            signature: String::new(),
533            bootstrap: false,
534        }
535    }
536
537    /// Mark this attestation as the bootstrap entry written at agent
538    /// create time. Bootstrap entries have empty `old_pubkey` and empty
539    /// `signature`; they exist only to anchor the rotation chain.
540    pub fn into_bootstrap(mut self) -> Self {
541        self.bootstrap = true;
542        self.old_pubkey = String::new();
543        self.signature = String::new();
544        self
545    }
546
547    /// Canonical bytes used for signing. Serializes every field of `self`
548    /// EXCEPT `signature` (which is being computed) using JSON with sorted
549    /// keys and no whitespace.
550    pub fn canonical_bytes(&self) -> Vec<u8> {
551        let mut clone = self.clone();
552        clone.signature = String::new();
553        canonical_json(&clone)
554    }
555
556    /// Compute the Ed25519 signature using the given signing key and store
557    /// it in `self.signature`. Idempotent.
558    pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
559        use ed25519_dalek::Signer;
560        let sig = signing.sign(&self.canonical_bytes());
561        self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
562    }
563
564    /// Verify `self.signature` against the supplied multibase-encoded
565    /// `old_pubkey`. Returns `Ok(())` on a valid signature.
566    ///
567    /// Bootstrap entries (`bootstrap = true`) are accepted unconditionally —
568    /// they have nothing to verify against.
569    /// Emergency entries (`reason = Emergency`) with empty signature are
570    /// REJECTED here; callers must use `verify_or_emergency` if they want
571    /// the emergency-allowed semantics.
572    pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
573        if self.bootstrap {
574            return Ok(());
575        }
576        if self.signature.is_empty() {
577            return Err(IdentityError::InvalidKey(
578                "attestation signature is empty".into(),
579            ));
580        }
581        let pub_bytes = decode_pubkey(old_pubkey)?;
582        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
583            .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
584        let (_base, sig_bytes) = multibase::decode(&self.signature)?;
585        let sig_arr: [u8; 64] = sig_bytes
586            .as_slice()
587            .try_into()
588            .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
589        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
590        verifying
591            .verify_strict(&self.canonical_bytes(), &sig)
592            .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
593        Ok(())
594    }
595
596    /// Like `verify`, but accepts emergency rotations with empty signature.
597    /// Caller is responsible for the out-of-band approval check.
598    pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
599        if self.reason == RotationReason::Emergency && self.signature.is_empty() {
600            return Ok(());
601        }
602        self.verify(old_pubkey)
603    }
604}
605
606// ---------------------------------------------------------------------------
607// Chain verification — M5.1
608// ---------------------------------------------------------------------------
609
610/// Per-call options for `verify_chain`.
611#[derive(Debug, Clone, Copy, Default)]
612pub struct ChainOptions {
613    /// If true, accept emergency entries with empty signature (i.e. use
614    /// `verify_or_emergency` instead of strict `verify`). Commander code
615    /// that has out-of-band approval already should set this true; peer
616    /// code that is mirroring without approval should leave it false.
617    pub allow_emergency: bool,
618}
619
620/// Outcome of a successful chain verification.
621#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct ChainOutcome {
623    /// Highest key_version observed.
624    pub head_key_version: u32,
625    /// Pubkey at head_key_version.
626    pub head_pubkey: String,
627    /// Total entries (including bootstrap).
628    pub length: usize,
629}
630
631/// Errors from `verify_chain`.
632#[derive(Debug)]
633pub enum ChainError {
634    /// Chain is empty or first entry is not a bootstrap.
635    MissingBootstrap,
636    /// Chain skipped a key_version (e.g. went 1 -> 3).
637    VersionSkip { expected: u32, got: u32 },
638    /// `a[i].old_pubkey` does not match `a[i-1].new_pubkey`.
639    PubkeyDiscontinuity { at_version: u32 },
640    /// Same `new_key_version` appears twice in the chain.
641    DuplicateVersion(u32),
642    /// Bad Ed25519 signature on a non-bootstrap, non-emergency entry.
643    BadSignature { at_version: u32, detail: String },
644    /// Emergency entry encountered with `allow_emergency = false`.
645    EmergencyDisallowed { at_version: u32 },
646}
647
648impl std::fmt::Display for ChainError {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        match self {
651            Self::MissingBootstrap => {
652                write!(
653                    f,
654                    "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
655                )
656            }
657            Self::VersionSkip { expected, got } => {
658                write!(f, "version skip: expected {expected}, got {got}")
659            }
660            Self::PubkeyDiscontinuity { at_version } => {
661                write!(
662                    f,
663                    "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
664                )
665            }
666            Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
667            Self::BadSignature { at_version, detail } => {
668                write!(f, "bad signature at key_version {at_version}: {detail}")
669            }
670            Self::EmergencyDisallowed { at_version } => {
671                write!(
672                    f,
673                    "emergency attestation at key_version {at_version} requires allow_emergency=true"
674                )
675            }
676        }
677    }
678}
679
680impl std::error::Error for ChainError {}
681
682/// Walk the chain top-to-bottom and verify it forms a valid history.
683/// Returns the head pubkey + version on success.
684pub fn verify_chain(
685    chain: &[RotationAttestation],
686    opts: ChainOptions,
687) -> std::result::Result<ChainOutcome, ChainError> {
688    if chain.is_empty() {
689        return Err(ChainError::MissingBootstrap);
690    }
691    let first = &chain[0];
692    if !first.bootstrap || first.new_key_version != 0 {
693        return Err(ChainError::MissingBootstrap);
694    }
695
696    let mut prev_pubkey = first.new_pubkey.clone();
697    let mut prev_version = 0u32;
698    let mut seen_versions = std::collections::HashSet::new();
699    seen_versions.insert(0u32);
700
701    for (i, a) in chain.iter().enumerate().skip(1) {
702        // No duplicate versions
703        if !seen_versions.insert(a.new_key_version) {
704            return Err(ChainError::DuplicateVersion(a.new_key_version));
705        }
706        // Strict +1 succession
707        let expected = prev_version + 1;
708        if a.old_key_version != prev_version || a.new_key_version != expected {
709            return Err(ChainError::VersionSkip {
710                expected,
711                got: a.new_key_version,
712            });
713        }
714        // Pubkey continuity
715        if a.old_pubkey != prev_pubkey {
716            return Err(ChainError::PubkeyDiscontinuity {
717                at_version: a.new_key_version,
718            });
719        }
720        // Signature (or emergency allowance)
721        if a.reason == RotationReason::Emergency {
722            if !opts.allow_emergency {
723                return Err(ChainError::EmergencyDisallowed {
724                    at_version: a.new_key_version,
725                });
726            }
727            // Lenient verify: empty signature is fine for emergency
728            if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
729                return Err(ChainError::BadSignature {
730                    at_version: a.new_key_version,
731                    detail: e.to_string(),
732                });
733            }
734        } else if let Err(e) = a.verify(&a.old_pubkey) {
735            return Err(ChainError::BadSignature {
736                at_version: a.new_key_version,
737                detail: e.to_string(),
738            });
739        }
740
741        prev_pubkey = a.new_pubkey.clone();
742        prev_version = a.new_key_version;
743        let _ = i; // silence unused
744    }
745
746    Ok(ChainOutcome {
747        head_key_version: prev_version,
748        head_pubkey: prev_pubkey,
749        length: chain.len(),
750    })
751}
752
753/// Canonical JSON: sorted keys, no whitespace. Used so that signers and
754/// verifiers compute identical byte sequences regardless of language /
755/// serializer choices.
756fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
757    // serde_json with a BTreeMap-like ordering. The simplest approach: round-trip
758    // through a `serde_json::Value`, then walk it depth-first emitting bytes.
759    let v: serde_json::Value =
760        serde_json::to_value(value).expect("serialize should not fail for our types");
761    let mut out = Vec::new();
762    write_canonical(&mut out, &v);
763    out
764}
765
766fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
767    use serde_json::Value;
768    match v {
769        Value::Null => out.extend_from_slice(b"null"),
770        Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
771        Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
772        Value::String(s) => {
773            // serde_json::to_string handles escaping for us
774            let escaped = serde_json::to_string(s).unwrap();
775            out.extend_from_slice(escaped.as_bytes());
776        }
777        Value::Array(arr) => {
778            out.push(b'[');
779            for (i, item) in arr.iter().enumerate() {
780                if i > 0 {
781                    out.push(b',');
782                }
783                write_canonical(out, item);
784            }
785            out.push(b']');
786        }
787        Value::Object(map) => {
788            // Sort keys for deterministic output
789            let mut keys: Vec<&String> = map.keys().collect();
790            keys.sort();
791            out.push(b'{');
792            for (i, k) in keys.iter().enumerate() {
793                if i > 0 {
794                    out.push(b',');
795                }
796                let kesc = serde_json::to_string(k).unwrap();
797                out.extend_from_slice(kesc.as_bytes());
798                out.push(b':');
799                write_canonical(out, &map[*k]);
800            }
801            out.push(b'}');
802        }
803    }
804}
805
806#[cfg(test)]
807mod identity_readability_tests {
808    use super::*;
809
810    /// The handoff crosses a pipe as ONE line and must rebuild the same key.
811    ///
812    /// Both halves matter: the receiver does `read_line`, so a payload that
813    /// could carry a newline would arrive truncated and silently unsigned; and
814    /// a rebuilt identity that is not bit-identical signs events no verifier
815    /// accepts, which reads as tampering rather than as a bug.
816    #[test]
817    fn a_signing_handoff_round_trips_to_the_same_key_on_one_line() {
818        let id = AgentIdentity::generate();
819        let line = serde_json::to_string(&SigningHandoff {
820            agent: "mur".into(),
821            key_version: 3,
822            secret: id.secret_bytes_for_handoff(),
823        })
824        .expect("serialize handoff");
825        assert!(!line.contains('\n'), "must survive read_line: {line}");
826
827        let back: SigningHandoff = serde_json::from_str(&line).expect("parse handoff");
828        assert_eq!(back.agent, "mur");
829        assert_eq!(back.key_version, 3);
830        assert_eq!(
831            AgentIdentity::from_secret_bytes(&back.secret).verifying_key_bytes(),
832            id.verifying_key_bytes(),
833            "the child must sign as the same agent, not a lookalike"
834        );
835    }
836
837    /// A key that exists but cannot be read must NOT report as absent.
838    ///
839    /// `Path::exists()` answers false for any stat failure, so before this the
840    /// two were indistinguishable and every caller's "no key yet" branch ran
841    /// when the truth was "you may not read this key" — which is exactly what
842    /// a sandbox deny produces.
843    #[cfg(unix)]
844    #[test]
845    fn an_unreadable_key_is_denied_not_notfound() {
846        use std::os::unix::fs::PermissionsExt;
847        let dir = tempfile::tempdir().unwrap();
848        AgentIdentity::generate().save(dir.path()).unwrap();
849        let key = dir.path().join("identity.key");
850
851        // Precondition: readable right now, so the assert below is about the
852        // permission change and nothing else.
853        assert!(AgentIdentity::load(dir.path()).is_ok());
854
855        // Case 1 — the file is unreadable but still STATtable (chmod 000).
856        // `exists()` says true here, so this exercises the read mapping.
857        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o000)).unwrap();
858        let unreadable = AgentIdentity::load(dir.path()).unwrap_err();
859        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
860        assert!(
861            matches!(unreadable, IdentityError::Denied(_)),
862            "an unreadable key must be Denied, got {unreadable:?}"
863        );
864
865        // Case 2 — the file cannot even be STATted, because the directory
866        // holding it is not searchable. THIS is what a sandbox deny looks
867        // like, and it is the case `Path::exists()` gets wrong: it answers
868        // false, so the old code reported NotFound and every caller's
869        // "no key yet" branch ran.
870        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
871        let unstattable = AgentIdentity::load(dir.path()).unwrap_err();
872        let exists_lies = !key.exists();
873        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
874        assert!(
875            exists_lies,
876            "precondition: Path::exists() must be answering false here, or this \
877             case is not reproducing a sandbox deny"
878        );
879        assert!(
880            matches!(unstattable, IdentityError::Denied(_)),
881            "a key that cannot be STATted must be Denied, not NotFound, got {unstattable:?}"
882        );
883    }
884
885    /// An agent home's private key moves; its public half does not.
886    #[test]
887    fn an_agent_key_moves_and_the_public_half_stays() {
888        let tmp = tempfile::tempdir().unwrap();
889        let mur = tmp.path();
890        let agent = mur.join("agents").join("pm");
891        AgentIdentity::generate().save(&agent).unwrap();
892
893        assert!(
894            mur.join("keys").join("pm").join("identity.key").exists(),
895            "private key must live under keys/"
896        );
897        assert!(
898            !agent.join("identity.key").exists(),
899            "the agents tree must hold no private key"
900        );
901        assert!(
902            agent.join("identity.pub").exists(),
903            "the public half must stay where peers read it"
904        );
905    }
906
907    /// The three callers that pass a directory outside `agents/` must be left
908    /// exactly as they are — the spec named this as the main correctness risk.
909    #[test]
910    fn non_agent_identities_are_not_remapped() {
911        let tmp = tempfile::tempdir().unwrap();
912        let mur = tmp.path();
913        for dir in [
914            mur.to_path_buf(),     // host key
915            mur.join("commander"), // commander signing identity
916            mur.join("publisher"), // skill publisher identity (#1013)
917        ] {
918            assert_eq!(
919                private_key_dir(&dir),
920                dir,
921                "{} must not be remapped",
922                dir.display()
923            );
924            AgentIdentity::generate().save(&dir).unwrap();
925            assert!(
926                dir.join("identity.key").exists(),
927                "{} lost its key to the remap",
928                dir.display()
929            );
930        }
931    }
932
933    /// A key left in the legacy location no longer loads on its own — and that
934    /// is the point of step 3. It becomes loadable by MIGRATING it, which is
935    /// what every agent does at startup before this call.
936    ///
937    /// This replaces step 1's fallback test. Keeping the fallback would have
938    /// meant a private key could sit in the agents tree indefinitely, readable
939    /// to every sibling, with nothing ever saying so.
940    #[test]
941    fn a_legacy_key_loads_only_after_migration() {
942        let tmp = tempfile::tempdir().unwrap();
943        let agent = tmp.path().join("agents").join("legacy");
944        std::fs::create_dir_all(&agent).unwrap();
945        let id = AgentIdentity::generate();
946        std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
947
948        // Before migration: not found at the only location that is consulted.
949        assert!(
950            matches!(
951                AgentIdentity::load(&agent).unwrap_err(),
952                IdentityError::NotFound
953            ),
954            "a key in the legacy location must not be silently honoured"
955        );
956
957        // Startup migrates, then loads — the real sequence.
958        assert!(migrate_private_key(&agent).unwrap());
959        assert_eq!(
960            AgentIdentity::load(&agent).unwrap().pubkey_text(),
961            id.pubkey_text()
962        );
963    }
964
965    /// ...and when both exist, the new location wins, so a completed migration
966    /// is authoritative even if a stale file is left behind.
967    #[test]
968    fn the_new_location_wins_over_a_leftover_legacy_key() {
969        let tmp = tempfile::tempdir().unwrap();
970        let mur = tmp.path();
971        let agent = mur.join("agents").join("dual");
972        let current = AgentIdentity::generate();
973        current.save(&agent).unwrap();
974        let stale = AgentIdentity::generate();
975        std::fs::create_dir_all(&agent).unwrap();
976        std::fs::write(agent.join("identity.key"), stale.signing.to_bytes()).unwrap();
977
978        let loaded = AgentIdentity::load(&agent).unwrap();
979        assert_eq!(
980            loaded.pubkey_text(),
981            current.pubkey_text(),
982            "the migrated key must win over the leftover"
983        );
984    }
985
986    /// The migration moves the key and leaves nothing behind.
987    #[test]
988    fn migration_moves_the_key_out_of_the_agents_tree() {
989        let tmp = tempfile::tempdir().unwrap();
990        let mur = tmp.path();
991        let agent = mur.join("agents").join("pm");
992        std::fs::create_dir_all(&agent).unwrap();
993        let id = AgentIdentity::generate();
994        std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
995
996        assert!(migrate_private_key(&agent).unwrap());
997
998        assert!(!agent.join("identity.key").exists(), "key left in agents/");
999        assert!(mur.join("keys/pm/identity.key").exists());
1000        assert_eq!(
1001            AgentIdentity::load(&agent).unwrap().pubkey_text(),
1002            id.pubkey_text(),
1003            "the same identity must load after the move"
1004        );
1005    }
1006
1007    /// Idempotent: a second run finds nothing to do.
1008    #[test]
1009    fn migration_is_idempotent() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let agent = tmp.path().join("agents").join("pm");
1012        AgentIdentity::generate().save(&agent).unwrap(); // already in keys/
1013        assert!(!migrate_private_key(&agent).unwrap());
1014        assert!(!migrate_private_key(&agent).unwrap());
1015    }
1016
1017    /// The property that protects an unrecoverable file: when the destination
1018    /// holds a DIFFERENT key, refuse and touch nothing. Picking one silently
1019    /// would change the agent's identity and forge attribution on every channel
1020    /// it has written to.
1021    #[test]
1022    fn migration_refuses_when_the_destination_differs() {
1023        let tmp = tempfile::tempdir().unwrap();
1024        let mur = tmp.path();
1025        let agent = mur.join("agents").join("pm");
1026        let migrated = AgentIdentity::generate();
1027        migrated.save(&agent).unwrap();
1028        let stray = AgentIdentity::generate();
1029        std::fs::write(agent.join("identity.key"), stray.signing.to_bytes()).unwrap();
1030
1031        let err = migrate_private_key(&agent).unwrap_err();
1032
1033        assert!(matches!(err, IdentityError::Exists(_)), "got {err:?}");
1034        assert_eq!(
1035            std::fs::read(mur.join("keys/pm/identity.key")).unwrap(),
1036            migrated.signing.to_bytes().to_vec(),
1037            "the destination key was modified despite the refusal"
1038        );
1039        assert!(
1040            agent.join("identity.key").exists(),
1041            "the source was removed despite the refusal"
1042        );
1043    }
1044
1045    /// An identical leftover copy is cleaned up rather than refused — leaving a
1046    /// private key in the agents tree is the exposure being removed.
1047    #[test]
1048    fn migration_clears_an_identical_leftover() {
1049        let tmp = tempfile::tempdir().unwrap();
1050        let agent = tmp.path().join("agents").join("pm");
1051        let id = AgentIdentity::generate();
1052        id.save(&agent).unwrap();
1053        std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
1054
1055        assert!(!migrate_private_key(&agent).unwrap());
1056        assert!(!agent.join("identity.key").exists());
1057    }
1058
1059    /// The three non-agent identities must never be migrated.
1060    #[test]
1061    fn migration_skips_non_agent_identities() {
1062        let tmp = tempfile::tempdir().unwrap();
1063        let mur = tmp.path();
1064        for dir in [
1065            mur.to_path_buf(),
1066            mur.join("commander"),
1067            mur.join("publisher"),
1068        ] {
1069            AgentIdentity::generate().save(&dir).unwrap();
1070            assert!(!migrate_private_key(&dir).unwrap());
1071            assert!(dir.join("identity.key").exists(), "{}", dir.display());
1072        }
1073    }
1074
1075    /// `save` must never clobber an existing private key.
1076    ///
1077    /// This is the mechanism that would have turned #1011 into a loud failure:
1078    /// `mur skill publish` called `save` on a directory that already held the
1079    /// HOST key, and `fs::write` truncated it. There is no `.prev` and no
1080    /// rotation attestation for such a swap, so the old key's signatures stop
1081    /// attributing with nothing to restore from.
1082    #[test]
1083    fn save_refuses_to_overwrite_an_existing_key() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let first = AgentIdentity::generate();
1086        first.save(dir.path()).unwrap();
1087        let original = std::fs::read(dir.path().join("identity.key")).unwrap();
1088
1089        let err = AgentIdentity::generate().save(dir.path()).unwrap_err();
1090
1091        assert!(
1092            matches!(err, IdentityError::Exists(_)),
1093            "expected Exists, got {err:?}"
1094        );
1095        assert_eq!(
1096            std::fs::read(dir.path().join("identity.key")).unwrap(),
1097            original,
1098            "the existing key was modified despite the refusal"
1099        );
1100    }
1101
1102    /// ...but a first save into a fresh directory still works, which is every
1103    /// legitimate caller (agent create, export minting a missing key, rekey
1104    /// writing to its scratch dir).
1105    #[test]
1106    fn save_into_an_empty_directory_succeeds() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let id = AgentIdentity::generate();
1109        id.save(dir.path()).unwrap();
1110        assert_eq!(
1111            AgentIdentity::load(dir.path()).unwrap().pubkey_text(),
1112            id.pubkey_text()
1113        );
1114    }
1115
1116    /// ...and a genuinely absent key still reports NotFound, because callers
1117    /// legitimately treat that as "nothing signed yet".
1118    #[test]
1119    fn a_missing_key_is_still_notfound() {
1120        let dir = tempfile::tempdir().unwrap();
1121        assert!(matches!(
1122            AgentIdentity::load(dir.path()).unwrap_err(),
1123            IdentityError::NotFound
1124        ));
1125    }
1126}
1127
1128#[cfg(test)]
1129mod identity_x25519_tests {
1130    use super::*;
1131
1132    #[test]
1133    fn x25519_pub_matches_secret_derivation() {
1134        // The public-side Ed25519→X25519 conversion must equal the X25519
1135        // public derived from the agent's own static secret — otherwise the
1136        // Noise peer-auth allowlist would never match `get_remote_static()`.
1137        let id = AgentIdentity::generate();
1138        let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
1139        let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
1140        assert_eq!(from_secret.as_bytes(), &from_pub);
1141    }
1142}