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
809#[cfg(test)]
810mod identity_x25519_tests;