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