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}