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