Skip to main content

choir_identity/
lib.rs

1//! L8 identity: one ed25519 key per actor, signatures over op entries
2//! (DECISIONS.md D13, key-per-agent).
3//!
4//! An actor's id is the self-describing [`ContentHash`] of its public
5//! key, so ids survive a future signature-scheme change the same way
6//! content addresses survive a hash change (D6). The signature covers
7//! [`choir_oplog::OpEntry::signing_hash`] — the entry with the signature
8//! field blanked — and lands in the entry's additive `author_sig` field,
9//! so pre-L8 logs remain valid and verification is opt-in per deployment
10//! until the daemon leaves localhost.
11//!
12//! Trust policy (who may touch which workspace/ref) is a later layer;
13//! this crate only answers "is this entry really from that key".
14//!
15//! # Examples
16//!
17//! ```
18//! use choir_identity::{ActorKey, Registry};
19//! use choir_oplog::{OpEntry, FORMAT_VERSION};
20//!
21//! let key = ActorKey::generate();
22//! let mut registry = Registry::new();
23//! registry.register(&key.public_key_bytes()).unwrap();
24//!
25//! let mut entry = OpEntry {
26//!     format_version: FORMAT_VERSION,
27//!     parent: None,
28//!     seq: 0,
29//!     channel: "agent-1".into(),
30//!     payload: b"op".to_vec(),
31//!     witnesses: Vec::new(),
32//!     author_sig: None,
33//! };
34//! key.sign_entry(&mut entry);
35//! assert_eq!(registry.verify_entry(&entry).unwrap(), key.actor_id());
36//! ```
37//!
38//! # Where this sits
39//!
40//! `docs/architecture.md` is the map of the whole workspace.
41//! This crate is L8, one ed25519 key per actor and the signatures over log entries.
42//!
43//! It builds on [`choir_hash`] and [`choir_oplog`].
44
45pub mod sync;
46
47use choir_hash::ContentHash;
48use choir_oplog::{OpEntry, Witness};
49use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
50
51/// Failure modes of signing and verification.
52#[derive(Debug, PartialEq, Eq)]
53pub enum IdentityError {
54    /// Entry has no `author_sig`.
55    Unsigned,
56    /// Signing key id is not in the registry.
57    UnknownKey(String),
58    /// Signature bytes are malformed or do not verify.
59    BadSignature,
60    /// Public key bytes are not a valid ed25519 key.
61    BadKey,
62    /// The external verifier could not be run at all, with the reason.
63    ///
64    /// Kept apart from [`IdentityError::BadSignature`] for the same reason
65    /// [`IdentityError::UnknownKey`] is: the repairs are opposite. A
66    /// signature that does not verify is evidence about the request; a
67    /// verifier that will not start is evidence about the host, and
68    /// answering the second with the first would report an operational
69    /// fault as an attack.
70    Verifier(String),
71    /// The signature names a scheme this verifier cannot check, carrying
72    /// the tag it named ([`choir_oplog::scheme`]).
73    ///
74    /// Separate from [`IdentityError::BadSignature`] because the two are
75    /// different events: a bad signature is a claim that failed, while
76    /// this is a claim never examined. Collapsing them would let a
77    /// future scheme look like an attack, and — worse in the other
78    /// direction — would let a caller believe an unexamined signature
79    /// had been rejected on its merits.
80    UnsupportedScheme(u16),
81    /// A WebAuthn assertion verified cryptographically but attests to a
82    /// different challenge than the one asked about, so it is a valid
83    /// signature over something nobody in this request agreed to.
84    ///
85    /// D39 treats this as forgery-class rather than as a mismatch: the
86    /// whole point of binding the challenge to
87    /// [`choir_oplog::OpEntry::signing_hash`] is that a signature cannot
88    /// be moved from the operation it approved to another one.
89    ChallengeMismatch,
90}
91
92/// An actor's signing keypair.
93pub struct ActorKey {
94    signing: SigningKey,
95}
96
97impl ActorKey {
98    /// Generates a fresh keypair from the OS RNG.
99    pub fn generate() -> Self {
100        Self {
101            signing: SigningKey::generate(&mut rand_core::OsRng),
102        }
103    }
104
105    /// Restores a keypair from its 32 secret bytes (e.g. loaded from an
106    /// on-disk key file).
107    pub fn from_secret_bytes(bytes: &[u8; 32]) -> Self {
108        Self {
109            signing: SigningKey::from_bytes(bytes),
110        }
111    }
112
113    /// The 32 secret bytes; callers own keeping them off disk or 0600.
114    pub fn secret_bytes(&self) -> [u8; 32] {
115        self.signing.to_bytes()
116    }
117
118    /// Public key bytes to publish/register.
119    pub fn public_key_bytes(&self) -> [u8; 32] {
120        self.signing.verifying_key().to_bytes()
121    }
122
123    /// This actor's id: the content address of its public key.
124    pub fn actor_id(&self) -> ContentHash {
125        ContentHash::blake3(&self.public_key_bytes())
126    }
127
128    /// Signs a submission's content — `(channel, payload)` — before
129    /// the sequencer assigns it a position. See
130    /// [`choir_oplog::signing_hash`] for what is and isn't covered.
131    pub fn sign_submission(&self, channel: &str, payload: &[u8]) -> Witness {
132        let hash = choir_oplog::signing_hash(channel, payload);
133        let sig = self.signing.sign(hash.to_hex().as_bytes());
134        Witness::ed25519(self.actor_id().to_hex(), sig.to_bytes().to_vec())
135    }
136
137    /// Signs `entry` in place: sets `author_sig` over the entry's
138    /// [`OpEntry::signing_hash`]. Any existing signature is replaced.
139    pub fn sign_entry(&self, entry: &mut OpEntry) {
140        entry.author_sig = Some(self.sign_submission(&entry.channel, &entry.payload));
141    }
142}
143
144/// Known public keys, indexed by actor id. This is the verification
145/// side's whole world: an unregistered key is an unknown author.
146#[derive(Default)]
147pub struct Registry {
148    keys: std::collections::HashMap<String, VerifyingKey>,
149}
150
151impl Registry {
152    /// Creates an empty registry.
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Registers a public key and returns the actor id it now answers to.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`IdentityError::BadKey`] when the bytes are not a valid
162    /// ed25519 public key.
163    pub fn register(&mut self, public_key_bytes: &[u8; 32]) -> Result<ContentHash, IdentityError> {
164        let key = VerifyingKey::from_bytes(public_key_bytes).map_err(|_| IdentityError::BadKey)?;
165        let id = ContentHash::blake3(public_key_bytes);
166        self.keys.insert(id.to_hex(), key);
167        Ok(id)
168    }
169
170    /// Verifies `entry`'s author signature and returns the author's id.
171    ///
172    /// # Errors
173    ///
174    /// [`IdentityError::Unsigned`] for a missing signature,
175    /// [`IdentityError::UnknownKey`] for an unregistered author, and
176    /// [`IdentityError::BadSignature`] when the signature does not match
177    /// the entry (tampered entry or wrong key).
178    pub fn verify_entry(&self, entry: &OpEntry) -> Result<ContentHash, IdentityError> {
179        let sig = entry.author_sig.as_ref().ok_or(IdentityError::Unsigned)?;
180        self.verify_submission(&entry.channel, &entry.payload, sig)
181    }
182
183    /// Verifies a signature over submission content — the sequencer-side
184    /// check before a position is assigned. Returns the author's id.
185    ///
186    /// # Errors
187    ///
188    /// Same failure modes as [`Registry::verify_entry`], minus
189    /// [`IdentityError::Unsigned`].
190    pub fn verify_submission(
191        &self,
192        channel: &str,
193        payload: &[u8],
194        sig: &Witness,
195    ) -> Result<ContentHash, IdentityError> {
196        self.verify_signing_hash(&choir_oplog::signing_hash(channel, payload), sig)
197    }
198
199    /// Same check, for a caller that has already computed the submission's
200    /// [`choir_oplog::signing_hash`] — an admission policy that indexes
201    /// submissions by it, for instance. Hashing the same bytes twice per
202    /// op is measurable on the write path, and the node's allocation
203    /// budget is the test that says so.
204    ///
205    /// # Errors
206    ///
207    /// Same failure modes as [`Registry::verify_submission`].
208    pub fn verify_signing_hash(
209        &self,
210        signing: &ContentHash,
211        sig: &Witness,
212    ) -> Result<ContentHash, IdentityError> {
213        // Dispatch on the tag before touching the bytes. Without this a
214        // WebAuthn signature would be handed to ed25519, fail, and be
215        // reported as a bad signature — safe by accident, and the wrong
216        // answer: it was never checked against the scheme it named. This
217        // registry holds ed25519 keys only; P-256 credentials arrive
218        // with enrolment (D39) and verify through
219        // [`verify_webauthn_assertion`].
220        let claimed = sig.scheme_id();
221        if claimed != choir_oplog::scheme::ED25519 {
222            return Err(IdentityError::UnsupportedScheme(claimed));
223        }
224        let key = self
225            .keys
226            .get(&sig.key_id)
227            .ok_or_else(|| IdentityError::UnknownKey(sig.key_id.clone()))?;
228        let signature =
229            Signature::from_slice(&sig.signature).map_err(|_| IdentityError::BadSignature)?;
230        key.verify(signing.to_hex().as_bytes(), &signature)
231            .map_err(|_| IdentityError::BadSignature)?;
232        Ok(ContentHash::blake3(&key.to_bytes()))
233    }
234}
235
236/// Prefix that turns a raw P-256 point into SubjectPublicKeyInfo DER.
237///
238/// A WebAuthn authenticator hands over coordinates, not a key file. This
239/// is the constant `SEQUENCE { AlgorithmIdentifier { id-ecPublicKey,
240/// prime256v1 }, BIT STRING }` header that precedes the uncompressed
241/// `04 ‖ X(32) ‖ Y(32)` point, so building a usable key is concatenation
242/// rather than a DER writer. Measured against `openssl`'s own output
243/// before it was relied on (D39).
244pub const P256_SPKI_PREFIX: [u8; 26] = [
245    0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
246    0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00,
247];
248
249/// Wraps a raw uncompressed P-256 point as SubjectPublicKeyInfo DER.
250///
251/// # Errors
252///
253/// [`IdentityError::BadKey`] unless `point` is exactly the 65 bytes of an
254/// uncompressed point beginning with `0x04`. Compressed points are refused
255/// rather than expanded: an authenticator that sends one is doing something
256/// this path has never seen, and guessing is the wrong response.
257pub fn p256_point_to_spki(point: &[u8]) -> Result<Vec<u8>, IdentityError> {
258    if point.len() != 65 || point[0] != 0x04 {
259        return Err(IdentityError::BadKey);
260    }
261    let mut spki = Vec::with_capacity(P256_SPKI_PREFIX.len() + point.len());
262    spki.extend_from_slice(&P256_SPKI_PREFIX);
263    spki.extend_from_slice(point);
264    Ok(spki)
265}
266
267/// Verifies an ECDSA-P-256/SHA-256 signature, the scheme WebAuthn calls
268/// ES256 (COSE algorithm -7).
269///
270/// `spki_der` is the credential public key in SubjectPublicKeyInfo DER —
271/// what `getPublicKey()` returns for a registration, and what
272/// [`p256_point_to_spki`] builds from raw coordinates. `message` is the
273/// bytes the authenticator signed, which for an assertion is
274/// `authenticatorData ‖ SHA-256(clientDataJSON)`. `signature_der` is the
275/// ASN.1 `(r, s)` pair, which is already the encoding `openssl` expects,
276/// so nothing is reshaped in between.
277///
278/// Verification runs in an `openssl` subprocess rather than through a
279/// P-256 crate. That is the trade `choir-bridge` already makes for RS256:
280/// one more dependency against one more process, and this workspace has
281/// consistently chosen the process (D39).
282///
283/// **This is the primitive only.** It answers "did this key sign these
284/// bytes" and deliberately not "is this assertion bound to the operation
285/// the caller has in mind". Binding the challenge to
286/// `signing_hash(channel, payload)` belongs to the caller, and D39 carries
287/// a tripwire for it because an assertion that verifies against the wrong
288/// challenge is a signature attesting to something nobody agreed to.
289///
290/// # Errors
291///
292/// [`IdentityError::BadSignature`] when the signature does not verify or
293/// the key is unusable, and [`IdentityError::Verifier`] when `openssl`
294/// could not be run.
295pub fn verify_es256(
296    spki_der: &[u8],
297    message: &[u8],
298    signature_der: &[u8],
299) -> Result<(), IdentityError> {
300    use std::sync::atomic::{AtomicU64, Ordering};
301    // Unique per call, not per process: verification runs on request
302    // threads, and two of them sharing a path would let one delete the
303    // other's key between write and read.
304    static NEXT: AtomicU64 = AtomicU64::new(0);
305    let work = std::env::temp_dir().join(format!(
306        "choir-es256-{}-{}",
307        std::process::id(),
308        NEXT.fetch_add(1, Ordering::Relaxed)
309    ));
310    let io = |e: std::io::Error| IdentityError::Verifier(e.to_string());
311    std::fs::create_dir_all(&work).map_err(io)?;
312    let result = verify_es256_in(&work, spki_der, message, signature_der);
313    std::fs::remove_dir_all(&work).ok();
314    result
315}
316
317fn verify_es256_in(
318    work: &std::path::Path,
319    spki_der: &[u8],
320    message: &[u8],
321    signature_der: &[u8],
322) -> Result<(), IdentityError> {
323    let io = |e: std::io::Error| IdentityError::Verifier(e.to_string());
324    let key_der = work.join("key.der");
325    let key_pem = work.join("key.pem");
326    let msg = work.join("message.bin");
327    let sig = work.join("signature.der");
328    std::fs::write(&key_der, spki_der).map_err(io)?;
329    std::fs::write(&msg, message).map_err(io)?;
330    std::fs::write(&sig, signature_der).map_err(io)?;
331
332    // DER in, PEM out: `dgst -verify` wants a PEM public key, and doing
333    // the conversion here keeps the caller free to pass whatever the
334    // browser handed it.
335    let converted = std::process::Command::new("openssl")
336        .args(["pkey", "-pubin", "-inform", "DER", "-in"])
337        .arg(&key_der)
338        .arg("-out")
339        .arg(&key_pem)
340        .output()
341        .map_err(io)?;
342    if !converted.status.success() {
343        // A key openssl will not parse is a bad key, not a broken host.
344        return Err(IdentityError::BadSignature);
345    }
346
347    let verified = std::process::Command::new("openssl")
348        .args(["dgst", "-sha256", "-verify"])
349        .arg(&key_pem)
350        .arg("-signature")
351        .arg(&sig)
352        .arg(&msg)
353        .output()
354        .map_err(io)?;
355    if verified.status.success() {
356        Ok(())
357    } else {
358        Err(IdentityError::BadSignature)
359    }
360}
361
362/// The challenge bytes a browser must be given so that the assertion it
363/// returns attests to this submission (D39).
364///
365/// This is the hex form of the [`choir_oplog::signing_hash`], the exact
366/// bytes the ed25519 path signs. Both schemes therefore commit to one
367/// definition of *what was approved*, and a reader comparing an ed25519
368/// op with a passkey op is comparing like with like rather than two
369/// encodings that happen to agree today.
370pub fn webauthn_challenge(signing: &ContentHash) -> Vec<u8> {
371    signing.to_hex().into_bytes()
372}
373
374/// Base64url without padding, WebAuthn's encoding for the challenge
375/// inside clientDataJSON.
376fn base64url_nopad(bytes: &[u8]) -> String {
377    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
378    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
379    for chunk in bytes.chunks(3) {
380        let b = [
381            chunk[0],
382            chunk.get(1).copied().unwrap_or(0),
383            chunk.get(2).copied().unwrap_or(0),
384        ];
385        let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
386        let take = chunk.len() + 1;
387        for i in 0..take {
388            let idx = (n >> (18 - 6 * i)) & 0x3f;
389            out.push(ALPHABET[idx as usize] as char);
390        }
391    }
392    out
393}
394
395/// SHA-256, via `openssl`, for the one place WebAuthn requires it.
396///
397/// No hash crate and no hand-rolled compression function: the second is
398/// the wrong thing to write for a security check, and the first would be
399/// a dependency bought for sixteen bytes of glue on a path that already
400/// runs `openssl` twice.
401fn sha256(bytes: &[u8]) -> Result<Vec<u8>, IdentityError> {
402    use std::io::Write as _;
403    let mut child = std::process::Command::new("openssl")
404        .args(["dgst", "-sha256", "-binary"])
405        .stdin(std::process::Stdio::piped())
406        .stdout(std::process::Stdio::piped())
407        .spawn()
408        .map_err(|e| IdentityError::Verifier(e.to_string()))?;
409    child
410        .stdin
411        .take()
412        .ok_or_else(|| IdentityError::Verifier("openssl stdin unavailable".into()))?
413        .write_all(bytes)
414        .map_err(|e| IdentityError::Verifier(e.to_string()))?;
415    let out = child
416        .wait_with_output()
417        .map_err(|e| IdentityError::Verifier(e.to_string()))?;
418    if !out.status.success() || out.stdout.len() != 32 {
419        return Err(IdentityError::Verifier("openssl dgst failed".into()));
420    }
421    Ok(out.stdout)
422}
423
424/// Verifies a WebAuthn assertion **and** that it attests to `signing`
425/// (D39).
426///
427/// This is the binding [`verify_es256`] deliberately does not do. It
428/// answers the whole question a caller actually has — "did the holder of
429/// this credential approve *this* operation" — rather than the primitive's
430/// narrower "did this key sign these bytes". D39 carries a tripwire for
431/// getting this wrong, because an assertion accepted against the wrong
432/// challenge is a real signature attesting to something nobody agreed to,
433/// which is forgery rather than a bug.
434///
435/// `spki_der` is the enrolled credential's public key in
436/// SubjectPublicKeyInfo DER; `sig` must carry
437/// [`choir_oplog::scheme::WEBAUTHN_ES256`] together with the
438/// authenticator data and client data JSON the browser returned.
439///
440/// The comparison is made in the *encoded* form: the expected challenge
441/// is base64url-encoded and compared to the string the browser sent,
442/// rather than decoding what the browser sent. That is deliberately the
443/// stricter direction — a non-canonical encoding that would decode to the
444/// right bytes is refused — and it means this path needs no base64
445/// decoder that an attacker's input reaches.
446///
447/// # Errors
448///
449/// [`IdentityError::UnsupportedScheme`] if `sig` names another scheme,
450/// [`IdentityError::BadSignature`] for missing WebAuthn fields, an
451/// unparseable clientDataJSON, a ceremony that is not `webauthn.get`, or
452/// a signature that does not verify, [`IdentityError::ChallengeMismatch`]
453/// when it verifies against a different challenge, and
454/// [`IdentityError::Verifier`] when `openssl` could not be run.
455pub fn verify_webauthn_assertion(
456    spki_der: &[u8],
457    signing: &ContentHash,
458    sig: &Witness,
459) -> Result<(), IdentityError> {
460    let claimed = sig.scheme_id();
461    if claimed != choir_oplog::scheme::WEBAUTHN_ES256 {
462        return Err(IdentityError::UnsupportedScheme(claimed));
463    }
464    let authenticator_data = sig
465        .authenticator_data
466        .as_ref()
467        .ok_or(IdentityError::BadSignature)?;
468    let client_data_json = sig
469        .client_data_json
470        .as_ref()
471        .ok_or(IdentityError::BadSignature)?;
472
473    let client: serde_json::Value =
474        serde_json::from_slice(client_data_json).map_err(|_| IdentityError::BadSignature)?;
475    // An assertion only. A registration ceremony over the same challenge
476    // would otherwise be replayable here as an approval.
477    if client.get("type").and_then(|t| t.as_str()) != Some("webauthn.get") {
478        return Err(IdentityError::BadSignature);
479    }
480    let challenge = client
481        .get("challenge")
482        .and_then(|c| c.as_str())
483        .ok_or(IdentityError::BadSignature)?;
484    if challenge != base64url_nopad(&webauthn_challenge(signing)) {
485        return Err(IdentityError::ChallengeMismatch);
486    }
487
488    // What an authenticator actually signs.
489    let mut message = authenticator_data.clone();
490    message.extend_from_slice(&sha256(client_data_json)?);
491    verify_es256(spki_der, &message, &sig.signature)
492}
493
494/// Verifies a WebAuthn assertion against the credential key the
495/// signature *carries* (D45), for a reader holding no credential store.
496///
497/// The same check as [`verify_webauthn_assertion`], differing only in
498/// where the key comes from — and that difference is the whole point:
499/// [`choir_oplog::Witness::credential_key`] travels with the entry, so a
500/// log restored from backup stays checkable after the store that
501/// admitted it is gone.
502///
503/// **This establishes integrity, not trust.** The key arrives with the
504/// signature, so success means "these bytes were signed by the
505/// credential this entry names" and says nothing about whether that
506/// credential belonged to the channel. An ed25519 signature is checked
507/// against a key the caller's [`Registry`] vouches for; nothing vouches
508/// here, and a caller that reports the two as one result is overstating
509/// this one.
510///
511/// Returns the actor id, `blake3` of the credential key — the same rule
512/// [`ActorKey::actor_id`] uses, so a passkey author and an ed25519
513/// author are named the same way.
514///
515/// # Errors
516///
517/// [`IdentityError::UnknownKey`] when the signature carries no
518/// credential key, which is every passkey entry written before D45.
519/// Otherwise the failure modes of [`verify_webauthn_assertion`].
520pub fn verify_carried_webauthn(
521    signing: &ContentHash,
522    sig: &Witness,
523) -> Result<ContentHash, IdentityError> {
524    let spki = sig
525        .credential_key
526        .as_ref()
527        .ok_or_else(|| IdentityError::UnknownKey(sig.key_id.clone()))?;
528    verify_webauthn_assertion(spki, signing, sig)?;
529    Ok(ContentHash::blake3(spki))
530}