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    #[error("io error: {0}")]
20    Io(#[from] io::Error),
21    #[error("invalid key material: {0}")]
22    InvalidKey(String),
23    #[error("multibase decode error: {0}")]
24    Multibase(#[from] multibase::Error),
25}
26
27#[derive(Clone)]
28pub struct AgentIdentity {
29    signing: SigningKey,
30}
31
32impl std::fmt::Debug for AgentIdentity {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("AgentIdentity")
35            .field("verifying_key", &self.signing.verifying_key())
36            .finish()
37    }
38}
39
40impl AgentIdentity {
41    /// Generate a fresh Ed25519 keypair using OS CSPRNG.
42    pub fn generate() -> Self {
43        Self {
44            signing: SigningKey::generate(&mut OsRng),
45        }
46    }
47
48    /// Write both halves of the keypair to the given directory.
49    /// Private key is mode 0600 on Unix.
50    pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
51        fs::create_dir_all(dir)?;
52        let priv_path = dir.join("identity.key");
53        let pub_path = dir.join("identity.pub");
54
55        fs::write(&priv_path, self.signing.to_bytes())?;
56        #[cfg(unix)]
57        {
58            let mut perms = fs::metadata(&priv_path)?.permissions();
59            perms.set_mode(0o600);
60            fs::set_permissions(&priv_path, perms)?;
61        }
62
63        let pub_text = encode_pubkey(&self.signing.verifying_key());
64        fs::write(&pub_path, pub_text)?;
65        Ok(())
66    }
67
68    /// Load both halves from the given directory. Prefers the private key
69    /// (since we can derive pubkey from it); but also validates that a
70    /// present `identity.pub` matches.
71    pub fn load(dir: &Path) -> Result<Self, IdentityError> {
72        let priv_path = dir.join("identity.key");
73        if !priv_path.exists() {
74            return Err(IdentityError::NotFound);
75        }
76        let bytes = fs::read(&priv_path)?;
77        if bytes.len() != SECRET_KEY_LENGTH {
78            return Err(IdentityError::InvalidKey(format!(
79                "expected {SECRET_KEY_LENGTH} bytes, got {}",
80                bytes.len()
81            )));
82        }
83        let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
84        let signing = SigningKey::from_bytes(&arr);
85
86        let pub_path = dir.join("identity.pub");
87        if pub_path.exists() {
88            let text = fs::read_to_string(&pub_path)?;
89            let loaded_pub = decode_pubkey(text.trim())?;
90            if loaded_pub != *signing.verifying_key().as_bytes() {
91                return Err(IdentityError::InvalidKey(
92                    "identity.pub does not match identity.key".into(),
93                ));
94            }
95        }
96
97        Ok(Self { signing })
98    }
99
100    /// Load ONLY the public key from `<dir>/identity.pub` — for verifiers
101    /// (inbox ingest, proposal review) that must not require the private key.
102    pub fn load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
103        let path = dir.join("identity.pub");
104        if !path.exists() {
105            return Err(IdentityError::NotFound);
106        }
107        decode_pubkey(fs::read_to_string(&path)?.trim())
108    }
109
110    pub fn signing_key(&self) -> &SigningKey {
111        &self.signing
112    }
113
114    /// Sign `msg` with the Ed25519 private key and return the raw 64-byte
115    /// signature. Callers that only have a `&AgentIdentity` (and therefore
116    /// cannot import `ed25519_dalek::Signer` themselves) should use this
117    /// instead of calling `signing_key().sign()` directly.
118    pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
119        use ed25519_dalek::Signer;
120        self.signing.sign(msg).to_bytes()
121    }
122
123    /// Sign `msg` and encode the signature as multibase Base58Btc — the exact
124    /// encoding `verify_bytes` decodes (mirrors mur-channel/src/sign.rs).
125    pub fn sign_multibase(&self, msg: &[u8]) -> String {
126        multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
127    }
128
129    pub fn verifying_key(&self) -> VerifyingKey {
130        self.signing.verifying_key()
131    }
132
133    pub fn verifying_key_bytes(&self) -> [u8; 32] {
134        *self.signing.verifying_key().as_bytes()
135    }
136
137    pub fn pubkey_text(&self) -> String {
138        encode_pubkey(&self.signing.verifying_key())
139    }
140
141    /// Alias for `pubkey_text()` — returns the verifying key as multibase
142    /// base58btc (`z`-prefixed string), matching the `bridge_pubkey_multibase`
143    /// field used in signed envelopes.
144    pub fn public_key_multibase(&self) -> String {
145        encode_pubkey(&self.signing.verifying_key())
146    }
147
148    /// Derive the X25519 static secret usable by Noise XK.
149    ///
150    /// Ed25519 and X25519 both use Curve25519 underneath; the Ed25519
151    /// SigningKey scalar maps directly to an X25519 StaticSecret.
152    /// ed25519-dalek 2.x exposes `to_scalar_bytes()` for exactly this.
153    pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
154        let scalar_bytes = self.signing.to_scalar_bytes();
155        x25519_dalek::StaticSecret::from(scalar_bytes)
156    }
157}
158
159/// Verify a multibase-encoded Ed25519 signature over `msg` against `pubkey`.
160/// Fail-closed: any decode/length/verify error returns false.
161pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
162    let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
163        return false;
164    };
165    let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
166        return false;
167    };
168    let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
169        return false;
170    };
171    vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
172        .is_ok()
173}
174
175/// True iff `bytes` is a valid Ed25519 verifying key (on-curve), not just 32 bytes.
176pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
177    VerifyingKey::from_bytes(bytes).is_ok()
178}
179
180/// Encode an Ed25519 public key to multibase base58btc (`z` prefix).
181pub fn encode_pubkey(key: &VerifyingKey) -> String {
182    multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
183}
184
185/// Decode a multibase-encoded pubkey. Accepts any multibase variant.
186pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
187    let (_base, bytes) = multibase::decode(text)?;
188    if bytes.len() != 32 {
189        return Err(IdentityError::InvalidKey(format!(
190            "pubkey must be 32 bytes, got {}",
191            bytes.len()
192        )));
193    }
194    let mut out = [0u8; 32];
195    out.copy_from_slice(&bytes);
196    Ok(out)
197}
198
199/// Convert an Ed25519 public key to its X25519 (Montgomery `u`) public key.
200///
201/// Ed25519 and X25519 share Curve25519; an Ed25519 verifying key is an Edwards
202/// point whose Montgomery form is the corresponding X25519 public key. This is
203/// the public-key analogue of [`AgentIdentity::to_x25519_static_secret`], and
204/// lets us match a Noise-XK peer's authenticated static key against a peer's
205/// Ed25519 identity. Returns `None` if `ed_pub` is not a valid Edwards point.
206pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
207    let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
208    let point = compressed.decompress()?;
209    Some(point.to_montgomery().to_bytes())
210}
211
212/// Decode a multibase Ed25519 pubkey and convert it to its X25519 public key.
213pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
214    let ed = decode_pubkey(text)?;
215    ed25519_pub_to_x25519(&ed)
216        .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
217}
218
219/// Default location: `<agent_home>/identity.{key,pub}`.
220pub fn default_dir(agent_home: &Path) -> PathBuf {
221    agent_home.to_path_buf()
222}
223
224// ---------------------------------------------------------------------------
225// RotationAttestation — proof that a key rotation was authorized by the
226// holder of the prior identity key.
227// ---------------------------------------------------------------------------
228
229use serde::{Deserialize, Serialize};
230
231/// Why a rotation happened. Free-form audit hint; does not affect verification
232/// rules other than `Emergency`, which permits an empty signature.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum RotationReason {
236    Scheduled,
237    SuspectCompromise,
238    OwnerChange,
239    Emergency,
240}
241
242/// Cryptographic proof of an identity-key rotation.
243///
244/// `signature` is multibase base58btc Ed25519 over `canonical_bytes()`
245/// (which serializes every field except `signature` itself).
246///
247/// For `reason = Emergency`, signature MAY be empty — those rotations
248/// require out-of-band admin approval to take effect.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct RotationAttestation {
251    /// Schema version. Always 1 for now.
252    pub schema: u32,
253    /// Agent UUIDv7 — stable across rotations.
254    pub uuid: String,
255    /// Signing algorithm. "ed25519" for now.
256    pub algorithm: String,
257    /// Outgoing pubkey (multibase). Empty string for the bootstrap entry only.
258    pub old_pubkey: String,
259    /// Incoming pubkey (multibase). Always present.
260    pub new_pubkey: String,
261    pub old_key_version: u32,
262    /// = old_key_version + 1 for non-emergency rotations.
263    pub new_key_version: u32,
264    /// RFC3339 timestamp.
265    pub rotated_at: String,
266    pub reason: RotationReason,
267    /// Multibase Ed25519 signature over canonical_bytes(). Empty for
268    /// Emergency reason or for the bootstrap entry.
269    #[serde(default, skip_serializing_if = "String::is_empty")]
270    pub signature: String,
271    /// True only for the create-time entry (no prior key existed).
272    #[serde(default, skip_serializing_if = "is_false")]
273    pub bootstrap: bool,
274}
275
276fn is_false(b: &bool) -> bool {
277    !*b
278}
279
280impl RotationAttestation {
281    /// Build a new (unsigned) attestation.
282    pub fn new(
283        uuid: impl Into<String>,
284        old_pubkey: impl Into<String>,
285        new_pubkey: impl Into<String>,
286        old_key_version: u32,
287        new_key_version: u32,
288        rotated_at: impl Into<String>,
289        reason: RotationReason,
290    ) -> Self {
291        Self {
292            schema: 1,
293            uuid: uuid.into(),
294            algorithm: "ed25519".into(),
295            old_pubkey: old_pubkey.into(),
296            new_pubkey: new_pubkey.into(),
297            old_key_version,
298            new_key_version,
299            rotated_at: rotated_at.into(),
300            reason,
301            signature: String::new(),
302            bootstrap: false,
303        }
304    }
305
306    /// Mark this attestation as the bootstrap entry written at agent
307    /// create time. Bootstrap entries have empty `old_pubkey` and empty
308    /// `signature`; they exist only to anchor the rotation chain.
309    pub fn into_bootstrap(mut self) -> Self {
310        self.bootstrap = true;
311        self.old_pubkey = String::new();
312        self.signature = String::new();
313        self
314    }
315
316    /// Canonical bytes used for signing. Serializes every field of `self`
317    /// EXCEPT `signature` (which is being computed) using JSON with sorted
318    /// keys and no whitespace.
319    pub fn canonical_bytes(&self) -> Vec<u8> {
320        let mut clone = self.clone();
321        clone.signature = String::new();
322        canonical_json(&clone)
323    }
324
325    /// Compute the Ed25519 signature using the given signing key and store
326    /// it in `self.signature`. Idempotent.
327    pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
328        use ed25519_dalek::Signer;
329        let sig = signing.sign(&self.canonical_bytes());
330        self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
331    }
332
333    /// Verify `self.signature` against the supplied multibase-encoded
334    /// `old_pubkey`. Returns `Ok(())` on a valid signature.
335    ///
336    /// Bootstrap entries (`bootstrap = true`) are accepted unconditionally —
337    /// they have nothing to verify against.
338    /// Emergency entries (`reason = Emergency`) with empty signature are
339    /// REJECTED here; callers must use `verify_or_emergency` if they want
340    /// the emergency-allowed semantics.
341    pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
342        if self.bootstrap {
343            return Ok(());
344        }
345        if self.signature.is_empty() {
346            return Err(IdentityError::InvalidKey(
347                "attestation signature is empty".into(),
348            ));
349        }
350        let pub_bytes = decode_pubkey(old_pubkey)?;
351        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
352            .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
353        let (_base, sig_bytes) = multibase::decode(&self.signature)?;
354        let sig_arr: [u8; 64] = sig_bytes
355            .as_slice()
356            .try_into()
357            .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
358        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
359        verifying
360            .verify_strict(&self.canonical_bytes(), &sig)
361            .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
362        Ok(())
363    }
364
365    /// Like `verify`, but accepts emergency rotations with empty signature.
366    /// Caller is responsible for the out-of-band approval check.
367    pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
368        if self.reason == RotationReason::Emergency && self.signature.is_empty() {
369            return Ok(());
370        }
371        self.verify(old_pubkey)
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Chain verification — M5.1
377// ---------------------------------------------------------------------------
378
379/// Per-call options for `verify_chain`.
380#[derive(Debug, Clone, Copy, Default)]
381pub struct ChainOptions {
382    /// If true, accept emergency entries with empty signature (i.e. use
383    /// `verify_or_emergency` instead of strict `verify`). Commander code
384    /// that has out-of-band approval already should set this true; peer
385    /// code that is mirroring without approval should leave it false.
386    pub allow_emergency: bool,
387}
388
389/// Outcome of a successful chain verification.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct ChainOutcome {
392    /// Highest key_version observed.
393    pub head_key_version: u32,
394    /// Pubkey at head_key_version.
395    pub head_pubkey: String,
396    /// Total entries (including bootstrap).
397    pub length: usize,
398}
399
400/// Errors from `verify_chain`.
401#[derive(Debug)]
402pub enum ChainError {
403    /// Chain is empty or first entry is not a bootstrap.
404    MissingBootstrap,
405    /// Chain skipped a key_version (e.g. went 1 -> 3).
406    VersionSkip { expected: u32, got: u32 },
407    /// `a[i].old_pubkey` does not match `a[i-1].new_pubkey`.
408    PubkeyDiscontinuity { at_version: u32 },
409    /// Same `new_key_version` appears twice in the chain.
410    DuplicateVersion(u32),
411    /// Bad Ed25519 signature on a non-bootstrap, non-emergency entry.
412    BadSignature { at_version: u32, detail: String },
413    /// Emergency entry encountered with `allow_emergency = false`.
414    EmergencyDisallowed { at_version: u32 },
415}
416
417impl std::fmt::Display for ChainError {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        match self {
420            Self::MissingBootstrap => {
421                write!(
422                    f,
423                    "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
424                )
425            }
426            Self::VersionSkip { expected, got } => {
427                write!(f, "version skip: expected {expected}, got {got}")
428            }
429            Self::PubkeyDiscontinuity { at_version } => {
430                write!(
431                    f,
432                    "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
433                )
434            }
435            Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
436            Self::BadSignature { at_version, detail } => {
437                write!(f, "bad signature at key_version {at_version}: {detail}")
438            }
439            Self::EmergencyDisallowed { at_version } => {
440                write!(
441                    f,
442                    "emergency attestation at key_version {at_version} requires allow_emergency=true"
443                )
444            }
445        }
446    }
447}
448
449impl std::error::Error for ChainError {}
450
451/// Walk the chain top-to-bottom and verify it forms a valid history.
452/// Returns the head pubkey + version on success.
453pub fn verify_chain(
454    chain: &[RotationAttestation],
455    opts: ChainOptions,
456) -> std::result::Result<ChainOutcome, ChainError> {
457    if chain.is_empty() {
458        return Err(ChainError::MissingBootstrap);
459    }
460    let first = &chain[0];
461    if !first.bootstrap || first.new_key_version != 0 {
462        return Err(ChainError::MissingBootstrap);
463    }
464
465    let mut prev_pubkey = first.new_pubkey.clone();
466    let mut prev_version = 0u32;
467    let mut seen_versions = std::collections::HashSet::new();
468    seen_versions.insert(0u32);
469
470    for (i, a) in chain.iter().enumerate().skip(1) {
471        // No duplicate versions
472        if !seen_versions.insert(a.new_key_version) {
473            return Err(ChainError::DuplicateVersion(a.new_key_version));
474        }
475        // Strict +1 succession
476        let expected = prev_version + 1;
477        if a.old_key_version != prev_version || a.new_key_version != expected {
478            return Err(ChainError::VersionSkip {
479                expected,
480                got: a.new_key_version,
481            });
482        }
483        // Pubkey continuity
484        if a.old_pubkey != prev_pubkey {
485            return Err(ChainError::PubkeyDiscontinuity {
486                at_version: a.new_key_version,
487            });
488        }
489        // Signature (or emergency allowance)
490        if a.reason == RotationReason::Emergency {
491            if !opts.allow_emergency {
492                return Err(ChainError::EmergencyDisallowed {
493                    at_version: a.new_key_version,
494                });
495            }
496            // Lenient verify: empty signature is fine for emergency
497            if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
498                return Err(ChainError::BadSignature {
499                    at_version: a.new_key_version,
500                    detail: e.to_string(),
501                });
502            }
503        } else if let Err(e) = a.verify(&a.old_pubkey) {
504            return Err(ChainError::BadSignature {
505                at_version: a.new_key_version,
506                detail: e.to_string(),
507            });
508        }
509
510        prev_pubkey = a.new_pubkey.clone();
511        prev_version = a.new_key_version;
512        let _ = i; // silence unused
513    }
514
515    Ok(ChainOutcome {
516        head_key_version: prev_version,
517        head_pubkey: prev_pubkey,
518        length: chain.len(),
519    })
520}
521
522/// Canonical JSON: sorted keys, no whitespace. Used so that signers and
523/// verifiers compute identical byte sequences regardless of language /
524/// serializer choices.
525fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
526    // serde_json with a BTreeMap-like ordering. The simplest approach: round-trip
527    // through a `serde_json::Value`, then walk it depth-first emitting bytes.
528    let v: serde_json::Value =
529        serde_json::to_value(value).expect("serialize should not fail for our types");
530    let mut out = Vec::new();
531    write_canonical(&mut out, &v);
532    out
533}
534
535fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
536    use serde_json::Value;
537    match v {
538        Value::Null => out.extend_from_slice(b"null"),
539        Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
540        Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
541        Value::String(s) => {
542            // serde_json::to_string handles escaping for us
543            let escaped = serde_json::to_string(s).unwrap();
544            out.extend_from_slice(escaped.as_bytes());
545        }
546        Value::Array(arr) => {
547            out.push(b'[');
548            for (i, item) in arr.iter().enumerate() {
549                if i > 0 {
550                    out.push(b',');
551                }
552                write_canonical(out, item);
553            }
554            out.push(b']');
555        }
556        Value::Object(map) => {
557            // Sort keys for deterministic output
558            let mut keys: Vec<&String> = map.keys().collect();
559            keys.sort();
560            out.push(b'{');
561            for (i, k) in keys.iter().enumerate() {
562                if i > 0 {
563                    out.push(b',');
564                }
565                let kesc = serde_json::to_string(k).unwrap();
566                out.extend_from_slice(kesc.as_bytes());
567                out.push(b':');
568                write_canonical(out, &map[*k]);
569            }
570            out.push(b'}');
571        }
572    }
573}
574
575#[cfg(test)]
576mod identity_x25519_tests {
577    use super::*;
578
579    #[test]
580    fn x25519_pub_matches_secret_derivation() {
581        // The public-side Ed25519→X25519 conversion must equal the X25519
582        // public derived from the agent's own static secret — otherwise the
583        // Noise peer-auth allowlist would never match `get_remote_static()`.
584        let id = AgentIdentity::generate();
585        let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
586        let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
587        assert_eq!(from_secret.as_bytes(), &from_pub);
588    }
589}