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
171impl AgentIdentity {
172    /// Generate a fresh Ed25519 keypair using OS CSPRNG.
173    pub fn generate() -> Self {
174        Self {
175            signing: SigningKey::generate(&mut OsRng),
176        }
177    }
178
179    /// Write both halves of the keypair to the given directory.
180    /// Private key is mode 0600 on Unix.
181    ///
182    /// **Write-if-absent.** Refuses when `identity.key` already exists, because
183    /// `fs::write` truncates and a private key has nothing behind it to restore
184    /// from — no `.prev`, and no rotation attestation to bridge the swap, so
185    /// every event the old key signed silently stops attributing. A caller that
186    /// means to replace a key writes to a scratch directory and renames, which
187    /// is what `mur agent rekey` does.
188    ///
189    /// This is not hypothetical caution: `mur skill publish` guarded on
190    /// `publisher-identity.key` and wrote `identity.key`, so it overwrote the
191    /// host key on every machine that had one (#1011).
192    pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
193        fs::create_dir_all(dir)?;
194        // Private half goes to `keys/<name>/` for an agent home, `dir` itself
195        // otherwise (#850 option (c), step 1). Public half never moves — it is
196        // what peers read to verify, and `agents/` staying public-only is the
197        // whole point.
198        let key_dir = private_key_dir(dir);
199        fs::create_dir_all(&key_dir)?;
200        let priv_path = key_dir.join("identity.key");
201        let pub_path = dir.join("identity.pub");
202
203        // `exists()` is the right call here despite #1010: a false from a
204        // denied stat means we are about to fail the write anyway, and the
205        // conservative reading (treat unknown as "might exist") would block
206        // legitimate first-time saves under a sandbox.
207        if priv_path.exists() {
208            return Err(IdentityError::Exists(priv_path.display().to_string()));
209        }
210
211        fs::write(&priv_path, self.signing.to_bytes())?;
212        #[cfg(unix)]
213        {
214            let mut perms = fs::metadata(&priv_path)?.permissions();
215            perms.set_mode(0o600);
216            fs::set_permissions(&priv_path, perms)?;
217        }
218
219        let pub_text = encode_pubkey(&self.signing.verifying_key());
220        fs::write(&pub_path, pub_text)?;
221        Ok(())
222    }
223
224    /// Load both halves from the given directory. Prefers the private key
225    /// (since we can derive pubkey from it); but also validates that a
226    /// present `identity.pub` matches.
227    pub fn load(dir: &Path) -> Result<Self, IdentityError> {
228        // One location only (#850 option (c) step 3). The legacy fallback is
229        // gone because the migration runs at startup, in the same binary,
230        // BEFORE this load — so a key still in `agents/` gets moved and then
231        // found here. A machine upgrading straight past step 2 is covered for
232        // the same reason: the migration is cumulative, not a one-release
233        // window.
234        //
235        // What this does change: if the migration could not move the key, the
236        // load now fails instead of quietly reading the old path. That is the
237        // intent — an unmigrated key is one that is still readable to every
238        // sibling, and it should be loud.
239        let priv_path = private_key_dir(dir).join("identity.key");
240        // `Path::exists()` cannot be used here: it answers false for ANY stat
241        // failure, so a sandbox deny is indistinguishable from a missing file
242        // and the caller's "no key yet" branch runs when the truth is "you may
243        // not read this key". Ask for the metadata and keep the error kind.
244        if let Err(e) = fs::metadata(&priv_path) {
245            return Err(match e.kind() {
246                io::ErrorKind::NotFound => IdentityError::NotFound,
247                _ => IdentityError::Denied(format!("{}: {e}", priv_path.display())),
248            });
249        }
250        let bytes = fs::read(&priv_path).map_err(|e| match e.kind() {
251            io::ErrorKind::NotFound => IdentityError::NotFound,
252            io::ErrorKind::PermissionDenied => {
253                IdentityError::Denied(format!("{}: {e}", priv_path.display()))
254            }
255            _ => IdentityError::Io(e),
256        })?;
257        if bytes.len() != SECRET_KEY_LENGTH {
258            return Err(IdentityError::InvalidKey(format!(
259                "expected {SECRET_KEY_LENGTH} bytes, got {}",
260                bytes.len()
261            )));
262        }
263        let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
264        let signing = SigningKey::from_bytes(&arr);
265
266        let pub_path = dir.join("identity.pub");
267        if pub_path.exists() {
268            let text = fs::read_to_string(&pub_path)?;
269            let loaded_pub = decode_pubkey(text.trim())?;
270            if loaded_pub != *signing.verifying_key().as_bytes() {
271                return Err(IdentityError::InvalidKey(
272                    "identity.pub does not match identity.key".into(),
273                ));
274            }
275        }
276
277        Ok(Self { signing })
278    }
279
280    /// Load ONLY the public key from `<dir>/identity.pub` — for verifiers
281    /// (inbox ingest, proposal review) that must not require the private key.
282    pub fn load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
283        let path = dir.join("identity.pub");
284        if !path.exists() {
285            return Err(IdentityError::NotFound);
286        }
287        decode_pubkey(fs::read_to_string(&path)?.trim())
288    }
289
290    pub fn signing_key(&self) -> &SigningKey {
291        &self.signing
292    }
293
294    /// Sign `msg` with the Ed25519 private key and return the raw 64-byte
295    /// signature. Callers that only have a `&AgentIdentity` (and therefore
296    /// cannot import `ed25519_dalek::Signer` themselves) should use this
297    /// instead of calling `signing_key().sign()` directly.
298    pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
299        use ed25519_dalek::Signer;
300        self.signing.sign(msg).to_bytes()
301    }
302
303    /// Sign `msg` and encode the signature as multibase Base58Btc — the exact
304    /// encoding `verify_bytes` decodes (mirrors mur-channel/src/sign.rs).
305    pub fn sign_multibase(&self, msg: &[u8]) -> String {
306        multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
307    }
308
309    pub fn verifying_key(&self) -> VerifyingKey {
310        self.signing.verifying_key()
311    }
312
313    pub fn verifying_key_bytes(&self) -> [u8; 32] {
314        *self.signing.verifying_key().as_bytes()
315    }
316
317    pub fn pubkey_text(&self) -> String {
318        encode_pubkey(&self.signing.verifying_key())
319    }
320
321    /// Alias for `pubkey_text()` — returns the verifying key as multibase
322    /// base58btc (`z`-prefixed string), matching the `bridge_pubkey_multibase`
323    /// field used in signed envelopes.
324    pub fn public_key_multibase(&self) -> String {
325        encode_pubkey(&self.signing.verifying_key())
326    }
327
328    /// Derive the X25519 static secret usable by Noise XK.
329    ///
330    /// Ed25519 and X25519 both use Curve25519 underneath; the Ed25519
331    /// SigningKey scalar maps directly to an X25519 StaticSecret.
332    /// ed25519-dalek 2.x exposes `to_scalar_bytes()` for exactly this.
333    pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
334        let scalar_bytes = self.signing.to_scalar_bytes();
335        x25519_dalek::StaticSecret::from(scalar_bytes)
336    }
337}
338
339/// Verify a multibase-encoded Ed25519 signature over `msg` against `pubkey`.
340/// Fail-closed: any decode/length/verify error returns false.
341pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
342    let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
343        return false;
344    };
345    let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
346        return false;
347    };
348    let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
349        return false;
350    };
351    vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
352        .is_ok()
353}
354
355/// True iff `bytes` is a valid Ed25519 verifying key (on-curve), not just 32 bytes.
356pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
357    VerifyingKey::from_bytes(bytes).is_ok()
358}
359
360/// Encode an Ed25519 public key to multibase base58btc (`z` prefix).
361pub fn encode_pubkey(key: &VerifyingKey) -> String {
362    multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
363}
364
365/// Decode a multibase-encoded pubkey. Accepts any multibase variant.
366pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
367    let (_base, bytes) = multibase::decode(text)?;
368    if bytes.len() != 32 {
369        return Err(IdentityError::InvalidKey(format!(
370            "pubkey must be 32 bytes, got {}",
371            bytes.len()
372        )));
373    }
374    let mut out = [0u8; 32];
375    out.copy_from_slice(&bytes);
376    Ok(out)
377}
378
379/// Convert an Ed25519 public key to its X25519 (Montgomery `u`) public key.
380///
381/// Ed25519 and X25519 share Curve25519; an Ed25519 verifying key is an Edwards
382/// point whose Montgomery form is the corresponding X25519 public key. This is
383/// the public-key analogue of [`AgentIdentity::to_x25519_static_secret`], and
384/// lets us match a Noise-XK peer's authenticated static key against a peer's
385/// Ed25519 identity. Returns `None` if `ed_pub` is not a valid Edwards point.
386pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
387    let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
388    let point = compressed.decompress()?;
389    Some(point.to_montgomery().to_bytes())
390}
391
392/// Decode a multibase Ed25519 pubkey and convert it to its X25519 public key.
393pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
394    let ed = decode_pubkey(text)?;
395    ed25519_pub_to_x25519(&ed)
396        .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
397}
398
399/// Default location: `<agent_home>/identity.{key,pub}`.
400pub fn default_dir(agent_home: &Path) -> PathBuf {
401    agent_home.to_path_buf()
402}
403
404// ---------------------------------------------------------------------------
405// RotationAttestation — proof that a key rotation was authorized by the
406// holder of the prior identity key.
407// ---------------------------------------------------------------------------
408
409use serde::{Deserialize, Serialize};
410
411/// Why a rotation happened. Free-form audit hint; does not affect verification
412/// rules other than `Emergency`, which permits an empty signature.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
414#[serde(rename_all = "snake_case")]
415pub enum RotationReason {
416    Scheduled,
417    SuspectCompromise,
418    OwnerChange,
419    Emergency,
420}
421
422/// Cryptographic proof of an identity-key rotation.
423///
424/// `signature` is multibase base58btc Ed25519 over `canonical_bytes()`
425/// (which serializes every field except `signature` itself).
426///
427/// For `reason = Emergency`, signature MAY be empty — those rotations
428/// require out-of-band admin approval to take effect.
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct RotationAttestation {
431    /// Schema version. Always 1 for now.
432    pub schema: u32,
433    /// Agent UUIDv7 — stable across rotations.
434    pub uuid: String,
435    /// Signing algorithm. "ed25519" for now.
436    pub algorithm: String,
437    /// Outgoing pubkey (multibase). Empty string for the bootstrap entry only.
438    pub old_pubkey: String,
439    /// Incoming pubkey (multibase). Always present.
440    pub new_pubkey: String,
441    pub old_key_version: u32,
442    /// = old_key_version + 1 for non-emergency rotations.
443    pub new_key_version: u32,
444    /// RFC3339 timestamp.
445    pub rotated_at: String,
446    pub reason: RotationReason,
447    /// Multibase Ed25519 signature over canonical_bytes(). Empty for
448    /// Emergency reason or for the bootstrap entry.
449    #[serde(default, skip_serializing_if = "String::is_empty")]
450    pub signature: String,
451    /// True only for the create-time entry (no prior key existed).
452    #[serde(default, skip_serializing_if = "is_false")]
453    pub bootstrap: bool,
454}
455
456fn is_false(b: &bool) -> bool {
457    !*b
458}
459
460impl RotationAttestation {
461    /// Build a new (unsigned) attestation.
462    pub fn new(
463        uuid: impl Into<String>,
464        old_pubkey: impl Into<String>,
465        new_pubkey: impl Into<String>,
466        old_key_version: u32,
467        new_key_version: u32,
468        rotated_at: impl Into<String>,
469        reason: RotationReason,
470    ) -> Self {
471        Self {
472            schema: 1,
473            uuid: uuid.into(),
474            algorithm: "ed25519".into(),
475            old_pubkey: old_pubkey.into(),
476            new_pubkey: new_pubkey.into(),
477            old_key_version,
478            new_key_version,
479            rotated_at: rotated_at.into(),
480            reason,
481            signature: String::new(),
482            bootstrap: false,
483        }
484    }
485
486    /// Mark this attestation as the bootstrap entry written at agent
487    /// create time. Bootstrap entries have empty `old_pubkey` and empty
488    /// `signature`; they exist only to anchor the rotation chain.
489    pub fn into_bootstrap(mut self) -> Self {
490        self.bootstrap = true;
491        self.old_pubkey = String::new();
492        self.signature = String::new();
493        self
494    }
495
496    /// Canonical bytes used for signing. Serializes every field of `self`
497    /// EXCEPT `signature` (which is being computed) using JSON with sorted
498    /// keys and no whitespace.
499    pub fn canonical_bytes(&self) -> Vec<u8> {
500        let mut clone = self.clone();
501        clone.signature = String::new();
502        canonical_json(&clone)
503    }
504
505    /// Compute the Ed25519 signature using the given signing key and store
506    /// it in `self.signature`. Idempotent.
507    pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
508        use ed25519_dalek::Signer;
509        let sig = signing.sign(&self.canonical_bytes());
510        self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
511    }
512
513    /// Verify `self.signature` against the supplied multibase-encoded
514    /// `old_pubkey`. Returns `Ok(())` on a valid signature.
515    ///
516    /// Bootstrap entries (`bootstrap = true`) are accepted unconditionally —
517    /// they have nothing to verify against.
518    /// Emergency entries (`reason = Emergency`) with empty signature are
519    /// REJECTED here; callers must use `verify_or_emergency` if they want
520    /// the emergency-allowed semantics.
521    pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
522        if self.bootstrap {
523            return Ok(());
524        }
525        if self.signature.is_empty() {
526            return Err(IdentityError::InvalidKey(
527                "attestation signature is empty".into(),
528            ));
529        }
530        let pub_bytes = decode_pubkey(old_pubkey)?;
531        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
532            .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
533        let (_base, sig_bytes) = multibase::decode(&self.signature)?;
534        let sig_arr: [u8; 64] = sig_bytes
535            .as_slice()
536            .try_into()
537            .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
538        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
539        verifying
540            .verify_strict(&self.canonical_bytes(), &sig)
541            .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
542        Ok(())
543    }
544
545    /// Like `verify`, but accepts emergency rotations with empty signature.
546    /// Caller is responsible for the out-of-band approval check.
547    pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
548        if self.reason == RotationReason::Emergency && self.signature.is_empty() {
549            return Ok(());
550        }
551        self.verify(old_pubkey)
552    }
553}
554
555// ---------------------------------------------------------------------------
556// Chain verification — M5.1
557// ---------------------------------------------------------------------------
558
559/// Per-call options for `verify_chain`.
560#[derive(Debug, Clone, Copy, Default)]
561pub struct ChainOptions {
562    /// If true, accept emergency entries with empty signature (i.e. use
563    /// `verify_or_emergency` instead of strict `verify`). Commander code
564    /// that has out-of-band approval already should set this true; peer
565    /// code that is mirroring without approval should leave it false.
566    pub allow_emergency: bool,
567}
568
569/// Outcome of a successful chain verification.
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct ChainOutcome {
572    /// Highest key_version observed.
573    pub head_key_version: u32,
574    /// Pubkey at head_key_version.
575    pub head_pubkey: String,
576    /// Total entries (including bootstrap).
577    pub length: usize,
578}
579
580/// Errors from `verify_chain`.
581#[derive(Debug)]
582pub enum ChainError {
583    /// Chain is empty or first entry is not a bootstrap.
584    MissingBootstrap,
585    /// Chain skipped a key_version (e.g. went 1 -> 3).
586    VersionSkip { expected: u32, got: u32 },
587    /// `a[i].old_pubkey` does not match `a[i-1].new_pubkey`.
588    PubkeyDiscontinuity { at_version: u32 },
589    /// Same `new_key_version` appears twice in the chain.
590    DuplicateVersion(u32),
591    /// Bad Ed25519 signature on a non-bootstrap, non-emergency entry.
592    BadSignature { at_version: u32, detail: String },
593    /// Emergency entry encountered with `allow_emergency = false`.
594    EmergencyDisallowed { at_version: u32 },
595}
596
597impl std::fmt::Display for ChainError {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        match self {
600            Self::MissingBootstrap => {
601                write!(
602                    f,
603                    "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
604                )
605            }
606            Self::VersionSkip { expected, got } => {
607                write!(f, "version skip: expected {expected}, got {got}")
608            }
609            Self::PubkeyDiscontinuity { at_version } => {
610                write!(
611                    f,
612                    "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
613                )
614            }
615            Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
616            Self::BadSignature { at_version, detail } => {
617                write!(f, "bad signature at key_version {at_version}: {detail}")
618            }
619            Self::EmergencyDisallowed { at_version } => {
620                write!(
621                    f,
622                    "emergency attestation at key_version {at_version} requires allow_emergency=true"
623                )
624            }
625        }
626    }
627}
628
629impl std::error::Error for ChainError {}
630
631/// Walk the chain top-to-bottom and verify it forms a valid history.
632/// Returns the head pubkey + version on success.
633pub fn verify_chain(
634    chain: &[RotationAttestation],
635    opts: ChainOptions,
636) -> std::result::Result<ChainOutcome, ChainError> {
637    if chain.is_empty() {
638        return Err(ChainError::MissingBootstrap);
639    }
640    let first = &chain[0];
641    if !first.bootstrap || first.new_key_version != 0 {
642        return Err(ChainError::MissingBootstrap);
643    }
644
645    let mut prev_pubkey = first.new_pubkey.clone();
646    let mut prev_version = 0u32;
647    let mut seen_versions = std::collections::HashSet::new();
648    seen_versions.insert(0u32);
649
650    for (i, a) in chain.iter().enumerate().skip(1) {
651        // No duplicate versions
652        if !seen_versions.insert(a.new_key_version) {
653            return Err(ChainError::DuplicateVersion(a.new_key_version));
654        }
655        // Strict +1 succession
656        let expected = prev_version + 1;
657        if a.old_key_version != prev_version || a.new_key_version != expected {
658            return Err(ChainError::VersionSkip {
659                expected,
660                got: a.new_key_version,
661            });
662        }
663        // Pubkey continuity
664        if a.old_pubkey != prev_pubkey {
665            return Err(ChainError::PubkeyDiscontinuity {
666                at_version: a.new_key_version,
667            });
668        }
669        // Signature (or emergency allowance)
670        if a.reason == RotationReason::Emergency {
671            if !opts.allow_emergency {
672                return Err(ChainError::EmergencyDisallowed {
673                    at_version: a.new_key_version,
674                });
675            }
676            // Lenient verify: empty signature is fine for emergency
677            if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
678                return Err(ChainError::BadSignature {
679                    at_version: a.new_key_version,
680                    detail: e.to_string(),
681                });
682            }
683        } else if let Err(e) = a.verify(&a.old_pubkey) {
684            return Err(ChainError::BadSignature {
685                at_version: a.new_key_version,
686                detail: e.to_string(),
687            });
688        }
689
690        prev_pubkey = a.new_pubkey.clone();
691        prev_version = a.new_key_version;
692        let _ = i; // silence unused
693    }
694
695    Ok(ChainOutcome {
696        head_key_version: prev_version,
697        head_pubkey: prev_pubkey,
698        length: chain.len(),
699    })
700}
701
702/// Canonical JSON: sorted keys, no whitespace. Used so that signers and
703/// verifiers compute identical byte sequences regardless of language /
704/// serializer choices.
705fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
706    // serde_json with a BTreeMap-like ordering. The simplest approach: round-trip
707    // through a `serde_json::Value`, then walk it depth-first emitting bytes.
708    let v: serde_json::Value =
709        serde_json::to_value(value).expect("serialize should not fail for our types");
710    let mut out = Vec::new();
711    write_canonical(&mut out, &v);
712    out
713}
714
715fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
716    use serde_json::Value;
717    match v {
718        Value::Null => out.extend_from_slice(b"null"),
719        Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
720        Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
721        Value::String(s) => {
722            // serde_json::to_string handles escaping for us
723            let escaped = serde_json::to_string(s).unwrap();
724            out.extend_from_slice(escaped.as_bytes());
725        }
726        Value::Array(arr) => {
727            out.push(b'[');
728            for (i, item) in arr.iter().enumerate() {
729                if i > 0 {
730                    out.push(b',');
731                }
732                write_canonical(out, item);
733            }
734            out.push(b']');
735        }
736        Value::Object(map) => {
737            // Sort keys for deterministic output
738            let mut keys: Vec<&String> = map.keys().collect();
739            keys.sort();
740            out.push(b'{');
741            for (i, k) in keys.iter().enumerate() {
742                if i > 0 {
743                    out.push(b',');
744                }
745                let kesc = serde_json::to_string(k).unwrap();
746                out.extend_from_slice(kesc.as_bytes());
747                out.push(b':');
748                write_canonical(out, &map[*k]);
749            }
750            out.push(b'}');
751        }
752    }
753}
754
755#[cfg(test)]
756mod identity_readability_tests {
757    use super::*;
758
759    /// A key that exists but cannot be read must NOT report as absent.
760    ///
761    /// `Path::exists()` answers false for any stat failure, so before this the
762    /// two were indistinguishable and every caller's "no key yet" branch ran
763    /// when the truth was "you may not read this key" — which is exactly what
764    /// a sandbox deny produces.
765    #[cfg(unix)]
766    #[test]
767    fn an_unreadable_key_is_denied_not_notfound() {
768        use std::os::unix::fs::PermissionsExt;
769        let dir = tempfile::tempdir().unwrap();
770        AgentIdentity::generate().save(dir.path()).unwrap();
771        let key = dir.path().join("identity.key");
772
773        // Precondition: readable right now, so the assert below is about the
774        // permission change and nothing else.
775        assert!(AgentIdentity::load(dir.path()).is_ok());
776
777        // Case 1 — the file is unreadable but still STATtable (chmod 000).
778        // `exists()` says true here, so this exercises the read mapping.
779        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o000)).unwrap();
780        let unreadable = AgentIdentity::load(dir.path()).unwrap_err();
781        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
782        assert!(
783            matches!(unreadable, IdentityError::Denied(_)),
784            "an unreadable key must be Denied, got {unreadable:?}"
785        );
786
787        // Case 2 — the file cannot even be STATted, because the directory
788        // holding it is not searchable. THIS is what a sandbox deny looks
789        // like, and it is the case `Path::exists()` gets wrong: it answers
790        // false, so the old code reported NotFound and every caller's
791        // "no key yet" branch ran.
792        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
793        let unstattable = AgentIdentity::load(dir.path()).unwrap_err();
794        let exists_lies = !key.exists();
795        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
796        assert!(
797            exists_lies,
798            "precondition: Path::exists() must be answering false here, or this \
799             case is not reproducing a sandbox deny"
800        );
801        assert!(
802            matches!(unstattable, IdentityError::Denied(_)),
803            "a key that cannot be STATted must be Denied, not NotFound, got {unstattable:?}"
804        );
805    }
806
807    /// An agent home's private key moves; its public half does not.
808    #[test]
809    fn an_agent_key_moves_and_the_public_half_stays() {
810        let tmp = tempfile::tempdir().unwrap();
811        let mur = tmp.path();
812        let agent = mur.join("agents").join("pm");
813        AgentIdentity::generate().save(&agent).unwrap();
814
815        assert!(
816            mur.join("keys").join("pm").join("identity.key").exists(),
817            "private key must live under keys/"
818        );
819        assert!(
820            !agent.join("identity.key").exists(),
821            "the agents tree must hold no private key"
822        );
823        assert!(
824            agent.join("identity.pub").exists(),
825            "the public half must stay where peers read it"
826        );
827    }
828
829    /// The three callers that pass a directory outside `agents/` must be left
830    /// exactly as they are — the spec named this as the main correctness risk.
831    #[test]
832    fn non_agent_identities_are_not_remapped() {
833        let tmp = tempfile::tempdir().unwrap();
834        let mur = tmp.path();
835        for dir in [
836            mur.to_path_buf(),     // host key
837            mur.join("commander"), // commander signing identity
838            mur.join("publisher"), // skill publisher identity (#1013)
839        ] {
840            assert_eq!(
841                private_key_dir(&dir),
842                dir,
843                "{} must not be remapped",
844                dir.display()
845            );
846            AgentIdentity::generate().save(&dir).unwrap();
847            assert!(
848                dir.join("identity.key").exists(),
849                "{} lost its key to the remap",
850                dir.display()
851            );
852        }
853    }
854
855    /// A key left in the legacy location no longer loads on its own — and that
856    /// is the point of step 3. It becomes loadable by MIGRATING it, which is
857    /// what every agent does at startup before this call.
858    ///
859    /// This replaces step 1's fallback test. Keeping the fallback would have
860    /// meant a private key could sit in the agents tree indefinitely, readable
861    /// to every sibling, with nothing ever saying so.
862    #[test]
863    fn a_legacy_key_loads_only_after_migration() {
864        let tmp = tempfile::tempdir().unwrap();
865        let agent = tmp.path().join("agents").join("legacy");
866        std::fs::create_dir_all(&agent).unwrap();
867        let id = AgentIdentity::generate();
868        std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
869
870        // Before migration: not found at the only location that is consulted.
871        assert!(
872            matches!(
873                AgentIdentity::load(&agent).unwrap_err(),
874                IdentityError::NotFound
875            ),
876            "a key in the legacy location must not be silently honoured"
877        );
878
879        // Startup migrates, then loads — the real sequence.
880        assert!(migrate_private_key(&agent).unwrap());
881        assert_eq!(
882            AgentIdentity::load(&agent).unwrap().pubkey_text(),
883            id.pubkey_text()
884        );
885    }
886
887    /// ...and when both exist, the new location wins, so a completed migration
888    /// is authoritative even if a stale file is left behind.
889    #[test]
890    fn the_new_location_wins_over_a_leftover_legacy_key() {
891        let tmp = tempfile::tempdir().unwrap();
892        let mur = tmp.path();
893        let agent = mur.join("agents").join("dual");
894        let current = AgentIdentity::generate();
895        current.save(&agent).unwrap();
896        let stale = AgentIdentity::generate();
897        std::fs::create_dir_all(&agent).unwrap();
898        std::fs::write(agent.join("identity.key"), stale.signing.to_bytes()).unwrap();
899
900        let loaded = AgentIdentity::load(&agent).unwrap();
901        assert_eq!(
902            loaded.pubkey_text(),
903            current.pubkey_text(),
904            "the migrated key must win over the leftover"
905        );
906    }
907
908    /// The migration moves the key and leaves nothing behind.
909    #[test]
910    fn migration_moves_the_key_out_of_the_agents_tree() {
911        let tmp = tempfile::tempdir().unwrap();
912        let mur = tmp.path();
913        let agent = mur.join("agents").join("pm");
914        std::fs::create_dir_all(&agent).unwrap();
915        let id = AgentIdentity::generate();
916        std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
917
918        assert!(migrate_private_key(&agent).unwrap());
919
920        assert!(!agent.join("identity.key").exists(), "key left in agents/");
921        assert!(mur.join("keys/pm/identity.key").exists());
922        assert_eq!(
923            AgentIdentity::load(&agent).unwrap().pubkey_text(),
924            id.pubkey_text(),
925            "the same identity must load after the move"
926        );
927    }
928
929    /// Idempotent: a second run finds nothing to do.
930    #[test]
931    fn migration_is_idempotent() {
932        let tmp = tempfile::tempdir().unwrap();
933        let agent = tmp.path().join("agents").join("pm");
934        AgentIdentity::generate().save(&agent).unwrap(); // already in keys/
935        assert!(!migrate_private_key(&agent).unwrap());
936        assert!(!migrate_private_key(&agent).unwrap());
937    }
938
939    /// The property that protects an unrecoverable file: when the destination
940    /// holds a DIFFERENT key, refuse and touch nothing. Picking one silently
941    /// would change the agent's identity and forge attribution on every channel
942    /// it has written to.
943    #[test]
944    fn migration_refuses_when_the_destination_differs() {
945        let tmp = tempfile::tempdir().unwrap();
946        let mur = tmp.path();
947        let agent = mur.join("agents").join("pm");
948        let migrated = AgentIdentity::generate();
949        migrated.save(&agent).unwrap();
950        let stray = AgentIdentity::generate();
951        std::fs::write(agent.join("identity.key"), stray.signing.to_bytes()).unwrap();
952
953        let err = migrate_private_key(&agent).unwrap_err();
954
955        assert!(matches!(err, IdentityError::Exists(_)), "got {err:?}");
956        assert_eq!(
957            std::fs::read(mur.join("keys/pm/identity.key")).unwrap(),
958            migrated.signing.to_bytes().to_vec(),
959            "the destination key was modified despite the refusal"
960        );
961        assert!(
962            agent.join("identity.key").exists(),
963            "the source was removed despite the refusal"
964        );
965    }
966
967    /// An identical leftover copy is cleaned up rather than refused — leaving a
968    /// private key in the agents tree is the exposure being removed.
969    #[test]
970    fn migration_clears_an_identical_leftover() {
971        let tmp = tempfile::tempdir().unwrap();
972        let agent = tmp.path().join("agents").join("pm");
973        let id = AgentIdentity::generate();
974        id.save(&agent).unwrap();
975        std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
976
977        assert!(!migrate_private_key(&agent).unwrap());
978        assert!(!agent.join("identity.key").exists());
979    }
980
981    /// The three non-agent identities must never be migrated.
982    #[test]
983    fn migration_skips_non_agent_identities() {
984        let tmp = tempfile::tempdir().unwrap();
985        let mur = tmp.path();
986        for dir in [
987            mur.to_path_buf(),
988            mur.join("commander"),
989            mur.join("publisher"),
990        ] {
991            AgentIdentity::generate().save(&dir).unwrap();
992            assert!(!migrate_private_key(&dir).unwrap());
993            assert!(dir.join("identity.key").exists(), "{}", dir.display());
994        }
995    }
996
997    /// `save` must never clobber an existing private key.
998    ///
999    /// This is the mechanism that would have turned #1011 into a loud failure:
1000    /// `mur skill publish` called `save` on a directory that already held the
1001    /// HOST key, and `fs::write` truncated it. There is no `.prev` and no
1002    /// rotation attestation for such a swap, so the old key's signatures stop
1003    /// attributing with nothing to restore from.
1004    #[test]
1005    fn save_refuses_to_overwrite_an_existing_key() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let first = AgentIdentity::generate();
1008        first.save(dir.path()).unwrap();
1009        let original = std::fs::read(dir.path().join("identity.key")).unwrap();
1010
1011        let err = AgentIdentity::generate().save(dir.path()).unwrap_err();
1012
1013        assert!(
1014            matches!(err, IdentityError::Exists(_)),
1015            "expected Exists, got {err:?}"
1016        );
1017        assert_eq!(
1018            std::fs::read(dir.path().join("identity.key")).unwrap(),
1019            original,
1020            "the existing key was modified despite the refusal"
1021        );
1022    }
1023
1024    /// ...but a first save into a fresh directory still works, which is every
1025    /// legitimate caller (agent create, export minting a missing key, rekey
1026    /// writing to its scratch dir).
1027    #[test]
1028    fn save_into_an_empty_directory_succeeds() {
1029        let dir = tempfile::tempdir().unwrap();
1030        let id = AgentIdentity::generate();
1031        id.save(dir.path()).unwrap();
1032        assert_eq!(
1033            AgentIdentity::load(dir.path()).unwrap().pubkey_text(),
1034            id.pubkey_text()
1035        );
1036    }
1037
1038    /// ...and a genuinely absent key still reports NotFound, because callers
1039    /// legitimately treat that as "nothing signed yet".
1040    #[test]
1041    fn a_missing_key_is_still_notfound() {
1042        let dir = tempfile::tempdir().unwrap();
1043        assert!(matches!(
1044            AgentIdentity::load(dir.path()).unwrap_err(),
1045            IdentityError::NotFound
1046        ));
1047    }
1048}
1049
1050#[cfg(test)]
1051mod identity_x25519_tests {
1052    use super::*;
1053
1054    #[test]
1055    fn x25519_pub_matches_secret_derivation() {
1056        // The public-side Ed25519→X25519 conversion must equal the X25519
1057        // public derived from the agent's own static secret — otherwise the
1058        // Noise peer-auth allowlist would never match `get_remote_static()`.
1059        let id = AgentIdentity::generate();
1060        let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
1061        let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
1062        assert_eq!(from_secret.as_bytes(), &from_pub);
1063    }
1064}