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