Skip to main content

auths_cli/commands/
tls_cert.rs

1//! `auths tls-cert` — KEL-rooted X.509 leaf certificates for TLS composition.
2//!
3//! TLS already authenticates endpoints through the WebPKI/CA system. This command
4//! lets a KERI AID compose *with* that pipe rather than replace it: it issues an
5//! X.509 leaf whose trust roots in the AID's key event log (a `did:keri:<aid>`
6//! SAN plus a binding extension carrying the replayed key-state), so a stock TLS
7//! stack — rustls, OpenSSL, BoringSSL, Go `crypto/tls` — completes a handshake
8//! with it, while an AID-aware peer re-derives the trust by replaying the KEL.
9//! That is how an auths identity deploys through every load balancer, mesh, and
10//! client that already speaks TLS.
11//!
12//! Two directions, both offline/hermetic (the KEL handed in is a local artifact):
13//!
14//! * `auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem` — us →
15//!   peer: replay the KEL, project its current key-state into a KEL-rooted leaf,
16//!   have the AID's current signing key authorize the leaf's TLS key (a KERI
17//!   signature over the leaf SPKI), and emit the cert PEM plus the ephemeral TLS
18//!   private key PEM the acceptor serves.
19//! * `auths tls-cert verify --cert cert.pem --from-kel kel.json` — peer → us: the
20//!   adversarial verifier. Parse a peer's leaf, replay the KEL we hold for its
21//!   AID, and confirm the cert binds to that exact replayed key-state (AID,
22//!   current keys, KEL tip, the `did:keri` SAN) **and** that the AID authorized
23//!   the leaf's TLS key. Rejects a forged binding (matching key-state over an
24//!   attacker's TLS key), a revoked/rotated AID, a stripped binding/authorization,
25//!   and a SAN spoof. Trust is rooted in the log, never in a CA.
26//!
27//! The wire/crypto definition lives in `auths-keri::tls_cert`; this is a thin CLI
28//! adapter over it. The cert's subject key is a fresh ephemeral TLS keypair, so
29//! the AID's long-term signing key never goes on the wire — only its detached
30//! authorization signature over the public TLS key does.
31
32use std::path::{Path, PathBuf};
33
34use anyhow::{Result, anyhow};
35use auths_crypto::TypedSignerKey;
36use auths_keri::{
37    IssuedCert, KeyState, QuicLoopbackOutcome, TlsCertError, TlsKeyAuthorizer, TrustedKel,
38    extract_aid_from_san, issue_authorized_kel_rooted_cert,
39    issue_authorized_kel_rooted_cert_with_key, issue_kel_rooted_cert,
40    issue_kel_rooted_cert_with_key, parse_kel_json, quic_loopback_compose,
41    verify_authorized_against_key_state,
42};
43use auths_utils::path::expand_tilde;
44use clap::{Parser, Subcommand};
45
46use crate::config::CliConfig;
47
48/// Adapter: a [`TlsKeyAuthorizer`] backed by the AID's current signing key.
49///
50/// The CLI holds the AID's signing key as a local PKCS#8 artifact (the same
51/// hermetic boundary `--from-kel` / `--tls-key` already use); this adapter signs
52/// the leaf's `SubjectPublicKeyInfo` DER with it so the issued leaf carries the
53/// AID's authorization. The core `auths-keri` never imports a key store — it only
54/// sees the port.
55struct SignerKeyAuthorizer {
56    signer: TypedSignerKey,
57    key_index: usize,
58}
59
60impl TlsKeyAuthorizer for SignerKeyAuthorizer {
61    fn current_key_index(&self) -> usize {
62        self.key_index
63    }
64
65    fn sign_tls_key(&self, spki_der: &[u8]) -> Result<Vec<u8>, TlsCertError> {
66        self.signer
67            .sign(spki_der)
68            .map_err(|e| TlsCertError::Generate(format!("authorize TLS key: {e}")))
69    }
70}
71
72/// Load the AID's current signing key from a PKCS#8 PEM file into a typed signer.
73fn load_signer(key_path: &Path) -> Result<TypedSignerKey> {
74    let path = expand_tilde(key_path)?;
75    let pem = std::fs::read_to_string(&path)
76        .map_err(|e| anyhow!("read signing key {}: {e}", path.display()))?;
77    let (_, der) = pkcs8::SecretDocument::from_pem(&pem)
78        .map_err(|e| anyhow!("parse signing key PEM {}: {e}", path.display()))?;
79    TypedSignerKey::from_pkcs8(der.as_bytes())
80        .map_err(|e| anyhow!("load signing key {}: {e}", path.display()))
81}
82
83/// Issue or verify a KEL-rooted X.509 certificate (TLS composition).
84#[derive(Parser, Debug, Clone)]
85#[command(
86    about = "Issue/verify a KEL-rooted X.509 cert, read its did:keri subjectAltName, and carry it over TLS or QUIC/HTTP3 — an auths identity that stock TLS stacks (rustls/openssl/go) handshake with",
87    after_help = "Examples:
88  auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --san localhost   # AID-authorized leaf
89  auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --out leaf         # writes leaf.cert.pem + leaf.key.pem
90  auths tls-cert identity --cert leaf.cert.pem                                       # read the did:keri AID out of the SAN
91  auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json                     # adversarial: rejects forged/revoked/stripped
92  auths tls-cert quic --from-kel kel.json                                            # carry the leaf + channel binding over QUIC/HTTP3"
93)]
94pub struct TlsCertCommand {
95    /// The direction to run: issue our leaf, or verify a peer's.
96    #[command(subcommand)]
97    pub action: TlsCertAction,
98}
99
100/// The directions of KEL-rooted mTLS composition.
101#[derive(Subcommand, Debug, Clone)]
102pub enum TlsCertAction {
103    /// Issue a KEL-rooted leaf certificate for an AID (us → peer).
104    Issue(IssueArgs),
105    /// Read the did:keri AID out of a leaf's subjectAltName (X.509-SVID identity).
106    Identity(IdentityArgs),
107    /// Verify a peer's leaf binds to the KEL we hold (peer → us).
108    Verify(VerifyArgs),
109    /// Carry the KEL-rooted leaf + channel binding over a QUIC/HTTP3 transport.
110    Quic(QuicArgs),
111}
112
113/// `auths tls-cert issue` — mint a KEL-rooted leaf for one of our AIDs.
114#[derive(Parser, Debug, Clone)]
115pub struct IssueArgs {
116    /// Replay this KEL file and project its current key-state into the leaf's
117    /// `did:keri` SAN + binding extension. The KEL is the root of trust.
118    #[clap(long, value_name = "KEL.json")]
119    pub from_kel: PathBuf,
120
121    /// Extra Subject-Alternative-Name host the leaf must serve (DNS name or IP
122    /// literal). Repeatable. Typically `localhost`, `127.0.0.1`, the LAN host.
123    #[clap(long = "san", value_name = "HOST")]
124    pub sans: Vec<String>,
125
126    /// Use this PKCS#8-PEM TLS keypair as the leaf's subject key instead of a
127    /// fresh ephemeral one (e.g. to reuse a key the acceptor already holds).
128    #[clap(long, value_name = "KEY.pem")]
129    pub tls_key: Option<PathBuf>,
130
131    /// The AID's current signing key (PKCS#8 PEM). When given, the leaf carries an
132    /// AID authorization over its TLS key (a KERI signature over the leaf SPKI), so
133    /// `verify` can reject a forged binding minted over an attacker's TLS key.
134    /// Without it the leaf only chains to the key-state (the discovery surface) and
135    /// is rejected by the adversarial verifier.
136    #[clap(long, value_name = "AID-KEY.pem")]
137    pub sign_key: Option<PathBuf>,
138
139    /// Which current key (index into the KEL's current key-state) `--sign-key`
140    /// corresponds to. Defaults to 0 (single-sig AIDs).
141    #[clap(long, default_value_t = 0, value_name = "N")]
142    pub sign_key_index: usize,
143
144    /// Write `<PREFIX>.cert.pem` + `<PREFIX>.key.pem` instead of printing to
145    /// stdout. Without it, the cert PEM is printed (the key never is, to avoid
146    /// leaking it into logs).
147    #[clap(long, value_name = "PREFIX")]
148    pub out: Option<PathBuf>,
149}
150
151/// `auths tls-cert identity` — read the did:keri AID out of a leaf's SAN.
152#[derive(Parser, Debug, Clone)]
153pub struct IdentityArgs {
154    /// The leaf certificate, PEM-encoded. Its `did:keri` subjectAltName names the
155    /// auths identity — the AID a verifier looks up before replaying its KEL.
156    #[clap(long, value_name = "CERT.pem")]
157    pub cert: PathBuf,
158}
159
160/// `auths tls-cert verify` — confirm a peer's leaf is rooted in a KEL we hold.
161#[derive(Parser, Debug, Clone)]
162pub struct VerifyArgs {
163    /// The peer's leaf certificate, PEM-encoded.
164    #[clap(long, value_name = "CERT.pem")]
165    pub cert: PathBuf,
166
167    /// Replay this KEL (the one we hold for the cert's AID) and require the cert
168    /// to bind to its replayed key-state.
169    #[clap(long, value_name = "KEL.json")]
170    pub from_kel: PathBuf,
171}
172
173/// `auths tls-cert quic` — carry the same composition over QUIC/HTTP3.
174///
175/// QUIC runs the same TLS 1.3 handshake inside its CRYPTO frames, so a KERI
176/// identity composes with QUIC — and therefore HTTP/3 — through exactly the same
177/// two mechanisms it composes with TLS-over-TCP: the KEL-rooted leaf the server
178/// presents, and the per-connection channel binding both endpoints export from
179/// the connection's TLS 1.3 secrets. This stands up a real loopback QUIC
180/// connection, serves the leaf over it, and proves both — the client re-roots the
181/// served leaf in the replayed KEL, and both endpoints derive the same channel
182/// binding (a proof bound to it cannot be relayed onto a different connection).
183#[derive(Parser, Debug, Clone)]
184pub struct QuicArgs {
185    /// Replay this KEL and serve its KEL-rooted leaf over the QUIC handshake. The
186    /// KEL is the root of trust the client re-derives by replay — over QUIC
187    /// exactly as over TCP.
188    #[clap(long, value_name = "KEL.json")]
189    pub from_kel: PathBuf,
190}
191
192impl TlsCertCommand {
193    /// Run the requested direction.
194    pub fn execute(&self, _ctx: &CliConfig) -> Result<()> {
195        match &self.action {
196            TlsCertAction::Issue(args) => args.run(),
197            TlsCertAction::Identity(args) => args.run(),
198            TlsCertAction::Verify(args) => args.run(),
199            TlsCertAction::Quic(args) => args.run(),
200        }
201    }
202}
203
204/// Replay a KEL file into its resolved current key-state. A KEL file the operator
205/// hands us is a local, self-owned artifact — the reviewable trust assertion that
206/// structural replay requires (the same boundary `did-webs`/`oobi` use).
207fn replay_kel(kel_path: &Path) -> Result<auths_keri::KeyState> {
208    let path = expand_tilde(kel_path)?;
209    let json =
210        std::fs::read_to_string(&path).map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?;
211    let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?;
212    TrustedKel::from_trusted_source(&events)
213        .replay()
214        .map_err(|e| anyhow!("replay KEL: {e}"))
215}
216
217impl IssueArgs {
218    fn run(&self) -> Result<()> {
219        let state = replay_kel(&self.from_kel)?;
220        let issued = self.issue(&state)?;
221
222        match &self.out {
223            Some(prefix) => {
224                let cert_path = with_suffix(prefix, "cert.pem");
225                let key_path = with_suffix(prefix, "key.pem");
226                std::fs::write(&cert_path, issued.cert_pem.as_bytes())
227                    .map_err(|e| anyhow!("write {}: {e}", cert_path.display()))?;
228                write_private_key(&key_path, &issued.key_pem)?;
229                println!(
230                    "issued KEL-rooted leaf for {}\n  cert: {}\n  key:  {}",
231                    issued.binding.did_keri(),
232                    cert_path.display(),
233                    key_path.display()
234                );
235            }
236            None => {
237                // Cert only on stdout; the private key is never printed, so a
238                // captured transcript can't leak it.
239                print!("{}", issued.cert_pem);
240            }
241        }
242        Ok(())
243    }
244
245    /// Mint the leaf, dispatching on whether the AID's signing key (authorized,
246    /// the secure path) and/or a supplied TLS key are provided. One source of
247    /// truth for the four issue paths.
248    fn issue(&self, state: &KeyState) -> Result<auths_keri::IssuedCert> {
249        let tls_key_pem = match &self.tls_key {
250            Some(key_path) => {
251                let path = expand_tilde(key_path)?;
252                Some(
253                    std::fs::read_to_string(&path)
254                        .map_err(|e| anyhow!("read TLS key {}: {e}", path.display()))?,
255                )
256            }
257            None => None,
258        };
259
260        match (&self.sign_key, tls_key_pem) {
261            (Some(sign_key_path), Some(tls_pem)) => {
262                let signer = load_signer(sign_key_path)?;
263                let authorizer = SignerKeyAuthorizer {
264                    signer,
265                    key_index: self.sign_key_index,
266                };
267                issue_authorized_kel_rooted_cert_with_key(state, &authorizer, &tls_pem, &self.sans)
268                    .map_err(|e| anyhow!("issue authorized KEL-rooted cert: {e}"))
269            }
270            (Some(sign_key_path), None) => {
271                let signer = load_signer(sign_key_path)?;
272                let authorizer = SignerKeyAuthorizer {
273                    signer,
274                    key_index: self.sign_key_index,
275                };
276                issue_authorized_kel_rooted_cert(state, &authorizer, &self.sans)
277                    .map_err(|e| anyhow!("issue authorized KEL-rooted cert: {e}"))
278            }
279            (None, Some(tls_pem)) => issue_kel_rooted_cert_with_key(state, &tls_pem, &self.sans)
280                .map_err(|e| anyhow!("issue KEL-rooted cert: {e}")),
281            (None, None) => issue_kel_rooted_cert(state, &self.sans)
282                .map_err(|e| anyhow!("issue KEL-rooted cert: {e}")),
283        }
284    }
285}
286
287impl IdentityArgs {
288    fn run(&self) -> Result<()> {
289        let cert_path = expand_tilde(&self.cert)?;
290        let cert_pem = std::fs::read_to_string(&cert_path)
291            .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?;
292
293        // The X.509-SVID identity read: the did:keri AID rides in the SAN every
294        // stock X.509 parser already exposes, so we learn which identity the cert
295        // claims before holding its KEL. The AID is parsed (not just extracted),
296        // so what we print is a valid KERI prefix, not a raw string.
297        let aid = extract_aid_from_san(&cert_pem)
298            .map_err(|e| anyhow!("read did:keri identity from cert SAN: {e}"))?;
299
300        println!(
301            "did:keri:{aid}\n  AID: {aid}\n  (the SAN names the identity; replay its KEL to root trust in the log)"
302        );
303        Ok(())
304    }
305}
306
307impl VerifyArgs {
308    fn run(&self) -> Result<()> {
309        let state = replay_kel(&self.from_kel)?;
310        let cert_path = expand_tilde(&self.cert)?;
311        let cert_pem = std::fs::read_to_string(&cert_path)
312            .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?;
313
314        // The adversarial verifier: the leaf must chain to the replayed log AND
315        // carry the AID's authorization over its TLS key. This rejects a forged
316        // binding (matching key-state, attacker's TLS key), a revoked/rotated AID
317        // (key-state diverges from the replay), a stripped binding/authorization,
318        // and a SAN spoof — the T3 rejection classes.
319        let binding = verify_authorized_against_key_state(&cert_pem, &state)
320            .map_err(|e| anyhow!("certificate rejected: {e}"))?;
321
322        let authorized_by = binding
323            .tls_key_authorization
324            .as_ref()
325            .map(|a| a.key_index)
326            .unwrap_or_default();
327        println!(
328            "verified: certificate is rooted in the KEL and the AID authorized its TLS key\n  did:keri: {}\n  current keys: {}\n  KEL tip: {}\n  TLS key authorized by current key #{}",
329            binding.did_keri(),
330            binding.current_keys.join(", "),
331            binding.kel_tip,
332            authorized_by,
333        );
334        Ok(())
335    }
336}
337
338impl QuicArgs {
339    fn run(&self) -> Result<()> {
340        let state = replay_kel(&self.from_kel)?;
341        // Mint the KEL-rooted leaf the QUIC server presents (localhost SANs so the
342        // loopback handshake is valid for the transport host).
343        let leaf = issue_kel_rooted_cert(&state, &["localhost".to_string(), "::1".to_string()])
344            .map_err(|e| anyhow!("issue KEL-rooted leaf for QUIC: {e}"))?;
345
346        // The loopback driver is a single Tokio runtime turn: stand up a QUIC
347        // endpoint, serve the leaf, connect a client, complete the TLS 1.3
348        // handshake inside QUIC, and prove the composition (leaf re-roots in the
349        // KEL, both ends agree on the channel binding). The transport plumbing
350        // lives in auths-keri; this command is the adapter.
351        let outcome = run_quic_loopback(&leaf, &state)?;
352
353        println!(
354            "QUIC/HTTP3 composition verified — the KEL-rooted leaf and channel binding carry over QUIC\n  did:keri: {}\n  ALPN: h3 (HTTP/3)\n  served leaf re-rooted in the replayed KEL: yes\n  both endpoints derive the same channel binding (anti-relay): {}\n  channel binding: {} ({} bytes, per-connection)",
355            outcome.did_keri,
356            if outcome.binding_agrees { "yes" } else { "no" },
357            outcome.channel_binding_hex,
358            outcome.channel_binding_len,
359        );
360        Ok(())
361    }
362}
363
364/// Drive the QUIC loopback composition on a fresh Tokio runtime.
365///
366/// `auths tls-cert` is otherwise synchronous; QUIC needs an async reactor, so we
367/// spin a current-thread runtime for this one command rather than make the whole
368/// CLI async. The transport itself lives behind [`quic_loopback_compose`] in
369/// `auths-keri` — this is just the runtime adapter.
370fn run_quic_loopback(leaf: &IssuedCert, state: &KeyState) -> Result<QuicLoopbackOutcome> {
371    let rt = tokio::runtime::Builder::new_current_thread()
372        .enable_all()
373        .build()
374        .map_err(|e| anyhow!("build QUIC runtime: {e}"))?;
375    rt.block_on(quic_loopback_compose(&leaf.cert_pem, &leaf.key_pem, state))
376        .map_err(|e| anyhow!("QUIC composition failed: {e}"))
377}
378
379/// `prefix` + `.suffix`, preserving any directory in `prefix`.
380fn with_suffix(prefix: &Path, suffix: &str) -> PathBuf {
381    let name = prefix
382        .file_name()
383        .map(|n| n.to_string_lossy().to_string())
384        .unwrap_or_default();
385    prefix.with_file_name(format!("{name}.{suffix}"))
386}
387
388/// Write the private key with owner-only permissions where the OS supports it,
389/// so an issued key isn't left world-readable.
390fn write_private_key(path: &Path, pem: &str) -> Result<()> {
391    std::fs::write(path, pem.as_bytes()).map_err(|e| anyhow!("write {}: {e}", path.display()))?;
392    #[cfg(unix)]
393    {
394        use std::os::unix::fs::PermissionsExt;
395        let perms = std::fs::Permissions::from_mode(0o600);
396        std::fs::set_permissions(path, perms)
397            .map_err(|e| anyhow!("chmod {}: {e}", path.display()))?;
398    }
399    Ok(())
400}