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