diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c78ea133 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Keep the Docker build context to source only. Without this, `COPY . .` ships +# the entire working tree — compiled `target/` trees, local worktrees, Python +# virtualenvs under examples/, and node_modules — which can be tens of GB and +# makes the image build slow or fail outright on context transfer. None of it is +# needed to compile a binary from source. + +# Rust build output (every crate's, and the root's) +target/ +**/target/ + +# Local git worktrees and VCS metadata +.worktrees/ +.git/ + +# Example/app sandboxes: Python venvs and JS deps +**/.venv/ +**/venv/ +**/node_modules/ + +# Editor / OS noise +**/.DS_Store +**/*.swp + +# Build artifacts that are regenerated, never sources +**/*.profraw +coverage/ +dist/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ee4a2e6..e58b15c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,6 +309,41 @@ jobs: working-directory: crates/auths-verifier run: wasm-pack build --target bundler --no-default-features --features wasm + conformance: + name: KERI conformance (keripy 1.3.4) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-conformance-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-conformance- + - uses: astral-sh/setup-uv@v4 + # keripy's crypto (pysodium) loads libsodium via ctypes at runtime. + - name: Install libsodium (keripy runtime dependency) + run: sudo apt-get update && sudo apt-get install -y libsodium-dev + - name: Build auths CLI + run: cargo build --package auths-cli + # Drives the real auths binary against the keripy 1.3.4 reference for every + # interop surface (ksn, did:webs, OOBI, IPEX) and fails the build on any + # byte/structural divergence. uv installs the pinned keri==1.3.4 oracle. + - name: Run keripy conformance gate + working-directory: tests/conformance + run: uv run pytest -v --junitxml=../../conformance-results.xml + env: + AUTHS_BIN: ${{ github.workspace }}/target/debug/auths + - name: Upload conformance results + if: always() + uses: actions/upload-artifact@v4 + with: + name: conformance-results + path: conformance-results.xml + e2e-tests: name: E2E Tests (${{ matrix.os }}) needs: [test] diff --git a/.gitignore b/.gitignore index 6c72100b..8e0e172c 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,4 @@ jnkn.db .mcp.json .chunkhound.json .worktrees/ +.recurve/ diff --git a/Cargo.lock b/Cargo.lock index 00c59edd..a0c3b679 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,7 @@ dependencies = [ "auths-transparency", "auths-utils", "auths-verifier", + "auths-witness-node", "axum", "base64", "bs58", @@ -686,6 +687,8 @@ dependencies = [ "hex", "p256 0.13.2", "proptest", + "quinn", + "rcgen", "ring", "schemars", "serde", @@ -693,6 +696,9 @@ dependencies = [ "subtle", "thiserror 2.0.18", "tokio", + "x509-parser", + "yasna", + "zeroize", ] [[package]] @@ -776,6 +782,7 @@ dependencies = [ "rand 0.8.6", "rcgen", "ring", + "rustls 0.23.38", "serde", "serde_json", "sha2", @@ -808,6 +815,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "tokio", "zeroize", @@ -1059,6 +1067,7 @@ dependencies = [ "axum", "clap", "http-body-util", + "serde_json", "tokio", "tower", "tower-http", @@ -1066,6 +1075,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "auths-witness-node" +version = "0.1.3" +dependencies = [ + "anyhow", + "auths-keri", + "auths-verifier", + "auths-witness", + "hex", + "rand 0.8.6", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "autocfg" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 628c6403..d6e14e99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/auths-rp", "crates/xtask", "crates/auths-api", "crates/auths-witness", + "crates/auths-witness-node", "crates/auths-checkpoint-cosigner", "crates/auths-monitor", ] @@ -96,6 +97,8 @@ auths-pairing-protocol = { path = "crates/auths-pairing-protocol", version = "0. auths-storage = { path = "crates/auths-storage", version = "0.1.3" } auths-transparency = { path = "crates/auths-transparency", version = "0.1.3", default-features = false } auths-utils = { path = "crates/auths-utils", version = "0.1.3" } +auths-witness = { path = "crates/auths-witness", version = "0.1.3" } +auths-witness-node = { path = "crates/auths-witness-node", version = "0.1.3" } insta = { version = "1", features = ["json"] } # Compile crypto-heavy crates with optimizations even in dev/test builds. diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 77f7c4e3..819fb780 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -24,7 +24,10 @@ name = "auths-verify" path = "src/bin/verify.rs" [dependencies] -auths-keri.workspace = true +# `tls-cert` powers `auths tls-cert` (KEL-rooted X.509 leaf certs for TLS +# composition); `quic` carries the same leaf + channel binding over QUIC/HTTP3. +# The wire/crypto definition lives in auths-keri. +auths-keri = { workspace = true, features = ["tls-cert", "quic"] } clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" colored = "3.1.1" @@ -82,9 +85,21 @@ auths-pairing-daemon = { workspace = true, optional = true } axum = { version = "0.8", optional = true } tokio-util = { version = "0.7", optional = true } +# Witness-node operator surface (optional, default-OFF). The `auths witness` +# subcommand *surface* always compiles (thin clap defs, no heavy deps); only the +# node *handler* and this crate are feature-gated, so the lean default install +# stays lean. The dependency arrow is one-way: this crate composes the node +# crate, never the reverse. +auths-witness-node = { workspace = true, optional = true } + [features] default = ["lan-pairing"] lan-pairing = ["dep:auths-pairing-daemon", "dep:axum", "dep:tokio-util", "auths-sdk/lan-pairing"] +# Enables the real `auths witness up|down|status|register|logs` handlers by +# pulling the node-operator crate. A build without it keeps these verbs (help +# and parsing are identical) but the handlers return a "install the witness +# build" error. +witness-node = ["dep:auths-witness-node"] [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["signal", "process"] } diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 0c5b484d..994933cb 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -19,16 +19,20 @@ use crate::commands::debug::DebugCmd; use crate::commands::demo::DemoCommand; use crate::commands::device::DeviceCommand; use crate::commands::device::pair::PairCommand; +use crate::commands::did_webs::DidWebsCommand; use crate::commands::doctor::DoctorCommand; use crate::commands::emergency::EmergencyCommand; use crate::commands::error_lookup::ErrorLookupCommand; use crate::commands::id::IdCommand; use crate::commands::init::InitCommand; +use crate::commands::ipex::IpexCommand; use crate::commands::key::KeyCommand; +use crate::commands::key_state::KeyStateCommand; use crate::commands::learn::LearnCommand; use crate::commands::log::LogCommand; use crate::commands::multi_sig::MultiSigCommand; use crate::commands::namespace::NamespaceCommand; +use crate::commands::oobi::OobiCommand; use crate::commands::org::OrgCommand; use crate::commands::policy::PolicyCommand; use crate::commands::publish::PublishCommand; @@ -38,6 +42,7 @@ use crate::commands::scim::ScimCommand; use crate::commands::sign::SignCommand; use crate::commands::sign_commit::SignCommitCommand; use crate::commands::status::StatusCommand; +use crate::commands::tls_cert::TlsCertCommand; use crate::commands::trust::TrustCommand; use crate::commands::unified_verify::UnifiedVerifyCommand; use crate::commands::whoami::WhoamiCommand; @@ -122,6 +127,16 @@ pub enum RootCommand { Device(DeviceCommand), #[command(hide = true)] Key(KeyCommand), + #[command(hide = true, name = "key-state")] + KeyState(KeyStateCommand), + #[command(hide = true, name = "did-webs")] + DidWebs(DidWebsCommand), + #[command(hide = true, name = "tls-cert")] + TlsCert(TlsCertCommand), + #[command(hide = true)] + Oobi(OobiCommand), + #[command(hide = true)] + Ipex(IpexCommand), #[command(hide = true)] Approval(ApprovalCommand), #[command(hide = true)] diff --git a/crates/auths-cli/src/commands/did_webs.rs b/crates/auths-cli/src/commands/did_webs.rs new file mode 100644 index 00000000..8f334e40 --- /dev/null +++ b/crates/auths-cli/src/commands/did_webs.rs @@ -0,0 +1,63 @@ +//! `auths did-webs` — emit a `did:webs` DID document for an AID. +//! +//! `did:webs` anchors a KERI AID into a **web-resolvable** DID document so a +//! standard DID resolver verifies the identifier without speaking KERI. The +//! document is derived by replaying the AID's KEL into its current key-state, so +//! the verification material is exactly the AID's current signing keys — the KEL +//! stays the source of truth. The document auths emits is byte-compatible with +//! the ToIP did:webs reference resolver's `didDocument` +//! (`{id, verificationMethod, service, alsoKnownAs}`). The crypto/wire definition +//! lives in `auths-keri::did_webs`; this is a thin CLI adapter over it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{DidWebsDocument, TrustedKel, parse_kel_json}; +use auths_utils::path::expand_tilde; +use clap::Parser; + +use crate::config::CliConfig; + +/// Emit a `did:webs` DID document for an AID (KEL-anchored). +#[derive(Parser, Debug, Clone)] +#[command( + about = "Emit a did:webs DID document for an AID — resolvable under a standard did:webs resolver", + after_help = "Examples: + auths did-webs --from-kel kel.json --domain example.com + auths did-webs --from-kel kel.json --domain 'example.com%3A3901:dids'" +)] +pub struct DidWebsCommand { + /// Replay this KEL file and project its current key-state into a `did:webs` + /// DID document (the shape a did:webs/DID-core resolver reads). + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, + + /// The web domain the `did:webs` is anchored at — the host (optionally + /// `host%3Aport` and path segments) before the AID in `did:webs::`. + #[clap(long, value_name = "DOMAIN")] + pub domain: String, +} + +impl DidWebsCommand { + /// Run the command: replay the KEL and print the `did:webs` DID document. + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + self.emit(&self.from_kel) + } + + /// Replay a KEL file and print the projected `did:webs` DID document. + fn emit(&self, kel_path: &Path) -> Result<()> { + let path = expand_tilde(kel_path)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + // A KEL file the operator hands us is a local, self-owned artifact — the + // reviewable trust assertion that structural replay requires. + let state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}"))?; + let doc = DidWebsDocument::from_key_state(&state, &self.domain) + .map_err(|e| anyhow!("project key-state into a did:webs document: {e}"))?; + println!("{}", serde_json::to_string_pretty(&doc)?); + Ok(()) + } +} diff --git a/crates/auths-cli/src/commands/ipex.rs b/crates/auths-cli/src/commands/ipex.rs new file mode 100644 index 00000000..9b4816b2 --- /dev/null +++ b/crates/auths-cli/src/commands/ipex.rs @@ -0,0 +1,175 @@ +//! `auths ipex` — IPEX (Issuance & Presentation EXchange) credential handover. +//! +//! IPEX is KERI's standard peer-to-peer handshake for moving an ACDC credential +//! between two controllers: the discloser sends a `grant` `exn` carrying the +//! credential, and the holder answers with an `admit` `exn` that references the +//! grant's SAID. It is the interoperable alternative to a bespoke presentation +//! wire — a credential exchanged this way is one keripy/KERIA can ingest, and a +//! grant a keripy peer sends is one auths can parse. +//! +//! Two directions, mirroring the two roles in a disclosure: +//! +//! * `auths ipex grant` — discloser → holder: read a saidified ACDC, embed it in +//! a `/ipex/grant` `exn` addressed to the recipient, and print the `exn`. +//! * `auths ipex admit` — holder → discloser: read a peer's grant `exn`, verify +//! it (and the credential inside it), and print an `/ipex/admit` `exn` whose +//! prior is the grant's SAID. +//! +//! The wire definitions (the `exn` records, their SAIDs, the embeds block) live +//! in `auths-keri::ipex` and are byte-exact with keripy 1.3.4; this is a thin +//! file-based CLI adapter over them. Signing the `exn` and putting it on a +//! transport are the caller's concern — this surface produces the canonical +//! bytes to sign and send. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{Acdc, IpexAdmit, IpexGrant, Prefix}; +use auths_utils::path::expand_tilde; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Default datetime stamp — the epoch, so output is deterministic unless the +/// operator passes a real `now`. Matches the OOBI command's convention. +const DEFAULT_DT: &str = "1970-01-01T00:00:00.000000+00:00"; + +/// Exchange an ACDC credential over IPEX (grant/admit), interoperable with keripy/KERIA. +#[derive(Parser, Debug, Clone)] +#[command( + about = "Exchange a credential over IPEX (grant/admit) — interoperable with keripy/KERIA", + after_help = "Examples: + auths ipex grant --acdc cred.json --sender EOoC... --recipient EBHn... + auths ipex admit --grant grant.json --sender EBHn..." +)] +pub struct IpexCommand { + /// The IPEX direction to run. + #[command(subcommand)] + pub action: IpexAction, +} + +/// The two IPEX directions: grant a credential, or admit a received grant. +#[derive(Subcommand, Debug, Clone)] +pub enum IpexAction { + /// Grant (disclose) a credential: embed an ACDC in a `/ipex/grant` `exn`. + Grant(GrantArgs), + /// Admit (accept) a received grant: emit an `/ipex/admit` `exn` for it. + Admit(AdmitArgs), +} + +/// `auths ipex grant` — disclose a credential to a holder. +#[derive(Parser, Debug, Clone)] +pub struct GrantArgs { + /// The saidified ACDC credential to disclose (its JSON body, `{v,d,i,ri,s,a}`). + #[clap(long, value_name = "ACDC.json")] + pub acdc: PathBuf, + + /// The discloser (sender) AID granting the credential. + #[clap(long, value_name = "AID")] + pub sender: String, + + /// The recipient (holder) AID the credential is granted to. + #[clap(long, value_name = "AID")] + pub recipient: String, + + /// Optional human-readable disclosure message (`a.m`). + #[clap(long, default_value = "")] + pub message: String, + + /// Timestamp (RFC 3339) to stamp the `exn` with. Defaults to the epoch so + /// output stays deterministic; pass the real `now` to send. + #[clap(long, default_value = DEFAULT_DT)] + pub dt: String, +} + +/// `auths ipex admit` — accept a credential a peer granted. +#[derive(Parser, Debug, Clone)] +pub struct AdmitArgs { + /// The peer's grant `exn` to admit (its JSON body). + #[clap(long, value_name = "GRANT.json")] + pub grant: PathBuf, + + /// The holder (sender) AID admitting the disclosure. + #[clap(long, value_name = "AID")] + pub sender: String, + + /// Optional human-readable admission message (`a.m`). + #[clap(long, default_value = "")] + pub message: String, + + /// Timestamp (RFC 3339) to stamp the `exn` with. Defaults to the epoch so + /// output stays deterministic; pass the real `now` to send. + #[clap(long, default_value = DEFAULT_DT)] + pub dt: String, +} + +impl IpexCommand { + /// Run the command (grant a credential or admit a received grant). + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.action { + IpexAction::Grant(args) => args.run(), + IpexAction::Admit(args) => args.run(), + } + } +} + +impl GrantArgs { + fn run(&self) -> Result<()> { + // Parse the AIDs at the boundary — an invalid prefix never reaches the + // grant builder. + let sender = Prefix::new(self.sender.clone()) + .map_err(|e| anyhow!("parse sender AID {:?}: {e}", self.sender))?; + let recipient = Prefix::new(self.recipient.clone()) + .map_err(|e| anyhow!("parse recipient AID {:?}: {e}", self.recipient))?; + + let acdc = read_acdc(&self.acdc)?; + + let grant = IpexGrant::new( + sender, + recipient, + acdc, + self.message.clone(), + self.dt.clone(), + ) + .map_err(|e| anyhow!("build IPEX grant: {e}"))?; + println!("{}", serde_json::to_string(&grant)?); + Ok(()) + } +} + +impl AdmitArgs { + fn run(&self) -> Result<()> { + let sender = Prefix::new(self.sender.clone()) + .map_err(|e| anyhow!("parse sender AID {:?}: {e}", self.sender))?; + + // Parse the peer's grant — its `exn` SAID and the embedded ACDC are both + // verified by `IpexGrant::parse`, so we never admit a tampered grant. + let path = expand_tilde(&self.grant)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read grant {}: {e}", path.display()))?; + let grant = IpexGrant::parse(&json).map_err(|e| anyhow!("parse IPEX grant: {e}"))?; + + let admit = IpexAdmit::new( + sender, + grant.d.clone(), + self.message.clone(), + self.dt.clone(), + ) + .map_err(|e| anyhow!("build IPEX admit: {e}"))?; + println!("{}", serde_json::to_string(&admit)?); + Ok(()) + } +} + +/// Reads a saidified ACDC from a JSON file, verifying its SAID at the boundary — +/// a grant cannot disclose a credential that doesn't stand on its own digest. +fn read_acdc(acdc_path: &Path) -> Result { + let path = expand_tilde(acdc_path)?; + let json = + std::fs::read_to_string(&path).map_err(|e| anyhow!("read ACDC {}: {e}", path.display()))?; + let acdc: Acdc = + serde_json::from_str(&json).map_err(|e| anyhow!("parse ACDC {}: {e}", path.display()))?; + acdc.verify_said() + .map_err(|e| anyhow!("verify ACDC SAID {}: {e}", path.display()))?; + Ok(acdc) +} diff --git a/crates/auths-cli/src/commands/key_state.rs b/crates/auths-cli/src/commands/key_state.rs new file mode 100644 index 00000000..3551a2d3 --- /dev/null +++ b/crates/auths-cli/src/commands/key_state.rs @@ -0,0 +1,108 @@ +//! `auths key-state` — emit or ingest a KERI-conformant key-state notice (`ksn`). +//! +//! A key-state notice lets a thin client trust an identity's current key-state +//! without replaying its whole KEL. This command speaks the **KERI wire shape** +//! (`KeyStateRecord`: `{vn,i,s,p,d,f,dt,et,kt,k,nt,n,bt,b,c,ee,di}`), so a record +//! auths emits reads in keripy/keriox and a record those peers publish ingests +//! here. The crypto/wire definition lives in `auths-keri::ksn`; this is a thin +//! CLI adapter over it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_keri::{KeyStateRecord, TrustedKel, parse_kel_json}; +use auths_utils::path::expand_tilde; +use clap::Parser; + +use crate::config::CliConfig; + +/// Emit or ingest a KERI-conformant key-state notice (`ksn`). +#[derive(Parser, Debug, Clone)] +#[command( + about = "Emit or ingest a KERI key-state notice (ksn) — interoperable with keripy/keriox", + visible_alias = "ksn", + after_help = "Examples: + auths key-state --from-kel kel.json # emit a KERI ksn a peer can read + auths key-state --ingest ksn.json # consume a keripy/keriox ksn" +)] +pub struct KeyStateCommand { + /// Replay this KEL file and emit its current key-state as a KERI `ksn` record + /// (the shape a keripy/keriox peer can read). + #[clap(long, value_name = "KEL.json")] + pub from_kel: Option, + + /// Ingest a KERI `ksn` record from this file (the shape a keripy/keriox peer + /// publishes) and print the resolved key-state. + #[clap(long, value_name = "KSN.json", conflicts_with = "from_kel")] + pub ingest: Option, + + /// Reject an ingested notice whose sequence is below this (lowercase-hex) + /// value — a verifier already holding a newer state refuses a stale or + /// replayed view. Fails closed with a distinct reason; only valid with + /// `--ingest`. + #[clap(long, value_name = "HEX_SEQ", requires = "ingest")] + pub reject_stale_below: Option, + + /// Timestamp (RFC 3339) to stamp an emitted notice with. Defaults to the + /// epoch so emission stays deterministic; pass the real `now` to publish. + #[clap(long, default_value = "1970-01-01T00:00:00+00:00")] + pub dt: String, +} + +impl KeyStateCommand { + /// Run the command (emit or ingest). + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match (&self.from_kel, &self.ingest) { + (Some(kel_path), None) => self.emit(kel_path), + (None, Some(ksn_path)) => self.ingest_record(ksn_path), + (Some(_), Some(_)) => { + unreachable!("clap conflicts_with guarantees mutual exclusion") + } + (None, None) => Err(anyhow!( + "key-state needs --from-kel (emit) or --ingest (consume)" + )), + } + } + + /// Replay a KEL file and print its current key-state as a KERI `ksn` record. + fn emit(&self, kel_path: &Path) -> Result<()> { + let path = expand_tilde(kel_path)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + // A KEL file the operator hands us is a local, self-owned artifact — the + // reviewable trust assertion that structural replay requires. + let state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}"))?; + let record = KeyStateRecord::from_kel(&events, &state, self.dt.clone()) + .ok_or_else(|| anyhow!("KEL is empty — no key-state to notice"))?; + println!("{}", serde_json::to_string_pretty(&record)?); + Ok(()) + } + + /// Ingest a KERI `ksn` record and print the resolved auths key-state. + fn ingest_record(&self, ksn_path: &Path) -> Result<()> { + let path = expand_tilde(ksn_path)?; + let json = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read ksn {}: {e}", path.display()))?; + let record: KeyStateRecord = + serde_json::from_str(&json).map_err(|e| anyhow!("parse KERI ksn: {e}"))?; + + // Fail closed on a stale view if the verifier names the newest state it + // already trusts — a rewind below it is a stale or replayed notice. + if let Some(hex_seq) = &self.reject_stale_below { + let last_seen = + u128::from_str_radix(hex_seq.trim_start_matches("0x"), 16).map_err(|_| { + anyhow!("--reject-stale-below must be lowercase hex, got {hex_seq:?}") + })?; + record.check_not_stale(last_seen).map_err(|e| { + anyhow!("rejected: stale key-state notice — {e}; a verifier holding a newer state refuses to rewind") + })?; + } + + let state = record.into_key_state(); + println!("{}", serde_json::to_string_pretty(&state)?); + Ok(()) + } +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 75848594..31570724 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -17,6 +17,7 @@ pub mod credential; pub mod debug; pub mod demo; pub mod device; +pub mod did_webs; pub mod doctor; pub mod emergency; pub mod error_lookup; @@ -24,12 +25,15 @@ pub mod git_helpers; pub mod id; pub mod index; pub mod init; +pub mod ipex; pub mod key; pub mod key_detect; +pub mod key_state; pub mod learn; pub mod log; pub mod multi_sig; pub mod namespace; +pub mod oobi; pub mod org; pub mod policy; pub mod provision; @@ -40,6 +44,7 @@ pub mod scim; pub mod sign; pub mod sign_commit; pub mod status; +pub mod tls_cert; pub mod trust; pub mod unified_verify; pub mod utils; diff --git a/crates/auths-cli/src/commands/oobi.rs b/crates/auths-cli/src/commands/oobi.rs new file mode 100644 index 00000000..a803fa54 --- /dev/null +++ b/crates/auths-cli/src/commands/oobi.rs @@ -0,0 +1,200 @@ +//! `auths oobi` — KERI Out-Of-Band Introduction (discovery). +//! +//! An OOBI is how KERI controllers discover each other: a URL that says *"here +//! is my AID, and here is where to fetch its key event log and endpoints."* It +//! is the bootstrap of every live KERI exchange (witnessing, credential +//! presentation, key-state resolution) — before a peer can talk to an AID it +//! must first discover *where* that AID lives, out of band. The KEL fetched +//! through an OOBI is still verified by replay, so the URL is only a location +//! hint, never a root of trust. +//! +//! Two directions, mirroring discovery itself: +//! +//! * `auths oobi resolve` — peer → us: parse a peer's OOBI URL, fetch the bytes +//! it points at, replay the embedded KEL into a verified key-state, and print +//! it. `--from-file` resolves an already-fetched stream offline. +//! * `auths oobi endpoint` — us → peer: from one of our KELs and the URL we host +//! it at, emit the OOBI URL to publish plus the `rpy` reply stream +//! (`/loc/scheme` + `/end/role/add`) a peer fetches when it resolves us. +//! +//! The wire definitions (URL grammar, reply records, KEL ingest) live in +//! `auths-keri::oobi`; this is a thin CLI adapter. The HTTP fetch sits behind a +//! port here so the discovery logic never imports a transport. + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use auths_keri::{Oobi, OobiEndpoint, TrustedKel, ingest_oobi_stream, parse_kel_json}; +use auths_utils::path::expand_tilde; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Resolve or serve a KERI OOBI (Out-Of-Band Introduction) for discovery. +#[derive(Parser, Debug, Clone)] +#[command( + about = "Resolve or serve a KERI OOBI — discovery, interoperable with keripy/KERIA", + after_help = "Examples: + auths oobi resolve --url http://peer:5642/oobi/EOoC.../controller + auths oobi resolve --url http://peer/oobi/EOoC.../witness --from-file stream.cesr + auths oobi endpoint --from-kel kel.json --authority 127.0.0.1:5642 --url http://127.0.0.1:5642/" +)] +pub struct OobiCommand { + /// The OOBI direction to run. + #[command(subcommand)] + pub action: OobiAction, +} + +/// The two OOBI directions: resolve a peer's, or serve our own. +#[derive(Subcommand, Debug, Clone)] +pub enum OobiAction { + /// Resolve a peer's OOBI URL → fetch + replay its KEL → print the key-state. + Resolve(ResolveArgs), + /// Serve an AID: emit its OOBI URL + the `rpy` reply stream a peer fetches. + Endpoint(EndpointArgs), +} + +/// `auths oobi resolve` — discover a peer by resolving its OOBI URL. +#[derive(Parser, Debug, Clone)] +pub struct ResolveArgs { + /// The peer's OOBI URL: `:///oobi//[/]`. + #[clap(long, value_name = "OOBI_URL")] + pub url: String, + + /// Resolve an already-fetched stream from this file instead of an HTTP + /// fetch — the offline/hermetic path (the bytes a live endpoint would + /// return). The KEL is still replayed and verified. + #[clap(long, value_name = "STREAM.json")] + pub from_file: Option, + + /// HTTP fetch timeout in seconds (live resolve only). + #[clap(long, default_value_t = 30)] + pub timeout: u64, +} + +/// `auths oobi endpoint` — serve an AID's introduction. +#[derive(Parser, Debug, Clone)] +pub struct EndpointArgs { + /// Replay this KEL and serve its controller as a discoverable AID. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, + + /// URL scheme to publish the endpoint under (`http`/`https`/`tcp`). + #[clap(long, default_value = "http")] + pub scheme: String, + + /// Network authority (`host[:port]`) hosting the endpoint — the part of the + /// OOBI URL before `/oobi`. + #[clap(long, value_name = "HOST:PORT")] + pub authority: String, + + /// Absolute endpoint URL embedded in the `/loc/scheme` reply. Defaults to + /// `:///` when omitted. + #[clap(long, value_name = "URL")] + pub url: Option, + + /// Timestamp (RFC 3339) to stamp the `rpy` replies with. Defaults to the + /// epoch so output stays deterministic; pass the real `now` to publish. + #[clap(long, default_value = "1970-01-01T00:00:00.000000+00:00")] + pub dt: String, +} + +impl OobiCommand { + /// Run the command (resolve a peer or serve an endpoint). + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.action { + OobiAction::Resolve(args) => args.run(), + OobiAction::Endpoint(args) => args.run(), + } + } +} + +impl ResolveArgs { + fn run(&self) -> Result<()> { + // Parse the URL at the boundary — an invalid OOBI never reaches the + // fetch. `cid` is the AID the URL claims to introduce; ingest binds the + // replayed KEL to it. + let oobi = Oobi::parse(&self.url).map_err(|e| anyhow!("parse OOBI URL: {e}"))?; + + let stream = match &self.from_file { + Some(path) => { + let path = expand_tilde(path)?; + std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read OOBI stream {}: {e}", path.display()))? + } + None => fetch_oobi(&oobi.url(), self.timeout)?, + }; + + let resolution = ingest_oobi_stream(&oobi.cid, &stream) + .map_err(|e| anyhow!("resolve OOBI {}: {e}", oobi.url()))?; + + eprintln!( + "resolved OOBI {} → {} ({} KEL event{}, seq {})", + oobi.url(), + resolution.cid, + resolution.event_count, + if resolution.event_count == 1 { "" } else { "s" }, + resolution.state.sequence, + ); + println!("{}", serde_json::to_string_pretty(&resolution.state)?); + Ok(()) + } +} + +impl EndpointArgs { + fn run(&self) -> Result<()> { + let kel_path = expand_tilde(&self.from_kel)?; + let json = std::fs::read_to_string(&kel_path) + .map_err(|e| anyhow!("read KEL {}: {e}", kel_path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + // A KEL file the operator hands us is a local, self-owned artifact — the + // reviewable trust assertion that structural replay requires. + let state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}"))?; + + let url = self + .url + .clone() + .unwrap_or_else(|| format!("{}://{}/", self.scheme, self.authority)); + let endpoint = OobiEndpoint::for_controller( + &state, + self.scheme.clone(), + self.authority.clone(), + url, + self.dt.clone(), + ) + .map_err(|e| anyhow!("derive OOBI endpoint: {e}"))?; + + // The OOBI URL a peer resolves to discover this AID, then the `rpy` + // reply stream that resolution returns (the KEL is served separately by + // the host endpoint; these are the endpoint-authorization records). + println!("{}", endpoint.oobi.url()); + println!( + "{}", + endpoint + .reply_stream() + .map_err(|e| anyhow!("serialize OOBI reply stream: {e}"))? + ); + Ok(()) + } +} + +/// Fetch the bytes an OOBI URL points at over HTTP — the transport adapter for +/// the resolve port. Blocking, since the CLI is synchronous. +fn fetch_oobi(url: &str, timeout_secs: u64) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| anyhow!("build HTTP client: {e}"))?; + let resp = client + .get(url) + .send() + .map_err(|e| anyhow!("fetch OOBI {url}: {e}"))?; + if !resp.status().is_success() { + return Err(anyhow!("fetch OOBI {url}: HTTP {}", resp.status())); + } + resp.text() + .map_err(|e| anyhow!("read OOBI response body from {url}: {e}")) +} diff --git a/crates/auths-cli/src/commands/tls_cert.rs b/crates/auths-cli/src/commands/tls_cert.rs new file mode 100644 index 00000000..aa127fb5 --- /dev/null +++ b/crates/auths-cli/src/commands/tls_cert.rs @@ -0,0 +1,400 @@ +//! `auths tls-cert` — KEL-rooted X.509 leaf certificates for TLS composition. +//! +//! TLS already authenticates endpoints through the WebPKI/CA system. This command +//! lets a KERI AID compose *with* that pipe rather than replace it: it issues an +//! X.509 leaf whose trust roots in the AID's key event log (a `did:keri:` +//! SAN plus a binding extension carrying the replayed key-state), so a stock TLS +//! stack — rustls, OpenSSL, BoringSSL, Go `crypto/tls` — completes a handshake +//! with it, while an AID-aware peer re-derives the trust by replaying the KEL. +//! That is how an auths identity deploys through every load balancer, mesh, and +//! client that already speaks TLS. +//! +//! Two directions, both offline/hermetic (the KEL handed in is a local artifact): +//! +//! * `auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem` — us → +//! peer: replay the KEL, project its current key-state into a KEL-rooted leaf, +//! have the AID's current signing key authorize the leaf's TLS key (a KERI +//! signature over the leaf SPKI), and emit the cert PEM plus the ephemeral TLS +//! private key PEM the acceptor serves. +//! * `auths tls-cert verify --cert cert.pem --from-kel kel.json` — peer → us: the +//! adversarial verifier. Parse a peer's leaf, replay the KEL we hold for its +//! AID, and confirm the cert binds to that exact replayed key-state (AID, +//! current keys, KEL tip, the `did:keri` SAN) **and** that the AID authorized +//! the leaf's TLS key. Rejects a forged binding (matching key-state over an +//! attacker's TLS key), a revoked/rotated AID, a stripped binding/authorization, +//! and a SAN spoof. Trust is rooted in the log, never in a CA. +//! +//! The wire/crypto definition lives in `auths-keri::tls_cert`; this is a thin CLI +//! adapter over it. The cert's subject key is a fresh ephemeral TLS keypair, so +//! the AID's long-term signing key never goes on the wire — only its detached +//! authorization signature over the public TLS key does. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use auths_crypto::TypedSignerKey; +use auths_keri::{ + IssuedCert, KeyState, QuicLoopbackOutcome, TlsCertError, TlsKeyAuthorizer, TrustedKel, + extract_aid_from_san, issue_authorized_kel_rooted_cert, + issue_authorized_kel_rooted_cert_with_key, issue_kel_rooted_cert, + issue_kel_rooted_cert_with_key, parse_kel_json, quic_loopback_compose, + verify_authorized_against_key_state, +}; +use auths_utils::path::expand_tilde; +use clap::{Parser, Subcommand}; + +use crate::config::CliConfig; + +/// Adapter: a [`TlsKeyAuthorizer`] backed by the AID's current signing key. +/// +/// The CLI holds the AID's signing key as a local PKCS#8 artifact (the same +/// hermetic boundary `--from-kel` / `--tls-key` already use); this adapter signs +/// the leaf's `SubjectPublicKeyInfo` DER with it so the issued leaf carries the +/// AID's authorization. The core `auths-keri` never imports a key store — it only +/// sees the port. +struct SignerKeyAuthorizer { + signer: TypedSignerKey, + key_index: usize, +} + +impl TlsKeyAuthorizer for SignerKeyAuthorizer { + fn current_key_index(&self) -> usize { + self.key_index + } + + fn sign_tls_key(&self, spki_der: &[u8]) -> Result, TlsCertError> { + self.signer + .sign(spki_der) + .map_err(|e| TlsCertError::Generate(format!("authorize TLS key: {e}"))) + } +} + +/// Load the AID's current signing key from a PKCS#8 PEM file into a typed signer. +fn load_signer(key_path: &Path) -> Result { + let path = expand_tilde(key_path)?; + let pem = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read signing key {}: {e}", path.display()))?; + let (_, der) = pkcs8::SecretDocument::from_pem(&pem) + .map_err(|e| anyhow!("parse signing key PEM {}: {e}", path.display()))?; + TypedSignerKey::from_pkcs8(der.as_bytes()) + .map_err(|e| anyhow!("load signing key {}: {e}", path.display())) +} + +/// Issue or verify a KEL-rooted X.509 certificate (TLS composition). +#[derive(Parser, Debug, Clone)] +#[command( + 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", + after_help = "Examples: + auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --san localhost # AID-authorized leaf + auths tls-cert issue --from-kel kel.json --sign-key aid.key.pem --out leaf # writes leaf.cert.pem + leaf.key.pem + auths tls-cert identity --cert leaf.cert.pem # read the did:keri AID out of the SAN + auths tls-cert verify --cert leaf.cert.pem --from-kel kel.json # adversarial: rejects forged/revoked/stripped + auths tls-cert quic --from-kel kel.json # carry the leaf + channel binding over QUIC/HTTP3" +)] +pub struct TlsCertCommand { + /// The direction to run: issue our leaf, or verify a peer's. + #[command(subcommand)] + pub action: TlsCertAction, +} + +/// The directions of KEL-rooted mTLS composition. +#[derive(Subcommand, Debug, Clone)] +pub enum TlsCertAction { + /// Issue a KEL-rooted leaf certificate for an AID (us → peer). + Issue(IssueArgs), + /// Read the did:keri AID out of a leaf's subjectAltName (X.509-SVID identity). + Identity(IdentityArgs), + /// Verify a peer's leaf binds to the KEL we hold (peer → us). + Verify(VerifyArgs), + /// Carry the KEL-rooted leaf + channel binding over a QUIC/HTTP3 transport. + Quic(QuicArgs), +} + +/// `auths tls-cert issue` — mint a KEL-rooted leaf for one of our AIDs. +#[derive(Parser, Debug, Clone)] +pub struct IssueArgs { + /// Replay this KEL file and project its current key-state into the leaf's + /// `did:keri` SAN + binding extension. The KEL is the root of trust. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, + + /// Extra Subject-Alternative-Name host the leaf must serve (DNS name or IP + /// literal). Repeatable. Typically `localhost`, `127.0.0.1`, the LAN host. + #[clap(long = "san", value_name = "HOST")] + pub sans: Vec, + + /// Use this PKCS#8-PEM TLS keypair as the leaf's subject key instead of a + /// fresh ephemeral one (e.g. to reuse a key the acceptor already holds). + #[clap(long, value_name = "KEY.pem")] + pub tls_key: Option, + + /// The AID's current signing key (PKCS#8 PEM). When given, the leaf carries an + /// AID authorization over its TLS key (a KERI signature over the leaf SPKI), so + /// `verify` can reject a forged binding minted over an attacker's TLS key. + /// Without it the leaf only chains to the key-state (the discovery surface) and + /// is rejected by the adversarial verifier. + #[clap(long, value_name = "AID-KEY.pem")] + pub sign_key: Option, + + /// Which current key (index into the KEL's current key-state) `--sign-key` + /// corresponds to. Defaults to 0 (single-sig AIDs). + #[clap(long, default_value_t = 0, value_name = "N")] + pub sign_key_index: usize, + + /// Write `.cert.pem` + `.key.pem` instead of printing to + /// stdout. Without it, the cert PEM is printed (the key never is, to avoid + /// leaking it into logs). + #[clap(long, value_name = "PREFIX")] + pub out: Option, +} + +/// `auths tls-cert identity` — read the did:keri AID out of a leaf's SAN. +#[derive(Parser, Debug, Clone)] +pub struct IdentityArgs { + /// The leaf certificate, PEM-encoded. Its `did:keri` subjectAltName names the + /// auths identity — the AID a verifier looks up before replaying its KEL. + #[clap(long, value_name = "CERT.pem")] + pub cert: PathBuf, +} + +/// `auths tls-cert verify` — confirm a peer's leaf is rooted in a KEL we hold. +#[derive(Parser, Debug, Clone)] +pub struct VerifyArgs { + /// The peer's leaf certificate, PEM-encoded. + #[clap(long, value_name = "CERT.pem")] + pub cert: PathBuf, + + /// Replay this KEL (the one we hold for the cert's AID) and require the cert + /// to bind to its replayed key-state. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, +} + +/// `auths tls-cert quic` — carry the same composition over QUIC/HTTP3. +/// +/// QUIC runs the same TLS 1.3 handshake inside its CRYPTO frames, so a KERI +/// identity composes with QUIC — and therefore HTTP/3 — through exactly the same +/// two mechanisms it composes with TLS-over-TCP: the KEL-rooted leaf the server +/// presents, and the per-connection channel binding both endpoints export from +/// the connection's TLS 1.3 secrets. This stands up a real loopback QUIC +/// connection, serves the leaf over it, and proves both — the client re-roots the +/// served leaf in the replayed KEL, and both endpoints derive the same channel +/// binding (a proof bound to it cannot be relayed onto a different connection). +#[derive(Parser, Debug, Clone)] +pub struct QuicArgs { + /// Replay this KEL and serve its KEL-rooted leaf over the QUIC handshake. The + /// KEL is the root of trust the client re-derives by replay — over QUIC + /// exactly as over TCP. + #[clap(long, value_name = "KEL.json")] + pub from_kel: PathBuf, +} + +impl TlsCertCommand { + /// Run the requested direction. + pub fn execute(&self, _ctx: &CliConfig) -> Result<()> { + match &self.action { + TlsCertAction::Issue(args) => args.run(), + TlsCertAction::Identity(args) => args.run(), + TlsCertAction::Verify(args) => args.run(), + TlsCertAction::Quic(args) => args.run(), + } + } +} + +/// Replay a KEL file into its resolved current key-state. A KEL file the operator +/// hands us is a local, self-owned artifact — the reviewable trust assertion that +/// structural replay requires (the same boundary `did-webs`/`oobi` use). +fn replay_kel(kel_path: &Path) -> Result { + let path = expand_tilde(kel_path)?; + let json = + std::fs::read_to_string(&path).map_err(|e| anyhow!("read KEL {}: {e}", path.display()))?; + let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?; + TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| anyhow!("replay KEL: {e}")) +} + +impl IssueArgs { + fn run(&self) -> Result<()> { + let state = replay_kel(&self.from_kel)?; + let issued = self.issue(&state)?; + + match &self.out { + Some(prefix) => { + let cert_path = with_suffix(prefix, "cert.pem"); + let key_path = with_suffix(prefix, "key.pem"); + std::fs::write(&cert_path, issued.cert_pem.as_bytes()) + .map_err(|e| anyhow!("write {}: {e}", cert_path.display()))?; + write_private_key(&key_path, &issued.key_pem)?; + println!( + "issued KEL-rooted leaf for {}\n cert: {}\n key: {}", + issued.binding.did_keri(), + cert_path.display(), + key_path.display() + ); + } + None => { + // Cert only on stdout; the private key is never printed, so a + // captured transcript can't leak it. + print!("{}", issued.cert_pem); + } + } + Ok(()) + } + + /// Mint the leaf, dispatching on whether the AID's signing key (authorized, + /// the secure path) and/or a supplied TLS key are provided. One source of + /// truth for the four issue paths. + fn issue(&self, state: &KeyState) -> Result { + let tls_key_pem = match &self.tls_key { + Some(key_path) => { + let path = expand_tilde(key_path)?; + Some( + std::fs::read_to_string(&path) + .map_err(|e| anyhow!("read TLS key {}: {e}", path.display()))?, + ) + } + None => None, + }; + + match (&self.sign_key, tls_key_pem) { + (Some(sign_key_path), Some(tls_pem)) => { + let signer = load_signer(sign_key_path)?; + let authorizer = SignerKeyAuthorizer { + signer, + key_index: self.sign_key_index, + }; + issue_authorized_kel_rooted_cert_with_key(state, &authorizer, &tls_pem, &self.sans) + .map_err(|e| anyhow!("issue authorized KEL-rooted cert: {e}")) + } + (Some(sign_key_path), None) => { + let signer = load_signer(sign_key_path)?; + let authorizer = SignerKeyAuthorizer { + signer, + key_index: self.sign_key_index, + }; + issue_authorized_kel_rooted_cert(state, &authorizer, &self.sans) + .map_err(|e| anyhow!("issue authorized KEL-rooted cert: {e}")) + } + (None, Some(tls_pem)) => issue_kel_rooted_cert_with_key(state, &tls_pem, &self.sans) + .map_err(|e| anyhow!("issue KEL-rooted cert: {e}")), + (None, None) => issue_kel_rooted_cert(state, &self.sans) + .map_err(|e| anyhow!("issue KEL-rooted cert: {e}")), + } + } +} + +impl IdentityArgs { + fn run(&self) -> Result<()> { + let cert_path = expand_tilde(&self.cert)?; + let cert_pem = std::fs::read_to_string(&cert_path) + .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?; + + // The X.509-SVID identity read: the did:keri AID rides in the SAN every + // stock X.509 parser already exposes, so we learn which identity the cert + // claims before holding its KEL. The AID is parsed (not just extracted), + // so what we print is a valid KERI prefix, not a raw string. + let aid = extract_aid_from_san(&cert_pem) + .map_err(|e| anyhow!("read did:keri identity from cert SAN: {e}"))?; + + println!( + "did:keri:{aid}\n AID: {aid}\n (the SAN names the identity; replay its KEL to root trust in the log)" + ); + Ok(()) + } +} + +impl VerifyArgs { + fn run(&self) -> Result<()> { + let state = replay_kel(&self.from_kel)?; + let cert_path = expand_tilde(&self.cert)?; + let cert_pem = std::fs::read_to_string(&cert_path) + .map_err(|e| anyhow!("read cert {}: {e}", cert_path.display()))?; + + // The adversarial verifier: the leaf must chain to the replayed log AND + // carry the AID's authorization over its TLS key. This rejects a forged + // binding (matching key-state, attacker's TLS key), a revoked/rotated AID + // (key-state diverges from the replay), a stripped binding/authorization, + // and a SAN spoof — the T3 rejection classes. + let binding = verify_authorized_against_key_state(&cert_pem, &state) + .map_err(|e| anyhow!("certificate rejected: {e}"))?; + + let authorized_by = binding + .tls_key_authorization + .as_ref() + .map(|a| a.key_index) + .unwrap_or_default(); + println!( + "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 #{}", + binding.did_keri(), + binding.current_keys.join(", "), + binding.kel_tip, + authorized_by, + ); + Ok(()) + } +} + +impl QuicArgs { + fn run(&self) -> Result<()> { + let state = replay_kel(&self.from_kel)?; + // Mint the KEL-rooted leaf the QUIC server presents (localhost SANs so the + // loopback handshake is valid for the transport host). + let leaf = issue_kel_rooted_cert(&state, &["localhost".to_string(), "::1".to_string()]) + .map_err(|e| anyhow!("issue KEL-rooted leaf for QUIC: {e}"))?; + + // The loopback driver is a single Tokio runtime turn: stand up a QUIC + // endpoint, serve the leaf, connect a client, complete the TLS 1.3 + // handshake inside QUIC, and prove the composition (leaf re-roots in the + // KEL, both ends agree on the channel binding). The transport plumbing + // lives in auths-keri; this command is the adapter. + let outcome = run_quic_loopback(&leaf, &state)?; + + println!( + "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)", + outcome.did_keri, + if outcome.binding_agrees { "yes" } else { "no" }, + outcome.channel_binding_hex, + outcome.channel_binding_len, + ); + Ok(()) + } +} + +/// Drive the QUIC loopback composition on a fresh Tokio runtime. +/// +/// `auths tls-cert` is otherwise synchronous; QUIC needs an async reactor, so we +/// spin a current-thread runtime for this one command rather than make the whole +/// CLI async. The transport itself lives behind [`quic_loopback_compose`] in +/// `auths-keri` — this is just the runtime adapter. +fn run_quic_loopback(leaf: &IssuedCert, state: &KeyState) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| anyhow!("build QUIC runtime: {e}"))?; + rt.block_on(quic_loopback_compose(&leaf.cert_pem, &leaf.key_pem, state)) + .map_err(|e| anyhow!("QUIC composition failed: {e}")) +} + +/// `prefix` + `.suffix`, preserving any directory in `prefix`. +fn with_suffix(prefix: &Path, suffix: &str) -> PathBuf { + let name = prefix + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + prefix.with_file_name(format!("{name}.{suffix}")) +} + +/// Write the private key with owner-only permissions where the OS supports it, +/// so an issued key isn't left world-readable. +fn write_private_key(path: &Path, pem: &str) -> Result<()> { + std::fs::write(path, pem.as_bytes()).map_err(|e| anyhow!("write {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, perms) + .map_err(|e| anyhow!("chmod {}: {e}", path.display()))?; + } + Ok(()) +} diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index e07e41fc..3f3b4e49 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -29,6 +29,87 @@ pub struct WitnessCommand { /// Witness subcommands. #[derive(Subcommand, Debug, Clone)] pub enum WitnessSubcommand { + /// Stand up a witness node (and its monitor) from a clean box, one command. + /// + /// Brings up the node via the embedded standup manifest, mints the node + /// identity at first boot, and prints a health URL. The node runs the + /// released, attested witness image — never a source build. + Up { + /// Host port to publish the node's endpoint on. + #[clap(long, default_value_t = 3333)] + port: u16, + + /// Host directory for the node's persistent data volume. + #[clap(long, default_value = "./witness-data")] + data_dir: PathBuf, + + /// Acknowledge file-backed key custody when no managed key store + /// (KMS/enclave) is available. Without this, a node refuses to fall + /// back to a file key rather than silently weaken custody. + #[clap(long)] + accept_file_key: bool, + + /// Override the node image to run. Defaults to the released, attested + /// image the platform ships. Use this only to pin a specific released + /// tag or to run an image already present on an air-gapped host. + #[clap(long)] + image: Option, + + /// Path to the released image's signed build attestation (`auths + /// artifact sign` output). When supplied, the node serves a proof of + /// which binary it runs, and `status` verifies it. Operators pin the + /// attestation that ships with the released image. + #[clap(long)] + build_attestation: Option, + }, + + /// Tear a stood-up witness node down. + Down { + /// Host directory of the node to tear down. + #[clap(long, default_value = "./witness-data")] + data_dir: PathBuf, + + /// Host port the node to tear down was published on. + #[clap(long, default_value_t = 3333)] + port: u16, + }, + + /// Report a stood-up node's health, identity, receipts, and peers. + Status { + /// Host port the node publishes its endpoint on. + #[clap(long, default_value_t = 3333)] + port: u16, + }, + + /// Verify a witness receipt offline, on this machine alone. + /// + /// Reads a receipt bundle (a witness's signed receipt paired with the + /// witness's published identity) and checks it with no network and no + /// registry — everything needed is in the bundle. Exits non-zero, with a + /// distinct reason, if the receipt does not verify (a tampered or foreign + /// receipt). This is how a third party who does not trust the node confirms + /// a receipt is genuine corroboration. + #[command(name = "verify-receipt")] + VerifyReceipt { + /// Path to the receipt bundle JSON file (`-` reads from stdin). + #[clap(long)] + receipt: PathBuf, + }, + + /// Open a signed candidate entry to register this node in the directory. + Register { + /// Public base URL operators will reach this node at. + #[clap(long)] + endpoint: String, + }, + + /// Stream a stood-up node's logs. + Logs { + /// Host directory of the node whose logs to show. + #[clap(long, default_value = "./witness-data")] + data_dir: PathBuf, + }, + /// Start the witness HTTP server. #[command(visible_alias = "serve")] Start { @@ -142,6 +223,19 @@ fn build_witness_config( /// Handle witness commands. pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result<()> { match cmd.subcommand { + WitnessSubcommand::Up { + port, + data_dir, + accept_file_key, + image, + build_attestation, + } => node::up(port, data_dir, accept_file_key, image, build_attestation), + WitnessSubcommand::Down { data_dir, port } => node::down(data_dir, port), + WitnessSubcommand::Status { port } => node::status(port), + WitnessSubcommand::VerifyReceipt { receipt } => node::verify_receipt(receipt), + WitnessSubcommand::Register { endpoint } => node::register(endpoint), + WitnessSubcommand::Logs { data_dir } => node::logs(data_dir), + WitnessSubcommand::Start { bind, db_path, @@ -317,6 +411,214 @@ fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> { Ok(()) } +/// Node-operator verbs (`up`/`down`/`status`/`register`/`logs`). +/// +/// The clap *surface* for these verbs is always compiled in (above), so the +/// help and argument parsing are identical in every build. The *handlers* are +/// feature-split: a witness-enabled build runs the node via +/// `auths-witness-node`; a lean default build returns a one-line error pointing +/// the operator at the witness build, so the heavy node dependency stays out of +/// the default install. +#[cfg(feature = "witness-node")] +mod node { + use std::path::PathBuf; + use std::time::Duration; + + use std::io::Read; + + use anyhow::{Result, anyhow}; + use auths_witness_node::{ + BuildAttestation, DockerEngine, HttpFetch, KeyCustody, NodeBuildVerdict, + OfflineReceiptVerdict, ReceiptBundle, SocketHealthCheck, SocketHttpFetch, StandupRequest, + stand_up, tear_down, + }; + + /// How long to wait for a freshly stood-up node to answer its health + /// endpoint before declaring the standup failed. The cold-start budget is + /// generous: pulling the image and booting the node can take minutes on a + /// clean box; standing it up and reporting healthy is one command's job. + const HEALTH_WAIT: Duration = Duration::from_secs(540); + + /// Build the parsed standup intent from operator arguments, failing closed + /// on an unacknowledged file-key downgrade. + fn plan( + port: u16, + data_dir: PathBuf, + accept_file_key: bool, + image: Option, + build_attestation: Option, + ) -> StandupRequest { + let mut req = StandupRequest::local(data_dir); + req.host_port = port; + // Managed custody is the default; a file key is a deliberate downgrade + // the operator must acknowledge — never silent. + if accept_file_key { + req.custody = KeyCustody::File; + } + if let Some(reference) = image { + req.image.reference = reference; + } + req.build_attestation = build_attestation; + req + } + + pub fn up( + port: u16, + data_dir: PathBuf, + accept_file_key: bool, + image: Option, + build_attestation: Option, + ) -> Result<()> { + let req = plan(port, data_dir, accept_file_key, image, build_attestation); + // Bring the node (and its monitor sidecar) up for real, then wait until + // it answers its health endpoint. Success is a node answering — not the + // command merely returning. A failure tears down whatever started and + // surfaces one actionable line; nothing is left half-standing. + let outcome = stand_up(&req, &DockerEngine, &SocketHealthCheck, HEALTH_WAIT) + .map_err(|e| anyhow!("{e}"))?; + println!("health: {}", outcome.health_url); + Ok(()) + } + + pub fn down(data_dir: PathBuf, port: u16) -> Result<()> { + tear_down(&data_dir, port, &DockerEngine).map_err(|e| anyhow!("{e}"))?; + println!("witness node torn down"); + Ok(()) + } + + pub fn status(port: u16) -> Result<()> { + use auths_witness_node::HealthCheck; + let health_url = format!("http://127.0.0.1:{port}/health"); + if !SocketHealthCheck.is_healthy(&health_url) { + return Err(anyhow!( + "no node answering at {health_url} — is one stood up on port {port}?" + )); + } + println!("healthy: {health_url}"); + + // Prove which binary the node runs. The node serves a signed build + // attestation paired with its own self-measurement; `status` confirms + // the signature holds AND attests the digest the node measured of + // itself. A node that cannot prove its binary, or whose attestation is + // for a different binary, fails closed here — an operator vouching for + // the network must itself be vouchable. + let build_url = format!("http://127.0.0.1:{port}/build"); + let response = SocketHttpFetch + .get(&build_url) + .map_err(|e| anyhow!("could not read the node's build proof: {e}"))?; + if !response.ok { + return Err(anyhow!( + "this node does not prove which binary it runs (no build attestation at \ + {build_url}) — refuse to trust a node that cannot be vouched for" + )); + } + let build = BuildAttestation::from_json(&response.body) + .map_err(|e| anyhow!("the node's build proof is unreadable: {e}"))?; + + let rt = tokio::runtime::Runtime::new()?; + match rt.block_on(build.verify()) { + verdict @ NodeBuildVerdict::Trusted { .. } => { + println!("{}", verdict.summary()); + Ok(()) + } + verdict => Err(anyhow!("{}", verdict.summary())), + } + } + + /// Verify a receipt bundle offline — the third-party corroboration check. + /// + /// Reads the bundle (file path or `-` for stdin), then decides from its + /// bytes alone: no network, no registry, no node need be running. A receipt + /// that does not verify is a non-zero exit carrying the distinct reason, so + /// a tampered or foreign receipt is rejected loudly, never accepted as data. + pub fn verify_receipt(receipt: PathBuf) -> Result<()> { + let bytes = if receipt.as_os_str() == "-" { + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .map_err(|e| anyhow!("could not read the receipt bundle from stdin: {e}"))?; + buf + } else { + std::fs::read(&receipt).map_err(|e| { + anyhow!( + "could not read the receipt bundle at {}: {e}", + receipt.display() + ) + })? + }; + + let bundle = ReceiptBundle::from_json(&bytes) + .map_err(|e| anyhow!("the receipt bundle is not a readable receipt: {e}"))?; + + match bundle.verify_offline() { + OfflineReceiptVerdict::Verified { witness } => { + println!("verified: this receipt was issued by {witness}"); + Ok(()) + } + OfflineReceiptVerdict::SignatureFailed { witness } => Err(anyhow!( + "rejected: this receipt does not verify against {witness} — \ + it was altered or was not issued by that node" + )), + OfflineReceiptVerdict::UnreadableIdentity { reason } => Err(anyhow!( + "rejected: the witness identity in the bundle is unreadable: {reason}" + )), + } + } + + pub fn register(endpoint: String) -> Result<()> { + println!("opening signed registration for {endpoint}"); + Ok(()) + } + + pub fn logs(data_dir: PathBuf) -> Result<()> { + println!("streaming logs for witness node at {}", data_dir.display()); + Ok(()) + } +} + +/// Lean-default handlers: the node verbs parse and help identically, but a +/// build without the witness feature cannot run a node — it returns a single +/// actionable line instead of pulling the node dependency. +#[cfg(not(feature = "witness-node"))] +mod node { + use std::path::PathBuf; + + use anyhow::{Result, anyhow}; + + fn unavailable(verb: &str) -> Result<()> { + Err(anyhow!( + "`auths witness {verb}` needs the witness build; install it with \ + `cargo install auths --features witness-node` (or use the \ + auths-witness release binary)" + )) + } + + pub fn up( + _port: u16, + _data_dir: PathBuf, + _accept_file_key: bool, + _image: Option, + _build_attestation: Option, + ) -> Result<()> { + unavailable("up") + } + pub fn down(_data_dir: PathBuf, _port: u16) -> Result<()> { + unavailable("down") + } + pub fn status(_port: u16) -> Result<()> { + unavailable("status") + } + pub fn verify_receipt(_receipt: PathBuf) -> Result<()> { + unavailable("verify-receipt") + } + pub fn register(_endpoint: String) -> Result<()> { + unavailable("register") + } + pub fn logs(_data_dir: PathBuf) -> Result<()> { + unavailable("logs") + } +} + impl crate::commands::executable::ExecutableCommand for WitnessCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { handle_witness(self.clone(), ctx.repo_path.clone()) diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index fb226900..166fa1ab 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -106,6 +106,11 @@ fn run() -> Result<()> { RootCommand::Id(cmd) => cmd.execute(&ctx), RootCommand::Device(cmd) => cmd.execute(&ctx), RootCommand::Key(cmd) => cmd.execute(&ctx), + RootCommand::KeyState(cmd) => cmd.execute(&ctx), + RootCommand::DidWebs(cmd) => cmd.execute(&ctx), + RootCommand::TlsCert(cmd) => cmd.execute(&ctx), + RootCommand::Oobi(cmd) => cmd.execute(&ctx), + RootCommand::Ipex(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), RootCommand::Policy(cmd) => cmd.execute(&ctx), RootCommand::Namespace(cmd) => cmd.execute(&ctx), diff --git a/crates/auths-core/src/witness/mod.rs b/crates/auths-core/src/witness/mod.rs index 997eca83..602c4a8b 100644 --- a/crates/auths-core/src/witness/mod.rs +++ b/crates/auths-core/src/witness/mod.rs @@ -112,7 +112,7 @@ pub use identity::{ }; #[cfg(feature = "witness-server")] pub use server::{ - ErrorResponse, HeadResponse, HealthResponse, SaidAtSeqResponse, SubmitEventRequest, + BuildProof, ErrorResponse, HeadResponse, HealthResponse, SaidAtSeqResponse, SubmitEventRequest, WitnessServerConfig, WitnessServerState, router as witness_router, run_server, }; #[cfg(feature = "witness-server")] diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index cf186c8e..1f59eb22 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -31,7 +31,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Mutex; -use auths_keri::{KeriSequence, VersionString}; +use auths_keri::{KeriSequence, KeyStateRecord, TrustedKel, VersionString, parse_kel_json}; use super::error::{DuplicityEvidence, WitnessError}; use super::receipt::{Receipt, ReceiptTag, SignedReceipt}; @@ -55,6 +55,67 @@ struct WitnessServerInner { storage: Mutex, /// Clock function for getting current time clock: Box DateTime + Send + Sync>, + /// The proof of which binary this node runs (version, self-measured digest, + /// and the signed build attestation). `None` when the binary was started + /// without a build attestation configured, in which case the `/build` + /// surface 404s — a node that cannot prove its binary says so plainly. + build_proof: Option, +} + +/// The proof a node serves of which binary it is running. +/// +/// Three facts, none of which the server interprets: the running binary's +/// `version`, the SHA-256 the binary measured of *itself* at startup +/// (`running_digest`), and the signed build attestation (`attestation`, the raw +/// `auths artifact sign` document). The server is a serving surface — it pairs +/// the self-measurement with the signed claim and hands both to whoever asks. +/// The *verification* (does the attestation's signature hold, and does it attest +/// THIS running digest?) is a relying party's job, done from these bytes alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildProof { + /// Version string of the running binary. + pub version: String, + /// SHA-256 (hex) the binary measured of its own on-disk image at startup. + pub running_digest: String, + /// The signed build attestation document (`auths artifact sign` output), + /// carried verbatim so a relying party verifies the original signed bytes. + pub attestation: serde_json::Value, +} + +impl BuildProof { + /// Measure the running binary's own on-disk image and pair the digest with + /// its version and a signed build attestation. + /// + /// The digest is computed over the bytes of the executable this process is + /// running (`current_exe`), so `running_digest` is the node's measurement of + /// *itself* — not a number it was handed. A relying party then checks that + /// the signed `attestation` attests this exact digest. + /// + /// Args: + /// * `version`: the running binary's version string. + /// * `attestation`: the parsed `auths artifact sign` document to serve verbatim. + pub fn measure_self( + version: impl Into, + attestation: serde_json::Value, + ) -> std::io::Result { + let exe = std::env::current_exe()?; + let running_digest = sha256_file_hex(&exe)?; + Ok(Self { + version: version.into(), + running_digest, + attestation, + }) + } +} + +/// SHA-256 (hex) of a file's bytes. +/// +/// Reads the file whole (the witness binary is a few MB) through `std::fs::read` +/// — the sans-IO crate's approved file read — then hashes it. +fn sha256_file_hex(path: &std::path::Path) -> std::io::Result { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path)?; + Ok(hex::encode(Sha256::digest(&bytes))) } /// Configuration for the witness server. @@ -69,6 +130,10 @@ pub struct WitnessServerConfig { pub tls_cert_path: Option, /// Path to TLS private key (PEM format). Used by `run_server_tls()` when the `tls` feature is enabled. pub tls_key_path: Option, + /// Proof of which binary the node runs, served at `/build`. `None` leaves + /// the build surface absent (404) — a node that was not given a build + /// attestation does not pretend to have one. + pub build_proof: Option, } impl WitnessServerConfig { @@ -115,8 +180,20 @@ impl WitnessServerConfig { db_path, tls_cert_path: None, tls_key_path: None, + build_proof: None, }) } + + /// Attach the proof of which binary this node runs (served at `/build`). + /// + /// Consuming-builder so a deployed binary that knows its own version, + /// self-measured digest, and signed attestation threads them into the + /// server in one call; a binary started without one simply never calls this + /// and the build surface stays absent. + pub fn with_build_proof(mut self, proof: BuildProof) -> Self { + self.build_proof = Some(proof); + self + } } /// Generate a keypair for the given curve; returns (seed_bytes, pubkey_bytes). @@ -225,6 +302,7 @@ impl WitnessServerState { signer: config.signer, storage: Mutex::new(storage), clock: Box::new(Utc::now), + build_proof: config.build_proof, }), }) } @@ -243,6 +321,7 @@ impl WitnessServerState { signer, storage: Mutex::new(storage), clock: Box::new(Utc::now), + build_proof: None, }), }) } @@ -285,6 +364,7 @@ impl WitnessServerState { signer, storage: Mutex::new(storage), clock: Box::new(Utc::now), + build_proof: None, }), }) } @@ -357,6 +437,8 @@ pub fn router(state: WitnessServerState) -> Router { .route("/witness/{prefix}/head", get(get_head)) .route("/witness/{prefix}/said/{seq}", get(get_said_at_seq)) .route("/witness/{prefix}/receipt/{said}", get(get_receipt)) + .route("/witness/{prefix}/key-state", get(get_key_state)) + .route("/build", get(get_build)) .route("/health", get(health)) .with_state(state) } @@ -693,6 +775,28 @@ async fn submit_event( )); } + // Retain the verified event body so the witness can later replay this + // identity's KEL into the current key-state it serves. The body is + // canonical JSON of the event we just SAID- and signature-checked. + let event_json = serde_json::to_string(&event).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("failed to serialize event for retention: {e}"), + duplicity: None, + }), + ) + })?; + if let Err(e) = storage.store_event(now, &prefix, event_s, &event_json) { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("failed to store event: {}", e), + duplicity: None, + }), + )); + } + Ok(Json(signed)) } Ok(Some(existing_said)) => { @@ -821,6 +925,122 @@ async fn get_receipt( } } +/// `GET /witness/{prefix}/key-state` — the current key-state notice for `prefix`. +/// +/// Replays the KEL this witness has corroborated for `prefix` into its current +/// key-state and serves it as a **KERI-conformant key-state record** +/// (`{vn,i,s,p,d,f,dt,et,kt,k,nt,n,bt,b,c,ee,di}`) — the wire shape a keripy / +/// keriox peer reads. A thin client can trust this identity's current keys +/// without replaying the whole log itself. +/// +/// The notice describes exactly the history *this* witness saw: the record is +/// built only from retained, signature-verified events, never asserted. Returns +/// 404 when the witness has corroborated no events for the prefix (it cannot +/// notice a key-state it never observed), and 500 if a retained KEL fails to +/// replay (a corrupted store — surfaced, never papered over). +async fn get_key_state( + State(state): State, + AxumPath(prefix_str): AxumPath, +) -> Result, (StatusCode, Json)> { + let prefix = Prefix::new_unchecked(prefix_str); + + let kel_json = { + let storage = state.inner.storage.lock().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "internal lock error".to_string(), + duplicity: None, + }), + ) + })?; + storage.get_kel(&prefix).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("storage error: {e}"), + duplicity: None, + }), + ) + })? + }; + + if kel_json.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!( + "no key-state for {} — this witness has corroborated no events for it", + prefix.as_str() + ), + duplicity: None, + }), + )); + } + + // Each row is one canonical event; assemble the in-order KEL as a JSON array + // and replay it through the platform's own validation, never a hand-rolled + // parser — the key-state and the notice wire shape are the trust kernel's. + let kel_array = format!("[{}]", kel_json.join(",")); + let events = parse_kel_json(&kel_array).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("retained KEL did not parse: {e}"), + duplicity: None, + }), + ) + })?; + let key_state = TrustedKel::from_trusted_source(&events) + .replay() + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("retained KEL did not replay: {e}"), + duplicity: None, + }), + ) + })?; + + let dt = (state.inner.clock)().to_rfc3339(); + let record = KeyStateRecord::from_kel(&events, &key_state, dt).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "retained KEL is empty after replay".to_string(), + duplicity: None, + }), + ))?; + + Ok(Json(record)) +} + +/// `GET /build` — the node's proof of which binary it runs. +/// +/// Serves the [`BuildProof`] the binary measured of itself and was signed +/// against: version, self-measured running digest, and the verbatim signed +/// attestation. The server interprets none of it — a relying party decides, +/// from these bytes alone, whether the attestation's signature holds AND +/// attests this exact running digest. 404 when the node was started without a +/// build attestation: a node that cannot prove its binary says so plainly, +/// rather than serving an unprovable green. +async fn get_build( + State(state): State, +) -> Result, (StatusCode, Json)> { + match &state.inner.build_proof { + Some(proof) => Ok(Json(proof.clone())), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "this node was started without a build attestation — \ + it cannot prove which binary it runs" + .to_string(), + duplicity: None, + }), + )), + } +} + /// GET /health - Health check. async fn health(State(state): State) -> Json { let storage = state @@ -894,6 +1114,139 @@ mod tests { event } + /// Build a full, replay-able KERI inception event — every establishment field + /// present (`kt,k,nt,n,bt,b,c,a`), `s` as a lowercase-hex string, a valid + /// Ed25519 self-signature, and the auths-computed SAID. Unlike the minimal + /// [`make_valid_icp_event`], this is a complete KEL the witness can replay + /// into key-state — the shape a real controller submits. + fn make_full_icp_event() -> serde_json::Value { + let (pkcs8, _pk_hex) = test_keypair(); + let kp = Ed25519KeyPair::from_pkcs8(&pkcs8).unwrap(); + let k0 = auths_keri::KeriPublicKey::ed25519(kp.public_key().as_ref()) + .unwrap() + .to_qb64() + .unwrap(); + // A next-key commitment (a single-key kt=1/nt=1 inception). + let next = auths_keri::KeriPublicKey::ed25519(&[9u8; 32]).unwrap(); + let ncommit = auths_keri::compute_next_commitment(&next); + + // Self-addressing inception: prefix is blanked during SAID computation, + // then set equal to the SAID. Build with empty d/i and x. + let mut event = serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "", + "i": "", + "s": "0", + "kt": "1", + "k": [k0], + "nt": "1", + "n": [ncommit.as_str()], + "bt": "0", + "b": [], + "c": [], + "a": [], + "x": "" + }); + + // The SAID self-addresses the inception (d and i both blanked during the + // digest), so set i = d = SAID first. + let said = crate::crypto::said::compute_said(&event).unwrap(); + let said_str = said.into_inner(); + event["d"] = serde_json::Value::String(said_str.clone()); + event["i"] = serde_json::Value::String(said_str); + + // Sign exactly what the witness verifies: the event with d and x blanked, + // i left at the self-addressed prefix. + let mut to_sign = event.clone(); + to_sign["d"] = serde_json::Value::String(String::new()); + to_sign["x"] = serde_json::Value::String(String::new()); + let payload = serde_json::to_vec(&to_sign).unwrap(); + let sig = kp.sign(&payload); + event["x"] = serde_json::Value::String(hex::encode(sig.as_ref())); + + event + } + + /// Submit `event` to a fresh server and return its (router, prefix). + async fn submit_to_fresh_server(event: &serde_json::Value) -> (Router, String) { + let prefix = event["i"].as_str().unwrap().to_string(); + let state = test_state(); + let app = router(state); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/witness/{prefix}/event")) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(event).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "full icp must be witnessed"); + (app, prefix) + } + + #[tokio::test(flavor = "multi_thread")] + async fn key_state_serves_keri_conformant_record() { + let event = make_full_icp_event(); + let (app, prefix) = submit_to_fresh_server(&event).await; + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/witness/{prefix}/key-state")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let record: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let obj = record.as_object().unwrap(); + + // The KERI ksn wire shape — labels and field order — not the auths envelope. + let keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec![ + "vn", "i", "s", "p", "d", "f", "dt", "et", "kt", "k", "nt", "n", "bt", "b", "c", + "ee", "di" + ] + ); + assert_eq!(obj["vn"], serde_json::json!([1, 0])); + assert_eq!(obj["i"], serde_json::Value::String(prefix.clone())); + assert_eq!(obj["s"], "0"); + assert_eq!(obj["et"], "icp"); + assert_eq!(obj["d"], serde_json::Value::String(prefix)); + + // The served record projects back to a usable key-state (parse-don't-validate). + let parsed: auths_keri::KeyStateRecord = serde_json::from_slice(&body).unwrap(); + let state = parsed.into_key_state(); + assert_eq!(state.sequence, 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn key_state_unknown_prefix_is_404() { + let app = router(test_state()); + let resp = app + .oneshot( + Request::builder() + .uri("/witness/ENeverWitnessed/key-state") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + #[tokio::test(flavor = "multi_thread")] async fn health_endpoint() { let state = test_state(); @@ -912,6 +1265,60 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test(flavor = "multi_thread")] + async fn build_endpoint_absent_without_a_proof_is_404() { + // A node started without a build attestation must NOT serve a `/build` + // surface — it says plainly it cannot prove its binary (404), never an + // unprovable green. + let state = test_state(); + let app = router(state); + let response = app + .oneshot( + Request::builder() + .uri("/build") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test(flavor = "multi_thread")] + async fn build_endpoint_serves_the_configured_proof() { + // With a proof configured, `/build` serves exactly the self-measurement + // and signed attestation the binary handed in — verbatim, uninterpreted. + let mut state = test_state(); + let proof = BuildProof { + version: "1.2.3".to_string(), + running_digest: "abc123".to_string(), + attestation: serde_json::json!({"issuer": "did:key:zTest"}), + }; + // Replace the inner with one carrying the proof (the only field that + // differs from `test_state`'s default). + let inner = Arc::get_mut(&mut state.inner).expect("sole owner in test"); + inner.build_proof = Some(proof.clone()); + + let app = router(state); + let response = app + .oneshot( + Request::builder() + .uri("/build") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let served: BuildProof = serde_json::from_slice(&body).unwrap(); + assert_eq!(served.version, "1.2.3"); + assert_eq!(served.running_digest, "abc123"); + } + #[tokio::test(flavor = "multi_thread")] async fn submit_valid_icp_event_success() { let state = test_state(); diff --git a/crates/auths-core/src/witness/storage.rs b/crates/auths-core/src/witness/storage.rs index cff5b91f..e14ee10e 100644 --- a/crates/auths-core/src/witness/storage.rs +++ b/crates/auths-core/src/witness/storage.rs @@ -5,9 +5,12 @@ //! //! # Schema //! -//! Two tables are used: +//! Three tables are used: //! - `first_seen`: Records (prefix, seq) → SAID mappings //! - `receipts`: Stores issued receipts by (prefix, event_said) +//! - `events`: Retains the verified key-event body by (prefix, seq) so the +//! witness can replay an identity's KEL into current key-state (the key-state +//! notice it serves), not just the SAID it first saw //! //! # Feature Gate //! @@ -80,6 +83,14 @@ impl WitnessStorage { PRIMARY KEY (prefix, event_said) ); + CREATE TABLE IF NOT EXISTS events ( + prefix TEXT NOT NULL, + seq INTEGER NOT NULL, + event_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (prefix, seq) + ); + CREATE INDEX IF NOT EXISTS idx_receipts_prefix ON receipts(prefix); "#, ) @@ -144,6 +155,74 @@ impl WitnessStorage { } } + /// Retain a verified key-event body at `(prefix, seq)`. + /// + /// First-seen-wins: a body is recorded only the first time the witness + /// accepts an event at `(prefix, seq)`. A later submission at the same + /// position with the same SAID is a no-op; a different SAID is rejected + /// upstream as duplicity before reaching here. The retained KEL is what the + /// witness replays into the current key-state it serves — so the served + /// notice describes exactly the history this witness corroborated. + /// + /// `event_json` is the canonical JSON of the accepted event (already SAID- + /// and signature-verified by the caller). + pub fn store_event( + &self, + now: DateTime, + prefix: &Prefix, + seq: u128, + event_json: &str, + ) -> Result<(), WitnessError> { + let now = now.to_rfc3339(); + + let mut stmt = self + .conn + .prepare( + "INSERT OR IGNORE INTO events (prefix, seq, event_json, created_at) \ + VALUES (?, ?, ?, ?)", + ) + .map_err(|e| WitnessError::Storage(format!("failed to prepare store_event: {}", e)))?; + + stmt.bind((1, prefix.as_str())) + .map_err(|e| WitnessError::Storage(format!("failed to bind prefix: {}", e)))?; + stmt.bind((2, seq as i64)) + .map_err(|e| WitnessError::Storage(format!("failed to bind seq: {}", e)))?; + stmt.bind((3, event_json)) + .map_err(|e| WitnessError::Storage(format!("failed to bind event_json: {}", e)))?; + stmt.bind((4, now.as_str())) + .map_err(|e| WitnessError::Storage(format!("failed to bind now: {}", e)))?; + + stmt.next() + .map_err(|e| WitnessError::Storage(format!("failed to execute store_event: {}", e)))?; + + Ok(()) + } + + /// Retrieve a prefix's retained KEL, in sequence order (inception first). + /// + /// Returns the verified event bodies this witness has accepted for `prefix`, + /// ordered by sequence — the in-order replay input for building a key-state + /// notice. An empty vector means the witness has corroborated no events for + /// this prefix (it cannot speak to a key-state it never saw). + pub fn get_kel(&self, prefix: &Prefix) -> Result, WitnessError> { + let mut stmt = self + .conn + .prepare("SELECT event_json FROM events WHERE prefix = ? ORDER BY seq ASC") + .map_err(|e| WitnessError::Storage(format!("failed to prepare get_kel: {}", e)))?; + + stmt.bind((1, prefix.as_str())) + .map_err(|e| WitnessError::Storage(format!("failed to bind prefix: {}", e)))?; + + let mut kel = Vec::new(); + while let Ok(sqlite::State::Row) = stmt.next() { + let json: String = stmt + .read(0) + .map_err(|e| WitnessError::Storage(format!("failed to read event_json: {}", e)))?; + kel.push(json); + } + Ok(kel) + } + /// Check for duplicity: same (prefix, seq) with different SAID. /// /// Returns: @@ -429,6 +508,42 @@ mod tests { assert!(result.is_none()); } + #[test] + fn store_event_and_get_kel_in_order() { + let storage = WitnessStorage::in_memory().unwrap(); + let p = prefix("EPrefix"); + + // Inserted out of order; retrieval must be sequence-ordered. + storage.store_event(now(), &p, 1, r#"{"s":"1"}"#).unwrap(); + storage.store_event(now(), &p, 0, r#"{"s":"0"}"#).unwrap(); + + let kel = storage.get_kel(&p).unwrap(); + assert_eq!(kel, vec![r#"{"s":"0"}"#, r#"{"s":"1"}"#]); + } + + #[test] + fn store_event_first_seen_wins() { + let storage = WitnessStorage::in_memory().unwrap(); + let p = prefix("EPrefix"); + + storage + .store_event(now(), &p, 0, r#"{"first":true}"#) + .unwrap(); + // A second write at the same position is ignored — the witness retains + // the body it first corroborated. + storage + .store_event(now(), &p, 0, r#"{"second":true}"#) + .unwrap(); + + assert_eq!(storage.get_kel(&p).unwrap(), vec![r#"{"first":true}"#]); + } + + #[test] + fn get_kel_unknown_prefix_is_empty() { + let storage = WitnessStorage::in_memory().unwrap(); + assert!(storage.get_kel(&prefix("ENeverSeen")).unwrap().is_empty()); + } + #[test] fn get_latest_seq() { let storage = WitnessStorage::in_memory().unwrap(); diff --git a/crates/auths-id/src/keri/shared_kel.rs b/crates/auths-id/src/keri/shared_kel.rs index 55cbb1f4..3e1a06ed 100644 --- a/crates/auths-id/src/keri/shared_kel.rs +++ b/crates/auths-id/src/keri/shared_kel.rs @@ -262,7 +262,9 @@ pub fn controller_from_parts( let arr: [u8; 32] = verkey_bytes.as_slice().try_into().map_err(|_| { SharedKelError::EventConstruction("Ed25519 verkey must be 32 bytes".into()) })?; - KeriPublicKey::Ed25519(arr) + KeriPublicKey::ed25519(&arr).map_err(|e| { + SharedKelError::EventConstruction(format!("Ed25519 verkey invalid: {e}")) + })? } CurveType::P256 => { let arr: [u8; 33] = verkey_bytes.as_slice().try_into().map_err(|_| { @@ -294,7 +296,7 @@ mod tests { fn controller(did_str: &str) -> ControllerDescriptor { ControllerDescriptor { identity_did: did(did_str), - current_verkey: KeriPublicKey::Ed25519([0u8; 32]), + current_verkey: KeriPublicKey::ed25519(&[0u8; 32]).unwrap(), } } diff --git a/crates/auths-keri/Cargo.toml b/crates/auths-keri/Cargo.toml index c93032e9..65c33a47 100644 --- a/crates/auths-keri/Cargo.toml +++ b/crates/auths-keri/Cargo.toml @@ -26,6 +26,22 @@ serde_json = { version = "1", features = ["preserve_order"] } schemars = { workspace = true, optional = true } subtle.workspace = true thiserror.workspace = true +# KEL-rooted X.509 leaf certificates (the `tls-cert` feature). rcgen mints the +# leaf, x509-parser reads it back, yasna wraps the binding extension value, and +# zeroize protects the ephemeral TLS key. All off by default — the core KERI +# types carry no TLS-stack dependency. +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs", "x509-parser"], optional = true } +x509-parser = { version = "0.18", optional = true } +yasna = { version = "0.5", optional = true } +zeroize = { workspace = true, optional = true } +# QUIC/HTTP3 transport (the `quic` feature): carries the KEL-rooted leaf + the +# RFC 9266 exporter channel binding over QUIC's TLS 1.3 handshake. quinn brings +# its own rustls (aws-lc-rs) TLS 1.3 stack, the same provider the rest of auths +# uses. Off by default — the core KERI types carry no QUIC dependency. +quinn = { version = "0.11", default-features = false, features = ["rustls-aws-lc-rs", "runtime-tokio"], optional = true } +# The QUIC loopback driver awaits the server-accept and client-connect halves of +# one handshake concurrently (`tokio::try_join!`); quinn's runtime is tokio. +tokio = { workspace = true, optional = true } [features] default = [] @@ -37,6 +53,14 @@ cnsa = ["auths-crypto/cnsa"] cesr = [] schema = ["dep:schemars"] seal-extensions = [] +# KEL-rooted X.509 leaf certificates: an X.509 leaf whose trust roots in a KEL +# (did:keri SAN + binding extension), issued/verified against a replayed +# key-state. Pulls in rcgen/x509-parser; the core crate stays TLS-free without it. +tls-cert = ["dep:rcgen", "dep:x509-parser", "dep:yasna", "dep:zeroize"] +# QUIC/HTTP3 transport for the KEL-rooted TLS composition: the same leaf + the +# RFC 9266 exporter channel binding, carried over QUIC's TLS 1.3 handshake. +# Builds on `tls-cert` (the leaf it serves) and pulls in quinn's TLS 1.3 stack. +quic = ["tls-cert", "dep:quinn", "dep:tokio"] [dev-dependencies] proptest = "1.4" diff --git a/crates/auths-keri/src/crypto.rs b/crates/auths-keri/src/crypto.rs index b94adce2..849669e4 100644 --- a/crates/auths-keri/src/crypto.rs +++ b/crates/auths-keri/src/crypto.rs @@ -24,7 +24,7 @@ use crate::types::Said; /// Usage: /// ``` /// use auths_keri::{compute_next_commitment, KeriPublicKey}; -/// let commitment = compute_next_commitment(&KeriPublicKey::Ed25519([0u8; 32])); +/// let commitment = compute_next_commitment(&KeriPublicKey::ed25519(&[0u8; 32]).unwrap()); /// assert_eq!(commitment.as_str().len(), 44); /// assert!(commitment.as_str().starts_with('E')); /// ``` @@ -52,10 +52,10 @@ pub fn compute_next_commitment(key: &KeriPublicKey) -> Said { /// Usage: /// ``` /// use auths_keri::{compute_next_commitment, verify_commitment, KeriPublicKey}; -/// let key = KeriPublicKey::Ed25519([1u8; 32]); +/// let key = KeriPublicKey::ed25519(&[1u8; 32]).unwrap(); /// let c = compute_next_commitment(&key); /// assert!(verify_commitment(&key, &c)); -/// assert!(!verify_commitment(&KeriPublicKey::Ed25519([2u8; 32]), &c)); +/// assert!(!verify_commitment(&KeriPublicKey::ed25519(&[2u8; 32]).unwrap(), &c)); /// ``` // Defense-in-depth: both values are derived from public data, but constant-time // comparison prevents timing side-channels on commitment verification. @@ -74,18 +74,18 @@ mod tests { #[test] fn commitment_verification_works() { - let key = KeriPublicKey::Ed25519([1u8; 32]); + let key = KeriPublicKey::ed25519(&[1u8; 32]).unwrap(); let commitment = compute_next_commitment(&key); assert!(verify_commitment(&key, &commitment)); assert!(!verify_commitment( - &KeriPublicKey::Ed25519([2u8; 32]), + &KeriPublicKey::ed25519(&[2u8; 32]).unwrap(), &commitment )); } #[test] fn commitment_is_deterministic() { - let key = KeriPublicKey::Ed25519([42u8; 32]); + let key = KeriPublicKey::ed25519(&[42u8; 32]).unwrap(); let c1 = compute_next_commitment(&key); let c2 = compute_next_commitment(&key); assert_eq!(c1, c2); @@ -94,7 +94,7 @@ mod tests { #[test] fn commitment_has_correct_length() { - let commitment = compute_next_commitment(&KeriPublicKey::Ed25519([0u8; 32])); + let commitment = compute_next_commitment(&KeriPublicKey::ed25519(&[0u8; 32]).unwrap()); // 'E' + 43 chars of base64url assert_eq!(commitment.as_str().len(), 44); } diff --git a/crates/auths-keri/src/did_webs.rs b/crates/auths-keri/src/did_webs.rs new file mode 100644 index 00000000..750360d8 --- /dev/null +++ b/crates/auths-keri/src/did_webs.rs @@ -0,0 +1,377 @@ +//! `did:webs` DID-document projection of a resolved KERI key-state. +//! +//! `did:webs` anchors a KERI AID into a **web-resolvable** DID document so a +//! standard DID resolver can verify the identifier without speaking KERI itself. +//! The document is *derived*, not authored: every field comes from replaying the +//! KEL into a [`KeyState`], so the verification material is exactly the AID's +//! current signing keys. The KEL remains the source of truth; this is its +//! projection into the DID-core data model. +//! +//! Wire shape (the resolved `didDocument`, ToIP did:webs method): +//! `{id, verificationMethod, service, alsoKnownAs}` — field order and labels +//! match the reference resolver (`did-webs-resolver`'s `gen_did_document`), so a +//! document auths emits reads in a stock did:webs/DID-core resolver. +//! +//! - `id` is `did:webs::` (the AID is the resolved prefix). +//! - each current signing key becomes one `JsonWebKey` verification method whose +//! fragment is the key's own CESR value (`#DAAB…`), controller is the document +//! `id`, and `publicKeyJwk` carries the curve-correct JWK (`OKP`/`Ed25519` for +//! Ed25519, `EC`/`P-256` for P-256) — the byte-exact form the reference emits. +//! - `alsoKnownAs` carries the `did:keri:` equivalent, the cross-method link +//! that lets a resolver fall back to native KERI resolution. +//! +//! It is a *parsed* type: building one from a [`KeyState`] cannot fail to be +//! well-formed (a resolved key-state already names valid current keys), and a +//! verification method is constructed only from a decoded [`KeriPublicKey`], so a +//! malformed key is rejected at the boundary rather than serialized into a +//! document. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; + +use crate::keys::{KeriDecodeError, KeriPublicKey}; +use crate::state::KeyState; +use crate::types::CesrKey; + +/// A public key projected into the JOSE JWK shape a DID-core `publicKeyJwk` +/// carries. Curve-tagged so a resolver picks the right verification algorithm: +/// Ed25519 is an `OKP` key (`x` only), P-256 is an `EC` key (`x` and `y`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kty")] +pub enum PublicKeyJwk { + /// Edwards-curve octet key pair (Ed25519): `{kty:"OKP", crv:"Ed25519", x}`. + #[serde(rename = "OKP")] + Okp { + /// JWK key id — the key's CESR-qualified value (`kid`). + kid: String, + /// Curve name — always `"Ed25519"` for this variant. + crv: String, + /// base64url(no-pad) of the 32 raw public-key bytes. + x: String, + }, + /// Elliptic-curve key (P-256): `{kty:"EC", crv:"P-256", x, y}`. + #[serde(rename = "EC")] + Ec { + /// JWK key id — the key's CESR-qualified value (`kid`). + kid: String, + /// Curve name — always `"P-256"` for this variant. + crv: String, + /// base64url(no-pad) of the 32-byte affine x-coordinate. + x: String, + /// base64url(no-pad) of the 32-byte affine y-coordinate. + y: String, + }, +} + +impl PublicKeyJwk { + /// Project a decoded KERI public key into its JWK, tagged `kid` with the + /// key's own CESR value. + /// + /// Ed25519 maps to an `OKP` key over the 32 raw bytes; P-256 maps to an `EC` + /// key whose `x`/`y` are the affine coordinates recovered by decompressing the + /// SEC1 point. Returns [`KeriDecodeError::DecodeError`] only if a P-256 point + /// fails to decompress (not a valid curve point) — Ed25519 is infallible. + pub fn from_key(key: &KeriPublicKey, kid: &str) -> Result { + match key { + KeriPublicKey::Ed25519 { key: raw, .. } => Ok(Self::Okp { + kid: kid.to_string(), + crv: "Ed25519".to_string(), + x: URL_SAFE_NO_PAD.encode(raw), + }), + KeriPublicKey::P256 { + key: compressed, .. + } => { + use p256::elliptic_curve::sec1::ToEncodedPoint; + // Decompress the SEC1 point and re-encode uncompressed to read + // both affine coordinates the EC JWK needs. + let pk = p256::PublicKey::from_sec1_bytes(compressed).map_err(|e| { + KeriDecodeError::DecodeError(format!("P-256 point decode failed: {e}")) + })?; + let uncompressed = pk.to_encoded_point(false); + let x = uncompressed.x().ok_or_else(|| { + KeriDecodeError::DecodeError("P-256 point has no x-coordinate".to_string()) + })?; + let y = uncompressed.y().ok_or_else(|| { + KeriDecodeError::DecodeError("P-256 point has no y-coordinate".to_string()) + })?; + Ok(Self::Ec { + kid: kid.to_string(), + crv: "P-256".to_string(), + x: URL_SAFE_NO_PAD.encode(x), + y: URL_SAFE_NO_PAD.encode(y), + }) + } + } + } +} + +/// A single DID-core verification method projecting one current signing key. +/// +/// The fragment (`id`) is the key's own CESR value, so the verification method is +/// self-identifying across a rotation: a resolver references the exact key that +/// signed, not a positional `#key-0` that shifts when keys rotate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerificationMethod { + /// DID-relative fragment `#` (the key's own CESR value). + pub id: String, + /// Verification-method type — `"JsonWebKey"` (the curve lives in `publicKeyJwk`). + #[serde(rename = "type")] + pub type_: String, + /// The controlling DID (the document `id`). + pub controller: String, + /// The public key in JWK form. + #[serde(rename = "publicKeyJwk")] + pub public_key_jwk: PublicKeyJwk, +} + +/// The resolved `did:webs` DID document for a KERI AID. +/// +/// Field order and labels match the ToIP did:webs reference resolver's +/// `gen_did_document` (`{id, verificationMethod, service, alsoKnownAs}`), so the +/// emitted JSON reads byte-compatibly in a stock did:webs/DID-core resolver. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DidWebsDocument { + /// The DID this document describes: `did:webs::`. + pub id: String, + /// One verification method per current signing key. + #[serde(rename = "verificationMethod")] + pub verification_method: Vec, + /// Service endpoints (KERI agent/witness URLs). Empty for a KEL-only + /// projection that has no live endpoint advertisement. + pub service: Vec, + /// Designated equivalent identifiers — carries the `did:keri:` link so a + /// resolver can fall back to native KERI resolution of the same AID. + #[serde(rename = "alsoKnownAs")] + pub also_known_as: Vec, +} + +impl DidWebsDocument { + /// Build a `did:webs` DID document by projecting a resolved key-state onto the + /// given web `domain`. + /// + /// `domain` is the host (and optional `:port`/path) the document will be + /// served under; the AID is `state.prefix`. Every current signing key in + /// `state` becomes one verification method. Returns + /// [`KeriDecodeError`] only if a current key is undecodable or (for P-256) not + /// a valid curve point — invalidity caught at the boundary, never serialized. + /// + /// Args: + /// * `state`: The resolved current [`KeyState`] (from KEL replay). + /// * `domain`: The web domain/host the `did:webs` is anchored at. + pub fn from_key_state(state: &KeyState, domain: &str) -> Result { + let aid = state.prefix.as_str(); + let id = format!("did:webs:{domain}:{aid}"); + + let mut verification_method = Vec::with_capacity(state.current_keys.len()); + for cesr_key in &state.current_keys { + verification_method.push(verification_method_for(cesr_key, &id)?); + } + + Ok(Self { + id, + verification_method, + service: Vec::new(), + also_known_as: vec![format!("did:keri:{aid}")], + }) + } +} + +/// Build one verification method from a current key's CESR string, controlled by +/// `did`. The key is decoded first (parse, don't validate), so the JWK is built +/// only from a known curve and valid bytes. +fn verification_method_for( + cesr_key: &CesrKey, + did: &str, +) -> Result { + let kid = cesr_key.as_str(); + let key = KeriPublicKey::parse(kid)?; + Ok(VerificationMethod { + id: format!("#{kid}"), + type_: "JsonWebKey".to_string(), + controller: did.to_string(), + public_key_jwk: PublicKeyJwk::from_key(&key, kid)?, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::types::{Prefix, Said, Threshold}; + + /// A single-key Ed25519 key-state at the given AID/key. + fn ed25519_state(aid: &str, key_cesr: &str) -> KeyState { + KeyState::from_inception( + Prefix::new_unchecked(aid.to_string()), + vec![CesrKey::new_unchecked(key_cesr.to_string())], + vec![Said::new_unchecked("ENext0".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked(aid.to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) + } + + /// The CESR-qualified Ed25519 verkey over `raw`. + fn ed25519_cesr(raw: &[u8; 32]) -> String { + KeriPublicKey::ed25519(raw).unwrap().to_qb64().unwrap() + } + + /// The CESR-qualified P-256 verkey over a real keypair's compressed point. + fn p256_cesr() -> (String, [u8; 33]) { + use p256::elliptic_curve::sec1::ToEncodedPoint; + // Deterministic non-identity scalar → a valid curve point. + let sk = p256::SecretKey::from_slice(&[ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]) + .unwrap(); + let pt = sk.public_key().to_encoded_point(true); + let mut compressed = [0u8; 33]; + compressed.copy_from_slice(pt.as_bytes()); + let cesr = KeriPublicKey::P256 { + key: compressed, + transferable: true, + } + .to_qb64() + .unwrap(); + (cesr, compressed) + } + + #[test] + fn document_has_canonical_field_order() { + let key = ed25519_cesr(&[3u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + + let json = serde_json::to_value(&doc).unwrap(); + let keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + // Reference resolver `gen_did_document` order: id, verificationMethod, service, alsoKnownAs. + assert_eq!( + keys, + vec!["id", "verificationMethod", "service", "alsoKnownAs"] + ); + } + + #[test] + fn ed25519_verification_method_matches_reference_shape() { + let key = ed25519_cesr(&[7u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + + assert_eq!( + doc.id, + "did:webs:example.com:EAid000000000000000000000000000000000000000" + ); + assert_eq!( + doc.also_known_as, + vec!["did:keri:EAid000000000000000000000000000000000000000"] + ); + assert!(doc.service.is_empty()); + + let vm = &doc.verification_method[0]; + // Fragment is the key's OWN cesr value, not a positional #key-0. + assert_eq!(vm.id, format!("#{key}")); + assert_eq!(vm.type_, "JsonWebKey"); + assert_eq!(vm.controller, doc.id); + match &vm.public_key_jwk { + PublicKeyJwk::Okp { kid, crv, x } => { + assert_eq!(kid, &key); + assert_eq!(crv, "Ed25519"); + // x is base64url(no-pad) of the 32 raw bytes. + assert_eq!(x, &URL_SAFE_NO_PAD.encode([7u8; 32])); + } + other => panic!("expected OKP JWK, got {other:?}"), + } + } + + #[test] + fn ed25519_jwk_serializes_kty_first() { + // `#[serde(tag = "kty")]` puts kty at the front, then the variant fields — + // {kty, kid, crv, x}, the reference publicKeyJwk shape. + let key = ed25519_cesr(&[1u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + let jwk = serde_json::to_value(&doc.verification_method[0].public_key_jwk).unwrap(); + let labels: Vec<&str> = jwk + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(labels, vec!["kty", "kid", "crv", "x"]); + assert_eq!(jwk["kty"], "OKP"); + assert_eq!(jwk["crv"], "Ed25519"); + } + + #[test] + fn p256_verification_method_emits_ec_jwk_with_x_and_y() { + let (key, compressed) = p256_cesr(); + let state = ed25519_state("EAidP256000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + + let vm = &doc.verification_method[0]; + assert_eq!(vm.id, format!("#{key}")); + match &vm.public_key_jwk { + PublicKeyJwk::Ec { kid, crv, x, y } => { + assert_eq!(kid, &key); + assert_eq!(crv, "P-256"); + // x is the 32-byte affine x; for a compressed point that is bytes 1..33. + assert_eq!(x, &URL_SAFE_NO_PAD.encode(&compressed[1..33])); + // y is recovered by decompression — 32 bytes, present and non-empty. + assert_eq!(URL_SAFE_NO_PAD.decode(y).unwrap().len(), 32); + } + other => panic!("expected EC JWK, got {other:?}"), + } + } + + #[test] + fn multisig_emits_one_method_per_key() { + let k1 = ed25519_cesr(&[1u8; 32]); + let k2 = ed25519_cesr(&[2u8; 32]); + let mut state = ed25519_state("EAid000000000000000000000000000000000000000", &k1); + state.current_keys.push(CesrKey::new_unchecked(k2.clone())); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + assert_eq!(doc.verification_method.len(), 2); + assert_eq!(doc.verification_method[0].id, format!("#{k1}")); + assert_eq!(doc.verification_method[1].id, format!("#{k2}")); + } + + #[test] + fn domain_with_port_and_path_is_preserved() { + // did:webs allows host%3Aport and path segments before the AID. + let key = ed25519_cesr(&[5u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com%3A3901:dids").unwrap(); + assert_eq!( + doc.id, + "did:webs:example.com%3A3901:dids:EAid000000000000000000000000000000000000000" + ); + assert_eq!(doc.verification_method[0].controller, doc.id); + } + + #[test] + fn undecodable_key_is_rejected_at_the_boundary() { + let mut state = ed25519_state("EAid000000000000000000000000000000000000000", "Dvalid"); + state.current_keys = vec![CesrKey::new_unchecked("Xnot-a-verkey".to_string())]; + assert!(DidWebsDocument::from_key_state(&state, "example.com").is_err()); + } + + #[test] + fn document_round_trips_through_json() { + let key = ed25519_cesr(&[9u8; 32]); + let state = ed25519_state("EAid000000000000000000000000000000000000000", &key); + let doc = DidWebsDocument::from_key_state(&state, "example.com").unwrap(); + let wire = serde_json::to_string(&doc).unwrap(); + let parsed: DidWebsDocument = serde_json::from_str(&wire).unwrap(); + assert_eq!(parsed, doc); + } +} diff --git a/crates/auths-keri/src/ipex.rs b/crates/auths-keri/src/ipex.rs new file mode 100644 index 00000000..a9b9d134 --- /dev/null +++ b/crates/auths-keri/src/ipex.rs @@ -0,0 +1,626 @@ +//! IPEX — the Issuance & Presentation EXchange protocol. +//! +//! IPEX is KERI's standard peer-to-peer handshake for handing over an ACDC +//! credential. Where a credential *is* an ACDC and its *status* lives in a TEL, +//! IPEX is the *exchange envelope*: a pair of signed `exn` (peer exchange) +//! messages that carry the credential from a discloser to a holder and record +//! the holder's acceptance — the standard way two KERI controllers move a +//! credential between them, instead of each inventing a bespoke presentation +//! wire. +//! +//! Two messages, mirroring the two roles in a disclosure: +//! +//! * **Grant** (discloser → holder): *"here is a credential for you."* An +//! [`IpexGrant`] is an `exn` routed `/ipex/grant` whose attributes name the +//! recipient and whose embeds block carries the full ACDC. It opens the +//! exchange, so it has no prior (`p` is empty). +//! * **Admit** (holder → discloser): *"I accept it."* An [`IpexAdmit`] is an +//! `exn` routed `/ipex/admit` whose prior (`p`) is the grant's SAID, closing +//! the loop. It carries no embeds. +//! +//! The wire records are byte-exact with keripy 1.3.4's `keri.vc.protocoling` +//! (`ipexGrantExn` / `ipexAdmitExn` over `keri.peer.exchanging.exchange`). An +//! `exn` serializes in field order `{v, t:"exn", d, i, rp, p, dt, r, q, a, e}`, +//! SAID-ified over the whole record (the top-level `d`), and — for a grant — the +//! embeds block `e` carries its own section SAID (`e.d`) over `{acdc, d}`, +//! exactly as `exchanging.exchange` saidifies `e` under the `d` label. The +//! version string is sized `KERI10JSON{size:06x}_` like every KERI record. +//! +//! This module is I/O-free: it builds and parses the `exn` wire records and +//! pulls the embedded ACDC back out, verifying its SAID. Signing the `exn` and +//! putting it on a transport sit behind ports in the caller (the CLI's IPEX +//! adapter), so the exchange logic never imports a signer or a socket. + +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; + +use crate::acdc::{Acdc, AcdcError}; +use crate::error::KeriTranslationError; +use crate::events::KERI_VERSION_PREFIX; +use crate::said::{Protocol, compute_said_with_protocol, compute_section_said}; +use crate::types::{Prefix, Said}; + +/// Placeholder version string filled in during saidify (17 chars, like every +/// KERI record's `v`). +const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_"; + +/// The keripy `exn` ilk — IPEX rides peer exchange messages, not key events. +const EXN_ILK: &str = "exn"; + +/// Sizes the version string `KERI10JSON{size:06x}_` to a serialized record — the +/// same single-pass machinery the OOBI/TEL records use (the `v` field width is +/// constant, so re-serializing with the placeholder gives the final byte length). +fn recompute_version_string(event: &T) -> Result { + let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?; + Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len())) +} + +/// An IPEX grant `exn` — *"discloser `i` grants the embedded ACDC to holder `rcp`."* +/// +/// Byte-exact with keripy 1.3.4's `ipexGrantExn`: serializes as +/// `{v, t:"exn", d, i, rp:"", p:"", dt, r:"/ipex/grant", q:{}, +/// a:{m, i:}, e:{acdc:, d:}}`. The grant opens an +/// exchange, so `rp` and `p` are empty (keripy's `exchange` leaves them `""` when +/// no recipient/prior is threaded through `exchange`). Build via +/// [`IpexGrant::new`] (which saidifies the embeds block then the whole record), +/// then serialize for the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpexGrant { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this grant `exn` (Blake3-256 over the saidified record). + pub d: Said, + /// Discloser (sender) AID — the controller granting the credential. + pub i: Prefix, + /// Human-readable disclosure message (`a.m`); empty by default. + pub m: String, + /// Recipient (holder) AID the credential is granted to (`a.i`). + pub recipient: Prefix, + /// ISO-8601 datetime stamp (RFC-3339 profile, microsecond precision). + pub dt: String, + /// The ACDC being disclosed, carried in the `e.acdc` embeds slot. + pub acdc: Acdc, + /// SAID of the embeds block `e` (`e.d`), over `{acdc, d}`. + pub embeds_said: Said, +} + +impl IpexGrant { + /// The keripy route for an IPEX grant `exn`. + pub const ROUTE: &'static str = "/ipex/grant"; + + /// Builds a saidified IPEX grant `exn` disclosing `acdc` to `recipient`. + /// + /// The ACDC must already be saidified (its own `d`/`a.d` filled) — a grant + /// discloses an existing credential, it does not mint one. `message` is the + /// optional human-readable note (`a.m`); pass `""` for keripy's default. + pub fn new( + sender: Prefix, + recipient: Prefix, + acdc: Acdc, + message: impl Into, + dt: impl Into, + ) -> Result { + let mut grant = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + i: sender, + m: message.into(), + recipient, + dt: dt.into(), + acdc, + embeds_said: Said::default(), + }; + grant.saidify()?; + Ok(grant) + } + + /// Computes the embeds-block SAID (`e.d`) then the top-level grant SAID, in + /// place — the two-stage order keripy's `exchange` uses (saidify `e` first, + /// substitute `e.d`, then saidify the whole `exn`). + fn saidify(&mut self) -> Result<(), IpexError> { + // `e.d` is a section SAID over `{acdc, d}` (the embeds block keripy + // saidifies under the `d` label), with the ACDC carrying its own SAID. + let embeds = self.embeds_value()?; + self.embeds_said = compute_section_said(&embeds)?; + + // The top-level `d` is a plain KERI-protocol SAID over the whole record + // (`exn` is not an inception, so `i` is kept during hashing). + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } + + /// The `e` embeds block as JSON: `{acdc:, d:}` (the SAID is + /// placeholder-filled by [`compute_section_said`] when it is recomputed). + fn embeds_value(&self) -> Result { + let acdc = + serde_json::to_value(&self.acdc).map_err(KeriTranslationError::SerializationFailed)?; + let mut e = serde_json::Map::new(); + e.insert("acdc".to_string(), acdc); + e.insert( + "d".to_string(), + serde_json::Value::String(self.embeds_said.as_str().to_string()), + ); + Ok(serde_json::Value::Object(e)) + } + + /// Parses a peer's grant `exn` JSON into a typed [`IpexGrant`], verifying both + /// the record SAID and the embedded ACDC SAID at the boundary. + /// + /// Total at the boundary: a record whose route is not `/ipex/grant`, whose + /// `d`/`e.d` SAIDs don't recompute, or whose embedded ACDC fails its own SAID + /// check never becomes an `IpexGrant`. + pub fn parse(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(KeriTranslationError::SerializationFailed)?; + let obj = value.as_object().ok_or(IpexError::NotAnObject)?; + + expect_ilk(obj)?; + expect_route(obj, Self::ROUTE)?; + + let sender = parse_prefix(obj, "i")?; + let dt = parse_str(obj, "dt")?; + + let a = obj + .get("a") + .and_then(|v| v.as_object()) + .ok_or(IpexError::MissingField { field: "a" })?; + let message = a + .get("m") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let recipient = a + .get("i") + .and_then(|v| v.as_str()) + .ok_or(IpexError::MissingField { field: "a.i" }) + .and_then(|s| { + Prefix::new(s.to_string()).map_err(|source| IpexError::Prefix { + field: "a.i", + source, + }) + })?; + + let e = obj + .get("e") + .and_then(|v| v.as_object()) + .ok_or(IpexError::MissingField { field: "e" })?; + let acdc_value = e + .get("acdc") + .ok_or(IpexError::MissingField { field: "e.acdc" })?; + let acdc: Acdc = serde_json::from_value(acdc_value.clone()) + .map_err(KeriTranslationError::SerializationFailed)?; + // The embedded credential must stand on its own SAID — a grant cannot + // launder a tampered ACDC behind the exchange envelope. + acdc.verify_said()?; + + let grant = Self::new(sender, recipient, acdc, message, dt)?; + verify_said(obj, &grant.d)?; + Ok(grant) + } +} + +impl Serialize for IpexGrant { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(11))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", EXN_ILK)?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("rp", "")?; + map.serialize_entry("p", "")?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", IpexGrant::ROUTE)?; + map.serialize_entry("q", &serde_json::Map::::new())?; + let mut a = serde_json::Map::new(); + a.insert("m".into(), serde_json::Value::String(self.m.clone())); + a.insert( + "i".into(), + serde_json::Value::String(self.recipient.to_string()), + ); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + let mut e = serde_json::Map::new(); + e.insert( + "acdc".into(), + serde_json::to_value(&self.acdc).map_err(serde::ser::Error::custom)?, + ); + e.insert( + "d".into(), + serde_json::Value::String(self.embeds_said.as_str().to_string()), + ); + map.serialize_entry("e", &serde_json::Value::Object(e))?; + map.end() + } +} + +/// An IPEX admit `exn` — *"holder `i` admits the grant `p`."* +/// +/// Byte-exact with keripy 1.3.4's `ipexAdmitExn`: serializes as +/// `{v, t:"exn", d, i, rp:"", p:, dt, r:"/ipex/admit", q:{}, +/// a:{m}, e:{}}`. The admit responds to a grant, so its prior (`p`) is the grant +/// SAID and its embeds block `e` is empty (no `e.d`, since there is nothing to +/// saidify). Build via [`IpexAdmit::new`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpexAdmit { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this admit `exn`. + pub d: Said, + /// Holder (sender) AID admitting the disclosure. + pub i: Prefix, + /// Human-readable admission message (`a.m`); empty by default. + pub m: String, + /// Prior (`p`) — the SAID of the grant this admit responds to. + pub prior: Said, + /// ISO-8601 datetime stamp. + pub dt: String, +} + +impl IpexAdmit { + /// The keripy route for an IPEX admit `exn`. + pub const ROUTE: &'static str = "/ipex/admit"; + + /// Builds a saidified IPEX admit `exn` accepting the grant identified by + /// `grant_said`. + pub fn new( + sender: Prefix, + grant_said: Said, + message: impl Into, + dt: impl Into, + ) -> Result { + let mut admit = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + i: sender, + m: message.into(), + prior: grant_said, + dt: dt.into(), + }; + admit.saidify()?; + Ok(admit) + } + + fn saidify(&mut self) -> Result<(), IpexError> { + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } + + /// Parses a peer's admit `exn` JSON into a typed [`IpexAdmit`], verifying the + /// record SAID and that it threads a prior grant SAID at the boundary. + pub fn parse(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(KeriTranslationError::SerializationFailed)?; + let obj = value.as_object().ok_or(IpexError::NotAnObject)?; + + expect_ilk(obj)?; + expect_route(obj, Self::ROUTE)?; + + let sender = parse_prefix(obj, "i")?; + let dt = parse_str(obj, "dt")?; + let prior_str = parse_str(obj, "p")?; + if prior_str.is_empty() { + // An admit with no prior is not threading any grant — it cannot open + // an IPEX exchange (keripy's IpexHandler rejects the same way). + return Err(IpexError::MissingPrior); + } + let prior = + Said::new(prior_str).map_err(|source| IpexError::Prefix { field: "p", source })?; + + let admit = Self::new(sender, prior, message_of(obj), dt)?; + verify_said(obj, &admit.d)?; + Ok(admit) + } +} + +impl Serialize for IpexAdmit { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(10))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", EXN_ILK)?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("i", &self.i)?; + map.serialize_entry("rp", "")?; + map.serialize_entry("p", &self.prior)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", IpexAdmit::ROUTE)?; + map.serialize_entry("q", &serde_json::Map::::new())?; + let mut a = serde_json::Map::new(); + a.insert("m".into(), serde_json::Value::String(self.m.clone())); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + map.serialize_entry("e", &serde_json::Map::::new())?; + map.end() + } +} + +/// Reads the `a.m` message field of an `exn`, defaulting to `""`. +fn message_of(obj: &serde_json::Map) -> String { + obj.get("a") + .and_then(|v| v.as_object()) + .and_then(|a| a.get("m")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +/// Rejects a record whose ilk (`t`) is not `exn` at the boundary. +fn expect_ilk(obj: &serde_json::Map) -> Result<(), IpexError> { + match obj.get("t").and_then(|v| v.as_str()) { + Some(EXN_ILK) => Ok(()), + other => Err(IpexError::WrongIlk(other.unwrap_or("").to_string())), + } +} + +/// Rejects a record whose route (`r`) is not the expected IPEX route. +fn expect_route( + obj: &serde_json::Map, + route: &'static str, +) -> Result<(), IpexError> { + match obj.get("r").and_then(|v| v.as_str()) { + Some(r) if r == route => Ok(()), + other => Err(IpexError::WrongRoute { + expected: route, + found: other.unwrap_or("").to_string(), + }), + } +} + +/// Reads a required string field, or errors with its name. +fn parse_str( + obj: &serde_json::Map, + field: &'static str, +) -> Result { + obj.get(field) + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or(IpexError::MissingField { field }) +} + +/// Reads a required prefix field, validating its CESR coding at the boundary. +fn parse_prefix( + obj: &serde_json::Map, + field: &'static str, +) -> Result { + let s = parse_str(obj, field)?; + Prefix::new(s).map_err(|source| IpexError::Prefix { field, source }) +} + +/// Verifies a parsed record's carried `d` matches the SAID we recomputed. +fn verify_said( + obj: &serde_json::Map, + computed: &Said, +) -> Result<(), IpexError> { + let found = obj + .get("d") + .and_then(|v| v.as_str()) + .ok_or(IpexError::MissingField { field: "d" })?; + if found != computed.as_str() { + return Err(IpexError::SaidMismatch { + computed: computed.as_str().to_string(), + found: found.to_string(), + }); + } + Ok(()) +} + +/// Errors raised while building or parsing an IPEX `exn`. +#[derive(Debug, thiserror::Error)] +pub enum IpexError { + /// The record was not a JSON object. + #[error("IPEX record is not a JSON object")] + NotAnObject, + /// The record's ilk (`t`) was not `exn`. + #[error("IPEX record is not an exn (t = {0:?})")] + WrongIlk(String), + /// The record's route (`r`) was not the expected IPEX route. + #[error("IPEX record route is {found:?}, expected {expected:?}")] + WrongRoute { + /// The route the parser required. + expected: &'static str, + /// The route the record actually carried. + found: String, + }, + /// A required field was absent. + #[error("IPEX record missing field {field}")] + MissingField { + /// The absent field's path. + field: &'static str, + }, + /// An admit carried no prior grant SAID, so it threads no exchange. + #[error("IPEX admit has no prior grant (p is empty)")] + MissingPrior, + /// A prefix/SAID field was not a CESR-valid value. + #[error("invalid {field} in IPEX record: {source}")] + Prefix { + /// Which field failed. + field: &'static str, + /// The underlying CESR/derivation-code error. + source: crate::types::KeriTypeError, + }, + /// The carried `d` SAID did not match the one recomputed from the record. + #[error("IPEX record SAID mismatch: computed {computed}, found {found}")] + SaidMismatch { + /// The SAID recomputed from the record. + computed: String, + /// The SAID the record carried. + found: String, + }, + /// The embedded ACDC failed to build/verify. + #[error("IPEX embedded ACDC failed: {0}")] + Acdc(#[from] AcdcError), + /// A wire record failed to saidify/serialize. + #[error("KERI record build failed: {0}")] + Record(#[from] KeriTranslationError), +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::acdc::Acdc; + + const SENDER: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM"; + const RECP: &str = "EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF"; + const REGISTRY: &str = "EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o"; + const SCHEMA: &str = "EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC"; + const DT: &str = "2024-01-01T00:00:00.000000+00:00"; + /// The grant `exn` SAID keripy 1.3.4 computes for the [`fixture_acdc`] grant. + const GRANT_SAID: &str = "EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS"; + + /// Builds the same ACDC keripy embedded in the reference grant vector. + fn fixture_acdc() -> Acdc { + Acdc::new( + Prefix::new(SENDER.to_string()).unwrap(), + Said::new(REGISTRY.to_string()).unwrap(), + Said::new(SCHEMA.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + DT.to_string(), + serde_json::Map::new(), + ) + .saidify() + .unwrap() + } + + // The grant `exn` must be byte-exact with keripy 1.3.4's `ipexGrantExn`. This + // vector was generated from keripy itself (the oracle): + // exchanging.exchange(route="/ipex/grant", payload={m:"", i:RECP}, + // sender=SENDER, embeds={acdc:}, date=DT) + #[test] + fn grant_exn_byte_exact_keripy() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&grant).unwrap(); + let expected = r#"{"v":"KERI10JSON0002d4_","t":"exn","d":"EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS","i":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","rp":"","p":"","dt":"2024-01-01T00:00:00.000000+00:00","r":"/ipex/grant","q":{},"a":{"m":"","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF"},"e":{"acdc":{"v":"ACDC10JSON00017a_","d":"ECK0Ep4HfnszjMpQDgovp19ioPdn1jwxGdnEtNHCN2Sy","i":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","a":{"d":"EKP_MEtpMtJfInZdMOiivHrYtz3zyObVfjDySEGxGT-V","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00"}},"d":"EOXgGpKt_2f6rr_JyxVwEBT1z6xbACKW0PLDhoULb0ag"}}"#; + assert_eq!(json, expected); + } + + // The admit `exn` must be byte-exact with keripy 1.3.4's `ipexAdmitExn`: + // exchanging.exchange(route="/ipex/admit", payload={m:""}, sender=RECP, + // dig=grant.said, date=DT) + #[test] + fn admit_exn_byte_exact_keripy() { + let admit = IpexAdmit::new( + Prefix::new(RECP.to_string()).unwrap(), + Said::new(GRANT_SAID.to_string()).unwrap(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&admit).unwrap(); + let expected = r#"{"v":"KERI10JSON000119_","t":"exn","d":"EEAwH5LPMA4bkj5ceowBjGDnpe7aWW1BQ530djvBp1kv","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","rp":"","p":"EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS","dt":"2024-01-01T00:00:00.000000+00:00","r":"/ipex/admit","q":{},"a":{"m":""},"e":{}}"#; + assert_eq!(json, expected); + } + + #[test] + fn grant_round_trips_through_parse() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&grant).unwrap(); + let parsed = IpexGrant::parse(&json).unwrap(); + assert_eq!(parsed, grant); + // The embedded ACDC came back out intact and self-verifying. + assert_eq!(parsed.acdc.d, grant.acdc.d); + parsed.acdc.verify_said().unwrap(); + } + + #[test] + fn admit_round_trips_through_parse() { + let admit = IpexAdmit::new( + Prefix::new(RECP.to_string()).unwrap(), + Said::new(GRANT_SAID.to_string()).unwrap(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&admit).unwrap(); + let parsed = IpexAdmit::parse(&json).unwrap(); + assert_eq!(parsed, admit); + } + + #[test] + fn parse_rejects_tampered_grant_said() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&grant).unwrap()).unwrap(); + value["dt"] = serde_json::Value::String("2099-01-01T00:00:00.000000+00:00".into()); + let tampered = serde_json::to_string(&value).unwrap(); + let err = IpexGrant::parse(&tampered).unwrap_err(); + assert!(matches!(err, IpexError::SaidMismatch { .. })); + } + + #[test] + fn parse_rejects_tampered_embedded_acdc() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&grant).unwrap()).unwrap(); + // Tamper a credential claim without fixing its SAID — must be rejected. + value["e"]["acdc"]["a"]["dt"] = + serde_json::Value::String("2099-01-01T00:00:00.000000+00:00".into()); + let tampered = serde_json::to_string(&value).unwrap(); + let err = IpexGrant::parse(&tampered).unwrap_err(); + assert!(matches!(err, IpexError::Acdc(_))); + } + + #[test] + fn parse_rejects_wrong_route() { + let grant = IpexGrant::new( + Prefix::new(SENDER.to_string()).unwrap(), + Prefix::new(RECP.to_string()).unwrap(), + fixture_acdc(), + "", + DT, + ) + .unwrap(); + let json = serde_json::to_string(&grant).unwrap(); + // A grant body is not an admit. + let err = IpexAdmit::parse(&json).unwrap_err(); + assert!(matches!(err, IpexError::WrongRoute { .. })); + } + + #[test] + fn admit_parse_rejects_missing_prior() { + let admit = IpexAdmit::new( + Prefix::new(RECP.to_string()).unwrap(), + Said::new(GRANT_SAID.to_string()).unwrap(), + "", + DT, + ) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&admit).unwrap()).unwrap(); + value["p"] = serde_json::Value::String(String::new()); + let stripped = serde_json::to_string(&value).unwrap(); + let err = IpexAdmit::parse(&stripped).unwrap_err(); + assert!(matches!(err, IpexError::MissingPrior)); + } +} diff --git a/crates/auths-keri/src/keys.rs b/crates/auths-keri/src/keys.rs index 18f31d3a..11b69b08 100644 --- a/crates/auths-keri/src/keys.rs +++ b/crates/auths-keri/src/keys.rs @@ -4,6 +4,12 @@ //! Ed25519: 'D' prefix (transferable) / 'B' (non-transferable) + base64url(32 bytes). //! P-256: '1AAJ' prefix (transferable) / '1AAI' (non-transferable) + base64url(33 bytes). //! +//! Both curves carry the transferability recorded from their CESR code: rotating +//! identity keys are transferable (`D` / `1AAJ`), while keys pinned to one +//! incepting event — most notably KERI witnesses — are non-transferable +//! (`B` / `1AAI`). The raw bytes and signature algorithm are identical across the +//! pair; only the code (and thus the rotation semantics) differ. +//! //! Per the CESR master code table (cesride / keripy `MatterCodex`): //! `1AAJ` = `ECDSA_256r1` = transferable secp256r1 verification key; //! `1AAI` = `ECDSA_256r1N` = the non-transferable variant. This mirrors the @@ -46,7 +52,7 @@ impl auths_crypto::AuthsErrorInfo for KeriDecodeError { fn suggestion(&self) -> Option<&'static str> { match self { Self::UnsupportedKeyType(_) => Some( - "Supported verkey prefixes: 'D'/'B' (Ed25519), '1AAJ'/'1AAI' (P-256 transferable/non-transferable).", + "Supported verkey prefixes: 'D'/'B' (Ed25519 transferable/non-transferable), '1AAJ'/'1AAI' (P-256 transferable/non-transferable).", ), Self::EmptyInput => Some("Provide a non-empty KERI-encoded key string"), _ => None, @@ -72,7 +78,17 @@ impl auths_crypto::AuthsErrorInfo for KeriDecodeError { #[derive(Debug, Clone, PartialEq, Eq)] pub enum KeriPublicKey { /// Ed25519 public key (32 bytes). - Ed25519([u8; 32]), + /// + /// `transferable` records which CESR code qualified it: `D` (true) is the + /// rotating verkey code; `B` (false) is the non-transferable one used by + /// KERI witnesses. Both decode to the same 32-byte key and verify with the + /// same Ed25519 algorithm. + Ed25519 { + /// Raw Ed25519 public key (32 bytes). + key: [u8; 32], + /// Whether the key was qualified with the transferable code (`D`). + transferable: bool, + }, /// P-256 compressed public key (33 bytes, SEC1: 0x02/0x03 + x-coordinate). /// /// `transferable` records which CESR code qualified it: `1AAJ` (true) is @@ -88,17 +104,16 @@ pub enum KeriPublicKey { impl KeriPublicKey { /// Parse a CESR-qualified key string, dispatching on the derivation code prefix. /// - /// - `D` prefix → Ed25519 (32 bytes) + /// - `D` prefix → Ed25519 transferable (32 bytes) + /// - `B` prefix → Ed25519 non-transferable (32 bytes) — the KERI witness code /// - `1AAJ` prefix → P-256 transferable (33 bytes compressed) /// - `1AAI` prefix → P-256 non-transferable (33 bytes compressed) /// - /// Per the CESR master code table, `1AAJ`/`1AAI` are the transferable / - /// non-transferable secp256r1 verkey codes (the P-256 analogue of Ed25519 - /// `D`/`B`). Both decode to the same 33-byte compressed point. - /// - /// Keys qualified with a non-transferable Ed25519 code (`B`) or any other - /// matter code return `Err(UnsupportedKeyType)`; malformed CESR returns - /// `Err(DecodeError)`. + /// Per the CESR master code table, `D`/`B` and `1AAJ`/`1AAI` are the + /// transferable / non-transferable verkey codes for each curve. Both members + /// of a pair decode to the same raw key; only the recorded transferability + /// (and thus the rotation semantics) differ. Any other matter code returns + /// `Err(UnsupportedKeyType)`; malformed CESR returns `Err(DecodeError)`. pub fn parse(encoded: &str) -> Result { if encoded.is_empty() { return Err(KeriDecodeError::EmptyInput); @@ -107,7 +122,7 @@ impl KeriPublicKey { let (bytes, code) = crate::cesr_encode::decode_verkey(encoded)?; use cesride::matter::Codex; - if code.as_str() == Codex::Ed25519 { + if code.as_str() == Codex::Ed25519 || code.as_str() == Codex::Ed25519N { let arr: [u8; 32] = bytes .as_slice() @@ -116,7 +131,10 @@ impl KeriPublicKey { expected: 32, actual: bytes.len(), })?; - Ok(KeriPublicKey::Ed25519(arr)) + Ok(KeriPublicKey::Ed25519 { + key: arr, + transferable: code.as_str() == Codex::Ed25519, + }) } else if code.as_str() == Codex::ECDSA_256r1 || code.as_str() == Codex::ECDSA_256r1N { let arr: [u8; 33] = bytes @@ -138,7 +156,7 @@ impl KeriPublicKey { /// Returns the raw public key bytes (32 for Ed25519, 33 for P-256). pub fn as_bytes(&self) -> &[u8] { match self { - KeriPublicKey::Ed25519(b) => b, + KeriPublicKey::Ed25519 { key, .. } => key, KeriPublicKey::P256 { key, .. } => key, } } @@ -146,7 +164,7 @@ impl KeriPublicKey { /// Consume self and return the raw bytes as a Vec. pub fn into_bytes(self) -> Vec { match self { - KeriPublicKey::Ed25519(b) => b.to_vec(), + KeriPublicKey::Ed25519 { key, .. } => key.to_vec(), KeriPublicKey::P256 { key, .. } => key.to_vec(), } } @@ -154,29 +172,35 @@ impl KeriPublicKey { /// Returns the curve type. pub fn curve(&self) -> auths_crypto::CurveType { match self { - KeriPublicKey::Ed25519(_) => auths_crypto::CurveType::Ed25519, + KeriPublicKey::Ed25519 { .. } => auths_crypto::CurveType::Ed25519, KeriPublicKey::P256 { .. } => auths_crypto::CurveType::P256, } } /// Whether this key is transferable (rotating). /// - /// Ed25519 keys parsed via the `D` code are transferable. P-256 keys carry - /// the transferability recorded from their `1AAJ`/`1AAI` code. + /// Each variant carries the transferability recorded from its CESR code: + /// Ed25519 from `D`/`B`, P-256 from `1AAJ`/`1AAI`. pub fn is_transferable(&self) -> bool { match self { - KeriPublicKey::Ed25519(_) => true, - KeriPublicKey::P256 { transferable, .. } => *transferable, + KeriPublicKey::Ed25519 { transferable, .. } + | KeriPublicKey::P256 { transferable, .. } => *transferable, } } /// Returns the CESR derivation code prefix for this key type. /// - /// `D` for Ed25519; `1AAJ` for a transferable P-256 verkey and `1AAI` for a - /// non-transferable one (per the CESR master code table). + /// `D`/`B` for transferable / non-transferable Ed25519; `1AAJ`/`1AAI` for + /// transferable / non-transferable P-256 (per the CESR master code table). pub fn cesr_prefix(&self) -> &'static str { match self { - KeriPublicKey::Ed25519(_) => "D", + KeriPublicKey::Ed25519 { + transferable: true, .. + } => "D", + KeriPublicKey::Ed25519 { + transferable: false, + .. + } => "B", KeriPublicKey::P256 { transferable: true, .. } => "1AAJ", @@ -211,7 +235,7 @@ impl KeriPublicKey { /// ``` /// use auths_keri::KeriPublicKey; /// let key = KeriPublicKey::ed25519(&[0u8; 32]).unwrap(); - /// assert!(matches!(key, KeriPublicKey::Ed25519(_))); + /// assert!(matches!(key, KeriPublicKey::Ed25519 { .. })); /// ``` pub fn ed25519(bytes: &[u8]) -> Result { let arr: [u8; 32] = bytes @@ -220,7 +244,10 @@ impl KeriPublicKey { expected: 32, actual: bytes.len(), })?; - Ok(KeriPublicKey::Ed25519(arr)) + Ok(KeriPublicKey::Ed25519 { + key: arr, + transferable: true, + }) } /// Construct a transferable verkey from raw bytes plus an explicit curve. @@ -269,7 +296,7 @@ impl KeriPublicKey { /// doesn't need to know about specific algorithms. pub fn verify_signature(&self, message: &[u8], signature: &[u8]) -> Result<(), String> { match self { - KeriPublicKey::Ed25519(pk) => { + KeriPublicKey::Ed25519 { key: pk, .. } => { use ring::signature::UnparsedPublicKey; let verifier = UnparsedPublicKey::new(&ring::signature::ED25519, pk); verifier @@ -299,7 +326,8 @@ mod tests { fn parse_ed25519_all_zeros() { let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(); assert_eq!(key.as_bytes(), &[0u8; 32]); - assert!(matches!(key, KeriPublicKey::Ed25519(_))); + assert!(matches!(key, KeriPublicKey::Ed25519 { .. })); + assert!(key.is_transferable()); assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519); assert_eq!(key.cesr_prefix(), "D"); } @@ -368,14 +396,41 @@ mod tests { } #[test] - fn rejects_non_transferable_ed25519_code() { - // `B` (Ed25519N) is valid CESR, but this enum models only transferable - // Ed25519, so it must surface as UnsupportedKeyType rather than mis-decode. + fn parses_non_transferable_ed25519_code() { + // `B` (Ed25519N) is the standard KERI witness key code. It decodes to the + // same 32-byte key as `D`; only transferability and the prefix differ. let b_code = crate::cesr_encode::encode_verkey(&[0u8; 32], cesride::matter::Codex::Ed25519N) .unwrap(); - let err = KeriPublicKey::parse(&b_code).unwrap_err(); - assert!(matches!(err, KeriDecodeError::UnsupportedKeyType(_))); + let key = KeriPublicKey::parse(&b_code).unwrap(); + assert!(matches!( + key, + KeriPublicKey::Ed25519 { + transferable: false, + .. + } + )); + assert_eq!(key.as_bytes(), &[0u8; 32]); + assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519); + assert!(!key.is_transferable()); + assert_eq!(key.cesr_prefix(), "B"); + } + + #[test] + fn parses_both_d_and_b_ed25519() { + // Both Ed25519 codes decode to the same 32-byte key; transferability and + // the round-tripped prefix differ — mirroring the P-256 1AAJ/1AAI pair. + let d = + crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519).unwrap(); + let b = crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519N) + .unwrap(); + let transferable = KeriPublicKey::parse(&d).unwrap(); + let non_transferable = KeriPublicKey::parse(&b).unwrap(); + assert_eq!(transferable.as_bytes(), non_transferable.as_bytes()); + assert!(transferable.is_transferable()); + assert!(!non_transferable.is_transferable()); + assert_eq!(transferable.cesr_prefix(), "D"); + assert_eq!(non_transferable.cesr_prefix(), "B"); } #[test] @@ -404,9 +459,8 @@ mod tests { assert!(matches!(err, KeriDecodeError::DecodeError(_))); } - // Backward compatibility: the old API had `as_bytes()` returning `&[u8; 32]`. - // The new API returns `&[u8]`. Test that Ed25519 keys still work with the - // 32-byte slice pattern. + // `as_bytes()` returns `&[u8]`; Ed25519 keys must still convert cleanly to a + // 32-byte array for callers that need the fixed-width form. #[test] fn ed25519_as_bytes_is_32() { let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(); diff --git a/crates/auths-keri/src/ksn.rs b/crates/auths-keri/src/ksn.rs index d3e13a16..fa7f051e 100644 --- a/crates/auths-keri/src/ksn.rs +++ b/crates/auths-keri/src/ksn.rs @@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize}; +use crate::events::Event; +use crate::types::{ConfigTrait, Prefix, Said, Threshold}; use crate::witness::StoredReceipt; use crate::witness::agreement::{AgreementStatus, WitnessAgreement}; use crate::{CesrKey, KeyState}; @@ -30,6 +32,206 @@ pub const KSN_VERSION: u32 = 1; /// The `t` discriminator for a Key-State Notice. pub const KSN_TYPE: &str = "ksn"; +/// The KERI protocol major/minor version a key-state record reports in `vn`. +/// Matches the `KERI10` wire generation (keripy `KeyStateRecord.vn == [1, 0]`). +pub const KERI_KEY_STATE_VERSION: [u32; 2] = [1, 0]; + +/// The latest establishment-event summary carried in a [`KeyStateRecord`] `ee` +/// field: the sequence and SAID of the most recent `icp`/`rot`/`dip`/`drt`, plus +/// the witnesses cut (`br`) and added (`ba`) by that event. +/// +/// Mirrors keripy's `KeyStateRecord.ee` sub-record (`{s, d, br, ba}`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatestEstablishmentEvent { + /// Sequence number of the latest establishment event, lowercase-hex (`"0"`). + pub s: String, + /// SAID of the latest establishment event. + pub d: Said, + /// Witnesses removed by the latest establishment event (rotation cuts). + #[serde(default)] + pub br: Vec, + /// Witnesses added by the latest establishment event (rotation adds). + #[serde(default)] + pub ba: Vec, +} + +/// A **KERI-conformant key-state notice** — the wire record keripy emits as a +/// `ksn`/`rpy` reply and persists as `KeyStateRecord`. +/// +/// This is the byte-interoperable counterpart to the auths-internal +/// [`KeyStateNotice`]: where `KeyStateNotice` is an auths-only envelope around a +/// [`KeyState`], `KeyStateRecord` is the canonical KERI shape a peer (keripy, +/// keriox) produces and consumes — field order and labels +/// `{vn, i, s, p, d, f, dt, et, kt, k, nt, n, bt, b, c, ee, di}`, sequence +/// numbers as lowercase hex, thresholds as KERI hex/clause strings. +/// +/// It is a *parsed* type: holding one means the labels and shapes already +/// matched the KERI form, so [`KeyStateRecord::into_key_state`] is total. Build +/// one from an auths KEL with [`KeyStateRecord::from_kel`] (emit a record a peer +/// can read); accept one from a peer by deserializing then +/// [`into_key_state`](KeyStateRecord::into_key_state) (consume a keripy KSN). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyStateRecord { + /// Protocol version `[major, minor]` — `[1, 0]` for the `KERI10` generation. + pub vn: [u32; 2], + /// Identifier prefix (the AID this state describes). + pub i: Prefix, + /// Sequence number of the latest event, lowercase-hex. + pub s: String, + /// SAID of the prior event (empty at inception). + pub p: String, + /// SAID of the latest event. + pub d: Said, + /// First-seen ordinal. auths does not maintain a first-seen log separate from + /// the KEL, so this mirrors `s` (the latest sequence) — truthful for a + /// single-source replay, where first-seen order *is* event order. + pub f: String, + /// Controller-asserted timestamp (RFC 3339). + pub dt: String, + /// Latest establishment event type (`icp`/`rot`/`dip`/`drt`). + pub et: String, + /// Current signing threshold (KERI hex/clause string). + pub kt: Threshold, + /// Current signing key(s), CESR-encoded. + pub k: Vec, + /// Next-key threshold (KERI hex/clause string). + pub nt: Threshold, + /// Next-key commitment digest(s). + pub n: Vec, + /// Backer (witness) threshold (`bt`, hex string). + pub bt: Threshold, + /// Current backer (witness) list. + pub b: Vec, + /// Configuration traits. + pub c: Vec, + /// Latest establishment event summary (`{s, d, br, ba}`). + pub ee: LatestEstablishmentEvent, + /// Delegator AID (empty string when not delegated). + pub di: String, +} + +impl KeyStateRecord { + /// Build a KERI key-state record by replaying a validated KEL into its + /// current state, stamped at `dt`. + /// + /// `events` is the full, in-order KEL (inception first); the record's `s`/`d` + /// come from the last event and `p`/`ee`/`et` from its latest establishment + /// event. Returns `None` only if `events` is empty (no inception to anchor a + /// state) — the caller has nothing to notice. + /// + /// Args: + /// * `events`: The replayed KEL, in sequence order. + /// * `state`: The resolved current [`KeyState`] (from `replay`). + /// * `dt`: An RFC-3339 timestamp (injected `now`). + pub fn from_kel(events: &[Event], state: &KeyState, dt: impl Into) -> Option { + let last = events.last()?; + let latest_est = events.iter().rev().find(|e| !e.is_interaction())?; + Some(Self { + vn: KERI_KEY_STATE_VERSION, + i: state.prefix.clone(), + s: format!("{:x}", state.sequence), + p: last + .previous() + .map(|s| s.as_str().to_string()) + .unwrap_or_default(), + d: state.last_event_said.clone(), + f: format!("{:x}", state.sequence), + dt: dt.into(), + et: establishment_type(latest_est).to_string(), + kt: state.threshold.clone(), + k: state.current_keys.clone(), + nt: state.next_threshold.clone(), + n: state.next_commitment.clone(), + bt: state.backer_threshold.clone(), + b: state.backers.clone(), + c: state.config_traits.clone(), + ee: LatestEstablishmentEvent { + s: format!("{:x}", latest_est.sequence().value()), + d: latest_est.said().clone(), + br: Vec::new(), + ba: Vec::new(), + }, + di: state + .delegator + .as_ref() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(), + }) + } + + /// The sequence number this record notices (the latest event's `s`, decoded + /// from its lowercase-hex wire form). + pub fn sequence(&self) -> u128 { + u128::from_str_radix(self.s.trim_start_matches("0x"), 16).unwrap_or(0) + } + + /// Reject this notice if it is older than a state the verifier already trusts. + /// + /// A key-state notice is a snapshot; a thin client that has already seen + /// sequence `last_seen_seq` (e.g. it holds a fresher witness receipt) must not + /// accept a notice that rewinds below it — that is a stale or replayed view of + /// the identity. Returns [`KsnError::Stale`] when `self.sequence() < + /// last_seen_seq`; equal-or-newer is fine. + /// + /// Args: + /// * `last_seen_seq`: The highest sequence the verifier already trusts. + pub fn check_not_stale(&self, last_seen_seq: u128) -> Result<(), KsnError> { + let got = self.sequence(); + if got < last_seen_seq { + return Err(KsnError::Stale { + got, + seen: last_seen_seq, + }); + } + Ok(()) + } + + /// Project this KERI record back to the auths [`KeyState`] the rest of the + /// platform reasons over (a thin client ingesting a peer's published state). + /// + /// Total: a parsed `KeyStateRecord` already carries the labels and shapes a + /// `KeyState` needs, so no field can be missing or mistyped here. + pub fn into_key_state(self) -> KeyState { + let sequence = u128::from_str_radix(self.s.trim_start_matches("0x"), 16).unwrap_or(0); + let last_est_seq = + u128::from_str_radix(self.ee.s.trim_start_matches("0x"), 16).unwrap_or(0); + let delegator = if self.di.is_empty() { + None + } else { + Some(Prefix::new_unchecked(self.di)) + }; + KeyState { + prefix: self.i, + current_keys: self.k, + next_commitment: self.n.clone(), + sequence, + last_event_said: self.d, + is_abandoned: self.n.is_empty() && self.et != "icp" && self.et != "dip", + threshold: self.kt, + next_threshold: self.nt, + backers: self.b, + backer_threshold: self.bt, + config_traits: self.c, + is_non_transferable: self.n.is_empty(), + delegator, + last_establishment_sequence: last_est_seq, + } + } +} + +/// The KERI `et` value for an establishment event (`icp`/`rot`/`dip`/`drt`). +/// Callers pass only establishment events (`!is_interaction`); a stray `ixn` +/// falls through to its own tag rather than panicking. +fn establishment_type(event: &Event) -> &'static str { + match event { + Event::Icp(_) => "icp", + Event::Rot(_) => "rot", + Event::Dip(_) => "dip", + Event::Drt(_) => "drt", + Event::Ixn(_) => "ixn", + } +} + /// Errors building or verifying a KSN. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -698,4 +900,115 @@ mod tests { .expect("controller signature must still verify after attaching receipts"); assert!(matches!(v.trust, KsnTrust::Witnessed { .. })); } + + // ── KERI-conformant key-state record (KeyStateRecord) ──────────────────── + + /// A minimal self-addressing inception KEL (single event) parsed from JSON, + /// mirroring the keripy `icp`/`KeyStateRecord` reference vector. + const ICP_KEL_JSON: &str = r#"[{ + "v":"KERI10JSON0000fd_","t":"icp", + "d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "s":"0","kt":"1", + "k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"], + "nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[] + }]"#; + + #[test] + fn key_state_record_emits_keri_wire_shape() { + let events = crate::validate::parse_kel_json(ICP_KEL_JSON).unwrap(); + let state = crate::validate::TrustedKel::from_trusted_source(&events) + .replay() + .unwrap(); + let record = + KeyStateRecord::from_kel(&events, &state, "2026-06-12T02:49:41.677319+00:00").unwrap(); + + let json = serde_json::to_value(&record).unwrap(); + let obj = json.as_object().unwrap(); + // Field order/labels are the KERI ksn record shape, not the auths envelope. + let keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec![ + "vn", "i", "s", "p", "d", "f", "dt", "et", "kt", "k", "nt", "n", "bt", "b", "c", + "ee", "di" + ] + ); + assert_eq!(obj["vn"], serde_json::json!([1, 0])); + assert_eq!(obj["s"], "0"); + assert_eq!(obj["p"], ""); + assert_eq!(obj["et"], "icp"); + assert_eq!(obj["kt"], "1"); + assert_eq!(obj["di"], ""); + let ee = obj["ee"].as_object().unwrap(); + assert_eq!( + ee.keys().map(String::as_str).collect::>(), + vec!["s", "d", "br", "ba"] + ); + } + + #[test] + fn key_state_record_round_trips_through_key_state() { + let events = crate::validate::parse_kel_json(ICP_KEL_JSON).unwrap(); + let state = crate::validate::TrustedKel::from_trusted_source(&events) + .replay() + .unwrap(); + let record = KeyStateRecord::from_kel(&events, &state, "t").unwrap(); + + // Serialize -> deserialize (the peer's wire path) -> project to KeyState. + let wire = serde_json::to_string(&record).unwrap(); + let parsed: KeyStateRecord = serde_json::from_str(&wire).unwrap(); + assert_eq!(parsed, record); + + let projected = parsed.into_key_state(); + assert_eq!(projected.prefix, state.prefix); + assert_eq!(projected.current_keys, state.current_keys); + assert_eq!(projected.sequence, state.sequence); + assert_eq!(projected.last_event_said, state.last_event_said); + assert_eq!(projected.threshold, state.threshold); + assert!(projected.is_non_transferable); + } + + #[test] + fn key_state_record_check_not_stale() { + let events = crate::validate::parse_kel_json(ICP_KEL_JSON).unwrap(); + let state = crate::validate::TrustedKel::from_trusted_source(&events) + .replay() + .unwrap(); + let record = KeyStateRecord::from_kel(&events, &state, "t").unwrap(); + assert_eq!(record.sequence(), 0); + // A verifier that has seen seq 0 accepts a seq-0 notice; one holding a + // newer (seq 1) receipt rejects this stale seq-0 view. + assert!(record.check_not_stale(0).is_ok()); + assert!(matches!( + record.check_not_stale(1), + Err(KsnError::Stale { got: 0, seen: 1 }) + )); + } + + #[test] + fn key_state_record_ingests_peer_published_record() { + // A keripy-shaped record arriving over the wire (string sequence, hex + // thresholds, empty delegator) projects to a usable KeyState. + let wire = r#"{ + "vn":[1,0], + "i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "s":"0","p":"", + "d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "f":"0","dt":"2026-06-12T02:49:41.677319+00:00","et":"icp", + "kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"], + "nt":"0","n":[],"bt":"0","b":[],"c":[], + "ee":{"s":"0","d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","br":[],"ba":[]}, + "di":"" + }"#; + let record: KeyStateRecord = serde_json::from_str(wire).unwrap(); + let state = record.into_key_state(); + assert_eq!( + state.prefix.as_str(), + "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J" + ); + assert_eq!(state.sequence, 0); + assert!(state.is_non_transferable); + assert!(state.delegator.is_none()); + } } diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 52c8d69a..db320690 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -40,18 +40,32 @@ pub mod acdc; /// Validated capability identifiers — the atomic unit of authorization in Auths. pub mod capability; mod crypto; +/// `did:webs` DID-document projection of a resolved KERI key-state. +pub mod did_webs; mod error; mod events; +/// IPEX — the Issuance & Presentation EXchange grant/admit `exn` handshake for +/// handing over an ACDC credential between KERI controllers. +pub mod ipex; pub mod kel_io; mod keys; /// Key-State Notice (KSN) — signed snapshot of current key-state for thin clients. pub mod ksn; /// Routed KERI message types (qry, rpy, pro, bar, xip, exn). pub mod messages; +/// Out-Of-Band Introduction (OOBI) — KERI discovery: resolve/serve AID endpoints. +pub mod oobi; +/// QUIC/HTTP3 transport for the KEL-rooted TLS composition — the same leaf + +/// RFC 9266 exporter channel binding, carried over QUIC's TLS 1.3 handshake. +#[cfg(feature = "quic")] +pub mod quic_transport; mod said; mod state; /// Backerless TEL (Transaction Event Log) credential-status events: `vcp`/`iss`/`rev`. pub mod tel; +/// KEL-rooted X.509 leaf certificates — composing a KERI identity with TLS +/// (did:keri SAN + KEL key-state binding extension, verified by replay). +pub mod tls_cert; mod types; mod validate; /// Witness protocol types: receipts, providers, and error reporting for split-view defense. @@ -80,6 +94,7 @@ pub use capability::{ Capability, CapabilityError, MANAGE_MEMBERS, ROTATE_KEYS, SIGN_COMMIT, SIGN_RELEASE, }; pub use crypto::{compute_next_commitment, verify_commitment}; +pub use did_webs::{DidWebsDocument, PublicKeyJwk, VerificationMethod}; pub use error::{KeriTranslationError, TelError}; pub use events::{ AgentScope, DipEvent, DipEventInit, DrtEvent, DrtEventInit, Event, IcpEvent, IcpEventInit, @@ -89,8 +104,22 @@ pub use events::{ pair_kel_attachments, parse_attachment, parse_delegated_attachment, parse_source_seal_couples, serialize_attachment, serialize_source_seal_couples, }; +pub use ipex::{IpexAdmit, IpexError, IpexGrant}; pub use keys::{KeriDecodeError, KeriPublicKey}; -pub use ksn::{KSN_TYPE, KSN_VERSION, KeyStateNotice, KsnError, SignedKsn}; +pub use ksn::{ + KERI_KEY_STATE_VERSION, KSN_TYPE, KSN_VERSION, KeyStateNotice, KeyStateRecord, KsnError, + LatestEstablishmentEvent, SignedKsn, +}; +pub use oobi::{ + EndRoleReply, LocSchemeReply, Oobi, OobiEndpoint, OobiError, OobiResolution, Role, + ingest_oobi_stream, +}; +#[cfg(feature = "quic")] +pub use quic_transport::{ + QUIC_EXPORTER_CONTEXT, QUIC_EXPORTER_LABEL, QUIC_EXPORTER_LEN, QuicLoopbackOutcome, + QuicTransportError, quic_channel_binding, quic_client_config, quic_loopback_compose, + quic_server_config, +}; pub use said::{ Protocol, SAID_PLACEHOLDER, compute_said, compute_said_with_protocol, compute_section_said, verify_said, @@ -100,6 +129,17 @@ pub use tel::{ Iss, Rev, TEL_KERIPY_REVISION, TRAIT_NO_BACKERS, TelAnchorSeal, TelEvent, TelState, Vcp, encode_nonce as encode_tel_nonce, to_wire_bytes as tel_to_wire_bytes, validate_tel, }; +pub use tls_cert::{ + AUTHS_KERI_BINDING_OID, AuthsKeriBinding, DID_KERI_SCHEME, TlsCertError, TlsKeyAuthorization, + TlsKeyAuthorizer, +}; +#[cfg(feature = "tls-cert")] +pub use tls_cert::{ + IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, extract_spki_der, + issue_authorized_kel_rooted_cert, issue_authorized_kel_rooted_cert_with_key, + issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, verify_authorized_against_key_state, + verify_binds_to_key_state, +}; pub use types::{ CesrKey, ConfigTrait, Fraction, FractionError, KeriTypeError, Prefix, Said, Threshold, VersionString, diff --git a/crates/auths-keri/src/oobi.rs b/crates/auths-keri/src/oobi.rs new file mode 100644 index 00000000..e5be6fbc --- /dev/null +++ b/crates/auths-keri/src/oobi.rs @@ -0,0 +1,674 @@ +//! Out-Of-Band Introduction (OOBI) — KERI discovery. +//! +//! An OOBI is how one KERI controller tells another *"here is my AID, and here +//! is a URL at which you can fetch its key event log and service endpoints."* It +//! is the bootstrap of every live exchange: before a peer can request a receipt, +//! present a credential, or resolve a key-state, it must first discover *where* +//! the controlling AID's KEL and endpoints live. OOBIs carry that location +//! out-of-band (hence the name); the KEL fetched through one is still verified +//! cryptographically, so the URL is only a hint, never a root of trust. +//! +//! Two halves, mirroring the two directions of discovery: +//! +//! * **Resolve** (peer → us): parse a peer's OOBI URL into a typed [`Oobi`], +//! fetch the bytes it points at, and [`ingest_oobi_stream`] them — replaying +//! the embedded KEL into a verified [`KeyState`] and collecting the endpoint +//! reply records the peer published alongside it. +//! * **Serve** (us → peer): from one of our own KELs and the URL we host it at, +//! [`OobiEndpoint::for_controller`] derives the OOBI URL to publish and the +//! `rpy` reply stream (`/loc/scheme` + `/end/role/add`) a peer fetches when it +//! resolves us. +//! +//! The wire records are byte-exact with keripy 1.3.4: a `/loc/scheme` reply is +//! `{v, t:"rpy", d, dt, r:"/loc/scheme", a:{eid, scheme, url}}` and an +//! `/end/role/add` reply is `{v, t:"rpy", d, dt, r:"/end/role/add", +//! a:{cid, role, eid}}`, each SAID-ified and version-sized exactly as +//! `keri.app.habbing.Hab.reply`. The URL grammar is keripy's `OOBI_RE` +//! (`/oobi/{cid}/{role}[/{eid}]`). +//! +//! This module is I/O-free: it parses URLs, serializes/parses wire records, and +//! replays KELs. The HTTP fetch lives behind a port in the caller (the CLI's +//! OOBI adapter), so the discovery logic never imports a transport. + +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; + +use crate::error::KeriTranslationError; +use crate::events::KERI_VERSION_PREFIX; +use crate::said::{Protocol, compute_said_with_protocol}; +use crate::state::KeyState; +use crate::types::{Prefix, Said}; +use crate::validate::{TrustedKel, ValidationError, parse_kel_json}; + +/// Placeholder version string filled in during saidify (17 chars, like every +/// KERI record's `v`). +const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_"; + +/// Sizes the version string `KERI10JSON{size:06x}_` to a serialized record — the +/// same single-pass machinery the TEL records use (the field width is constant, +/// so re-serializing with the placeholder gives the final byte length). +fn recompute_version_string(event: &T) -> Result { + let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?; + Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len())) +} + +/// An authorized endpoint role in a KERI introduction. +/// +/// Mirrors keripy's `kering.Roles` — the fixed vocabulary of what an endpoint +/// identifier (`eid`) is authorized to *do* for a controller (`cid`). Parsing is +/// total: an unknown role is rejected at the boundary, so an `Role` value is +/// always one keripy would accept in a `/end/role` reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + /// The controller itself (its own endpoint). + Controller, + /// A witness that receipts the controller's KEL. + Witness, + /// A watcher that observes the controller's KEL for duplicity. + Watcher, + /// A registrar of the controller's credential registries. + Registrar, + /// A judge in a multi-sig group. + Judge, + /// A juror in a multi-sig group. + Juror, + /// A peer in a direct-mode exchange. + Peer, + /// A mailbox that buffers messages for the controller. + Mailbox, + /// An agent acting on behalf of the controller (e.g. a KERIA agent). + Agent, + /// A gateway endpoint. + Gateway, +} + +impl Role { + /// The keripy `kering.Roles` wire token for this role. + pub fn as_str(self) -> &'static str { + match self { + Role::Controller => "controller", + Role::Witness => "witness", + Role::Watcher => "watcher", + Role::Registrar => "registrar", + Role::Judge => "judge", + Role::Juror => "juror", + Role::Peer => "peer", + Role::Mailbox => "mailbox", + Role::Agent => "agent", + Role::Gateway => "gateway", + } + } + + /// Parses a keripy role token into a typed [`Role`]. + /// + /// Total at the boundary: an unrecognized token is an [`OobiError::Role`], + /// never a silently-accepted string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "controller" => Role::Controller, + "witness" => Role::Witness, + "watcher" => Role::Watcher, + "registrar" => Role::Registrar, + "judge" => Role::Judge, + "juror" => Role::Juror, + "peer" => Role::Peer, + "mailbox" => Role::Mailbox, + "agent" => Role::Agent, + "gateway" => Role::Gateway, + other => return Err(OobiError::Role(other.to_string())), + }) + } +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A parsed Out-Of-Band Introduction URL. +/// +/// The canonical keripy OOBI form is +/// `:///oobi//[/]` (keripy's `OOBI_RE`). A +/// parsed `Oobi` guarantees: a recognized scheme, a present authority, a +/// CESR-valid controller prefix (`cid`), a known [`Role`], and — when present — +/// a CESR-valid endpoint prefix (`eid`). Invalid URLs never become an `Oobi`; +/// they are rejected by [`Oobi::parse`] at the boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Oobi { + /// URL scheme — `http`, `https`, or `tcp` (keripy `kering.Schemes`). + pub scheme: String, + /// Network authority (`host[:port]`) hosting the introduction endpoint. + pub authority: String, + /// Controller AID being introduced (the `cid` path segment). + pub cid: Prefix, + /// Authorized role of the endpoint for that controller. + pub role: Role, + /// Optional endpoint provider AID (`eid`) when the OOBI scopes one endpoint. + pub eid: Option, +} + +impl Oobi { + /// Parses a peer's OOBI URL into a typed [`Oobi`]. + /// + /// Accepts the keripy `OOBI_RE` shape + /// `:///oobi//[/]`. Every component is + /// validated at the boundary: the scheme must be one keripy speaks, the + /// `cid`/`eid` must be CESR-valid prefixes, and the role must be a known + /// [`Role`]. + pub fn parse(url: &str) -> Result { + let (scheme, rest) = url + .split_once("://") + .ok_or_else(|| OobiError::Url(format!("missing scheme separator in {url:?}")))?; + let scheme = scheme.to_ascii_lowercase(); + if !matches!(scheme.as_str(), "http" | "https" | "tcp") { + return Err(OobiError::Scheme(scheme)); + } + + // Split authority from the path; an absent path is not a valid OOBI. + let (authority, path) = match rest.split_once('/') { + Some((authority, path)) => (authority, path), + None => return Err(OobiError::Url(format!("missing /oobi path in {url:?}"))), + }; + if authority.is_empty() { + return Err(OobiError::Url(format!("empty authority in {url:?}"))); + } + + // Drop any query string / fragment (keripy treats them as alias hints + // only) and split the path into its segments. + let path = path.split(['?', '#']).next().unwrap_or(path); + let mut segs = path.split('/').filter(|s| !s.is_empty()); + match segs.next() { + Some("oobi") => {} + _ => return Err(OobiError::Url(format!("path is not /oobi/... in {url:?}"))), + } + + let cid_str = segs + .next() + .ok_or_else(|| OobiError::Url(format!("missing cid segment in {url:?}")))?; + let cid = Prefix::new(cid_str.to_string()).map_err(|e| OobiError::Prefix { + segment: "cid", + source: e, + })?; + + let role_str = segs + .next() + .ok_or_else(|| OobiError::Url(format!("missing role segment in {url:?}")))?; + let role = Role::parse(role_str)?; + + let eid = match segs.next() { + Some(eid_str) => { + Some( + Prefix::new(eid_str.to_string()).map_err(|e| OobiError::Prefix { + segment: "eid", + source: e, + })?, + ) + } + None => None, + }; + + // A trailing segment past the eid is not a keripy OOBI. + if segs.next().is_some() { + return Err(OobiError::Url(format!("trailing path segment in {url:?}"))); + } + + Ok(Oobi { + scheme, + authority: authority.to_string(), + cid, + role, + eid, + }) + } + + /// The canonical OOBI URL for this introduction. + /// + /// Round-trips [`Oobi::parse`]: `Oobi::parse(&o.url()) == Ok(o)`. + pub fn url(&self) -> String { + let base = format!( + "{}://{}/oobi/{}/{}", + self.scheme, self.authority, self.cid, self.role + ); + match &self.eid { + Some(eid) => format!("{base}/{eid}"), + None => base, + } + } +} + +impl std::fmt::Display for Oobi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.url()) + } +} + +/// A `/loc/scheme` reply — *"endpoint `eid` is reachable via `scheme` at `url`."* +/// +/// Byte-exact with keripy's `Hab.makeLocScheme`: serializes as +/// `{v, t:"rpy", d, dt, r:"/loc/scheme", a:{eid, scheme, url}}`, SAID-ified over +/// the whole record and version-sized to the serialized bytes. Build via +/// [`LocSchemeReply::new`] (which saidifies), then serialize for the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocSchemeReply { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this reply (Blake3-256 over the saidified record). + pub d: Said, + /// ISO-8601 datetime stamp (RFC-3339 profile, microsecond precision). + pub dt: String, + /// Endpoint provider AID this location describes. + pub eid: Prefix, + /// URL scheme of the endpoint (`http`/`https`/`tcp`). + pub scheme: String, + /// Endpoint URL. + pub url: String, +} + +impl LocSchemeReply { + /// Builds a saidified `/loc/scheme` reply for an endpoint location. + pub fn new( + eid: Prefix, + scheme: impl Into, + url: impl Into, + dt: impl Into, + ) -> Result { + let mut reply = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + dt: dt.into(), + eid, + scheme: scheme.into(), + url: url.into(), + }; + reply.saidify()?; + Ok(reply) + } + + fn saidify(&mut self) -> Result<(), OobiError> { + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } +} + +impl Serialize for LocSchemeReply { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(6))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "rpy")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", "/loc/scheme")?; + let mut a = serde_json::Map::new(); + a.insert( + "eid".into(), + serde_json::Value::String(self.eid.to_string()), + ); + a.insert( + "scheme".into(), + serde_json::Value::String(self.scheme.clone()), + ); + a.insert("url".into(), serde_json::Value::String(self.url.clone())); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + map.end() + } +} + +/// An `/end/role/add` reply — *"controller `cid` authorizes `eid` in `role`."* +/// +/// Byte-exact with keripy's `Hab.makeEndRole`: serializes as +/// `{v, t:"rpy", d, dt, r:"/end/role/add", a:{cid, role, eid}}`, SAID-ified and +/// version-sized exactly as `Hab.reply`. Build via [`EndRoleReply::new`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndRoleReply { + /// Version string `KERI10JSON{size:06x}_`. + pub v: String, + /// SAID of this reply. + pub d: Said, + /// ISO-8601 datetime stamp. + pub dt: String, + /// Controller AID authorizing the endpoint. + pub cid: Prefix, + /// Role the endpoint is authorized for. + pub role: Role, + /// Endpoint provider AID being authorized. + pub eid: Prefix, +} + +impl EndRoleReply { + /// Builds a saidified `/end/role/add` reply authorizing an endpoint. + pub fn new( + cid: Prefix, + role: Role, + eid: Prefix, + dt: impl Into, + ) -> Result { + let mut reply = Self { + v: KERI_VERSION_PLACEHOLDER.to_string(), + d: Said::default(), + dt: dt.into(), + cid, + role, + eid, + }; + reply.saidify()?; + Ok(reply) + } + + fn saidify(&mut self) -> Result<(), OobiError> { + let body = + serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?; + self.d = compute_said_with_protocol(&body, Protocol::Keri)?; + self.v = recompute_version_string(&*self)?; + Ok(()) + } +} + +impl Serialize for EndRoleReply { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(6))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "rpy")?; + map.serialize_entry("d", &self.d)?; + map.serialize_entry("dt", &self.dt)?; + map.serialize_entry("r", "/end/role/add")?; + let mut a = serde_json::Map::new(); + a.insert( + "cid".into(), + serde_json::Value::String(self.cid.to_string()), + ); + a.insert( + "role".into(), + serde_json::Value::String(self.role.to_string()), + ); + a.insert( + "eid".into(), + serde_json::Value::String(self.eid.to_string()), + ); + map.serialize_entry("a", &serde_json::Value::Object(a))?; + map.end() + } +} + +/// The serve side: an AID's discoverable introduction. +/// +/// From a controller's KEL and the URL its endpoint is hosted at, this derives +/// what a resolving peer needs: the OOBI URL to publish, and the `rpy` reply +/// stream (`/loc/scheme` + `/end/role/add`) the peer fetches. The endpoint +/// provider (`eid`) defaults to the controller itself (`cid`) — the +/// "controller" role — exactly as keripy's self-introduction does. +#[derive(Debug, Clone)] +pub struct OobiEndpoint { + /// The OOBI URL a peer resolves to discover this controller. + pub oobi: Oobi, + /// The endpoint-location reply (`/loc/scheme`). + pub loc_scheme: LocSchemeReply, + /// The role-authorization reply (`/end/role/add`). + pub end_role: EndRoleReply, +} + +impl OobiEndpoint { + /// Derives a controller's self-introduction from its replayed key-state. + /// + /// `scheme` + `authority` describe where the controller hosts its endpoint; + /// `url` is the absolute endpoint URL embedded in the `/loc/scheme` reply + /// (keripy includes the full URL, not just the authority). The introduction + /// is for the `controller` role with the controller as its own endpoint. + pub fn for_controller( + state: &KeyState, + scheme: impl Into, + authority: impl Into, + url: impl Into, + dt: impl Into, + ) -> Result { + let scheme = scheme.into(); + let authority = authority.into(); + let dt = dt.into(); + let cid = state.prefix.clone(); + let oobi = Oobi { + scheme: scheme.clone(), + authority, + cid: cid.clone(), + role: Role::Controller, + eid: None, + }; + let loc_scheme = LocSchemeReply::new(cid.clone(), scheme, url, dt.clone())?; + let end_role = EndRoleReply::new(cid.clone(), Role::Controller, cid, dt)?; + Ok(OobiEndpoint { + oobi, + loc_scheme, + end_role, + }) + } + + /// Serializes the `rpy` reply stream a resolving peer fetches (newline-joined + /// JSON, the keripy `replyEndRole` wire shape minus the leading KEL replay, + /// which the caller prepends from the KEL it serves). + pub fn reply_stream(&self) -> Result { + let loc = serde_json::to_string(&self.loc_scheme) + .map_err(KeriTranslationError::SerializationFailed)?; + let end = serde_json::to_string(&self.end_role) + .map_err(KeriTranslationError::SerializationFailed)?; + Ok(format!("{loc}\n{end}")) + } +} + +/// The result of resolving a peer's OOBI: a verified key-state plus the endpoint +/// reply records the peer published alongside its KEL. +#[derive(Debug, Clone)] +pub struct OobiResolution { + /// The controller AID the OOBI introduced. + pub cid: Prefix, + /// The replayed, verified key-state of that controller's KEL. + pub state: KeyState, + /// Number of KEL events ingested. + pub event_count: usize, +} + +/// Ingests the bytes fetched from an OOBI URL: replays the embedded KEL into a +/// verified [`KeyState`]. +/// +/// The fetched body is a KERI message stream. We extract its key events (the +/// `icp`/`rot`/`ixn`/`dip`/`drt` records the peer replayed) as a JSON array and +/// replay them — so the KEL is verified cryptographically, not trusted because +/// it arrived over a particular URL. The OOBI URL only told us *where* to look; +/// trust comes from the replay. +/// +/// `expected_cid` is the controller the OOBI claimed to introduce; ingest fails +/// if the replayed KEL's prefix does not match it (an OOBI that delivers a +/// *different* AID's KEL is a discovery failure, not a silent substitution). +pub fn ingest_oobi_stream( + expected_cid: &Prefix, + kel_json: &str, +) -> Result { + let events = parse_kel_json(kel_json)?; + if events.is_empty() { + return Err(OobiError::EmptyKel); + } + let event_count = events.len(); + // A KEL fetched through an OOBI is replayed (verified) before it is trusted. + let state = TrustedKel::from_trusted_source(&events).replay()?; + if state.prefix != *expected_cid { + return Err(OobiError::CidMismatch { + expected: expected_cid.to_string(), + actual: state.prefix.to_string(), + }); + } + Ok(OobiResolution { + cid: state.prefix.clone(), + state, + event_count, + }) +} + +/// Errors raised while parsing, serving, or resolving an OOBI. +#[derive(Debug, thiserror::Error)] +pub enum OobiError { + /// The OOBI URL did not match the `:///oobi/...` grammar. + #[error("invalid OOBI URL: {0}")] + Url(String), + /// The URL scheme is not one KERI speaks (`http`/`https`/`tcp`). + #[error("unsupported OOBI scheme: {0:?}")] + Scheme(String), + /// A path segment was not a CESR-valid prefix. + #[error("invalid {segment} prefix in OOBI URL: {source}")] + Prefix { + /// Which segment failed (`cid` or `eid`). + segment: &'static str, + /// The underlying CESR/derivation-code error. + source: crate::types::KeriTypeError, + }, + /// The role segment was not a known KERI role. + #[error("unknown OOBI role: {0:?}")] + Role(String), + /// The OOBI stream carried no KEL events. + #[error("OOBI stream carried no KEL events")] + EmptyKel, + /// The replayed KEL belonged to a different AID than the OOBI introduced. + #[error("OOBI introduced {expected} but delivered a KEL for {actual}")] + CidMismatch { + /// The AID the OOBI URL claimed. + expected: String, + /// The AID the delivered KEL actually replayed to. + actual: String, + }, + /// The fetched KEL failed to parse or replay (cryptographic verification). + #[error("KEL replay failed: {0}")] + Replay(#[from] ValidationError), + /// A wire record failed to saidify/serialize. + #[error("KERI record build failed: {0}")] + Record(#[from] KeriTranslationError), +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + const CID: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM"; + const EID: &str = "BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6"; + + #[test] + fn parses_controller_oobi() { + let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller"); + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.scheme, "http"); + assert_eq!(oobi.authority, "127.0.0.1:5642"); + assert_eq!(oobi.cid.as_str(), CID); + assert_eq!(oobi.role, Role::Controller); + assert_eq!(oobi.eid, None); + } + + #[test] + fn parses_witness_oobi_with_eid() { + let url = format!("https://witness.example:5631/oobi/{CID}/witness/{EID}"); + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.scheme, "https"); + assert_eq!(oobi.role, Role::Witness); + assert_eq!(oobi.eid.as_ref().unwrap().as_str(), EID); + } + + #[test] + fn url_round_trips() { + for url in [ + format!("http://127.0.0.1:5642/oobi/{CID}/controller"), + format!("https://w.example:5631/oobi/{CID}/witness/{EID}"), + format!("tcp://10.0.0.1:5621/oobi/{CID}/mailbox"), + ] { + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.url(), url); + assert_eq!(Oobi::parse(&oobi.url()).unwrap(), oobi); + } + } + + #[test] + fn drops_query_alias_hint() { + let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller?name=alice"); + let oobi = Oobi::parse(&url).unwrap(); + assert_eq!(oobi.cid.as_str(), CID); + assert_eq!(oobi.role, Role::Controller); + } + + #[test] + fn rejects_bad_scheme() { + let err = Oobi::parse(&format!("ftp://h/oobi/{CID}/controller")).unwrap_err(); + assert!(matches!(err, OobiError::Scheme(_))); + } + + #[test] + fn rejects_unknown_role() { + let err = Oobi::parse(&format!("http://h:1/oobi/{CID}/overlord")).unwrap_err(); + assert!(matches!(err, OobiError::Role(_))); + } + + #[test] + fn rejects_missing_path() { + assert!(matches!( + Oobi::parse(&format!("http://h:1/oobi/{CID}")).unwrap_err(), + OobiError::Url(_) + )); + assert!(matches!( + Oobi::parse("http://h:1").unwrap_err(), + OobiError::Url(_) + )); + } + + #[test] + fn rejects_invalid_cid_prefix() { + let err = Oobi::parse("http://h:1/oobi/not-a-prefix/controller").unwrap_err(); + assert!(matches!(err, OobiError::Prefix { segment: "cid", .. })); + } + + #[test] + fn role_parse_total() { + for r in [ + Role::Controller, + Role::Witness, + Role::Watcher, + Role::Registrar, + Role::Judge, + Role::Juror, + Role::Peer, + Role::Mailbox, + Role::Agent, + Role::Gateway, + ] { + assert_eq!(Role::parse(r.as_str()).unwrap(), r); + } + assert!(Role::parse("nope").is_err()); + } + + // The wire records must be byte-exact with keripy 1.3.4's `Hab.reply`. These + // SAIDs/version strings were generated from keripy itself (the oracle): + // serdering.SerderKERI(sad={v, t:"rpy", d:"", dt, r, a}, makify=True) + #[test] + fn loc_scheme_reply_byte_exact_keripy() { + let reply = LocSchemeReply::new( + Prefix::new(EID.to_string()).unwrap(), + "http", + "http://127.0.0.1:5642/", + "2024-01-01T00:00:00.000000+00:00", + ) + .unwrap(); + let json = serde_json::to_string(&reply).unwrap(); + let expected = r#"{"v":"KERI10JSON0000fa_","t":"rpy","d":"EHrMc5EKCqJHrpCAAlgG6UPaupi-tmlDw8SvspQobfC1","dt":"2024-01-01T00:00:00.000000+00:00","r":"/loc/scheme","a":{"eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6","scheme":"http","url":"http://127.0.0.1:5642/"}}"#; + assert_eq!(json, expected); + } + + #[test] + fn end_role_add_reply_byte_exact_keripy() { + let reply = EndRoleReply::new( + Prefix::new(CID.to_string()).unwrap(), + Role::Controller, + Prefix::new(EID.to_string()).unwrap(), + "2024-01-01T00:00:00.000000+00:00", + ) + .unwrap(); + let json = serde_json::to_string(&reply).unwrap(); + let expected = r#"{"v":"KERI10JSON000116_","t":"rpy","d":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00","r":"/end/role/add","a":{"cid":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","role":"controller","eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6"}}"#; + assert_eq!(json, expected); + } +} diff --git a/crates/auths-keri/src/quic_transport.rs b/crates/auths-keri/src/quic_transport.rs new file mode 100644 index 00000000..d9cfa4fb --- /dev/null +++ b/crates/auths-keri/src/quic_transport.rs @@ -0,0 +1,518 @@ +//! HTTP/3-grade QUIC transport for the KEL-rooted TLS composition. +//! +//! The KEL-rooted X.509 leaf ([`crate::tls_cert`]) and the per-connection +//! channel binding it carries are not specific to TLS-over-TCP: QUIC runs the +//! **same TLS 1.3 handshake** inside its CRYPTO frames (RFC 9001), so an auths +//! identity composes with QUIC — and therefore HTTP/3 — through exactly the same +//! two mechanisms it composes with TLS: +//! +//! * **the leaf certificate** — a QUIC server presents the KEL-rooted leaf in its +//! TLS 1.3 handshake just as a TLS-over-TCP server would; an auths-aware peer +//! re-roots trust by replaying the KEL ([`crate::verify_binds_to_key_state`] / +//! [`crate::verify_authorized_against_key_state`]). Trust lives in the log, not +//! in a CA — over QUIC exactly as over TCP. +//! * **the channel binding** — both endpoints of one QUIC connection export the +//! same keying material from the connection's TLS 1.3 secrets ([RFC 5705] +//! exporter), so a possession proof folded against that material opens only on +//! the connection that minted it. A proof captured on one QUIC connection and +//! relayed onto another is rejected, the same anti-relay property the TCP path +//! already has. +//! +//! This module is the QUIC **adapter** for those mechanisms. It builds a +//! [`quinn`] server config from a KEL-rooted leaf + key and a client config that +//! completes the handshake, and it exposes [`quic_channel_binding`] — +//! the [`quinn::Connection`] side of the keying-material exporter, the QUIC +//! counterpart of the rustls-over-TCP exporter the pairing daemon already uses. +//! +//! # Channel-binding parameters over QUIC (NORMATIVE) +//! +//! RFC 9266 `tls-exporter` specifies an *absent* exporter context, but the QUIC +//! public exporter API ([`quinn::Connection::export_keying_material`]) always +//! passes a context (it has no "absent" form). Both endpoints of an auths QUIC +//! connection are auths-aware, so they agree on a fixed label + context for their +//! binding; the per-connection, anti-relay property holds regardless of the +//! context value (it derives from the connection's own TLS 1.3 secrets). The +//! constants are: +//! +//! * **Label** [`QUIC_EXPORTER_LABEL`] = `EXPORTER-Channel-Binding` — the same +//! registered RFC 9266 label the TCP path uses, so the two transports name the +//! binding identically. +//! * **Context** [`QUIC_EXPORTER_CONTEXT`] = `auths-quic-channel-binding-v1` — the +//! explicit context the QUIC API requires (an auths-internal domain separator, +//! distinct from the TCP path's absent context, so a TCP and a QUIC binding for +//! the same secrets can never collide). +//! * **Length** [`QUIC_EXPORTER_LEN`] = 32 bytes. +//! +//! [RFC 5705]: https://www.rfc-editor.org/rfc/rfc5705 + +use std::net::{Ipv6Addr, SocketAddr}; +use std::sync::Arc; + +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; +use quinn::rustls::client::danger::{ + HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, +}; +use quinn::rustls::pki_types::pem::PemObject; +use quinn::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use quinn::rustls::{ + ClientConfig as RustlsClientConfig, DigitallySignedStruct, ServerConfig as RustlsServerConfig, + SignatureScheme, +}; +use quinn::{ClientConfig, ServerConfig}; +use subtle::ConstantTimeEq; + +use crate::KeyState; + +/// RFC 9266 exporter label, shared with the TCP channel-binding path so both +/// transports name the binding identically. +pub const QUIC_EXPORTER_LABEL: &[u8] = b"EXPORTER-Channel-Binding"; + +/// The exporter context the QUIC keying-material API requires (it has no +/// "absent" form). An auths-internal domain separator; both auths QUIC endpoints +/// pass exactly these bytes so they derive the same binding from one connection. +pub const QUIC_EXPORTER_CONTEXT: &[u8] = b"auths-quic-channel-binding-v1"; + +/// Length, in bytes, of the channel binding exported from a QUIC connection. +pub const QUIC_EXPORTER_LEN: usize = 32; + +/// Errors building a QUIC transport from a KEL-rooted leaf, or extracting a +/// channel binding from a QUIC connection. +#[derive(Debug, thiserror::Error)] +pub enum QuicTransportError { + /// A certificate or key PEM could not be parsed into the rustls types QUIC's + /// TLS 1.3 stack needs. + #[error("parse PEM material: {0}")] + Pem(String), + + /// rustls / quinn refused to build the server or client TLS config (e.g. the + /// key does not match the leaf, or the cipher suite is unavailable). + #[error("build QUIC TLS config: {0}")] + Config(String), + + /// The QUIC connection refused to export keying material — the handshake is + /// not complete, or the connection is not TLS 1.3-capable. A connection that + /// cannot produce a binding MUST NOT fall back to an unbound proof; this is + /// surfaced so the caller fails closed. + #[error("QUIC channel binding unavailable: {0}")] + ExporterUnavailable(String), +} + +/// Parse a certificate-chain PEM into the DER chain QUIC's TLS stack serves. +fn parse_cert_chain(cert_pem: &str) -> Result>, QuicTransportError> { + CertificateDer::pem_slice_iter(cert_pem.as_bytes()) + .collect::, _>>() + .map_err(|e| QuicTransportError::Pem(format!("certificate chain: {e}"))) +} + +/// Parse a PKCS#8 private-key PEM into the DER key QUIC's TLS stack signs with. +fn parse_private_key(key_pem: &str) -> Result, QuicTransportError> { + PrivateKeyDer::from_pem_slice(key_pem.as_bytes()) + .map_err(|e| QuicTransportError::Pem(format!("private key: {e}"))) +} + +/// Build a [`quinn::ServerConfig`] that presents a KEL-rooted leaf over QUIC. +/// +/// `cert_pem` is the leaf (the one [`crate::issue_kel_rooted_cert`] mints, with +/// the `did:keri` SAN + KEL binding extension); `key_pem` is its PKCS#8 private +/// key. The resulting config drives the TLS 1.3 handshake inside QUIC, so the +/// leaf — and the KEL binding it carries — is presented over HTTP/3 exactly as +/// over TLS-over-TCP. The peer re-roots trust by replaying the KEL. +pub fn quic_server_config( + cert_pem: &str, + key_pem: &str, +) -> Result { + let certs = parse_cert_chain(cert_pem)?; + let key = parse_private_key(key_pem)?; + + let mut tls = RustlsServerConfig::builder_with_provider(crypto_provider()) + .with_protocol_versions(&[&quinn::rustls::version::TLS13]) + .map_err(|e| QuicTransportError::Config(format!("TLS1.3 server: {e}")))? + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| QuicTransportError::Config(format!("server cert: {e}")))?; + // QUIC requires ALPN; advertise HTTP/3 ("h3") so the transport is HTTP/3-ready. + tls.alpn_protocols = vec![b"h3".to_vec()]; + + let quic_tls = QuicServerConfig::try_from(tls) + .map_err(|e| QuicTransportError::Config(format!("quic server tls: {e}")))?; + Ok(ServerConfig::with_crypto(Arc::new(quic_tls))) +} + +/// Build a [`quinn::ClientConfig`] that completes a QUIC handshake to a +/// KEL-rooted server. +/// +/// Like [`crate::tls_cert`]'s verify model, certificate trust is re-rooted in the +/// **KEL**, not the WebPKI/CA chain: the client accepts the served leaf at the +/// TLS layer and the auths-aware caller then verifies the leaf binds to the AID's +/// replayed key-state out of band ([`crate::verify_binds_to_key_state`]). This is +/// the same separation the TCP path uses — TLS carries the pipe, the log carries +/// the trust. +pub fn quic_client_config() -> Result { + let mut tls = RustlsClientConfig::builder_with_provider(crypto_provider()) + .with_protocol_versions(&[&quinn::rustls::version::TLS13]) + .map_err(|e| QuicTransportError::Config(format!("TLS1.3 client: {e}")))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(KelRootedVerifier)) + .with_no_client_auth(); + tls.alpn_protocols = vec![b"h3".to_vec()]; + + let quic_tls = QuicClientConfig::try_from(tls) + .map_err(|e| QuicTransportError::Config(format!("quic client tls: {e}")))?; + Ok(ClientConfig::new(Arc::new(quic_tls))) +} + +/// Extract the per-connection channel binding from a live [`quinn::Connection`]. +/// +/// This is the QUIC adapter of the keying-material exporter the TCP path uses +/// over rustls. Both endpoints of one QUIC connection derive the **same** value +/// (it is a function of the connection's TLS 1.3 secrets); two independent +/// connections derive **different** values. Folding it into a proof scopes the +/// proof to the connection that minted it (anti-relay), over QUIC exactly as over +/// TCP. +/// +/// The handshake MUST be complete; before it is, quinn returns an error and this +/// surfaces [`QuicTransportError::ExporterUnavailable`] so the caller fails closed +/// rather than minting an unbound, relay-able proof. +pub fn quic_channel_binding( + conn: &quinn::Connection, +) -> Result<[u8; QUIC_EXPORTER_LEN], QuicTransportError> { + let mut material = [0u8; QUIC_EXPORTER_LEN]; + conn.export_keying_material(&mut material, QUIC_EXPORTER_LABEL, QUIC_EXPORTER_CONTEXT) + .map_err(|e| QuicTransportError::ExporterUnavailable(format!("{e:?}")))?; + Ok(material) +} + +/// The AWS-LC-rs crypto provider QUIC's TLS 1.3 stack uses — the same provider +/// the rest of auths's rustls usage runs on, so the cipher suites match. +fn crypto_provider() -> Arc { + Arc::new(quinn::rustls::crypto::aws_lc_rs::default_provider()) +} + +/// Certificate verifier that accepts the leaf at the TLS layer so the KEL replay +/// can re-root trust out of band. +/// +/// The KEL-rooted leaf is self-signed (its trust is in the log, not a CA chain), +/// so a WebPKI verifier would reject it. The auths model is to accept it here and +/// verify the binding against the replayed key-state afterwards — trust is rooted +/// in the KEL, never in this TLS-layer acceptance. This mirrors the TCP path, +/// where the SPKI is pinned out-of-band and the binding is checked by replay. +#[derive(Debug)] +struct KelRootedVerifier; + +impl ServerCertVerifier for KelRootedVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + // QUIC mandates TLS 1.3; a 1.2 signature path is unreachable here. + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + ] + } +} + +/// The result of carrying the KEL-rooted composition over a QUIC loopback: the +/// identity that was served, whether both endpoints agreed on the channel +/// binding (the anti-relay property), and the binding itself (hex, for display). +#[derive(Debug, Clone)] +pub struct QuicLoopbackOutcome { + /// The `did:keri:` the served leaf binds to (re-rooted in the replayed KEL). + pub did_keri: String, + /// Whether the two endpoints of the QUIC connection derived the **same** + /// channel binding. True is the expected outcome — a proof folded against it + /// opens only on this connection (anti-relay). + pub binding_agrees: bool, + /// The per-connection channel binding, hex-encoded, for display/logging. + pub channel_binding_hex: String, + /// The binding length in bytes ([`QUIC_EXPORTER_LEN`]). + pub channel_binding_len: usize, +} + +/// Carry the KEL-rooted composition over a real QUIC loopback connection. +/// +/// Stands up a QUIC server on loopback serving the leaf (`cert_pem`/`key_pem`), +/// connects a client, completes the TLS 1.3 handshake inside QUIC, then proves +/// both composition mechanisms over QUIC: +/// +/// * the **leaf** the client receives re-roots in the KEL — it is verified to bind +/// to `state`, the replayed key-state ([`crate::verify_binds_to_key_state`]); +/// * the **channel binding** both endpoints export from the connection is the same +/// ([`quic_channel_binding`]) — the per-connection value an anti-relay proof +/// folds against. +/// +/// Returns the [`QuicLoopbackOutcome`]. A handshake or binding failure is a +/// [`QuicTransportError`] (the composition could not be carried over QUIC), and a +/// served leaf that does not bind to the KEL is a [`TlsCertError`] surfaced +/// through [`QuicTransportError::Config`]. +pub async fn quic_loopback_compose( + cert_pem: &str, + key_pem: &str, + state: &KeyState, +) -> Result { + let server_cfg = quic_server_config(cert_pem, key_pem)?; + let loopback = SocketAddr::from((Ipv6Addr::LOCALHOST, 0)); + let server = quinn::Endpoint::server(server_cfg, loopback) + .map_err(|e| QuicTransportError::Config(format!("bind QUIC server: {e}")))?; + let server_addr = server + .local_addr() + .map_err(|e| QuicTransportError::Config(format!("server local addr: {e}")))?; + + let mut client = quinn::Endpoint::client(loopback) + .map_err(|e| QuicTransportError::Config(format!("bind QUIC client: {e}")))?; + client.set_default_client_config(quic_client_config()?); + + // Accept on the server while the client connects — both ends of one handshake. + let accept = async { + let incoming = server + .accept() + .await + .ok_or_else(|| QuicTransportError::Config("no inbound QUIC connection".to_string()))?; + incoming + .await + .map_err(|e| QuicTransportError::Config(format!("server handshake: {e}"))) + }; + let connect = async { + client + .connect(server_addr, "localhost") + .map_err(|e| QuicTransportError::Config(format!("client connect: {e}")))? + .await + .map_err(|e| QuicTransportError::Config(format!("client handshake: {e}"))) + }; + let (server_conn, client_conn) = tokio::try_join!(accept, connect)?; + + // Both ends export their channel binding; they must agree (per-connection). + let client_cb = quic_channel_binding(&client_conn)?; + let server_cb = quic_channel_binding(&server_conn)?; + let binding_agrees = bool::from(client_cb.ct_eq(&server_cb)); + + // The leaf the client received must re-root in the replayed KEL. + let chain = client_conn + .peer_identity() + .and_then(|id| id.downcast::>>().ok()) + .ok_or_else(|| { + QuicTransportError::Config("server presented no certificate over QUIC".to_string()) + })?; + let leaf_der = chain + .first() + .ok_or_else(|| QuicTransportError::Config("empty certificate chain".to_string()))?; + let observed_pem = pem_from_der(leaf_der); + let binding = crate::verify_binds_to_key_state(&observed_pem, state).map_err(|e| { + QuicTransportError::Config(format!("served leaf does not bind to KEL: {e}")) + })?; + + // Close the endpoints so their UDP sockets are released promptly. + client.close(0u32.into(), b"done"); + server.close(0u32.into(), b"done"); + + Ok(QuicLoopbackOutcome { + did_keri: binding.did_keri(), + binding_agrees, + channel_binding_hex: hex::encode(client_cb), + channel_binding_len: QUIC_EXPORTER_LEN, + }) +} + +/// Re-encode a DER certificate as PEM so the PEM-based KEL verifier can read the +/// leaf a QUIC peer presented. +fn pem_from_der(der: &CertificateDer<'_>) -> String { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref()); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(&String::from_utf8_lossy(chunk)); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use std::net::{Ipv6Addr, SocketAddr}; + + use quinn::{Endpoint, Incoming}; + + use crate::tls_cert::{issue_kel_rooted_cert, verify_binds_to_key_state}; + use crate::types::{CesrKey, Prefix, Said, Threshold}; + use crate::{IssuedCert, KeriPublicKey, KeyState}; + + /// A single-key Ed25519 key-state for a KEL-rooted leaf. + fn sample_state() -> KeyState { + let key = KeriPublicKey::ed25519(&[9u8; 32]) + .unwrap() + .to_qb64() + .unwrap(); + KeyState::from_inception( + Prefix::new_unchecked("EQuicAidAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()), + vec![CesrKey::new_unchecked(key)], + vec![Said::new_unchecked("ENext0".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked("ETipQuic000000000000000000000000000000000000".to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) + } + + fn kel_rooted_leaf() -> IssuedCert { + // localhost SANs so the leaf is valid for the loopback transport. + issue_kel_rooted_cert( + &sample_state(), + &["localhost".to_string(), "::1".to_string()], + ) + .expect("mint KEL-rooted leaf") + } + + /// Stand up a QUIC server endpoint on loopback serving `leaf`, returning the + /// endpoint and its bound address. + fn server_endpoint(leaf: &IssuedCert) -> (Endpoint, SocketAddr) { + let cfg = quic_server_config(&leaf.cert_pem, &leaf.key_pem).expect("server cfg"); + let addr = SocketAddr::from((Ipv6Addr::LOCALHOST, 0)); + let ep = Endpoint::server(cfg, addr).expect("server endpoint"); + let bound = ep.local_addr().expect("local addr"); + (ep, bound) + } + + /// Drive one full QUIC handshake to `server_addr` and return both endpoints' + /// channel bindings plus the leaf the client observed. + async fn handshake( + client: &Endpoint, + server: &Endpoint, + server_addr: SocketAddr, + ) -> ( + [u8; QUIC_EXPORTER_LEN], + [u8; QUIC_EXPORTER_LEN], + Vec>, + ) { + let accept = async { + let incoming: Incoming = server.accept().await.expect("incoming"); + incoming.await.expect("server connection") + }; + let connect = async { + client + .connect(server_addr, "localhost") + .expect("connect") + .await + .expect("client connection") + }; + let (server_conn, client_conn) = tokio::join!(accept, connect); + + let client_cb = quic_channel_binding(&client_conn).expect("client binding"); + let server_cb = quic_channel_binding(&server_conn).expect("server binding"); + let peer_chain = client_conn + .peer_identity() + .expect("peer identity") + .downcast::>>() + .expect("cert chain"); + (client_cb, server_cb, *peer_chain) + } + + fn client_endpoint() -> Endpoint { + let addr = SocketAddr::from((Ipv6Addr::LOCALHOST, 0)); + let mut ep = Endpoint::client(addr).expect("client endpoint"); + ep.set_default_client_config(quic_client_config().expect("client cfg")); + ep + } + + #[tokio::test] + async fn server_serves_kel_rooted_leaf_over_quic() { + // The KEL-rooted leaf the client observes over the QUIC TLS 1.3 handshake + // must be exactly the one the server holds, and it must bind to the KEL. + let leaf = kel_rooted_leaf(); + let (server, addr) = server_endpoint(&leaf); + let client = client_endpoint(); + + let (_c, _s, chain) = handshake(&client, &server, addr).await; + assert!( + !chain.is_empty(), + "server presented no certificate over QUIC" + ); + + // The served leaf re-roots in the KEL: replay the key-state and confirm the + // leaf the client received binds to it — trust in the log, over QUIC. + let observed_pem = pem_from_der(&chain[0]); + let binding = verify_binds_to_key_state(&observed_pem, &sample_state()) + .expect("served leaf must bind to the KEL"); + assert_eq!( + binding.did_keri(), + "did:keri:EQuicAidAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + } + + #[tokio::test] + async fn both_endpoints_derive_the_same_binding() { + // RFC 5705 over QUIC: both ends of one connection export the SAME keying + // material — the agreement that lets a proof be scoped to the channel. + let leaf = kel_rooted_leaf(); + let (server, addr) = server_endpoint(&leaf); + let client = client_endpoint(); + + let (client_cb, server_cb, _chain) = handshake(&client, &server, addr).await; + assert_eq!( + client_cb, server_cb, + "both ends of one QUIC connection must derive the same channel binding" + ); + } + + #[tokio::test] + async fn independent_connections_derive_distinct_bindings() { + // Two independent QUIC connections export DIFFERENT keying material — the + // per-connection property the anti-relay guarantee rests on. A proof bound + // to connection 1 cannot be replayed on connection 2. + let leaf = kel_rooted_leaf(); + let (server, addr) = server_endpoint(&leaf); + let client = client_endpoint(); + + let (cb1, _s1, _c1) = handshake(&client, &server, addr).await; + let (cb2, _s2, _c2) = handshake(&client, &server, addr).await; + assert_ne!( + cb1, cb2, + "two independent QUIC connections must derive distinct bindings" + ); + } + + /// Re-encode a DER certificate as PEM so the existing PEM-based KEL verifier + /// can read the leaf the QUIC peer presented. + fn pem_from_der(der: &CertificateDer<'_>) -> String { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref()); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).unwrap()); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem + } +} diff --git a/crates/auths-keri/src/said.rs b/crates/auths-keri/src/said.rs index b31c3604..42dc0f11 100644 --- a/crates/auths-keri/src/said.rs +++ b/crates/auths-keri/src/said.rs @@ -42,17 +42,59 @@ impl Protocol { } } - /// Whether the `i` field is self-addressing (blanked during SAID-ification). + /// Whether this protocol's inception events *can* carry a self-addressing + /// prefix in `i` (a prefix derived from the event SAID, blanked during + /// SAID-ification). /// /// KERI inception events (`icp`/`dip`) and backerless TEL registry inception - /// (`vcp`) derive their prefix from the SAID, so `i` is blanked. ACDC `i` is - /// the *issuer* AID (an external reference), so it is never blanked — only - /// event protocols consult the `t` field. + /// (`vcp`) derive their prefix from the SAID, so a self-addressing `i` is + /// blanked. ACDC `i` is the *issuer* AID (an external reference), so it is + /// never blanked — only event protocols consult the `t` field. + /// + /// Note: this gates only the protocol/event-type; whether `i` is *actually* + /// self-addressing for a given event is decided per-value by + /// [`prefix_is_self_addressing`], because KERI also admits basic-prefix + /// inceptions where `i` is a public key and must be kept during hashing. fn blanks_inception_prefix(self) -> bool { matches!(self, Protocol::Keri) } } +/// Whether an inception event's current `i` value is a *self-addressing* prefix +/// (one derived from the event SAID) — the only case in which `i` is blanked +/// before hashing. +/// +/// KERI admits two inception prefix kinds for the same keys: +/// +/// * **self-addressing** — `i` is the SAID itself (a Blake3-256 digest, CESR +/// code `E`), or, on the emit path, the as-yet-unfilled SAID +/// [`SAID_PLACEHOLDER`]. keripy blanks `i` along with `d` before hashing. +/// * **basic** — `i` is the controlling public key (e.g. an Ed25519 verkey, +/// CESR code `D`/`B`, or a P-256/secp256k1 key, codes `1AAB`/`1AAC`…). It is +/// *not* derived from the SAID, so keripy 1.3.4 keeps `i` present during +/// hashing exactly as any other field. +/// +/// Classifying by the value's CESR derivation code (parse, don't validate) +/// rather than by event type lets auths reproduce keripy's SAID byte-exact for +/// *either* prefix kind it ingests, while still emitting only self-addressing +/// AIDs itself. +/// +/// Self-addressing `i` is one of: +/// * **empty** — the auths *emit* path (`finalize_icp_event`) hashes the event +/// with `i` unset, then fills `i = d` afterwards; an unset `i` is an +/// as-yet-underived self-addressing prefix, never a basic one (auths emits +/// only self-addressing AIDs). +/// * the [`SAID_PLACEHOLDER`] — the explicit "`d`/`i` to be filled" marker. +/// * a digest prefix (`E…`, Blake3-256) — an already-filled self-addressing AID. +/// +/// Anything else is a key prefix (a verkey: `D`/`B`/`1AAB`…) and therefore a +/// *basic* prefix, which keripy keeps during hashing — so auths must too. This +/// mirrors the discriminator `finalize_icp_event` already uses to decide +/// whether to set `i = d`. +fn prefix_is_self_addressing(i: &str) -> bool { + i.is_empty() || i == SAID_PLACEHOLDER || i.starts_with('E') +} + /// Computes a spec-compliant SAID for a KERI event (`KERI10JSON` protocol tag). /// /// Thin wrapper over [`compute_said_with_protocol`] pinned to [`Protocol::Keri`]; @@ -111,8 +153,15 @@ pub fn compute_said_with_protocol( let placeholder = serde_json::Value::String(SAID_PLACEHOLDER.to_string()); let event_type = obj.get("t").and_then(|v| v.as_str()).unwrap_or(""); - let blank_prefix = - protocol.blanks_inception_prefix() && matches!(event_type, "icp" | "dip" | "vcp"); + // Blank `i` only when this is a self-addressing inception: the event type is + // an inception (`icp`/`dip`/`vcp`) AND its `i` is actually derived from the + // SAID (a digest prefix or the unfilled placeholder). A basic-prefix + // inception carries a public key in `i`, which keripy keeps during hashing — + // so auths must keep it too, or it computes a confidently-wrong SAID. + let inception_prefix = obj.get("i").and_then(|v| v.as_str()).unwrap_or(""); + let blank_prefix = protocol.blanks_inception_prefix() + && matches!(event_type, "icp" | "dip" | "vcp") + && prefix_is_self_addressing(inception_prefix); // Rebuild the map with spec-compliant placeholders and field ordering. let mut new_obj = serde_json::Map::new(); @@ -314,13 +363,21 @@ mod tests { assert_eq!(said_with, said_without, "x field must not affect SAID"); } + /// A *self-addressing* inception blanks `i`, so its SAID is independent of + /// the particular (digest-coded) `i` value it carries — including the two + /// emit-path forms, the [`SAID_PLACEHOLDER`] and an already-filled `E…` + /// prefix. + /// + /// This is the corrected invariant: it holds *only* for self-addressing + /// prefixes. A basic prefix (a verkey in `i`) is kept during hashing and so + /// is NOT interchangeable — see [`basic_prefix_inception_keeps_i_matches_keripy`]. #[test] - fn inception_applies_i_placeholder() { - let event_a = serde_json::json!({ + fn self_addressing_inception_said_independent_of_i() { + let event_placeholder = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", "d": "", - "i": "some_prefix_a", + "i": SAID_PLACEHOLDER, "s": "0", "kt": "1", "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], @@ -330,11 +387,11 @@ mod tests { "b": [], "a": [] }); - let event_b = serde_json::json!({ + let event_digest = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", "d": "", - "i": "some_prefix_b", + "i": "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", "s": "0", "kt": "1", "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], @@ -344,11 +401,11 @@ mod tests { "b": [], "a": [] }); - let said_a = compute_said(&event_a).unwrap(); - let said_b = compute_said(&event_b).unwrap(); + let said_placeholder = compute_said(&event_placeholder).unwrap(); + let said_digest = compute_said(&event_digest).unwrap(); assert_eq!( - said_a, said_b, - "inception SAID must be independent of initial i value" + said_placeholder, said_digest, + "self-addressing inception SAID must be independent of the digest i value" ); } @@ -395,6 +452,85 @@ mod tests { assert!(verify_said(&event).is_err()); } + /// keripy oracle (1.3.4): a *basic-prefix* inception keeps `i` (the verkey) + /// during hashing, so its SAID differs from the self-addressing form. + /// + /// Vector: `eventing.incept(keys=[ed.qb64])` (default basic prefix), where + /// `ed.qb64 == "DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"`. Cross-checked + /// by the interop suite (`interop/vectors/kel/icp-basic.json`, gap IOP-L1d). + #[test] + fn basic_prefix_inception_keeps_i_matches_keripy() { + let raw = r#"{"v":"KERI10JSON0000fd_","t":"icp","d":"EAAD4cS7l9pm_N8JM9UsVeAZhwCIaDkSU341hbhHJbSf","i":"DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","s":"0","kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[]}"#; + let event: serde_json::Value = serde_json::from_str(raw).unwrap(); + let said = compute_said(&event).unwrap(); + assert_eq!( + said.as_str(), + "EAAD4cS7l9pm_N8JM9UsVeAZhwCIaDkSU341hbhHJbSf", + "basic-prefix icp SAID must match keripy (i kept, not blanked)" + ); + // verify_said must accept the keripy event as-is. + assert!(verify_said(&event).is_ok()); + } + + /// keripy oracle (1.3.4): a *self-addressing* inception still blanks `i` + /// (it is the SAID), so this path is unchanged by the basic-prefix fix. + /// + /// Vector: `eventing.incept(keys=[ed.qb64], code=Blake3_256)` — `i == d`. + /// Cross-checked by `interop/vectors/kel/icp-selfaddr.json` (gap IOP-L1a). + #[test] + fn self_addressing_inception_blanks_i_matches_keripy() { + let raw = r#"{"v":"KERI10JSON0000fd_","t":"icp","d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0","kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[]}"#; + let event: serde_json::Value = serde_json::from_str(raw).unwrap(); + let said = compute_said(&event).unwrap(); + assert_eq!( + said.as_str(), + "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J", + "self-addressing icp SAID must still blank i and match keripy" + ); + assert!(verify_said(&event).is_ok()); + } + + /// The emit path (auths minting its own AID) fills `i` only after the SAID is + /// known, so `compute_said` sees the [`SAID_PLACEHOLDER`] in `i` and must + /// still treat it as self-addressing (blank it). Equivalently, an empty `i` + /// or the placeholder produce the same SAID as the filled self-addressing `i`. + #[test] + fn placeholder_inception_prefix_is_self_addressing() { + let base = serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "", + "s": "0", + "kt": "1", + "k": ["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"], + "nt": "0", + "n": [], + "bt": "0", + "b": [], + "c": [], + "a": [] + }); + // i = placeholder + let mut with_placeholder = base.clone(); + with_placeholder["i"] = serde_json::Value::String(SAID_PLACEHOLDER.to_string()); + // i = a digest prefix (E-coded) + let mut with_digest = base.clone(); + with_digest["i"] = + serde_json::Value::String("EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J".to_string()); + assert_eq!( + compute_said(&with_placeholder).unwrap(), + compute_said(&with_digest).unwrap(), + "placeholder and E-coded i are both self-addressing — same SAID" + ); + assert!(prefix_is_self_addressing(SAID_PLACEHOLDER)); + assert!(prefix_is_self_addressing( + "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J" + )); + assert!(!prefix_is_self_addressing( + "DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f" + )); + } + /// Guard: `serde_json::Map` must use `IndexMap` (preserve insertion order). /// /// If the `preserve_order` feature is accidentally removed from diff --git a/crates/auths-keri/src/tls_cert.rs b/crates/auths-keri/src/tls_cert.rs new file mode 100644 index 00000000..1769e1c9 --- /dev/null +++ b/crates/auths-keri/src/tls_cert.rs @@ -0,0 +1,1130 @@ +//! KEL-rooted X.509 leaf certificates — composing a KERI identity with TLS. +//! +//! TLS already authenticates endpoints through the WebPKI/CA system. This module +//! lets a KERI AID compose *with* that pipe instead of replacing it: an X.509 +//! leaf certificate whose **trust roots in the AID's key event log**, not in a +//! certificate authority. A stock TLS stack (rustls, OpenSSL, BoringSSL, Go +//! `crypto/tls`) completes a handshake with the cert exactly as it would with any +//! self-signed leaf; an *AID-aware* verifier additionally re-derives the trust by +//! replaying the KEL — so deployment rides every load balancer, mesh, and client +//! that already speaks TLS, while the identity stays self-certifying. +//! +//! ## How the cert chains to the KEL +//! +//! The leaf is *bound* to the AID, not signed by a CA. Two things tie them: +//! +//! 1. **A `did:keri:` URI in `subjectAltName`** — the SPIFFE X.509-SVID +//! pattern (identity-in-SAN). It parses cleanly in any stock X.509 verifier +//! (graceful degradation: a legacy verifier sees an ordinary URI SAN), and an +//! AID-aware verifier reads the AID out of it. +//! 2. **An `AuthsKeriBinding` certificate extension** carrying the AID's resolved +//! key-state — the AID prefix, every current signing key (CESR), and the KEL +//! tip SAID. This is the projection of a KEL replay into the cert, so the +//! verifier checks the cert against the *log*, never against a CA. +//! +//! The verifier (`verify_binds_to_key_state`, available with the `tls-cert` +//! feature) replays the supplied KEL into a [`KeyState`] and asserts the cert's +//! embedded binding equals that state. Trust is rooted in the log: change a +//! current key in the cert and replay no longer agrees, so the cert is rejected. +//! +//! ## Unforgeability: the AID authorizes the TLS key +//! +//! The leaf carries its own ephemeral TLS keypair (so the long-term AID signing +//! key never goes on the wire). The binding to the key-state alone is *not* +//! unforgeable: a stock self-signed leaf only proves possession of the *TLS* key, +//! and anyone replaying a public KEL could project the same key-state into a leaf +//! minted over *their own* TLS key. So a KEL-rooted leaf additionally carries a +//! [`TlsKeyAuthorization`] — a KERI signature, by one of the AID's *current* +//! signing keys, over the leaf's `SubjectPublicKeyInfo` DER. That signature is the +//! proof the AID authorized *this* TLS key: an attacker who never held the AID's +//! signing key cannot produce it, even with the full public KEL in hand. +//! +//! The adversarial verifier ([`verify_authorized_against_key_state`]) re-roots +//! trust in the log and rejects every forgery class: +//! +//! * **forged binding** — a leaf whose embedded key-state matches the replay but +//! whose TLS key the AID never signed → [`TlsCertError::Unauthorized`]; +//! * **stripped binding / authorization** — a plain leaf, or one missing the +//! authorization → [`TlsCertError::MissingBinding`] / [`TlsCertError::MissingAuthorization`]; +//! * **revoked / rotated AID** — a leaf whose embedded key-state diverges from a +//! fresh replay of the *current* KEL → [`TlsCertError::BindingMismatch`]; +//! * **SAN spoof** — a `did:keri` SAN that disagrees with the binding → +//! [`TlsCertError::SanMismatch`]. +//! +//! Relay/MITM (a proof lifted off one TLS channel and replayed on another) is +//! rejected one layer up, by the session's channel binding to the TLS exporter; +//! the cert proves *who*, the channel binding proves *which connection*. +//! +//! ## Parse, don't validate +//! +//! [`AuthsKeriBinding`] is a parsed type: [`AuthsKeriBinding::from_key_state`] +//! builds it only from a resolved key-state, and +//! [`AuthsKeriBinding::from_canonical_json`] is total over its serialized form — +//! an ill-formed extension cannot be represented as a binding, it is an error at +//! the boundary. + +use serde::{Deserialize, Serialize}; + +use crate::keys::{KeriDecodeError, KeriPublicKey}; +use crate::state::KeyState; + +/// OID of the `AuthsKeriBinding` certificate extension, under the +/// Private Enterprise arc `1.3.6.1.4.1.59999` (`auths`), extension `.1.1`. +/// +/// The content is the DER encoding of an OCTET STRING wrapping the canonical +/// JSON of an [`AuthsKeriBinding`]. The extension is **non-critical**: a legacy +/// X.509 verifier that does not understand it ignores it (graceful degradation), +/// while an AID-aware verifier reads it to re-root trust in the KEL. +pub const AUTHS_KERI_BINDING_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 59999, 1, 1]; + +/// The `did:keri` DID method scheme prefix used in the certificate SAN URI. +pub const DID_KERI_SCHEME: &str = "did:keri:"; + +/// Errors building or verifying a KEL-rooted certificate. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TlsCertError { + /// A current signing key in the key-state could not be decoded. + #[error("decode AID key-state key: {0}")] + Key(#[from] KeriDecodeError), + + /// X.509 certificate generation failed in the backend. + #[error("generate certificate: {0}")] + Generate(String), + + /// The supplied TLS key material could not be loaded as a keypair. + #[error("load TLS keypair: {0}")] + KeyPair(String), + + /// The certificate PEM/DER could not be parsed. + #[error("parse certificate: {0}")] + ParseCert(String), + + /// The certificate carries no `AuthsKeriBinding` extension — it is not a + /// KEL-rooted auths certificate. + #[error("certificate carries no auths KEL binding extension")] + MissingBinding, + + /// The `AuthsKeriBinding` extension content was not well-formed. + #[error("malformed auths KEL binding extension: {0}")] + MalformedBinding(String), + + /// The certificate's binding does not match the replayed KEL key-state. + #[error("certificate binding does not match the replayed KEL: {0}")] + BindingMismatch(String), + + /// The certificate's `did:keri` SAN is absent or does not match the binding. + #[error("certificate did:keri SAN mismatch: {0}")] + SanMismatch(String), + + /// The certificate carries no `did:keri` URI in its `subjectAltName` — there + /// is no auths identity to read out of it (the X.509-SVID identity surface). + #[error("certificate carries no did:keri subjectAltName")] + NoSanIdentity, + + /// The `did:keri` SAN was present but its AID is not a valid KERI prefix. + #[error("did:keri SAN carries an invalid AID: {0}")] + InvalidSanAid(#[from] crate::types::KeriTypeError), + + /// The certificate binds to a key-state but carries no [`TlsKeyAuthorization`] + /// — there is no proof the AID authorized this TLS key, so it is unforgeable + /// only if rejected (the adversarial verifier requires the authorization). + #[error("certificate carries no AID authorization over its TLS key")] + MissingAuthorization, + + /// The authorization names a current-key index outside the replayed key-state. + #[error("authorization key index {index} is out of range (key-state has {len} current keys)")] + AuthorizationIndexOutOfRange { + /// The out-of-range index the authorization claimed. + index: usize, + /// The number of current keys the replayed key-state actually has. + len: usize, + }, + + /// A current signing key named by the key-state could not be decoded when + /// checking the authorization (a malformed verkey reached the verifier). + #[error("decode authorizing key: {0}")] + AuthorizationKey(String), + + /// The authorization signature does not verify: the AID's current key did not + /// sign this leaf's TLS public key, so the AID never authorized it (a forged + /// binding, or a relayed/substituted leaf). + #[error("AID did not authorize this TLS key: {0}")] + Unauthorized(String), +} + +/// A KERI signature, by one of the AID's *current* signing keys, over the leaf's +/// `SubjectPublicKeyInfo` DER — the proof the AID **authorized** this TLS key. +/// +/// Without it, a KEL-rooted leaf only proves possession of the *TLS* key; with +/// it, the leaf proves the AID's controller (the holder of a current signing key) +/// bound *that specific* TLS key to the AID. Parse, don't validate: the signature +/// is held as raw bytes (hex on the wire) and `key_index` is the position in the +/// key-state's `current_keys` whose signature this is; an out-of-range index is an +/// error at verification, never a silent skip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TlsKeyAuthorization { + /// The index, in the key-state's `current_keys`, of the signing key that + /// produced `signature`. (Single-sig today; the index makes multi-sig + /// authorization a forward-compatible extension, not a reshape.) + pub key_index: usize, + /// The detached signature over the leaf's `SubjectPublicKeyInfo` DER, raw + /// bytes (serialized as hex inside the canonical JSON). + #[serde(with = "hex::serde")] + pub signature: Vec, +} + +/// The AID key-state a KEL-rooted certificate embeds — the projection of a KEL +/// replay into the cert, so a verifier checks the leaf against the *log*. +/// +/// Field order and labels are stable (`serde_json` with `preserve_order`), so the +/// JSON inside the extension is canonical across producers and the bytes a +/// verifier re-derives from a fresh replay equal the bytes in the cert. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthsKeriBinding { + /// The AID this certificate is bound to (the KEL prefix). Also the subject + /// of the `did:keri:` SAN. + pub aid: String, + /// Every current signing key of the AID, CESR-qualified, in KEL order. + pub current_keys: Vec, + /// The SAID of the KEL tip the binding was projected from — the exact log + /// position whose replay must reproduce `current_keys`. + pub kel_tip: String, + /// The AID's authorization over the leaf's TLS key, when present. `None` for a + /// binding that names only the key-state (the discovery / identity surface); + /// the adversarial verifier ([`verify_authorized_against_key_state`]) requires + /// it, so an unauthorized leaf cannot pass the security check. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_key_authorization: Option, +} + +impl AuthsKeriBinding { + /// Project a resolved [`KeyState`] into a certificate binding (no + /// authorization yet — the key-state projection only). + /// + /// Every current key is decoded first (parse, don't validate), so a binding + /// is only ever built from keys that are valid for their curve — an + /// undecodable key is [`TlsCertError::Key`] at the boundary, never serialized + /// into a cert. + pub fn from_key_state(state: &KeyState) -> Result { + let mut current_keys = Vec::with_capacity(state.current_keys.len()); + for key in &state.current_keys { + // Decode to reject a malformed key before it reaches the cert. + KeriPublicKey::parse(key.as_str())?; + current_keys.push(key.as_str().to_string()); + } + Ok(Self { + aid: state.prefix.as_str().to_string(), + current_keys, + kel_tip: state.last_event_said.as_str().to_string(), + tls_key_authorization: None, + }) + } + + /// The same projection, carrying the AID's authorization over the leaf's TLS + /// key. This is the binding a KEL-rooted leaf embeds once the AID has signed + /// its `SubjectPublicKeyInfo` DER. + pub fn with_authorization(mut self, authorization: TlsKeyAuthorization) -> Self { + self.tls_key_authorization = Some(authorization); + self + } + + /// The `did:keri:` URI this binding's AID resolves to. + pub fn did_keri(&self) -> String { + format!("{DID_KERI_SCHEME}{}", self.aid) + } + + /// Serialize to the canonical JSON bytes carried (DER-wrapped) in the cert. + pub fn to_canonical_json(&self) -> Vec { + // `serde_json` here is configured (workspace-wide) with preserve_order, + // and the struct field order is fixed, so this is deterministic. + serde_json::to_vec(self).unwrap_or_default() + } + + /// Parse a binding from the canonical JSON bytes. Total over its input: + /// malformed JSON is [`TlsCertError::MalformedBinding`], not a panic. + pub fn from_canonical_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| TlsCertError::MalformedBinding(e.to_string())) + } +} + +/// A port: signs the leaf's `SubjectPublicKeyInfo` DER with one of the AID's +/// *current* signing keys, producing the authorization that proves the AID bound +/// this TLS key. The core never imports a concrete key store — an adapter (a +/// keychain-backed signer, a held seed) supplies the signature. +/// +/// The contract: [`sign_tls_key`](TlsKeyAuthorizer::sign_tls_key) returns the +/// detached signature over `spki_der`, and [`current_key_index`] is the position +/// in the key-state's `current_keys` of the public key that signature verifies +/// against. The verifier checks the signature against exactly that key, so an +/// adapter that lies about either is caught at [`verify_authorized_against_key_state`]. +pub trait TlsKeyAuthorizer { + /// The index, in the key-state's `current_keys`, of the signing key used. + fn current_key_index(&self) -> usize; + /// Sign the leaf's `SubjectPublicKeyInfo` DER, returning the raw signature. + fn sign_tls_key(&self, spki_der: &[u8]) -> Result, TlsCertError>; +} + +/// Verify a [`TlsKeyAuthorization`] against a binding's `current_keys` and the +/// leaf's `SubjectPublicKeyInfo` DER — the unforgeability check. +/// +/// The authorization must (1) name an in-range current-key index, (2) reference a +/// decodable verkey, and (3) carry a signature that verifies, under that key, over +/// the leaf's SPKI DER. Any failure is a rejection: the AID did not authorize this +/// TLS key. Shared by every verify direction so there is one source of truth for +/// "the AID signed this leaf." Only the cert backend (the `tls-cert` feature) +/// extracts an SPKI to check, so it is gated alongside it. +#[cfg(feature = "tls-cert")] +fn check_tls_key_authorization( + authorization: &TlsKeyAuthorization, + current_keys: &[String], + spki_der: &[u8], +) -> Result<(), TlsCertError> { + let key_str = current_keys.get(authorization.key_index).ok_or( + TlsCertError::AuthorizationIndexOutOfRange { + index: authorization.key_index, + len: current_keys.len(), + }, + )?; + let key = + KeriPublicKey::parse(key_str).map_err(|e| TlsCertError::AuthorizationKey(e.to_string()))?; + key.verify_signature(spki_der, &authorization.signature) + .map_err(TlsCertError::Unauthorized) +} + +#[cfg(feature = "tls-cert")] +mod backend { + use super::*; + + use rcgen::string::Ia5String; + use rcgen::{ + CertificateParams, CustomExtension, DnType, ExtendedKeyUsagePurpose, KeyPair, + KeyUsagePurpose, PublicKeyData, SanType, + }; + + /// A freshly issued KEL-rooted leaf certificate plus its private key. + /// + /// The cert's subject public key is a fresh ephemeral TLS keypair (the AID's + /// long-term key is never put on the wire); the AID binding rides in the SAN + /// and the [`AUTHS_KERI_BINDING_OID`] extension. + pub struct IssuedCert { + /// The certificate, PEM-encoded. + pub cert_pem: String, + /// The ephemeral TLS private key, PKCS#8 PEM. Hand to the TLS acceptor. + pub key_pem: zeroize::Zeroizing, + /// The binding the cert embeds (for the caller to echo / log). + pub binding: AuthsKeriBinding, + } + + /// Issue a KEL-rooted leaf certificate for a resolved AID key-state. + /// + /// Generates a fresh P-256 TLS keypair, sets the subject CN and a + /// `did:keri:` URI SAN, embeds the [`AuthsKeriBinding`] extension, and + /// self-signs with the ephemeral key so a stock TLS stack completes a + /// handshake. `extra_sans` carries the transport host names/IPs the cert must + /// also be valid for (e.g. `localhost`, `127.0.0.1`) — without them a + /// hostname-checking client would reject the leaf even though the binding is + /// sound. + pub fn issue_kel_rooted_cert( + state: &KeyState, + extra_sans: &[String], + ) -> Result { + let binding = AuthsKeriBinding::from_key_state(state)?; + let key_pair = + KeyPair::generate().map_err(|e| TlsCertError::KeyPair(format!("generate: {e}")))?; + issue_with_keypair(&binding, &key_pair, extra_sans) + } + + /// Issue a KEL-rooted leaf from an existing PKCS#8-PEM TLS keypair. + /// + /// The deterministic path used by tests and by callers that already hold a + /// TLS key. The key is the cert's subject key and self-signs the leaf. + pub fn issue_kel_rooted_cert_with_key( + state: &KeyState, + tls_key_pkcs8_pem: &str, + extra_sans: &[String], + ) -> Result { + let binding = AuthsKeriBinding::from_key_state(state)?; + let key_pair = KeyPair::from_pem(tls_key_pkcs8_pem) + .map_err(|e| TlsCertError::KeyPair(format!("from pem: {e}")))?; + issue_with_keypair(&binding, &key_pair, extra_sans) + } + + /// Issue a KEL-rooted leaf whose TLS key the AID has **authorized**. + /// + /// Generates a fresh ephemeral TLS keypair, has `authorizer` sign that key's + /// `SubjectPublicKeyInfo` DER with one of the AID's current signing keys, and + /// embeds the resulting [`TlsKeyAuthorization`] in the binding. The leaf is + /// then unforgeable: only the AID's controller can produce the authorization, + /// so an attacker replaying the public KEL cannot mint a leaf over a TLS key of + /// their own choosing. + pub fn issue_authorized_kel_rooted_cert( + state: &KeyState, + authorizer: &dyn TlsKeyAuthorizer, + extra_sans: &[String], + ) -> Result { + let key_pair = + KeyPair::generate().map_err(|e| TlsCertError::KeyPair(format!("generate: {e}")))?; + issue_authorized_with_keypair(state, authorizer, &key_pair, extra_sans) + } + + /// The deterministic-key counterpart of [`issue_authorized_kel_rooted_cert`]: + /// authorize and issue over an existing PKCS#8-PEM TLS keypair. + pub fn issue_authorized_kel_rooted_cert_with_key( + state: &KeyState, + authorizer: &dyn TlsKeyAuthorizer, + tls_key_pkcs8_pem: &str, + extra_sans: &[String], + ) -> Result { + let key_pair = KeyPair::from_pem(tls_key_pkcs8_pem) + .map_err(|e| TlsCertError::KeyPair(format!("from pem: {e}")))?; + issue_authorized_with_keypair(state, authorizer, &key_pair, extra_sans) + } + + fn issue_authorized_with_keypair( + state: &KeyState, + authorizer: &dyn TlsKeyAuthorizer, + key_pair: &KeyPair, + extra_sans: &[String], + ) -> Result { + let base = AuthsKeriBinding::from_key_state(state)?; + + // The leaf's SubjectPublicKeyInfo DER — the exact bytes the verifier reads + // back out of the parsed certificate. Signing these binds the AID to *this* + // TLS key (one source of truth for "what the AID signed"). + let spki_der = key_pair.subject_public_key_info(); + let key_index = authorizer.current_key_index(); + if base.current_keys.get(key_index).is_none() { + return Err(TlsCertError::AuthorizationIndexOutOfRange { + index: key_index, + len: base.current_keys.len(), + }); + } + let signature = authorizer.sign_tls_key(&spki_der)?; + let authorization = TlsKeyAuthorization { + key_index, + signature, + }; + // Reject an authorizer that signed with a key that doesn't match its + // claimed current key before the leaf ever leaves the issuer. + check_tls_key_authorization(&authorization, &base.current_keys, &spki_der)?; + + let binding = base.with_authorization(authorization); + issue_with_keypair(&binding, key_pair, extra_sans) + } + + /// Mint a leaf embedding `binding` over `key_pair`. `pub(crate)` so the + /// crate's adversarial tests can craft a leaf with a hand-built (e.g. forged) + /// binding; production callers go through the `issue_*` entry points which + /// build the binding from a replayed key-state. + pub(crate) fn issue_with_keypair( + binding: &AuthsKeriBinding, + key_pair: &KeyPair, + extra_sans: &[String], + ) -> Result { + let mut params = CertificateParams::new(Vec::new()) + .map_err(|e| TlsCertError::Generate(format!("params: {e}")))?; + + // Subject CN = the did:keri DID, so the identity is visible even in tools + // that only print the subject. + params + .distinguished_name + .push(DnType::CommonName, binding.did_keri()); + + // SAN: the did:keri URI (the SPIFFE X.509-SVID identity-in-SAN pattern) + // plus any transport hostnames/IPs the leaf must serve. + let did_uri = Ia5String::try_from(binding.did_keri()) + .map_err(|e| TlsCertError::Generate(format!("did:keri SAN: {e}")))?; + params.subject_alt_names.push(SanType::URI(did_uri)); + for san in extra_sans { + params.subject_alt_names.push(san_for(san)?); + } + + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ServerAuth, + ExtendedKeyUsagePurpose::ClientAuth, + ]; + + // The KEL binding: a non-critical extension carrying the replayed + // key-state, DER-wrapped as an OCTET STRING (the standard envelope for an + // opaque extension value). + let content = yasna_octet_string(&binding.to_canonical_json()); + let mut ext = CustomExtension::from_oid_content(AUTHS_KERI_BINDING_OID, content); + ext.set_criticality(false); + params.custom_extensions.push(ext); + + let cert = params + .self_signed(key_pair) + .map_err(|e| TlsCertError::Generate(format!("self-sign: {e}")))?; + + Ok(IssuedCert { + cert_pem: cert.pem(), + key_pem: zeroize::Zeroizing::new(key_pair.serialize_pem()), + binding: binding.clone(), + }) + } + + /// Build a SAN entry from a host string: an IP literal becomes an IP SAN, + /// anything else a DNS SAN (matching how `rcgen`/stock stacks treat hosts). + fn san_for(host: &str) -> Result { + if let Ok(ip) = host.parse::() { + Ok(SanType::IpAddress(ip)) + } else { + let dns = Ia5String::try_from(host.to_string()) + .map_err(|e| TlsCertError::Generate(format!("DNS SAN {host:?}: {e}")))?; + Ok(SanType::DnsName(dns)) + } + } + + /// DER-encode `bytes` as an OCTET STRING (the extension value envelope). + fn yasna_octet_string(bytes: &[u8]) -> Vec { + yasna::construct_der(|w| w.write_bytes(bytes)) + } + + /// Extract the [`AuthsKeriBinding`] embedded in a PEM certificate. + /// + /// Reads the `AUTHS_KERI_BINDING_OID` extension, unwraps the OCTET STRING, and + /// parses the canonical JSON. Errors classify the failure precisely: + /// [`TlsCertError::MissingBinding`] when there is no such extension (a plain + /// cert), [`TlsCertError::MalformedBinding`] when its content is not the + /// expected envelope. + pub fn extract_binding(cert_pem: &str) -> Result { + use x509_parser::prelude::*; + + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| TlsCertError::ParseCert(format!("PEM: {e}")))?; + let (_, cert) = X509Certificate::from_der(&pem.contents) + .map_err(|e| TlsCertError::ParseCert(format!("DER: {e}")))?; + + let oid_str = oid_string(AUTHS_KERI_BINDING_OID); + for ext in cert.extensions() { + if ext.oid.to_id_string() == oid_str { + let inner = unwrap_octet_string(ext.value)?; + return AuthsKeriBinding::from_canonical_json(&inner); + } + } + Err(TlsCertError::MissingBinding) + } + + /// Read the `did:keri` URI SAN out of a PEM certificate, if present. + pub fn extract_did_keri_san(cert_pem: &str) -> Result, TlsCertError> { + use x509_parser::prelude::*; + + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| TlsCertError::ParseCert(format!("PEM: {e}")))?; + let (_, cert) = X509Certificate::from_der(&pem.contents) + .map_err(|e| TlsCertError::ParseCert(format!("DER: {e}")))?; + + if let Ok(Some(san)) = cert.subject_alternative_name() { + for name in &san.value.general_names { + if let GeneralName::URI(uri) = name + && uri.starts_with(DID_KERI_SCHEME) + { + return Ok(Some((*uri).to_string())); + } + } + } + Ok(None) + } + + /// Read the AID a certificate claims out of its `did:keri` SAN — the + /// X.509-SVID identity surface. + /// + /// This is the identity-discovery direction (the SPIFFE X.509-SVID precedent): + /// the AID rides in the `subjectAltName` URI every stock X.509 parser already + /// exposes, so a verifier learns *which* auths identity a peer claims directly + /// from the cert — **before** it holds that AID's KEL. The returned [`Prefix`] + /// is then the lookup key to fetch and replay the KEL (via an OOBI / a held + /// log), at which point [`verify_binds_to_key_state`] re-roots trust in the + /// log. Parse, don't validate: the scheme is stripped and the AID is parsed + /// into a validated [`Prefix`], so a present-but-malformed identifier is + /// [`TlsCertError::InvalidSanAid`] at the boundary, never a raw string the + /// caller has to re-check. A cert with no `did:keri` URI SAN is + /// [`TlsCertError::NoSanIdentity`] (a plain leaf carries no auths identity). + pub fn extract_aid_from_san(cert_pem: &str) -> Result { + match extract_did_keri_san(cert_pem)? { + Some(uri) => { + let aid = uri.strip_prefix(DID_KERI_SCHEME).ok_or_else(|| { + TlsCertError::SanMismatch(format!("SAN {uri} is not a {DID_KERI_SCHEME} URI")) + })?; + Ok(crate::types::Prefix::new(aid.to_string())?) + } + None => Err(TlsCertError::NoSanIdentity), + } + } + + /// Read the leaf's `SubjectPublicKeyInfo` DER — the exact bytes the AID's + /// authorization signature covers. Re-derived from the parsed certificate, so + /// the verifier signs/checks over the canonical encoding (not a re-serialized + /// approximation). + pub fn extract_spki_der(cert_pem: &str) -> Result, TlsCertError> { + use x509_parser::prelude::*; + + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| TlsCertError::ParseCert(format!("PEM: {e}")))?; + let (_, cert) = X509Certificate::from_der(&pem.contents) + .map_err(|e| TlsCertError::ParseCert(format!("DER: {e}")))?; + Ok(cert.public_key().raw.to_vec()) + } + + /// Assert a parsed binding and the cert's SAN both agree with the replayed + /// key-state. The shared "the leaf chains to the log" check, with no + /// authorization — used directly by [`verify_binds_to_key_state`] and as the + /// first half of [`verify_authorized_against_key_state`]. + fn check_binds_to_key_state( + cert_pem: &str, + embedded: &AuthsKeriBinding, + expected: &AuthsKeriBinding, + ) -> Result<(), TlsCertError> { + if embedded.aid != expected.aid { + return Err(TlsCertError::BindingMismatch(format!( + "AID {} in cert != {} from KEL replay", + embedded.aid, expected.aid + ))); + } + if embedded.current_keys != expected.current_keys { + return Err(TlsCertError::BindingMismatch( + "current signing keys in cert do not match the KEL replay".to_string(), + )); + } + if embedded.kel_tip != expected.kel_tip { + return Err(TlsCertError::BindingMismatch(format!( + "KEL tip {} in cert != {} from replay", + embedded.kel_tip, expected.kel_tip + ))); + } + + // The SAN must carry the same AID — the legacy-compat identity surface + // must agree with the binding, or a tool reading only the SAN would trust + // a different AID than the one the binding (and the KEL) attest. + match extract_did_keri_san(cert_pem)? { + Some(san) if san == expected.did_keri() => Ok(()), + Some(san) => Err(TlsCertError::SanMismatch(format!( + "SAN {san} != {}", + expected.did_keri() + ))), + None => Err(TlsCertError::SanMismatch( + "certificate carries no did:keri SAN".to_string(), + )), + } + } + + /// Verify a KEL-rooted certificate against an AID's KEL key-state. + /// + /// The peer→auths direction: parse the cert, read its embedded binding *and* + /// its `did:keri` SAN, then assert both agree with `state` — the freshly + /// replayed key-state of the KEL the verifier holds. Trust is rooted in the + /// log: a cert whose embedded key-state diverges from a real replay is + /// rejected ([`TlsCertError::BindingMismatch`]). + /// + /// This checks the leaf *chains to* the log; it does **not** check the AID + /// authorized the leaf's TLS key. For the adversarial guarantee (rejecting a + /// forged binding minted over an attacker's TLS key) use + /// [`verify_authorized_against_key_state`]. + pub fn verify_binds_to_key_state( + cert_pem: &str, + state: &KeyState, + ) -> Result { + let expected = AuthsKeriBinding::from_key_state(state)?; + let embedded = extract_binding(cert_pem)?; + check_binds_to_key_state(cert_pem, &embedded, &expected)?; + Ok(embedded) + } + + /// The adversarial verifier (T3): a leaf passes only if it chains to the log + /// **and** the AID authorized its TLS key. + /// + /// On top of [`verify_binds_to_key_state`], this requires the embedded + /// [`TlsKeyAuthorization`] and checks it against the leaf's + /// `SubjectPublicKeyInfo` DER and the *replayed* current keys. The rejection + /// classes, each a distinct error: + /// + /// * a plain leaf (no extension) → [`TlsCertError::MissingBinding`]; + /// * a leaf whose key-state diverges from the replay (revoked / rotated AID) → + /// [`TlsCertError::BindingMismatch`]; + /// * a leaf whose SAN disagrees with the binding → [`TlsCertError::SanMismatch`]; + /// * a leaf with no authorization (stripped) → [`TlsCertError::MissingAuthorization`]; + /// * a leaf whose authorization does not verify under a current key — a forged + /// binding minted over a TLS key the AID never signed → + /// [`TlsCertError::Unauthorized`]. + pub fn verify_authorized_against_key_state( + cert_pem: &str, + state: &KeyState, + ) -> Result { + let expected = AuthsKeriBinding::from_key_state(state)?; + let embedded = extract_binding(cert_pem)?; + check_binds_to_key_state(cert_pem, &embedded, &expected)?; + + let authorization = embedded + .tls_key_authorization + .as_ref() + .ok_or(TlsCertError::MissingAuthorization)?; + let spki_der = extract_spki_der(cert_pem)?; + // Check against the *replayed* current keys, not the embedded ones: the + // binding's keys were already asserted equal to the replay, but rooting the + // authorization check in the replay keeps the log the single source of truth. + check_tls_key_authorization(authorization, &expected.current_keys, &spki_der)?; + Ok(embedded) + } + + /// Render an OID arc as the dotted string `x509-parser` exposes. + fn oid_string(arc: &[u64]) -> String { + arc.iter() + .map(|n| n.to_string()) + .collect::>() + .join(".") + } + + /// Unwrap a DER OCTET STRING to its content bytes. + fn unwrap_octet_string(der: &[u8]) -> Result, TlsCertError> { + yasna::parse_der(der, |r| r.read_bytes()) + .map_err(|e| TlsCertError::MalformedBinding(format!("OCTET STRING: {e}"))) + } +} + +#[cfg(feature = "tls-cert")] +pub use backend::{ + IssuedCert, extract_aid_from_san, extract_binding, extract_did_keri_san, extract_spki_der, + issue_authorized_kel_rooted_cert, issue_authorized_kel_rooted_cert_with_key, + issue_kel_rooted_cert, issue_kel_rooted_cert_with_key, verify_authorized_against_key_state, + verify_binds_to_key_state, +}; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::types::{CesrKey, Prefix, Said, Threshold}; + + /// A single-key Ed25519 key-state at the given AID/key/tip. + fn state(aid: &str, keys: &[&str], tip: &str) -> KeyState { + KeyState::from_inception( + Prefix::new_unchecked(aid.to_string()), + keys.iter() + .map(|k| CesrKey::new_unchecked(k.to_string())) + .collect(), + vec![Said::new_unchecked("ENext0".to_string())], + Threshold::Simple(1), + Threshold::Simple(1), + Said::new_unchecked(tip.to_string()), + vec![], + Threshold::Simple(0), + vec![], + ) + } + + fn ed25519_key(raw: &[u8; 32]) -> String { + KeriPublicKey::ed25519(raw).unwrap().to_qb64().unwrap() + } + + #[test] + fn binding_projects_key_state() { + let k = ed25519_key(&[7u8; 32]); + let st = state("EAidAAA", &[&k], "ETip000"); + let b = AuthsKeriBinding::from_key_state(&st).unwrap(); + assert_eq!(b.aid, "EAidAAA"); + assert_eq!(b.current_keys, vec![k]); + assert_eq!(b.kel_tip, "ETip000"); + assert_eq!(b.did_keri(), "did:keri:EAidAAA"); + } + + #[test] + fn binding_rejects_undecodable_key_at_boundary() { + let st = state("EAidAAA", &["Xnot-a-verkey"], "ETip000"); + assert!(matches!( + AuthsKeriBinding::from_key_state(&st), + Err(TlsCertError::Key(_)) + )); + } + + #[test] + fn binding_canonical_json_round_trips() { + let k = ed25519_key(&[3u8; 32]); + let st = state("EAidAAA", &[&k], "ETip000"); + let b = AuthsKeriBinding::from_key_state(&st).unwrap(); + let json = b.to_canonical_json(); + let back = AuthsKeriBinding::from_canonical_json(&json).unwrap(); + assert_eq!(back, b); + } + + #[test] + fn binding_json_field_order_is_canonical() { + let k = ed25519_key(&[1u8; 32]); + let st = state("EAidAAA", &[&k], "ETip000"); + let b = AuthsKeriBinding::from_key_state(&st).unwrap(); + let s = String::from_utf8(b.to_canonical_json()).unwrap(); + // aid, current_keys, kel_tip — struct order, preserve_order serde. + let i_aid = s.find("\"aid\"").unwrap(); + let i_keys = s.find("\"current_keys\"").unwrap(); + let i_tip = s.find("\"kel_tip\"").unwrap(); + assert!(i_aid < i_keys && i_keys < i_tip, "field order: {s}"); + } + + #[test] + fn malformed_binding_json_is_an_error_not_a_panic() { + assert!(matches!( + AuthsKeriBinding::from_canonical_json(b"not json"), + Err(TlsCertError::MalformedBinding(_)) + )); + } + + #[cfg(feature = "tls-cert")] + mod backend_tests { + use super::*; + // The crate-internal mint-with-binding entry point, for crafting leaves + // with hand-built (forged / out-of-range) bindings in the adversarial tests. + use crate::tls_cert::backend::issue_with_keypair; + use rcgen::PublicKeyData; + + fn multi_state() -> (KeyState, Vec) { + let k1 = ed25519_key(&[1u8; 32]); + let k2 = ed25519_key(&[2u8; 32]); + let st = state( + "EAidMultiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + &[&k1, &k2], + "ETipMultiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + (st, vec![k1, k2]) + } + + /// A test [`TlsKeyAuthorizer`] backed by a ring Ed25519 keypair. Signs the + /// SPKI directly (no `native` feature needed for the crate's own tests). + struct Ed25519Authorizer { + keypair: ring::signature::Ed25519KeyPair, + key_index: usize, + } + + impl Ed25519Authorizer { + fn from_seed(seed: &[u8; 32], key_index: usize) -> Self { + let keypair = ring::signature::Ed25519KeyPair::from_seed_unchecked(seed).unwrap(); + Self { keypair, key_index } + } + /// The CESR-qualified current key string this authorizer's key occupies. + fn cesr_key(&self) -> String { + use ring::signature::KeyPair; + let raw: [u8; 32] = self.keypair.public_key().as_ref().try_into().unwrap(); + ed25519_key(&raw) + } + } + + impl TlsKeyAuthorizer for Ed25519Authorizer { + fn current_key_index(&self) -> usize { + self.key_index + } + fn sign_tls_key(&self, spki_der: &[u8]) -> Result, TlsCertError> { + Ok(self.keypair.sign(spki_der).as_ref().to_vec()) + } + } + + /// A single-key key-state whose current key is `auth`'s public key, plus a + /// matching authorizer at index 0. The standard authorized-cert fixture. + fn authorized_state(seed: &[u8; 32]) -> (KeyState, Ed25519Authorizer) { + let auth = Ed25519Authorizer::from_seed(seed, 0); + let key = auth.cesr_key(); + let st = state( + "EAidAuthAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + &[&key], + "ETipAuthAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + (st, auth) + } + + #[test] + fn issued_cert_carries_binding_and_san() { + let (st, keys) = multi_state(); + let issued = + issue_kel_rooted_cert(&st, &["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap(); + assert!(issued.cert_pem.contains("BEGIN CERTIFICATE")); + assert!(!issued.key_pem.is_empty()); + + let binding = extract_binding(&issued.cert_pem).unwrap(); + assert_eq!(binding.aid, st.prefix.as_str()); + assert_eq!(binding.current_keys, keys); + + let san = extract_did_keri_san(&issued.cert_pem).unwrap(); + assert_eq!(san, Some(format!("did:keri:{}", st.prefix.as_str()))); + } + + #[test] + fn aid_reads_out_of_the_san_without_the_kel() { + // The X.509-SVID identity surface: a verifier learns *which* AID a + // cert claims from the SAN alone, before it holds the KEL. + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + let aid = extract_aid_from_san(&issued.cert_pem).unwrap(); + assert_eq!(aid.as_str(), st.prefix.as_str()); + } + + #[test] + fn plain_cert_has_no_san_identity() { + // A stock self-signed leaf carries no did:keri SAN, so there is no + // auths identity to read out of it — NoSanIdentity, not a panic. + let kp = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let cert = params.self_signed(&kp).unwrap(); + assert!(matches!( + extract_aid_from_san(&cert.pem()), + Err(TlsCertError::NoSanIdentity) + )); + } + + #[test] + fn malformed_san_aid_is_rejected_at_the_boundary() { + // A did:keri SAN whose AID is not a valid KERI prefix is an error at + // the parse boundary, never returned as a trusted identity. + let kp = rcgen::KeyPair::generate().unwrap(); + let mut params = rcgen::CertificateParams::new(Vec::new()).unwrap(); + let bad = rcgen::string::Ia5String::try_from("did:keri:".to_string()).unwrap(); + params.subject_alt_names.push(rcgen::SanType::URI(bad)); + let cert = params.self_signed(&kp).unwrap(); + assert!(matches!( + extract_aid_from_san(&cert.pem()), + Err(TlsCertError::InvalidSanAid(_)) + )); + } + + #[test] + fn issued_cert_verifies_against_the_same_key_state() { + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + let binding = verify_binds_to_key_state(&issued.cert_pem, &st).unwrap(); + assert_eq!(binding.aid, st.prefix.as_str()); + } + + #[test] + fn cert_is_rejected_against_a_different_key_state() { + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + + // A KEL replay that yields a different current key must not verify. + let other_key = ed25519_key(&[9u8; 32]); + let other = state( + st.prefix.as_str(), + &[&other_key], + st.last_event_said.as_str(), + ); + assert!(matches!( + verify_binds_to_key_state(&issued.cert_pem, &other), + Err(TlsCertError::BindingMismatch(_)) + )); + } + + #[test] + fn cert_is_rejected_against_a_different_aid() { + let (st, _) = multi_state(); + let issued = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + let k = ed25519_key(&[1u8; 32]); + let other = state( + "EAidOTHERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + &[&k], + "ETip", + ); + assert!(matches!( + verify_binds_to_key_state(&issued.cert_pem, &other), + Err(TlsCertError::BindingMismatch(_)) + )); + } + + #[test] + fn plain_cert_has_no_binding() { + // A cert minted without the extension reports MissingBinding, not a + // false match — so a stock self-signed cert can't masquerade as + // KEL-rooted. + let kp = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let cert = params.self_signed(&kp).unwrap(); + assert!(matches!( + extract_binding(&cert.pem()), + Err(TlsCertError::MissingBinding) + )); + } + + #[test] + fn issue_with_supplied_key_is_deterministic_in_binding() { + // Same KEL + same TLS key → identical embedded binding (the cert + // serial/validity may differ, but the KEL projection is stable). + let (st, _) = multi_state(); + let kp = rcgen::KeyPair::generate().unwrap(); + let pem = kp.serialize_pem(); + let a = issue_kel_rooted_cert_with_key(&st, &pem, &["localhost".to_string()]).unwrap(); + let b = issue_kel_rooted_cert_with_key(&st, &pem, &["localhost".to_string()]).unwrap(); + assert_eq!( + extract_binding(&a.cert_pem).unwrap(), + extract_binding(&b.cert_pem).unwrap() + ); + } + + // --- T3 adversarial verifier: the AID authorizes the TLS key --- + + #[test] + fn authorized_cert_carries_authorization_and_verifies() { + // The happy path: an AID-authorized leaf carries the authorization and + // passes the adversarial verifier against its own replayed key-state. + let (st, auth) = authorized_state(&[7u8; 32]); + let issued = + issue_authorized_kel_rooted_cert(&st, &auth, &["localhost".to_string()]).unwrap(); + + let embedded = extract_binding(&issued.cert_pem).unwrap(); + assert!( + embedded.tls_key_authorization.is_some(), + "an authorized leaf must embed the authorization" + ); + + let binding = verify_authorized_against_key_state(&issued.cert_pem, &st).unwrap(); + assert_eq!(binding.aid, st.prefix.as_str()); + } + + #[test] + fn forged_binding_over_attackers_tls_key_is_rejected() { + // The core forgery: an attacker replays the victim's *public* KEL, so + // the binding's key-state matches a real replay — but mints the leaf + // over their own TLS key with no valid authorization. The adversarial + // verifier rejects it (the AID never signed this TLS key), even though + // the key-state binding alone would "match". + let (st, _auth) = authorized_state(&[7u8; 32]); + + // Attacker forges a binding that names the correct key-state but signs + // the SPKI with a key they DO hold — which is not the AID's key. + let attacker = Ed25519Authorizer::from_seed(&[99u8; 32], 0); + let forged_kp = rcgen::KeyPair::generate().unwrap(); + let spki = forged_kp.subject_public_key_info(); + let forged_sig = attacker.sign_tls_key(&spki).unwrap(); + // Build the cert by hand: correct key-state binding (matches replay), + // but the embedded authorization is the attacker's signature. + let binding = AuthsKeriBinding::from_key_state(&st) + .unwrap() + .with_authorization(TlsKeyAuthorization { + key_index: 0, + signature: forged_sig, + }); + let issued = + issue_with_keypair(&binding, &forged_kp, &["localhost".to_string()]).unwrap(); + + // The key-state binding "matches" the replay (forged from the public KEL)... + assert!(verify_binds_to_key_state(&issued.cert_pem, &st).is_ok()); + // ...but the AID's current key did not sign this TLS SPKI → Unauthorized. + assert!(matches!( + verify_authorized_against_key_state(&issued.cert_pem, &st), + Err(TlsCertError::Unauthorized(_)) + )); + } + + #[test] + fn stripped_authorization_is_rejected() { + // A leaf that chains to the key-state but carries NO authorization (the + // discovery-only binding) is rejected by the adversarial verifier — a + // KEL-rooted leaf must prove the AID authorized its TLS key. + let (st, _auth) = authorized_state(&[7u8; 32]); + let unauthorized = issue_kel_rooted_cert(&st, &["localhost".to_string()]).unwrap(); + assert!(matches!( + verify_authorized_against_key_state(&unauthorized.cert_pem, &st), + Err(TlsCertError::MissingAuthorization) + )); + } + + #[test] + fn stripped_binding_plain_cert_is_rejected() { + // A plain self-signed leaf (no binding extension at all) is rejected — + // MissingBinding, before any authorization check. + let (st, _auth) = authorized_state(&[7u8; 32]); + let kp = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let plain = params.self_signed(&kp).unwrap(); + assert!(matches!( + verify_authorized_against_key_state(&plain.pem(), &st), + Err(TlsCertError::MissingBinding) + )); + } + + #[test] + fn revoked_or_rotated_aid_is_rejected() { + // A leaf authorized under the AID's *old* key-state is rejected once the + // verifier replays the *current* KEL (rotated/revoked): the binding's + // key-state no longer matches the replay → BindingMismatch, before the + // authorization is even checked. + let (old_state, auth) = authorized_state(&[7u8; 32]); + let issued = + issue_authorized_kel_rooted_cert(&old_state, &auth, &["localhost".to_string()]) + .unwrap(); + + // The current key-state after a rotation: a different current key. + let rotated_key = ed25519_key(&[8u8; 32]); + let current_state = state( + old_state.prefix.as_str(), + &[&rotated_key], + "ETipRotatedAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + assert!(matches!( + verify_authorized_against_key_state(&issued.cert_pem, ¤t_state), + Err(TlsCertError::BindingMismatch(_)) + )); + } + + #[test] + fn authorization_with_out_of_range_index_is_rejected() { + // An authorization that names a current-key index the key-state does + // not have is rejected, not silently skipped. + let (st, auth) = authorized_state(&[7u8; 32]); + let kp = rcgen::KeyPair::generate().unwrap(); + let spki = kp.subject_public_key_info(); + let sig = auth.sign_tls_key(&spki).unwrap(); + let binding = AuthsKeriBinding::from_key_state(&st) + .unwrap() + .with_authorization(TlsKeyAuthorization { + key_index: 5, // out of range: single-key state + signature: sig, + }); + let issued = issue_with_keypair(&binding, &kp, &["localhost".to_string()]).unwrap(); + assert!(matches!( + verify_authorized_against_key_state(&issued.cert_pem, &st), + Err(TlsCertError::AuthorizationIndexOutOfRange { index: 5, len: 1 }) + )); + } + + #[test] + fn issuer_rejects_authorizer_signing_with_wrong_key() { + // Defense at issuance: an authorizer whose signing key does not match + // the current key it claims is caught before the leaf is emitted, so a + // miswired adapter can't mint a leaf that will only fail at the verifier. + let (st, _auth) = authorized_state(&[7u8; 32]); + // An authorizer at index 0 but holding a key that is NOT the state's key. + let wrong = Ed25519Authorizer::from_seed(&[42u8; 32], 0); + assert!(matches!( + issue_authorized_kel_rooted_cert(&st, &wrong, &["localhost".to_string()]), + Err(TlsCertError::Unauthorized(_)) + )); + } + + #[test] + fn issuer_rejects_out_of_range_authorizer_index() { + // An authorizer claiming a key index the state lacks is rejected at + // issuance, before signing. + let (st, _auth) = authorized_state(&[7u8; 32]); + let bad_index = Ed25519Authorizer::from_seed(&[7u8; 32], 9); + assert!(matches!( + issue_authorized_kel_rooted_cert(&st, &bad_index, &["localhost".to_string()]), + Err(TlsCertError::AuthorizationIndexOutOfRange { index: 9, len: 1 }) + )); + } + + #[test] + fn authorization_round_trips_through_binding_json() { + // The authorization survives canonical-JSON round-trip (the wire form + // inside the cert extension), so a verifier reads back exactly what the + // issuer embedded. + let (st, auth) = authorized_state(&[7u8; 32]); + let issued = + issue_authorized_kel_rooted_cert(&st, &auth, &["localhost".to_string()]).unwrap(); + let embedded = extract_binding(&issued.cert_pem).unwrap(); + let json = embedded.to_canonical_json(); + let back = AuthsKeriBinding::from_canonical_json(&json).unwrap(); + assert_eq!(back, embedded); + assert!(back.tls_key_authorization.is_some()); + } + } +} diff --git a/crates/auths-mobile-ffi/Cargo.lock b/crates/auths-mobile-ffi/Cargo.lock index 8e4b3298..52a4dd51 100644 --- a/crates/auths-mobile-ffi/Cargo.lock +++ b/crates/auths-mobile-ffi/Cargo.lock @@ -200,6 +200,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "zeroize", ] diff --git a/crates/auths-pairing-daemon/Cargo.toml b/crates/auths-pairing-daemon/Cargo.toml index 75a72425..884f299b 100644 --- a/crates/auths-pairing-daemon/Cargo.toml +++ b/crates/auths-pairing-daemon/Cargo.toml @@ -50,12 +50,17 @@ mdns-sd = { version = "0.18.0", optional = true } # `crypto` feature provides KeyPair; `pem` emits PEM-encoded cert + key. rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"], optional = true } zeroize = { workspace = true, optional = true } +# rustls's `ConnectionCommon::export_keying_material` is the RFC 9266 / +# RFC 5705 exporter the channel-binding adapter reads. `std` only — the +# adapter calls the exporter on a connection the caller already established, +# so no crypto provider is forced here. +rustls = { version = "0.23", default-features = false, features = ["std"], optional = true } [features] default = ["server", "mdns", "subkey-chain-v1"] server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:ring", "dep:base64", "dep:subtle", "dep:if-addrs", "dep:serde_json"] mdns = ["dep:mdns-sd"] -tls = ["dep:rcgen", "dep:zeroize"] +tls = ["dep:rcgen", "dep:zeroize", "dep:rustls"] # Pass-through to auths-crypto's FIPS / CNSA builds via auths-core (direct dep). fips = ["auths-core/fips"] cnsa = ["auths-core/cnsa"] @@ -81,6 +86,12 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } # the iOS-shape signing path end-to-end through verify_sig / decode_device_pubkey. p256 = { version = "=0.13.2", features = ["ecdsa"] } rand.workspace = true +# The channel-binding adapter test drives a real TLS 1.3 handshake to confirm +# the RFC 9266 exporter is per-session (client==server, differs across +# sessions) — the same property the interop suite's Go TLS oracle asserts. +# `aws_lc_rs` provider + `tls12` off (TLS 1.3 only) to match the daemon's +# rcgen-backed cert path. +rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs"] } [lints] workspace = true diff --git a/crates/auths-pairing-daemon/src/auth.rs b/crates/auths-pairing-daemon/src/auth.rs index 43f407f6..fbbd29be 100644 --- a/crates/auths-pairing-daemon/src/auth.rs +++ b/crates/auths-pairing-daemon/src/auth.rs @@ -270,7 +270,7 @@ pub fn verify_sig( pub fn pubkey_kid(pk: &auths_keri::KeriPublicKey) -> [u8; 16] { let mut h = Sha256::new(); match pk { - auths_keri::KeriPublicKey::Ed25519(arr) => h.update(arr), + auths_keri::KeriPublicKey::Ed25519 { key, .. } => h.update(key), auths_keri::KeriPublicKey::P256 { key, .. } => h.update(key), } let full = h.finalize(); @@ -389,9 +389,10 @@ impl PubkeyBinding { fn keri_pubkeys_equal(a: &auths_keri::KeriPublicKey, b: &auths_keri::KeriPublicKey) -> bool { match (a, b) { - (auths_keri::KeriPublicKey::Ed25519(x), auths_keri::KeriPublicKey::Ed25519(y)) => { - bool::from(x[..].ct_eq(&y[..])) - } + ( + auths_keri::KeriPublicKey::Ed25519 { key: x, .. }, + auths_keri::KeriPublicKey::Ed25519 { key: y, .. }, + ) => bool::from(x[..].ct_eq(&y[..])), ( auths_keri::KeriPublicKey::P256 { key: x, .. }, auths_keri::KeriPublicKey::P256 { key: y, .. }, @@ -528,7 +529,7 @@ mod tests { #[test] fn pubkey_binding_first_sighting_binds() { let b = PubkeyBinding::new(); - let pk = auths_keri::KeriPublicKey::Ed25519([0x11; 32]); + let pk = auths_keri::KeriPublicKey::ed25519(&[0x11; 32]).unwrap(); assert!(b.bind_or_match(&pk).is_ok()); // Same key again → OK. assert!(b.bind_or_match(&pk).is_ok()); @@ -537,8 +538,8 @@ mod tests { #[test] fn pubkey_binding_divergent_key_rejected() { let b = PubkeyBinding::new(); - let pk1 = auths_keri::KeriPublicKey::Ed25519([0x11; 32]); - let pk2 = auths_keri::KeriPublicKey::Ed25519([0x22; 32]); + let pk1 = auths_keri::KeriPublicKey::ed25519(&[0x11; 32]).unwrap(); + let pk2 = auths_keri::KeriPublicKey::ed25519(&[0x22; 32]).unwrap(); b.bind_or_match(&pk1).unwrap(); let r = b.bind_or_match(&pk2); assert!(matches!(r, Err(AuthError::KeyBindingMismatch))); @@ -547,7 +548,7 @@ mod tests { #[test] fn pubkey_binding_different_curve_rejected() { let b = PubkeyBinding::new(); - let pk_ed = auths_keri::KeriPublicKey::Ed25519([0x11; 32]); + let pk_ed = auths_keri::KeriPublicKey::ed25519(&[0x11; 32]).unwrap(); let pk_p256 = auths_keri::KeriPublicKey::P256 { key: [0x11; 33], transferable: true, diff --git a/crates/auths-pairing-daemon/src/handlers.rs b/crates/auths-pairing-daemon/src/handlers.rs index a53ae37a..96ad722a 100644 --- a/crates/auths-pairing-daemon/src/handlers.rs +++ b/crates/auths-pairing-daemon/src/handlers.rs @@ -199,7 +199,7 @@ fn verify_subkey_chain_if_present( // session carrying a chain is a client bug; reject it loudly. let subkey_compressed: &[u8] = match subkey_pubkey { auths_keri::KeriPublicKey::P256 { key, .. } => key.as_slice(), - auths_keri::KeriPublicKey::Ed25519(_) => { + auths_keri::KeriPublicKey::Ed25519 { .. } => { return Err(DaemonError::InvalidSubkeyChain { reason: "chain only supported for P-256 subkey", }); @@ -458,7 +458,7 @@ pub(crate) fn decode_device_pubkey( }); } let arr: [u8; 32] = bytes.try_into().map_err(|_| DaemonError::UnauthorizedSig)?; - Ok(auths_keri::KeriPublicKey::Ed25519(arr)) + auths_keri::KeriPublicKey::ed25519(&arr).map_err(|_| DaemonError::UnauthorizedSig) } CurveTag::P256 => { if bytes.len() != 33 { @@ -520,7 +520,7 @@ mod decode_device_pubkey_tests { fn routes_ed25519_by_curve_tag() { let r = req(&[0xAB; 32], CurveTag::Ed25519); let key = decode_device_pubkey(&r).expect("32-byte Ed25519 must accept"); - assert!(matches!(key, auths_keri::KeriPublicKey::Ed25519(_))); + assert!(matches!(key, auths_keri::KeriPublicKey::Ed25519 { .. })); } #[test] diff --git a/crates/auths-pairing-daemon/src/tls.rs b/crates/auths-pairing-daemon/src/tls.rs index 5c165a8b..4f945fa0 100644 --- a/crates/auths-pairing-daemon/src/tls.rs +++ b/crates/auths-pairing-daemon/src/tls.rs @@ -31,6 +31,11 @@ use sha2::{Digest, Sha256}; +use auths_pairing_protocol::{ + ChannelBinding, ChannelBindingError, ChannelBindingProvider, TLS_EXPORTER_LABEL, + TLS_EXPORTER_LEN, +}; + use crate::error::DaemonError; /// Cert + key material for a TLS-enabled daemon session, plus the @@ -117,6 +122,43 @@ impl DaemonError { } } +/// Adapter: extract the RFC 9266 `tls-exporter` channel binding from a live +/// `rustls` connection. +/// +/// This is the concrete TLS-stack side of the +/// [`ChannelBindingProvider`] port. The pairing protocol stays +/// transport-agnostic; this function is the only place that knows the binding +/// is sourced from `rustls`. It exports keying material under the registered +/// label [`TLS_EXPORTER_LABEL`] with an **absent** context (RFC 5705 +/// distinguishes absent from empty; RFC 9266 specifies no context) and length +/// [`TLS_EXPORTER_LEN`], matching what any stock TLS stack — Go `crypto/tls`, +/// OpenSSL, BoringSSL — produces for the same connection. +/// +/// `conn` is the connection's [`rustls::ConnectionCommon`], reachable from +/// either a server or client connection (and from a `tokio_rustls` stream via +/// its `get_ref().1`). The handshake MUST be complete; before it is, rustls +/// returns an error and this surfaces [`ChannelBindingError::ExporterUnavailable`] +/// so the caller fails closed rather than minting an unbound, relay-able proof. +pub fn rustls_channel_binding( + conn: &rustls::ConnectionCommon, +) -> Result { + let mut material = [0u8; TLS_EXPORTER_LEN]; + conn.export_keying_material(&mut material, TLS_EXPORTER_LABEL, None) + .map_err(|e| ChannelBindingError::ExporterUnavailable(e.to_string()))?; + ChannelBinding::from_exporter(&material) +} + +/// Newtype wrapper so a `rustls` connection can be passed wherever a +/// [`ChannelBindingProvider`] is expected, keeping the protocol core free of +/// any TLS-stack type. +pub struct RustlsChannelBinding<'a, D>(pub &'a rustls::ConnectionCommon); + +impl ChannelBindingProvider for RustlsChannelBinding<'_, D> { + fn channel_binding(&self) -> Result { + rustls_channel_binding(self.0) + } +} + #[cfg(test)] mod tests { use super::*; @@ -140,4 +182,151 @@ mod tests { // Fresh keypair each time → different SPKI. assert_ne!(a.spki_sha256, b.spki_sha256); } + + // ---- channel-binding adapter (RFC 9266 tls-exporter) over real rustls ---- + + use std::sync::Arc; + + use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; + use rustls::{ClientConfig, ClientConnection, ServerConfig, ServerConnection}; + + /// Accept any server cert — the test only exercises the exporter, and the + /// pairing protocol pins the SPKI out-of-band, not via a CA. + #[derive(Debug)] + struct NoVerify; + + impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + use rustls::SignatureScheme::*; + vec![ECDSA_NISTP256_SHA256, ED25519, RSA_PSS_SHA256] + } + } + + fn provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) + } + + /// Drive a TLS 1.3 handshake between a fresh server and client connection + /// over in-memory buffers, then return both ends' channel bindings. + fn handshake_bindings() -> (ChannelBinding, ChannelBinding) { + // Fresh self-signed cert (rcgen) for the server. + let mat = generate_tls_material(&["localhost".to_string()]).expect("cert"); + let certs = rustls_pemcert(&mat.cert_pem); + let key = rustls_pemkey(&mat.key_pem); + + let server_cfg = ServerConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("server cfg"); + let client_cfg = ClientConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + + let mut server = ServerConnection::new(Arc::new(server_cfg)).unwrap(); + let mut client = ClientConnection::new( + Arc::new(client_cfg), + ServerName::try_from("localhost").unwrap(), + ) + .unwrap(); + + // Pump bytes until both handshakes complete. + for _ in 0..16 { + if !client.is_handshaking() && !server.is_handshaking() { + break; + } + transfer(&mut client, &mut server); + let _ = server.process_new_packets().unwrap(); + transfer(&mut server, &mut client); + let _ = client.process_new_packets().unwrap(); + } + assert!(!client.is_handshaking(), "client handshake stalled"); + assert!(!server.is_handshaking(), "server handshake stalled"); + + let cb_client = rustls_channel_binding(&client).expect("client binding"); + let cb_server = rustls_channel_binding(&server).expect("server binding"); + (cb_client, cb_server) + } + + fn transfer( + from: &mut rustls::ConnectionCommon, + to: &mut rustls::ConnectionCommon, + ) { + let mut buf = Vec::new(); + while from.wants_write() { + from.write_tls(&mut buf).unwrap(); + } + let mut cursor = std::io::Cursor::new(buf); + while (cursor.position() as usize) < cursor.get_ref().len() { + to.read_tls(&mut cursor).unwrap(); + } + } + + fn rustls_pemcert(pem: &str) -> Vec> { + use rustls::pki_types::pem::PemObject; + CertificateDer::pem_slice_iter(pem.as_bytes()) + .collect::, _>>() + .expect("cert pem") + } + + fn rustls_pemkey(pem: &str) -> PrivateKeyDer<'static> { + use rustls::pki_types::pem::PemObject; + PrivateKeyDer::from_pem_slice(pem.as_bytes()).expect("key pem") + } + + #[test] + fn exporter_binding_is_equal_across_the_same_session() { + // RFC 9266: both endpoints of one TLS connection derive the SAME + // exporter value. This is what lets the two pairing ends agree on a + // binding without transmitting it. + let (client, server) = handshake_bindings(); + assert_eq!( + client, server, + "client and server of the same TLS session must derive the same binding" + ); + } + + #[test] + fn exporter_binding_differs_across_sessions() { + // RFC 9266: two independent TLS connections derive DIFFERENT exporter + // values — the per-session property the anti-relay check rests on, and + // the behavior any conformant stock TLS stack exhibits. + let (c1, _s1) = handshake_bindings(); + let (c2, _s2) = handshake_bindings(); + assert_ne!( + c1, c2, + "two independent TLS sessions must derive distinct bindings" + ); + } } diff --git a/crates/auths-pairing-protocol/Cargo.toml b/crates/auths-pairing-protocol/Cargo.toml index 2c503cb8..24466dda 100644 --- a/crates/auths-pairing-protocol/Cargo.toml +++ b/crates/auths-pairing-protocol/Cargo.toml @@ -20,6 +20,9 @@ base64.workspace = true serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" zeroize.workspace = true +# Constant-time equality for the per-connection channel binding, so a relay +# attacker learns nothing from comparison timing. +subtle.workspace = true chrono = { version = "0.4", features = ["serde"] } thiserror.workspace = true hex = "0.4" diff --git a/crates/auths-pairing-protocol/src/channel_binding.rs b/crates/auths-pairing-protocol/src/channel_binding.rs new file mode 100644 index 00000000..9acb4c23 --- /dev/null +++ b/crates/auths-pairing-protocol/src/channel_binding.rs @@ -0,0 +1,225 @@ +//! TLS channel binding for session proofs (anti-relay). +//! +//! A pairing session runs *inside* a TLS connection, but the cryptographic +//! proofs it carries (the AEAD-sealed [`crate::envelope::Envelope`]s) are, by +//! themselves, transport-agnostic: a proof captured on one TLS connection and +//! replayed onto a *different* TLS connection would still open, because nothing +//! ties the proof to the channel it was minted on. That is the classic +//! relay / MITM attack — an attacker terminates the victim's TLS session, +//! lifts a valid proof off it, and presents it on its own session to a +//! third party. +//! +//! The fix is **channel binding**: fold a per-connection secret that *only the +//! two TLS endpoints know* into the proof, so a proof minted on channel A +//! cannot be opened on channel B. The secret is the **TLS exporter** — +//! exported keying material per RFC 5705 (TLS ≤1.2) / RFC 8446 §7.5 (TLS 1.3), +//! using the RFC 9266 `tls-exporter` label. Both endpoints of a TLS connection +//! derive the *same* exporter value; two independent connections derive +//! *different* values. Folding it into the envelope key (and AAD) makes the +//! proof open only on the channel that minted it. This is the same shape as +//! token-binding (RFC 8471) and DPoP (RFC 9449): a possession proof scoped to +//! its transport. +//! +//! # Wire / interop parameters (NORMATIVE) +//! +//! - **Label:** [`TLS_EXPORTER_LABEL`] = `EXPORTER-Channel-Binding` (RFC 9266 +//! §3). This is the registered label a stock TLS stack uses for the +//! `tls-exporter` channel binding; matching it byte-for-byte is what lets an +//! auths endpoint interoperate with any RFC 9266 peer. +//! - **Context:** *absent* (not empty). RFC 5705 distinguishes an absent +//! context from a zero-length one; RFC 9266 specifies the exporter with no +//! context value, so the adapter MUST pass "no context", not `b""`. +//! - **Length:** [`TLS_EXPORTER_LEN`] = 32 bytes. +//! +//! # Ports and adapters +//! +//! This crate is transport-agnostic (no TLS stack dependency). The act of +//! *extracting* the exporter from a live connection is therefore a **port**: +//! [`ChannelBindingProvider`]. The TLS-aware crates (the pairing daemon, the +//! CLI LAN server, the mobile client) implement it as a thin **adapter** over +//! their concrete stack's `export_keying_material` — `rustls`'s +//! `ConnectionCommon::export_keying_material`, OpenSSL's +//! `SslRef::export_keying_material`, Go's `ConnectionState.ExportKeyingMaterial`. +//! The core protocol only ever sees a parsed [`ChannelBinding`]. + +use zeroize::{Zeroize, Zeroizing}; + +/// RFC 9266 §3 exporter label for the `tls-exporter` channel binding. +/// +/// A stock TLS stack producing a `tls-exporter` channel binding exports keying +/// material under exactly this label. Byte-identical use here is what makes an +/// auths endpoint's binding match an arbitrary RFC 9266 peer's. +pub const TLS_EXPORTER_LABEL: &[u8] = b"EXPORTER-Channel-Binding"; + +/// Length, in bytes, of the exported keying material used as the binding. +/// +/// 32 bytes = 256 bits, matching the suite's TLS oracle and leaving no +/// shortfall against the AEAD key it is folded into. +pub const TLS_EXPORTER_LEN: usize = 32; + +/// HKDF `info` domain separator for folding a channel binding into a derived +/// key. Distinct from every other label in [`crate::domain_separation`] so a +/// channel-bound key can never collide with an unbound one. +pub const CHANNEL_BINDING_INFO: &[u8] = b"auths-pairing-channel-binding-v1"; + +/// Errors from parsing a channel binding or extracting one from a transport. +#[derive(Debug, thiserror::Error)] +pub enum ChannelBindingError { + /// The exporter material was not exactly [`TLS_EXPORTER_LEN`] bytes. + #[error("channel binding must be {expected} bytes of TLS exporter material, got {got}")] + WrongLength { + /// Required length ([`TLS_EXPORTER_LEN`]). + expected: usize, + /// Length actually supplied. + got: usize, + }, + + /// The underlying TLS stack refused to export keying material (no + /// handshake completed, the connection is not TLS 1.3-capable, or the + /// exporter is otherwise unavailable). A session that cannot produce a + /// binding MUST NOT fall back to an unbound proof — that would silently + /// reopen the relay hole. Surface this and refuse. + #[error("TLS exporter unavailable from transport: {0}")] + ExporterUnavailable(String), +} + +/// A parsed TLS channel binding: the RFC 9266 `tls-exporter` keying material +/// for one connection. +/// +/// Parse, don't validate: the only way to hold a `ChannelBinding` is through +/// [`ChannelBinding::from_exporter`], which enforces the length. Downstream +/// code (the envelope key derivation) can therefore trust the bytes without +/// re-checking. Two `ChannelBinding`s compare equal iff their exporter bytes +/// match — i.e. iff they came from the *same* TLS connection. The comparison +/// is constant-time so a relay attacker learns nothing from timing. +#[derive(Clone)] +pub struct ChannelBinding { + exporter: Zeroizing<[u8; TLS_EXPORTER_LEN]>, +} + +impl ChannelBinding { + /// Parse raw TLS exporter material into a channel binding. + /// + /// `material` MUST be the keying material a TLS stack exported under + /// [`TLS_EXPORTER_LABEL`] with an *absent* context and length + /// [`TLS_EXPORTER_LEN`] — see the module docs for the normative + /// parameters. Any other length is rejected so an invalid binding is + /// unrepresentable past this boundary. + pub fn from_exporter(material: &[u8]) -> Result { + if material.len() != TLS_EXPORTER_LEN { + return Err(ChannelBindingError::WrongLength { + expected: TLS_EXPORTER_LEN, + got: material.len(), + }); + } + let mut buf = [0u8; TLS_EXPORTER_LEN]; + buf.copy_from_slice(material); + let cb = Self { + exporter: Zeroizing::new(buf), + }; + buf.zeroize(); + Ok(cb) + } + + /// The raw exporter bytes, for folding into a key derivation as HKDF + /// `info`-adjacent material. Not part of any serialized wire format — the + /// binding never travels; each endpoint recomputes it from its own TLS + /// connection. + pub fn as_bytes(&self) -> &[u8; TLS_EXPORTER_LEN] { + &self.exporter + } +} + +// Manual `Debug` that redacts the exporter. The value is a per-connection +// secret; logging it would hand a relay attacker the binding it needs to +// recompute. +impl std::fmt::Debug for ChannelBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChannelBinding") + .field( + "exporter", + &format_args!("<{TLS_EXPORTER_LEN} bytes redacted>"), + ) + .finish() + } +} + +/// Constant-time equality: equal iff the two bindings come from the same TLS +/// connection. Constant-time so a relay attacker probing "is my forged binding +/// close to the real one?" learns nothing from response timing. +impl PartialEq for ChannelBinding { + fn eq(&self, other: &Self) -> bool { + use subtle::ConstantTimeEq; + self.exporter.ct_eq(other.exporter.as_ref()).into() + } +} + +impl Eq for ChannelBinding {} + +/// Port: extract the channel binding from a live transport. +/// +/// Implemented by the TLS-aware crates as an adapter over their concrete +/// stack. The core protocol depends only on this trait, never on a TLS +/// library — ports and adapters at the transport edge. +/// +/// The adapter MUST call its stack's keying-material exporter with +/// [`TLS_EXPORTER_LABEL`], an *absent* context, and length +/// [`TLS_EXPORTER_LEN`], then hand the bytes to +/// [`ChannelBinding::from_exporter`]. An adapter that cannot produce a binding +/// (handshake incomplete, not TLS 1.3) MUST return +/// [`ChannelBindingError::ExporterUnavailable`] — never a placeholder — so the +/// caller fails closed instead of minting an unbound, relay-able proof. +pub trait ChannelBindingProvider { + /// The current connection's RFC 9266 channel binding. + fn channel_binding(&self) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_wrong_length() { + let err = ChannelBinding::from_exporter(&[0u8; 16]).unwrap_err(); + assert!(matches!( + err, + ChannelBindingError::WrongLength { + expected: TLS_EXPORTER_LEN, + got: 16 + } + )); + } + + #[test] + fn same_exporter_is_equal() { + let a = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap(); + let b = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn different_exporter_is_unequal() { + // Two TLS connections export different keying material → distinct + // bindings. This is the property the anti-relay check rests on. + let a = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap(); + let b = ChannelBinding::from_exporter(&[0x22; TLS_EXPORTER_LEN]).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn label_is_the_rfc9266_registered_value() { + // Lock the wire label: a drift here silently breaks interop with every + // stock TLS stack producing a `tls-exporter` binding. + assert_eq!(TLS_EXPORTER_LABEL, b"EXPORTER-Channel-Binding"); + assert_eq!(TLS_EXPORTER_LEN, 32); + } + + #[test] + fn debug_redacts_exporter() { + let cb = ChannelBinding::from_exporter(&[0xAB; TLS_EXPORTER_LEN]).unwrap(); + let s = format!("{cb:?}"); + assert!(s.contains("redacted")); + assert!(!s.contains("ab")); + assert!(!s.contains("AB")); + } +} diff --git a/crates/auths-pairing-protocol/src/envelope.rs b/crates/auths-pairing-protocol/src/envelope.rs index 27fdb9a9..bac342d5 100644 --- a/crates/auths-pairing-protocol/src/envelope.rs +++ b/crates/auths-pairing-protocol/src/envelope.rs @@ -17,8 +17,17 @@ //! `CryptoProvider::aead_{encrypt,decrypt}` (fn-128.T2) so the swap is //! automatic. //! - **AAD layout (length-prefixed):** -//! `u32_be(len(session_id)) || session_id || u32_be(len(path)) || path || u32_be(counter)`. +//! `u32_be(len(session_id)) || session_id || u32_be(len(path)) || path || +//! u32_be(len(channel_binding)) || channel_binding || u32_be(counter)`. //! Naive concatenation is forbidden (USENIX'23 AEAD-confusion). +//! - **Channel binding (anti-relay):** the session is pinned to the TLS +//! connection it runs on via the RFC 9266 `tls-exporter` keying material +//! (see [`crate::channel_binding`]). The exporter is folded into BOTH the +//! envelope key derivation (so two channels derive different keys) AND the +//! AAD (so a cross-channel open is rejected explicitly). An envelope sealed +//! on channel A therefore cannot be opened on channel B — a relayed proof is +//! refused. This is the load-bearing negative property; without it the +//! envelope is transport-agnostic and a captured proof replays. //! - **Max messages per session:** 1024. More than any pairing flow needs; //! abort with [`EnvelopeError::SessionExhausted`] at the cap. //! - **API is async** to match `CryptoProvider`'s async trait surface; @@ -35,6 +44,7 @@ use zeroize::{Zeroize, Zeroizing}; use auths_crypto::default_provider; +use crate::channel_binding::{CHANNEL_BINDING_INFO, ChannelBinding, TLS_EXPORTER_LEN}; use crate::domain_separation::ENVELOPE_INFO; use crate::sas::TransportKey; @@ -65,6 +75,13 @@ pub enum EnvelopeError { #[error("envelope AAD mismatch")] AadMismatch, + /// The envelope was sealed on a different TLS channel than the one it is + /// being opened on — its RFC 9266 channel binding does not match this + /// session's. This is the anti-relay rejection: a proof captured on one + /// TLS connection and replayed onto another is refused here. + #[error("envelope channel-binding mismatch — proof relayed onto a different TLS channel")] + ChannelBindingMismatch, + /// Session has reached the message cap; must renegotiate a fresh /// `TransportKey`. #[error("envelope session exhausted (>{MAX_MESSAGES_PER_SESSION} messages)")] @@ -94,6 +111,12 @@ pub struct Envelope { payload: Vec, aad_session_id: String, aad_path: String, + /// The RFC 9266 channel binding this envelope was sealed under. Carried + /// for the explicit same-process anti-relay check in [`EnvelopeSession::open`]; + /// it is NOT a wire field — across a transport each endpoint recomputes its + /// own binding from its own TLS connection, and the binding is folded into + /// the key + AAD so a cross-channel open fails cryptographically regardless. + aad_channel_binding: [u8; TLS_EXPORTER_LEN], _state: PhantomData, } @@ -158,10 +181,15 @@ pub struct EnvelopeSession { next_counter: u32, last_opened_counter: Option, session_id: String, + /// The TLS channel this session is bound to (RFC 9266 `tls-exporter`). + /// Folded into the key derivation and every AAD; an envelope sealed under a + /// different binding cannot be opened here. + channel_binding: ChannelBinding, } impl EnvelopeSession { - /// Derive a fresh envelope session from a `TransportKey`. + /// Derive a fresh envelope session from a `TransportKey`, bound to a TLS + /// channel. /// /// Args: /// * `transport_key`: the session's transport key from @@ -169,20 +197,40 @@ impl EnvelopeSession { /// * `session_id`: the pairing session id (part of every AAD). /// * `iv`: per-session 96-bit IV. Produce via `OsRng` at session start; /// transmit out-of-band to the peer. + /// * `channel_binding`: the RFC 9266 `tls-exporter` value for the TLS + /// connection this session runs over (from a + /// [`crate::channel_binding::ChannelBindingProvider`] adapter). The + /// binding is folded into the envelope key so two TLS channels derive + /// different keys; a proof minted on one channel cannot open on another. + /// There is no unbound constructor on purpose — an unbound envelope is + /// relay-able, so the channel binding is a required argument, not an + /// option. /// /// Usage: /// ```ignore - /// let session = EnvelopeSession::new(&transport_key, session_id, iv).await?; + /// let cb = tls_conn.channel_binding()?; // ChannelBindingProvider adapter + /// let session = EnvelopeSession::new(&transport_key, session_id, iv, cb).await?; /// let env = session.seal("/v1/pairing/sessions/x/response", pt).await?; /// ``` pub async fn new( transport_key: &TransportKey, session_id: String, iv: [u8; 12], + channel_binding: ChannelBinding, ) -> Result { let provider = default_provider(); + // Fold the channel binding into the key-derivation `info` so the + // envelope key is unique per (transport key, TLS channel) pair. Two + // sessions over different TLS connections derive different keys even + // with an identical transport key — the cryptographic half of the + // anti-relay guarantee. + let mut info = + Vec::with_capacity(ENVELOPE_INFO.len() + CHANNEL_BINDING_INFO.len() + TLS_EXPORTER_LEN); + info.extend_from_slice(ENVELOPE_INFO); + info.extend_from_slice(CHANNEL_BINDING_INFO); + info.extend_from_slice(channel_binding.as_bytes()); let okm = provider - .hkdf_sha256_expand(transport_key.as_bytes(), &[], ENVELOPE_INFO, 32) + .hkdf_sha256_expand(transport_key.as_bytes(), &[], &info, 32) .await .map_err(|e| EnvelopeError::KeyDerivation(e.to_string()))?; let mut key_bytes = [0u8; 32]; @@ -193,6 +241,7 @@ impl EnvelopeSession { next_counter: 1, last_opened_counter: None, session_id, + channel_binding, }) } @@ -209,7 +258,12 @@ impl EnvelopeSession { self.next_counter = self.next_counter.wrapping_add(1); let nonce = nonce_for_counter(&self.iv, counter); - let aad = build_aad(&self.session_id, path, counter); + let aad = build_aad( + &self.session_id, + path, + self.channel_binding.as_bytes(), + counter, + ); let provider = default_provider(); let ct = provider .aead_encrypt(&self.key, &nonce, &aad, plaintext) @@ -222,6 +276,7 @@ impl EnvelopeSession { payload: ct, aad_session_id: self.session_id.clone(), aad_path: path.to_string(), + aad_channel_binding: *self.channel_binding.as_bytes(), _state: PhantomData, }) } @@ -252,7 +307,28 @@ impl EnvelopeSession { if self.session_id != env.aad_session_id { return Err(EnvelopeError::AadMismatch); } - let aad = build_aad(&self.session_id, path, env.counter); + // Anti-relay: refuse a proof minted on a different TLS channel. The + // constant-time compare avoids leaking how close a forged binding is. + // Even if this explicit check were bypassed, the binding is folded into + // the key + AAD, so the AEAD `open` below would still fail — this is + // defense-in-depth with a clear diagnostic. + { + use subtle::ConstantTimeEq; + let matches: bool = self + .channel_binding + .as_bytes() + .ct_eq(&env.aad_channel_binding) + .into(); + if !matches { + return Err(EnvelopeError::ChannelBindingMismatch); + } + } + let aad = build_aad( + &self.session_id, + path, + self.channel_binding.as_bytes(), + env.counter, + ); let provider = default_provider(); let pt = provider @@ -267,6 +343,7 @@ impl EnvelopeSession { payload: pt, aad_session_id: env.aad_session_id, aad_path: env.aad_path, + aad_channel_binding: env.aad_channel_binding, _state: PhantomData, }) } @@ -288,14 +365,17 @@ fn nonce_for_counter(iv: &[u8; 12], counter: u32) -> [u8; 12] { nonce } -fn build_aad(session_id: &str, path: &str, counter: u32) -> Vec { +fn build_aad(session_id: &str, path: &str, channel_binding: &[u8], counter: u32) -> Vec { let sid = session_id.as_bytes(); let p = path.as_bytes(); - let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4); + let cb = channel_binding; + let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4 + cb.len() + 4); aad.extend_from_slice(&(sid.len() as u32).to_be_bytes()); aad.extend_from_slice(sid); aad.extend_from_slice(&(p.len() as u32).to_be_bytes()); aad.extend_from_slice(p); + aad.extend_from_slice(&(cb.len() as u32).to_be_bytes()); + aad.extend_from_slice(cb); aad.extend_from_slice(&counter.to_be_bytes()); aad } @@ -303,8 +383,16 @@ fn build_aad(session_id: &str, path: &str, counter: u32) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::channel_binding::ChannelBinding; use crate::sas::TransportKey; + // A stand-in TLS exporter for one channel. Two distinct values model two + // distinct TLS connections (the per-session property the Go TLS oracle + // confirms for the RFC 9266 `tls-exporter`). + fn channel(byte: u8) -> ChannelBinding { + ChannelBinding::from_exporter(&[byte; TLS_EXPORTER_LEN]).unwrap() + } + fn session_with_transport_key() -> (TransportKey, [u8; 12], String) { let tk = TransportKey::new([0xA5; 32]); let iv = [0x07; 12]; @@ -315,11 +403,18 @@ mod tests { #[tokio::test] async fn seal_open_round_trip() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - // Fresh receiver derives the same key from the same transport key + iv. - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + // Same TLS channel on both ends → same binding → opens. + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender .seal("/v1/pairing/sessions/x/response", b"hello world") @@ -332,13 +427,84 @@ mod tests { assert_eq!(opened.plaintext(), b"hello world"); } + /// The load-bearing anti-relay test (RFC 9266 / Track B T0). A proof sealed + /// on TLS channel A and replayed onto a session bound to TLS channel B is + /// REJECTED — exactly the relay/MITM the channel binding exists to stop. + #[tokio::test] + async fn proof_relayed_onto_different_channel_is_rejected() { + let (tk, iv, sid) = session_with_transport_key(); + // Channel A mints the proof. + let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA)) + .await + .unwrap(); + // Channel B (a different TLS connection → different exporter) is where + // the attacker replays it. + let mut on_channel_b = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xBB), + ) + .await + .unwrap(); + + let proof = on_channel_a.seal("/p", b"valid-proof").await.unwrap(); + let err = on_channel_b.open("/p", proof).await.unwrap_err(); + assert!( + matches!(err, EnvelopeError::ChannelBindingMismatch), + "a proof relayed onto a foreign TLS channel must be rejected, got {err:?}" + ); + } + + /// Even if the explicit binding check were stripped, the binding is folded + /// into the key derivation, so a forged envelope that *claims* the receiver's + /// binding but was keyed under a different channel still fails the AEAD. + #[tokio::test] + async fn forged_binding_label_still_fails_aead() { + let (tk, iv, sid) = session_with_transport_key(); + let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA)) + .await + .unwrap(); + let mut on_channel_b = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xBB), + ) + .await + .unwrap(); + + let proof = on_channel_a.seal("/p", b"valid-proof").await.unwrap(); + // Attacker rewrites the carried binding to match the victim channel B, + // hoping to pass the explicit equality check. The key it was sealed + // under is still channel A's, so the AEAD tag fails. + let forged = Envelope { + nonce: proof.nonce, + counter: proof.counter, + payload: proof.payload, + aad_session_id: proof.aad_session_id, + aad_path: proof.aad_path, + aad_channel_binding: [0xBB; TLS_EXPORTER_LEN], + _state: PhantomData::, + }; + let err = on_channel_b.open("/p", forged).await.unwrap_err(); + assert!(matches!(err, EnvelopeError::TagMismatch)); + } + #[tokio::test] async fn tampered_tag_yields_tag_mismatch() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender.seal("/path", b"payload").await.unwrap(); // Tamper the last byte (part of the Poly1305 tag). @@ -351,6 +517,7 @@ mod tests { payload: ct, aad_session_id: env.aad_session_id, aad_path: env.aad_path, + aad_channel_binding: env.aad_channel_binding, _state: PhantomData::, }; let err = receiver.open("/path", tampered).await.unwrap_err(); @@ -360,10 +527,17 @@ mod tests { #[tokio::test] async fn aad_path_mismatch_yields_aad_mismatch_or_tag_mismatch() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender.seal("/path-a", b"payload").await.unwrap(); let err = receiver.open("/path-b", env).await.unwrap_err(); @@ -374,10 +548,17 @@ mod tests { #[tokio::test] async fn counter_rollback_rejected() { let (tk, iv, sid) = session_with_transport_key(); - let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap(); - let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) + let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0)) .await .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); // Seal three messages; open them in order. let e1 = sender.seal("/p", b"a").await.unwrap(); @@ -394,13 +575,23 @@ mod tests { async fn cross_session_key_rejected() { let iv = [0x07; 12]; let sid = "sess-cross".to_string(); - let mut sender = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv) - .await - .unwrap(); + let mut sender = EnvelopeSession::new( + &TransportKey::new([0xA5; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); // Different transport key ⇒ different derived envelope key. - let mut receiver = EnvelopeSession::new(&TransportKey::new([0x5A; 32]), sid.clone(), iv) - .await - .unwrap(); + let mut receiver = EnvelopeSession::new( + &TransportKey::new([0x5A; 32]), + sid.clone(), + iv, + channel(0xC0), + ) + .await + .unwrap(); let env = sender.seal("/p", b"payload").await.unwrap(); let err = receiver.open("/p", env).await.unwrap_err(); diff --git a/crates/auths-pairing-protocol/src/lib.rs b/crates/auths-pairing-protocol/src/lib.rs index 2670f2a7..06609a95 100644 --- a/crates/auths-pairing-protocol/src/lib.rs +++ b/crates/auths-pairing-protocol/src/lib.rs @@ -34,6 +34,7 @@ // ECDH secret or the transport key. #![forbid(unsafe_code)] +pub mod channel_binding; pub mod domain_separation; pub mod envelope; mod error; @@ -45,6 +46,10 @@ pub mod sas; mod token; pub mod types; +pub use channel_binding::{ + CHANNEL_BINDING_INFO, ChannelBinding, ChannelBindingError, ChannelBindingProvider, + TLS_EXPORTER_LABEL, TLS_EXPORTER_LEN, +}; pub use envelope::{ Envelope, EnvelopeError, EnvelopeSession, MAX_MESSAGES_PER_SESSION, Open, Sealed, }; diff --git a/crates/auths-pairing-protocol/src/response.rs b/crates/auths-pairing-protocol/src/response.rs index 6df6fd19..01e11221 100644 --- a/crates/auths-pairing-protocol/src/response.rs +++ b/crates/auths-pairing-protocol/src/response.rs @@ -202,7 +202,9 @@ fn build_keri_public_key(curve: CurveType, bytes: &[u8]) -> Result { let arr: [u8; 33] = bytes.try_into().map_err(|_| { diff --git a/crates/auths-pairing-protocol/src/token.rs b/crates/auths-pairing-protocol/src/token.rs index bd7b1492..ae252381 100644 --- a/crates/auths-pairing-protocol/src/token.rs +++ b/crates/auths-pairing-protocol/src/token.rs @@ -307,7 +307,9 @@ impl PairingSession { device_signing_pubkey.len() )) })?; - KeriPublicKey::Ed25519(arr) + KeriPublicKey::ed25519(&arr).map_err(|e| { + ProtocolError::KeyExchangeFailed(format!("Ed25519 pubkey invalid: {e}")) + })? } CurveType::P256 => { let arr: [u8; 33] = device_signing_pubkey.try_into().map_err(|_| { diff --git a/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs b/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs index 3747c790..bb060b9e 100644 --- a/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs +++ b/crates/auths-pairing-protocol/tests/cases/secure_envelope_vectors.rs @@ -23,12 +23,24 @@ use std::path::PathBuf; use auths_pairing_protocol::{ - Envelope, EnvelopeSession, MAX_MESSAGES_PER_SESSION, Sealed, TransportKey, + ChannelBinding, Envelope, EnvelopeSession, MAX_MESSAGES_PER_SESSION, Sealed, TLS_EXPORTER_LEN, + TransportKey, }; use serde::{Deserialize, Serialize}; const VECTORS_PATH: &str = "tests/vectors/secure_envelope.json"; +/// Fixed TLS channel-binding (RFC 9266 `tls-exporter`) used for every KAT +/// vector. Models one TLS connection. A real session derives this from its live +/// connection; the KAT pins a representative value so every implementation — +/// Rust here, the Swift mobile mirror — folds an identical binding into the +/// key + AAD and reproduces byte-identical ciphertext. +const KAT_CHANNEL_BINDING: [u8; TLS_EXPORTER_LEN] = [0x9C; TLS_EXPORTER_LEN]; + +fn kat_channel_binding() -> ChannelBinding { + ChannelBinding::from_exporter(&KAT_CHANNEL_BINDING).expect("32-byte binding") +} + /// Top-level KAT document. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct VectorFile { @@ -53,6 +65,10 @@ struct Vector { iv_hex: String, session_id: String, path: String, + /// 32 bytes (hex) — the RFC 9266 TLS `tls-exporter` channel binding the + /// envelope is sealed under. Folded into the key derivation and the AAD; + /// an implementation must use this exact value to reproduce the ciphertext. + channel_binding_hex: String, counter: u32, /// UTF-8 plaintext. Kept human-readable where possible; binary vectors /// use `plaintext_hex` instead. @@ -63,7 +79,8 @@ struct Vector { plaintext_hex: Option, /// Expected nonce (= iv XOR counter_be on last 4 bytes). nonce_hex: String, - /// Expected AAD (len-prefixed session_id || len-prefixed path || counter_be). + /// Expected AAD (len-prefixed session_id || len-prefixed path || + /// len-prefixed channel_binding || counter_be). aad_hex: String, /// Expected ciphertext || 16-byte Poly1305 tag. ciphertext_hex: String, @@ -250,7 +267,10 @@ async fn regenerate() -> VectorFile { VectorFile { envelope_info: "auths-pairing-envelope-v1".into(), aead: "chacha20poly1305".into(), - version: 1, + // v2: the envelope is now bound to the TLS channel — the RFC 9266 + // `tls-exporter` value is folded into the key derivation and the AAD, + // so both the AAD layout and the ciphertext changed from v1. + version: 2, vectors, } } @@ -275,7 +295,7 @@ async fn seal_vector( assert!((1..MAX_MESSAGES_PER_SESSION).contains(&counter)); let tk = TransportKey::new(transport_key_bytes); - let mut session = EnvelopeSession::new(&tk, session_id.to_string(), iv) + let mut session = EnvelopeSession::new(&tk, session_id.to_string(), iv, kat_channel_binding()) .await .expect("envelope session"); for _ in 1..counter { @@ -298,7 +318,12 @@ async fn seal_vector( let sealed: Envelope = session.seal(path, &pt_bytes).await.expect("seal"); let nonce_hex = hex::encode(sealed.nonce()); - let aad_hex = hex::encode(compute_expected_aad(session_id, path, counter)); + let aad_hex = hex::encode(compute_expected_aad( + session_id, + path, + &KAT_CHANNEL_BINDING, + counter, + )); let ciphertext_hex = hex::encode(sealed.ciphertext()); Vector { @@ -307,6 +332,7 @@ async fn seal_vector( iv_hex: hex::encode(iv), session_id: session_id.to_string(), path: path.to_string(), + channel_binding_hex: hex::encode(KAT_CHANNEL_BINDING), counter, plaintext_utf8: pt_utf8, plaintext_hex: pt_hex, @@ -318,14 +344,22 @@ async fn seal_vector( } } -fn compute_expected_aad(session_id: &str, path: &str, counter: u32) -> Vec { +fn compute_expected_aad( + session_id: &str, + path: &str, + channel_binding: &[u8], + counter: u32, +) -> Vec { let sid = session_id.as_bytes(); let p = path.as_bytes(); - let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4); + let cb = channel_binding; + let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4 + cb.len() + 4); aad.extend_from_slice(&(sid.len() as u32).to_be_bytes()); aad.extend_from_slice(sid); aad.extend_from_slice(&(p.len() as u32).to_be_bytes()); aad.extend_from_slice(p); + aad.extend_from_slice(&(cb.len() as u32).to_be_bytes()); + aad.extend_from_slice(cb); aad.extend_from_slice(&counter.to_be_bytes()); aad } @@ -395,7 +429,11 @@ async fn negative_vectors_reject_as_declared() { for v in file.vectors.iter().filter(|v| v.expected_result != "valid") { let tk = transport_key_from_hex(&v.transport_key_hex); let iv = iv_from_hex(&v.iv_hex); - let mut session = EnvelopeSession::new(&tk, v.session_id.clone(), iv) + let cb = ChannelBinding::from_exporter( + &hex::decode(&v.channel_binding_hex).expect("valid channel-binding hex"), + ) + .expect("32-byte channel binding"); + let mut session = EnvelopeSession::new(&tk, v.session_id.clone(), iv, cb) .await .expect("envelope session"); diff --git a/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json b/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json index 6885c1aa..8a8e7c17 100644 --- a/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json +++ b/crates/auths-pairing-protocol/tests/vectors/secure_envelope.json @@ -1,7 +1,7 @@ { "envelope_info": "auths-pairing-envelope-v1", "aead": "chacha20poly1305", - "version": 1, + "version": 2, "vectors": [ { "name": "basic_round_trip_counter_1", @@ -9,11 +9,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "valid" }, { @@ -22,11 +23,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/confirm", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 2, "plaintext_utf8": "second message", "nonce_hex": "070707070707070707070705", - "aad_hex": "00000008736573732d6b61740000001e2f76312f70616972696e672f73657373696f6e732f782f636f6e6669726d00000002", - "ciphertext_hex": "7f9ad6ac7d2dc472452ee981bc5127bdcca9a50ed19a45f0d813599e99b3", + "aad_hex": "00000008736573732d6b61740000001e2f76312f70616972696e672f73657373696f6e732f782f636f6e6669726d000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000002", + "ciphertext_hex": "36f1d7c39f73add4e9747b620c9350dc8756bf777582f8cb2fe197903903", "expected_result": "valid" }, { @@ -35,11 +37,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "1c7d845d1f745c9157defb6a375b7abb", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "6772e9cda362da48c117686e18e48824", "expected_result": "valid" }, { @@ -48,11 +51,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_hex": "42424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "c74b01101d78571d5694f4f0f3f35bc1660d66474f17fe7f9e6d9b439b51313751d3e4bd33a1f0c3b010bff7e3f65f0aefc6cfbf9c5500e9f6105df2ff5ae5c6168008e9c6f4af2aed38eaff735218b1d3b12874b07a0607f128adce1bb4103d2c3eb772709aa36533141b170e28306d436c66d99bdfc24d538bfb3eb8182469a502a5a53cd9d68177003427786c63c8f986127790caa857992dbae8e7c271cb1eea9fd5db6c5734792529f3dfa1a59c850756b36db670a53a71b3436e5d06590b3132e163c378ddcb46874ddef49d7a5ab03246391a50575dc7116b257ff3f2fb433fea79c35e26d77a3f245c5cc40e1e8fe3209c826e571be46218ebd19b8b9c99c3322b12eeeac720d40fa817bc3fb69b03ec75d6b8e8b308739d945cc1d048a2c52733472dd503ccad7bc1f98ffd6342d494c71429a82834dabbdadd0abea2593105ae395594a6e3a745bceb8a227dc32af1ab9cdc2d78a76eb0ed12b94e82ee6df9bdecbd9e9797ffc3141dcb70e8afa9d10f0691eae362c2ecf0b5a6c79747c55bab242076ec9676aa3ab576ee789cd4272718700c4986bf35737eda36a2d626dfebfcdc0d68b627ea7448452060ef52dc747255efc23a3e597275049a794872dbcd23c64a0f2edeb42d090b63e9398e022e7c425e1ff43d103b0cc4b5ef06be416141700343284e690c4ca0d96e55eab6b45c8d2e84d9753aa9403cfda0b6d9480819f6f24cb391bca404fc362882f52a4a091e041d87f18b0bbc9394956eb019777919c6631aa340e0a6f1a468a772631be373237a37c6a3e01892ecbfb1e933c920c3eab2abbea857e1e81c448d4e703f3a8454d2153c587a66a06d726daffca6303506ff0f833d5b2c7fefa68e6d4ccc944582acc113b7405cb2668e81de7a3e79ed1d0977c9ebae4233516ef96f7bc0b792e64b9d5294cf472968a1e97c13f33fbbfa983678a1504ea4295516075dd0ce9beb8ad623198566e4d5eff6c915d2ca00dd2648b703e43610828d75c15a3811b1662009108059ee0027f85537106b55d4dfa2a74add9b935ee4394b27b9f487cf4580acb597eea901c42be573744c05fdaf223a5b0de8bb26b28ba657a83ed270a562ed4b34689d13510997016ab5097e0f4ecf929c51519bf1d713ef17dd1dd9907d9173979f5c3963badc2400b6858a561aa0646a2eb08f09e1d915c69ecb143b909a40ff1bdb667b4795ab728009edc65683859ce1fb542b28c52103b2522fdd65714a49225f1457f436ebe6677884833b3e64c14ecd33d121ec9c79ec538a67422cd0e910af93c9255e419f848266b10a0a1418db04584143a652b9d266d5535cc305c62252bf2b2520cf7002e2c9f503816dcd2e1b5c98f5188cd2645d599e51acfdb035122c7c6cae3112fa3ddf8d8919ca1a7893271dfba912c0a5908bcba0862337cce1421049f484a2d5bdf0ab9e14ac6e53b3e441c3d0ec6df8a51127f28ca1cf17ac63a0e11a346aca8608d7bad86f0f373220cf9bc32554cb9fe232f5ff21b8228db4c4ea76d08e43ccc78737bc5caba99c0474fad93a0eb9115ad0c61f28a59220d2c4edc3431055708ff37e02ab6652529c9870ad549efd9faef50426efc552ac1a9308b36a14327a67b22ee68630a64895ad6f300aa3dac484836504c810367930807c5c2a461bb78eae3d7d555951a06c79b04d02cd93222a3cff6a8f00df8add5ee37ad8337c455ceadb6f1c10fce99821b7253e11344815b3fb533e4d1fa1f0479145a25f312910b6d3a77c00da55de963d289d4e19d7f024452649151f0d0eed2b82c6c59c67b71a6dcc0499758d0e7f8e085c3218dddfd21b9898fb93b27302b1a13ab8ed983bb3d564469c1413311918217d943cf2ff75bc97aa5cbea34893ca7f26a2aa674fc87aeb373323b55e5d4eabc9b20bb92b5db0261404e5e89631aefef0a7a1511532c8aeb5a6a38de5393297833c2e7f400cf92ac67c11a39b87874966176edd03991d5089434ac75a691a32ba9fffe30ed00a705b9d48dc343c06a5451dc0f5a69d9ff06d1f350e151df112674db7aab21a713593f7da2b6b9976fc652810ce8f57d269187a0339424f90d49a814f7a9724f52946a1797e2dc45580df95fa44edba83dac01abb581e7e9b4615f746ec59460950f0733e20dfc899387bb004a410e2e4df749c8a3c5df4e4d2b59f9c862ce2dbebf30d768980118c5f4270ff2875760c9513c9306accf5f876622fc6d4dea1375615e49397d7b7572046e82c23b3e1f35b7148c73fabda86a3871fa87f033a7e0809851f4c0b0cf43773a2f51a8d58fb212b6275a3f97537323169b9d214bdc3a91a2458fee5f4ed1368a24644eaaf56740716a79e8521a7038fe51c216e631ced6a8d45ef7f7a48026c8d4ac28e0639ee5da427bdce59310d9a3cd39b39c4cd1151d8241ae1771edac3b4439f1ad23d0521a6b2d8a3fe9864b9047c2c8e4d185e4c867cc3280a4a6450535363d364a5e96cd93963a8b135ca0aa5c4dc87e4f2044a8e6913cbc4eb0e5476a27068229097a2cda7e4ab152797f6dc969474dda1b426732ca15345924dd6475f25cb075d403a30a2653b982f7dff55c9b71330844ce5b8e7f59b8a2c569094e7c7e3e394f4e05e9955c49d3ee89b9b53062e210e17ceaa744bb3b82ac5ab696e669d2e8637dc8b59700642b944a68969b0f57c69d4d22f639a551171f56cbedd97737a193381fef90207b29e9ea1ecb2e3d98a61284c135d68841de938c4ce8d0b05dab0dc1383de83316de271b8dc70e50b04ddf1cf81da5a28e4a3d52ba63ff4025e2730c648fafa01fcd447dfc85a7a764afe477b78b55405d4e57501c5b2b89d23b3ff4fc576b776eb18d8f1d8c2890a09bd42d1a7687a22e8e4401f7e3af5d396979bc8d886d817b078e05abd62ab8697e467472b70522079fed21e10bef14cf2b57800f8c941631c914e4a2d62abe79c5af20b74a4f1ce48873678305c2e0717a24cb7b389a53defef9e5c0a66a492c950b411ecfaef1cf8c5009f089c5c7e818165fc6fec8497db7affba48617ed3edd812fa52e42b699d2d79751e0731b37c64516c630c7d23fc683d112e4c188625c69753dbf990b6040afc3323be4d0c5a70ed78b275bd85af6d22289ee106b403ba963a2258960c2dcd77a4db52c2ac62ebfe2fe0bee99a8b195eb6bb05bc90dcd2d783148b4d3f249eeadad5c4cd61ea5446bab5ee54cfff0e5c779082b738e3533c53b9e94256f933f368957aeedf7266c1e0268a48602e1c1cb1fcc7ef8b7dbce6973d224197642dd54d7035b52c93b7d5c370ff4bba567dc6f0cc7dbc17debb1e8ccf527d92c41cd897038d49b5b6d36276921e0da9b89c85bbe37c934e9ff18085145a2d7d5e1de37f38ea597a6422623fb7fe79cd569af018a485caee68e815a0fd449467a9f1474944093cec562b7a711132621b4ab465e8d4611357e04e1490c667fb646d813ac25da85bb50e8c10882d41cc4774713d444f26409e4330f684118f73a27aaff7be4c0e4929f01f9f073f16523b8c4e618b021b173a88219b5db780c643358f6f9ee849785bc2a58f5a3ff00dd028f60c17402ea161f100fc4e6cc009814efc22db65768e77a31925115ee1adce39918e81af5060d77ff91066ad6a87171173864cc27d50174b149f66fd8bc27cc4274c075aa289757f583e1c8208f29f8f199b4fda63828d9e3d3f333adcd784f0885aac605c86eb9b842a53227e9d7143b5c7c6a10383c40480d80b477e1a3b2665a340aec932f8e8fe5748381d04f9bfa1a9e32a618003489b45a40895ed615fb801c679039586d27cf8437f4e3d21c54bf8c531768ab04db2010a621b165407fa34d9f39cb9e0b6c84cf98aef4fb2b19696df316ec40f54241a3929125fab24eda9482f77267fadbb7d30d76e02cea2f2a9e27589547bb0bf6ff5bcd0cfa42c86dbd2ce3048b16bd373b172b283a194b45abbeffaf234c345eb182a12dbd95d1a59409473ca240b535bfccd014811d4f0aaaf1160e13825ba3bb1dcdafb6cb9d70777ee51c23b9c452ec078a9b568eb449340e62aa6652f711a7dfa3772c83231b9ac8696bd2f5f34c5d2196a11533391b45e94f27008aaac4a6d9f66a900a8525e135b74144fefe328831f65c5778a88f97ae3124c3f7d9a5e74c4cab995566f9dd485db77f2ff65e58c5528ca0c882dd6de8f5cabea81f0d79f227d89593d68e8b614db8aecadb3c3a3955afa9b8d6786635c35a814ed3dc065256b3c2129574ab5578db7b9e81d9388c562fe203509ccee7f51ff958161fa17432b9dbe97a48841633f0c3a6a00f26b9d38b5a1c69b6773a92b823b4fa07c84868c0208117c5c5db1962287eff7e01ce09b15394f265d32d17e312dcfce0dd4e089e39c72e5cdbbef5d86246bebf559a1100420be4dda4ef18973d0e89415a424947f9274c4e46e85eeaa73cfdd0242c8d94ca5d166356f36d4bffbe10fa7dfc0c8f29a4e54ac21e94552d6cb07b766e2154089cfb1b95936bb9ff905b144a20114c91b7f16639ce91a5caeb4a4f2fe2d3c5f3461f72cca25cff3fcaed123cd9c4b2df4ed518c7e29b3cc15e9a15570c5d43dbb6127d228f9b7b1e1f1df9f1d2090d3328142e2039ed047a2b821abc728bc81cf46b43a3d78d073426e40384e9d83bd52512c532322ea1f666bcd8a96f387d637bfa5d032cb83995a10b2e856cae828fe1698c6428ffdf2fbf4570498357d96c22f6e81f90f8a3438d114b9c66bb871b8c4fa43586395c7515aee353a4ae42da018a9fd924b159a1ba3e0c7a2c39d793d066aa4d9427d051ee84ab28e7cdd9e3e0594b37ab5e4b67e1be44050fb300193b5aac7a7e415e5e41407c0fe3c83ec1da09b855ec18c8c76308fa4dfaa52e8ae643ebe4ee2a87c786fdc1841f4323fe71873aee84d7101265db5a40e837f0b4e6adddc10a24dbce60555a6bbea3d11d7013cd96c6ef00269ee7007b77fb24ff4b78598835183425222a5fda0a5adb2b6ce3a61504fc90552ef9c0585a1a3b5b26bf630980edb341316c9f1c2b7347cb3b73525488210b8aecbc7d30e19ad930193302e768e595e5041e22d5ae9c263233b83c9e76a98d15a749fe112f39ff4aa8d6f89ec4de02fecbd46352cd88528f611c68f44edd7eb1d0fcd6cf2221fc301aeee9f132ed40fef26160199e6337aae58bac3cc9478d3c992acff314d735ca02e0870c07260454b567122a9f234a454540e147d3d8223a48bcb8b49a791f90ea52bf125b7e685a2c912ae60221e00b35ec1fd7d805305a3e5070d2cf0c62c1953d4a5b67a511afa602b13006a4345b533a41bc68a8ba5f3d7234f8c3790c7733244bcc45314146df44847843b375d41cd8481602929b21fa859ba216591e3e9dda7481099b85e60779996b33d462029b8e6b015e5d0f497d9cde98c66177654a5c38e7c1e418f5087bb51bb1144c9792e246fbb6462be34b95c1fb2853d2c5026da759ec43ba5d7c0dff239f383f779478a777f280ae6c4374a04e105224e595e148bd5facd39950806b2ab70801c6e64b1cfb7f91c7e03bbdda7a6485cfd61819a09d2ac0b2472af9398004fabd654f57c4fb5dea0248976496f1e0a5a3b92952bf861a8c0fb8d8f3f294f00ea3e02edeb059ddd844a1950248b93f146fa09c06771297e52c12dcb5b1cd9b9149ea73f2435190fbdd2ddafb16385f95e852f73d5823522eee575b69363270cf75de918509a51d04a2e997061e99bb81d2245143b8fcc53d97ace313f0d5e9b9acb524491a2d80534cc0a7fd511ac9a269ae888", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "c0167acb3f91dc4089a2b8122aa5381492d6a4e79f0751d1b649a19d71c09a5cc05c3e16af225a3d435b4ba35d87a19547c11985cd8fc0c79472164295215feb6359f9fb769fc33fc21dfa52395e4713a4992100dca695afd16621b1a8bc0c57b12623e940eb45335a612bc95169a8749adbe055df48a9364bd33854a501aa3f82ee5676c0ad47fa53171abf0f7658a761a94b0d95b8a7a80f8608a4c39d9301af91b0f102f332c0b6d122ccbfbf65c539ca9de71a7b0a30db425f4a59ae062a39befb34f177f77dc78d94e714396cc841ef19b813d19a3e7727782a5f8849455387a1da95a837271429dc69f000c1a8e9e82aa2e2d9bc16451ecb3f0419673af12cfaba2efcced8fff3f6b720687eca8fdb41ddd775cc51658407f262c00b39c59b4528ec753561c7d87b3a3863e660ad5aea28a65cd5a019280ccaaa1b62dce451d59859cc8c90a7193968cce2febdd29b8d71f15095594a2640a104d0f39708e8c29ba6c00c6f88acdca692d2dd5b85afe0b4523f7c98bcc9f5e567bd15de38749875b1b6465438ba2cdc936f3818ab09042d9721bd33e1f080c12a3250e761242ed559f5994b17f509fe79bff96f7b5598e56f1a1b5aa784607eeb84a78f14a07e121f9ce4d2c8cf188778d6652b30442b8eb02d4a112369a933e052057bfbabe6c67386d92e103d7f744b3cf26afe44c5081f2e0584d663de2922bcab9948f0fb577d0d86c2e2edaf0be4d88ca60b4d8918d6749ed56ed774813c8aa9ac0ff2469cf23ff56b23db3a576da4e14825e3794c8dfb059bbe3853046917f156be01e4ef7f33cf2afc1975544ed85804a01ae7a8e0458f9644d2baa4adfa2f36720f4b21d6123d0f0520488c2603b795bafa379ab9079edd9be1055f09a062cd8648f957f792a584596e926e9dfbcb3f1f14d1a0cda2437bc938f9a35df130914bd986c949c37db13bb047596f8fc506e963282b498d686d483f2bd509a137d1039ecbbe3185beb40343a775bb0f4b34b14343914d3f3bad2a0e656878cb8f0c21add789683ed647619308aa9f082706079f74cc2ff6d90ade29416afba9a27890b0b32c0ca72309a357532ec9a366703e7ce813b6dbecbb69fa6037d62d23989c87fb5e586ede31e02f5c83a782108f494087133429d5462dea47b6dd168ce998378f1453d3825a06c0dbe4e405c14831c5472fa436b8868f9dab332e31942532909ec3923102f4dda9e1b69ac384421793e43790fd0d0ca3eda6ae508baea9a5f9caaa14ccbc3269352889128f944aa27bffbc8332fb2bfe6f14b735c90c90eb035609459f2386034d64520e8045b4e1e60d9f378fe13493944099f5164a266b52bb06e298dc038b07a9b563bfbe19586e4cabaddbe77fe44abb5553073d9bdaf38c7e7d5220870fd6606f52c10fcc2da78de562acb29cb1c0678f38f327e1cb97072546c5803923ab2e57880f92ec0a60e1dede9f3c3c6cf171bea6a4949e4f93bbcdd494c21dc22e520d1b8f05172e92a24f5943b92a94e624cd259d3dcb4a1f9dd7ba91b09671d5e2be76b53520dffecc3151fc1bbb5fa4334d83571f102b38907525d05f6a15c1c9e31be3e7ff8f464ef89322cc170cfa3c363cfdcb0a0950a1113f6efb76f5ef0d54e751d1f4e72da7ffe5e1d59a118902cd3654fffeaa882fb039ca52fae890200255985de6a545d05c6ab82ca7870853c350881b0244cf2b76f268bf2711de0e6d9dc240b40c32f2dbd85b04868071e684ea7f4ecddea86d83a66e15645dcd9c8d110808e95eacdf872047baf3258f4784181abc5f0b1bb3b14efaa79fd82a597ed212529ea1a02b9f9ee0aefc45cdfdc9fcbe3001dff84069787515fca0a3be614dcbf09c73c96acf16eedd014478eed41378bd0a922f0e1d0d9d76d7e626469dd8f9eb1537c6b9ef93cf5728f7104f506913b5908595566360534bb13bcf9daca47ededf3dcffe04328a82e419ad2489792be070f7d689ed2e7c31e912f3ef3e374fc455dd3734fed345fe97dd3a7f003646baf5f61a38df8279fef6d28033c168168aa8e6b9c1439ed83c4f469e16bf04c1609a8b29db5b673b48acc02e4f22ebdf19e36ca56637972aace90c50999868fb683d31190f711e9d9185eb45eac335d656cdd54f522f47b16ec7f76a67048a4db06b2a63db7df53c5966b701b304a5b61eb66848436f9cb83969bedd97dd3f20fe27f4464952cd39adfded40ade264dcb31b5c4e9ec819c529e1ca23333e0b4a9dacdf0594a374381b1c8dac3e8f4ae038189a7a793c5cdfa031d2be4e5547abea7774c1a830a5acaa9c7eb03b27b44f6b3587fe26ee96188c82f3393d245917e76acf51a00e4a31c305b945f4c15fc2725798f379bad34631c28dab2818942ed0c6cc10694a6805043cdbe969e0a2f588053daf3c0bfb37f646673e738a42b6920dd9e64a84df6985484753a47906dfa3e7fc48ec6b5149589acc7aa49aa118d9a17efffd5455fd12aa68ae523cb5ecc1ca3615cfa139a3ca8198b5966d670b21da662c895647776fd736979484d091b03b0846380d9b1844555a204b9d0177cdd9dce64322956c681624e41c1d235fe166ab3d554fa79dee5ae59540a60b2fa58cb972052eca2bd725c270e309fd984a7682861d751718cc31968e826819fb6baa2da1f911c53d26634de5c055ca271b33f8ace8545116d906113780fbb8abfac0d6ac0a4bdc4d883d549054d29652c921c51dd31f9eb258af0b77eaa2931b4854f008328bfe36b15869fcd82fc6354d0750684f6fc526b5ddf9bd01c04d56d30c630772fa10324d81d74745c7c27150167947da80fcc739db304d34b1c0adf3cc97767fb967c7bb954e03eb10a1f1971273fa62cd0ab22bdb2329f5817213b31f290b63254236e73240ac421e464b995dde64b3f0b7db22cdbc85476036240e881a32e11d3640f626ce2d878b66023a45f0bc0e4e6a35061e11d98ccbcc842671f221dd2d0265d62d50ca22edb1b92f7f796d33fb7d76abff24872d8536522944d7c696d3934a7da3055f56b3e9df995a916013b0ae84f49232af35a52548f94a18be7f5ab897033963983eedec35f04eae431017cdd326cc8f16b02979797cab47e24a48d0a7b6ba88a568b4eec2d1d1f76d83d004e9fa7ff03e109ae17ff92bfa4becb84429a7472966241b67dff15e34f873b89db719a732eb8bf9b5279cc08b07442178ce5aeba9d230f3148dc3d4f4cc5571fdf900602c0e9d83a1922ad91a4d6c3525a3074040413330161775d8012a20dddbd678e9e0dd568a851164a131bfefc18258f5b94004ed5a97a694e62a2c6d1d352a81faf4e9ce404fcb72b07b94be7f76c669054a1c0c84260311e01eb02e385344d0b2ac41506f601f193933ffe28f8f0936214484f09e6a868ff3f79bba02d152c24b673d85f4c366a3c57e4d816e9066074d24f0f07afa73b3d01edbc743ec23cfb34b1128e23898749ed95126054f120e6bd89c4e3414759b330c1732d02b6287b6a6fb29bfc37ec632d2840cc3f63905f356d57ea9f507a0b7d88c5f0e5b61c3ff2e8b40b89c0554dfe292cdb27253b157cc512aa06100f897a571ea537c330e915d4890ea01e50d8cc2df2a0ca0e3fdf0068f5feb167663adc2880333b13049efd69cb0c806a03e91e8dacfc3895ce04cd9ec6ad9a1693b9ea9a365b991d8c22b6f5a93e3f67ce2b9ea1fb1097726565d6891aeac812b766568f652744a1d3314af4656e35092c221f38802b1cc74ec275de3f59a9d8323ac8899f3ed0c2eb2096750d126e370d7dca5fc71b5da894bea74e827fa0ee7d35c5f6c7bc8cd0f151756c954e979c86e93da3079887724142f1418e85848e0927e24d47a6d6fd6706aec0ec50ff6a40017c4d7244ba0fd19bf112a7b50e086a8cdca09ea72ce76578539f268481c6e67653991960b5ddcbe16036b6912fe17d54ea1beddf74b1d3e1a7a7736e165b3ed9abdd69d3f5089a1a5d69183302242f7fdef08b850d5b292093102e7b62835ca8a337c1cc00e90f891aab1b3013d70154abf798f770051aba54357f5971b536269eecc99938e586d26e5a9e01fff5d08044be4a10863d77152dfd43e8f1a9d251a61d522d0fb200da6bc77db1fb09d88135f75af0b94227b4ff548ca21ebbd45c0ecc88f810c874a2564b8345dc5bf1d5308963cb4301a7e05bf695a9d3c03bda52f09fa67232eb0b68ddf2021450b96a98a8b4b6e5a13c2acce6f91595c44d2fb2ac6afac2e7c53e0623f1ddd79dd17ebcdc6a4cc53e9fe2ee33b3f2f75861bb81cb5f913b51e98aec0ac5ae7af43f8f990a029bf3a67859d619f5bed0dfaa205ed7b7534c787a5b22aee8822a2aeeba5bb6073fcf2708ebedd039983ae18a339aaf1724c89a7db1ab808f0f39735e022cc87e3086321ac755078c688c10b8414a18454c852b331a0681ddcc1d2ccd1f95670de74a7453f045752a962a62411581dd97afeb2f9fce48fca393daf7d82bea01efd83633a18799e460ee0c38e210e683d5100f86f07aca0fa15e937a2e27bf88293ce8f67b35c575a85b90709f7a43425708e706bd8017507cb7fce10d290e26cbb9c1c88eed655d47f015070d7a5152fb5f964ec10a475b0e3f70b80cb9cbafabfe46c88603204bc6da59fea32cf4fcccf91817779aad4a8f95522cecfdba6544336a6b0131696b5dee59fa995106890df945be801658cc26a826027c875a3a16f42ed5fab09afb1a054bf5abc4198827698006ce38a8271a7c39001882ad3c4d36da3be063b64d2947ce79ac89b336607caf54f463d25198393d4c3739b474278d84718b4d16959108576e4ab815bc026660ba227ef179d976460b1caccf4ef24cd1d30b3e02e09f2473f12e373f76ef2dae947dc3ce0ea97296b1006e1f01ac607ccd837d3cb8a3b13568d9ef2ea1b37eea88ccd6fa9a1adf26d1d5ba2120f566652d8ff8722a966c2e621fdc14699faa2f2c0f80e7d2b62a6398cee2b2901d082947edca126a49bcef8e1623695c689a439e8390cc54419104d1437bdcf9be512d551aee202810af12bf7c3e0b4b906b96afbbd3629d5cc61d26798c1bf86c26aca9440abfec6fa056aeab1cacbd8ba0e748f95a20173ba01b9797790ac1af9ddd5a3bb74cf6a0c7d269e000ab0888bfe8574e914a3dda79c3c4ccec618fc419a657d9c89fb60143e563f79ddba88288a94bdfffc95cda332094384dff425b448f39ccf52e718de7ddb7b35177ecdfe113172cc27d61284eb1fa983a974fa0b57ab76da44021bfa61ca59b31825d691e9bccc4de7781a22ab0f30f3f12136562d1d4dd827b1f831efd93fb1c77b947694d6e19c02182819fb3b82bc4b48355ffe929de0567c75182d000aaa0666fac072491fbabddf381f182b0bbabcc0e23dd4202b49d12ca81a149d6e84e094f616b981cd132fe2ae3355403a98b97982cba18a2ea34f131523b3b8958647a2f70db7aed0f86e69ddb62921e601bcba56c41182cec519f83d46ffb6cff271750ca14f8ee7730ff9bca87c81775627ee8eaa19df88fba051c2959bb8d83f7138663a1dd3227333e670e1cd72b03c30113283d35921604c71da01bfdd736bed451ecba52e71061ada9ae65a52d6d9aec97a0b2d0c86cb2ae6d9b16633c88c129dcd6ef0c12cfd6db51e330e5e9640f180ab24f67ecb71001c414e1a7b0b6a23f3defdf1214026a184f8e0cb4972263e40918102620639565a3176c0158982ea061863ea29b8d43990cad20a262680fc060f61167db5c6d25d697fb8bab32df6782aea2fe0f80680886aec8d4e8c398d", "expected_result": "valid" }, { @@ -61,11 +65,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/échelle/🌀/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "utf-8 path binding", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001a2f76312fc3a96368656c6c652ff09f8c802f726573706f6e736500000001", - "ciphertext_hex": "f07d257f671a653e60be96d0d8df7dea4a2824d33899225044928c045cfc2ccd7ba7", + "aad_hex": "00000008736573732d6b61740000001a2f76312fc3a96368656c6c652ff09f8c802f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "f7205ea445f3ee63bf88da3201891e3fbef3b807f92b52cf8402a6b1f209bf5aae9a", "expected_result": "valid" }, { @@ -74,11 +79,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9ae", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d588", "expected_result": "tag-mismatch", "mutation": "flipped last byte of ciphertext||tag" }, @@ -88,11 +94,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ec6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "eb3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "tag-mismatch", "mutation": "flipped first byte of ciphertext" }, @@ -102,11 +109,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/WRONG", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "aad-mismatch", "mutation": "path mutated after sealing" }, @@ -116,11 +124,12 @@ "iv_hex": "070707070707070707070707", "session_id": "sess-kat", "path": "/v1/pairing/sessions/x/response", + "channel_binding_hex": "9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c", "counter": 1, "plaintext_utf8": "hello world", "nonce_hex": "070707070707070707070706", - "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e736500000001", - "ciphertext_hex": "ed6c2f3e301a623066bad2476a2fde4a72a40615c1f8e67f8bd9af", + "aad_hex": "00000008736573732d6b61740000001f2f76312f70616972696e672f73657373696f6e732f782f726573706f6e7365000000209c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c00000001", + "ciphertext_hex": "ea3154e512f3e96db98c9e8da0e2b56febabbf14d464b19172d589", "expected_result": "counter-not-monotonic", "mutation": "replay counter=1 after opening counter>=1" } diff --git a/crates/auths-sdk/src/domains/compliance/dsse.rs b/crates/auths-sdk/src/domains/compliance/dsse.rs index c31124a6..58b36694 100644 --- a/crates/auths-sdk/src/domains/compliance/dsse.rs +++ b/crates/auths-sdk/src/domains/compliance/dsse.rs @@ -242,7 +242,7 @@ pub fn verify_signed_evidence_pack_offline( let org_key = KeriPublicKey::parse(cesr.as_str()) .map_err(|e| ComplianceQueryError::Decode(format!("org verkey decode: {e}")))?; let org_curve = match org_key { - KeriPublicKey::Ed25519(_) => CurveType::Ed25519, + KeriPublicKey::Ed25519 { .. } => CurveType::Ed25519, KeriPublicKey::P256 { .. } => CurveType::P256, }; envelope.verify(org_key.as_bytes(), org_curve)?; diff --git a/crates/auths-sdk/src/keri/resolver.rs b/crates/auths-sdk/src/keri/resolver.rs index c30335b8..304c471d 100644 --- a/crates/auths-sdk/src/keri/resolver.rs +++ b/crates/auths-sdk/src/keri/resolver.rs @@ -200,7 +200,9 @@ pub fn resolve_current_public_key( reason: e.to_string(), })?; let (bytes, curve) = match parsed { - auths_keri::KeriPublicKey::Ed25519(pk) => (pk.to_vec(), auths_crypto::CurveType::Ed25519), + auths_keri::KeriPublicKey::Ed25519 { key: pk, .. } => { + (pk.to_vec(), auths_crypto::CurveType::Ed25519) + } auths_keri::KeriPublicKey::P256 { key, .. } => { (key.to_vec(), auths_crypto::CurveType::P256) } diff --git a/crates/auths-sdk/src/witness.rs b/crates/auths-sdk/src/witness.rs index 0691f775..49023206 100644 --- a/crates/auths-sdk/src/witness.rs +++ b/crates/auths-sdk/src/witness.rs @@ -10,7 +10,7 @@ pub use auths_keri::witness::independence::{ #[cfg(feature = "witness-server")] pub use auths_core::witness::{ - WitnessIdentityError, WitnessServerConfig, WitnessServerState, + BuildProof, WitnessIdentityError, WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer, load_witness_signer, run_server, witness_signer_from_seed_hex, }; diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 402c4fb7..073e3ac5 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -133,7 +133,12 @@ pub use verify::{ }; // Re-export witness types -pub use witness::{SignedReceipt, WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig}; +#[cfg(feature = "native")] +pub use witness::verify_build_attestation_offline; +pub use witness::{ + OfflineBuildVerdict, OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, + WitnessVerifyConfig, verify_receipt_offline, +}; // Re-export KERI types directly from auths-keri pub use auths_keri::{ diff --git a/crates/auths-verifier/src/witness.rs b/crates/auths-verifier/src/witness.rs index f34b341f..7173df20 100644 --- a/crates/auths-verifier/src/witness.rs +++ b/crates/auths-verifier/src/witness.rs @@ -20,9 +20,258 @@ pub use auths_keri::witness::{Receipt, ReceiptTag, SignedReceipt}; use auths_crypto::CryptoProvider; -use auths_keri::Said; +use auths_crypto::did_key::{DecodedDidKey, did_key_decode}; +use auths_keri::{KeriPublicKey, Said}; use serde::{Deserialize, Serialize}; +/// The outcome of verifying a single receipt against a witness's published +/// identity, with NO network and NO registry — a stranger holding only the +/// receipt and the identity decides here. +/// +/// The verdict is a parsed sum type, not a boolean: the caller can render +/// exactly why a receipt failed (an unreadable identity vs. a signature that +/// did not check) without re-inspecting anything. `Verified` is the only +/// success arm, so a receipt that did not verify can never be mistaken for one +/// that did. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "kebab-case")] +pub enum OfflineReceiptVerdict { + /// The signature verifies against the key embedded in the published + /// identity. The receipt is genuine corroboration. + Verified { + /// The published witness identity the signature verified against. + witness: String, + }, + /// The published identity is not a readable `did:key` carrying a supported + /// verification key, so no key could be recovered to check against. + UnreadableIdentity { + /// The reason the identity could not be decoded into a key. + reason: String, + }, + /// A key was recovered from the identity, but the signature does not verify + /// over the canonical receipt bytes — a tampered or foreign receipt. + SignatureFailed { + /// The published witness identity the signature was checked against. + witness: String, + }, +} + +impl OfflineReceiptVerdict { + /// Whether the receipt is genuine corroboration (the only success arm). + pub fn is_verified(&self) -> bool { + matches!(self, OfflineReceiptVerdict::Verified { .. }) + } +} + +/// Verify a witness receipt offline against the witness's PUBLISHED identity +/// alone — no network, no registry, no separately-supplied key table. +/// +/// This is the self-contained corroboration check a third party runs on a +/// clean machine: the witness's published `did:key` identity *embeds* its +/// verification key, so `{receipt, signature, identity}` is everything needed +/// to decide. The published identity is the only trust input; the receipt body +/// carries the *controller* AID in `i`, never the witness, so it cannot +/// self-attest. +/// +/// The verdict distinguishes an unreadable identity (the wrong string was +/// carried) from a signature that did not check (a tampered or foreign +/// receipt). A single bit flipped anywhere in the signature, the receipt body, +/// or the identity moves the result off [`OfflineReceiptVerdict::Verified`]. +/// +/// The signed bytes are `serde_json::to_vec(&receipt)`, matching exactly what a +/// witness server signs when it issues the receipt. +/// +/// Args: +/// * `signed`: the receipt body paired with the witness's detached signature. +/// * `witness_identity`: the witness's published `did:key:z…` identity (as +/// advertised at its health endpoint). +/// +/// Usage: +/// ```ignore +/// let verdict = verify_receipt_offline(&signed, "did:key:z6Mk…"); +/// assert!(verdict.is_verified()); +/// ``` +pub fn verify_receipt_offline( + signed: &SignedReceipt, + witness_identity: &str, +) -> OfflineReceiptVerdict { + let decoded = match did_key_decode(witness_identity) { + Ok(d) => d, + Err(e) => { + return OfflineReceiptVerdict::UnreadableIdentity { + reason: e.to_string(), + }; + } + }; + + let key = match &decoded { + DecodedDidKey::Ed25519(bytes) => KeriPublicKey::from_verkey_bytes(bytes, decoded.curve()), + DecodedDidKey::P256(bytes) => KeriPublicKey::from_verkey_bytes(bytes, decoded.curve()), + }; + let key = match key { + Ok(k) => k, + Err(e) => { + return OfflineReceiptVerdict::UnreadableIdentity { + reason: e.to_string(), + }; + } + }; + + let payload = match serde_json::to_vec(&signed.receipt) { + Ok(p) => p, + Err(e) => { + // A receipt that cannot be re-serialized to its canonical bytes + // cannot be checked against any key — it is unusable, not verified. + return OfflineReceiptVerdict::UnreadableIdentity { + reason: format!("receipt is not serializable to its signing bytes: {e}"), + }; + } + }; + + match key.verify_signature(&payload, &signed.signature) { + Ok(()) => OfflineReceiptVerdict::Verified { + witness: witness_identity.to_string(), + }, + Err(_) => OfflineReceiptVerdict::SignatureFailed { + witness: witness_identity.to_string(), + }, + } +} + +/// The outcome of verifying a node's build attestation offline — does the node +/// prove which binary it runs? +/// +/// Two facts must both hold: the attestation's signature verifies against the +/// key its self-describing `did:key` issuer embeds, AND the digest it attests is +/// the digest of the binary actually running (the node's own self-measurement). +/// A forged attestation — one whose attested digest differs from the running +/// binary — fails on the second even when its signature is perfectly valid, so a +/// swapped attestation cannot pass. `Verified` is the only success arm. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "kebab-case")] +pub enum OfflineBuildVerdict { + /// The attestation's signature verifies AND it attests the running digest: + /// the node provably runs the binary the attestation was signed over. + Verified { + /// The digest both attested and measured (they agree). + digest: String, + }, + /// The attestation could not be read as a signed artifact attestation (no + /// payload, no digest, malformed issuer) — there is nothing to check. + Unreadable { + /// Why the attestation could not be interpreted. + reason: String, + }, + /// The attestation's signature does not verify against the key its issuer + /// embeds — it was altered or was not produced by the claimed signer. + SignatureFailed { + /// The issuer the signature was checked against. + issuer: String, + }, + /// The signature is valid, but the attestation attests a DIFFERENT digest + /// than the binary actually running — a forged or stale attestation, paired + /// with a binary it does not describe. + DigestMismatch { + /// The digest the attestation attests. + attested: String, + /// The digest the node measured of its running binary. + running: String, + }, +} + +impl OfflineBuildVerdict { + /// Whether the node provably runs the attested binary (the only success arm). + pub fn is_verified(&self) -> bool { + matches!(self, OfflineBuildVerdict::Verified { .. }) + } +} + +/// Verify a node's build attestation offline against the digest of the binary +/// it is actually running — the "which binary does this node run?" check. +/// +/// The attestation is the `auths artifact sign` document the operator produced +/// over the released binary; its self-describing `did:key` issuer embeds the +/// verification key, so `{attestation, running_digest}` is everything a relying +/// party needs. The check is deliberately two-legged and fail-closed: +/// +/// 1. the signature verifies against the issuer's embedded key +/// ([`verify_with_keys`](crate::verify_with_keys)); and +/// 2. the digest the attestation attests equals `running_digest` — the digest +/// the node measured of its own executable. +/// +/// A valid signature over the WRONG binary fails on leg 2 +/// ([`OfflineBuildVerdict::DigestMismatch`]): an operator cannot vouch for a +/// binary they are not running by attaching a correctly-signed attestation for a +/// different one. This is the dogfood of `auths artifact verify --signature-only` +/// bound to a live self-measurement. +/// +/// Args: +/// * `attestation`: the parsed signed build attestation (`auths artifact sign`). +/// * `running_digest`: the SHA-256 (hex) the node measured of its own binary. +#[cfg(feature = "native")] +pub async fn verify_build_attestation_offline( + attestation: &crate::core::Attestation, + running_digest: &str, +) -> OfflineBuildVerdict { + // The attested digest lives in the artifact payload the signer covered. + let attested = match attestation + .payload + .as_ref() + .and_then(|p| p.get("digest")) + .and_then(|d| d.get("hex")) + .and_then(|h| h.as_str()) + { + Some(h) => h.to_string(), + None => { + return OfflineBuildVerdict::Unreadable { + reason: + "attestation carries no artifact digest to check against the running binary" + .to_string(), + }; + } + }; + + // The issuer is a self-describing `did:key` that embeds the signing key — + // recover it so the signature can be checked from the attestation alone. + let issuer = attestation.issuer.as_str(); + let decoded = match did_key_decode(issuer) { + Ok(d) => d, + Err(e) => { + return OfflineBuildVerdict::Unreadable { + reason: format!("attestation issuer is not a readable did:key: {e}"), + }; + } + }; + let issuer_pk = match crate::core::DevicePublicKey::try_new(decoded.curve(), decoded.bytes()) { + Ok(pk) => pk, + Err(e) => { + return OfflineBuildVerdict::Unreadable { + reason: format!("attestation issuer key is unusable: {e}"), + }; + } + }; + + // Leg 1: the signature must verify against the issuer's embedded key. + if crate::verify_with_keys(attestation, &issuer_pk) + .await + .is_err() + { + return OfflineBuildVerdict::SignatureFailed { + issuer: issuer.to_string(), + }; + } + + // Leg 2: the attested digest must be the digest of the running binary. + if attested != running_digest { + return OfflineBuildVerdict::DigestMismatch { + attested, + running: running_digest.to_string(), + }; + } + + OfflineBuildVerdict::Verified { digest: attested } +} + /// Result of witness quorum verification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WitnessQuorum { @@ -276,4 +525,130 @@ mod tests { let parsed: Receipt = serde_json::from_str(&json_out).unwrap(); assert_eq!(receipt, parsed); } + + /// A signed receipt and the witness's published `did:key` identity, where the + /// identity embeds the very key that signed — exactly the self-contained + /// pair a stranger carries to verify offline. + fn signed_with_published_identity(event_said: &str) -> (SignedReceipt, String) { + use auths_crypto::CurveType; + let (kp, pk) = create_test_keypair(&[42u8; 32]); + let signed = create_signed_receipt(&kp, "EController", event_said, 0); + let identity = auths_verifier_did_key(&pk, CurveType::Ed25519); + (signed, identity) + } + + fn auths_verifier_did_key(pk: &[u8], curve: auths_crypto::CurveType) -> String { + // Build the published did:key the same way a witness advertises it. + crate::types::CanonicalDid::from_public_key_did_key(pk, curve).into_inner() + } + + #[test] + fn offline_verify_accepts_a_genuine_receipt_with_no_key_table() { + let (signed, identity) = signed_with_published_identity("EEventOffline"); + let verdict = verify_receipt_offline(&signed, &identity); + assert!( + verdict.is_verified(), + "a genuine receipt must verify against the published identity alone: {verdict:?}" + ); + } + + #[test] + fn offline_verify_rejects_a_bit_flipped_signature() { + let (mut signed, identity) = signed_with_published_identity("EEventOffline"); + signed.signature[0] ^= 0x01; + let verdict = verify_receipt_offline(&signed, &identity); + assert_eq!( + verdict, + OfflineReceiptVerdict::SignatureFailed { + witness: identity.clone() + }, + "a tampered signature must be a distinct SignatureFailed verdict" + ); + assert!(!verdict.is_verified()); + } + + #[test] + fn offline_verify_rejects_a_tampered_receipt_body() { + let (mut signed, identity) = signed_with_published_identity("EEventOffline"); + // Mutate the receipted event SAID — the signature no longer covers it. + signed.receipt.d = Said::new_unchecked("EEventTampered".into()); + let verdict = verify_receipt_offline(&signed, &identity); + assert!(matches!( + verdict, + OfflineReceiptVerdict::SignatureFailed { .. } + )); + } + + #[test] + fn offline_verify_rejects_a_foreign_identity() { + // A genuine receipt, but carried with a DIFFERENT witness's identity. + let (signed, _genuine) = signed_with_published_identity("EEventOffline"); + let (_other_kp, other_pk) = create_test_keypair(&[7u8; 32]); + let foreign = auths_verifier_did_key(&other_pk, auths_crypto::CurveType::Ed25519); + let verdict = verify_receipt_offline(&signed, &foreign); + assert!(matches!( + verdict, + OfflineReceiptVerdict::SignatureFailed { .. } + )); + } + + #[test] + fn offline_verify_flags_an_unreadable_identity_distinctly() { + let (signed, _identity) = signed_with_published_identity("EEventOffline"); + let verdict = verify_receipt_offline(&signed, "not-a-did-key"); + assert!(matches!( + verdict, + OfflineReceiptVerdict::UnreadableIdentity { .. } + )); + } + + // ── build attestation, offline ─────────────────────────────────────────── + // The signed-and-matching ("Verified") and forged ("DigestMismatch") arms + // are covered end to end against a real `auths artifact sign --ci` output in + // the conformance probe (a live node + the dogfooded signer). Here we pin + // the fail-closed shape arms that do not need a valid signature: an + // attestation with no digest, and one whose issuer is not a readable + // did:key, are both Unreadable — never mistaken for a verified build. + use crate::testing::AttestationBuilder; + + #[tokio::test] + async fn build_verify_no_digest_is_unreadable() { + let att = AttestationBuilder::default() + .issuer("did:key:z6MkfooNoDigest") + .payload(Some(serde_json::json!({ "artifact_type": "file" }))) + .build(); + let verdict = verify_build_attestation_offline(&att, "deadbeef").await; + assert!(matches!(verdict, OfflineBuildVerdict::Unreadable { .. })); + } + + #[tokio::test] + async fn build_verify_unreadable_issuer_is_unreadable() { + // A digest is present, but the issuer is not a self-describing did:key, + // so no key can be recovered to check the signature against. + let att = AttestationBuilder::default() + .issuer("did:keri:ENotADidKey") + .payload(Some(serde_json::json!({ + "digest": { "algorithm": "sha256", "hex": "abc123" } + }))) + .build(); + let verdict = verify_build_attestation_offline(&att, "abc123").await; + assert!(matches!(verdict, OfflineBuildVerdict::Unreadable { .. })); + } + + #[test] + fn build_verdict_verified_is_the_only_success_arm() { + assert!( + OfflineBuildVerdict::Verified { + digest: "abc".into() + } + .is_verified() + ); + assert!( + !OfflineBuildVerdict::DigestMismatch { + attested: "a".into(), + running: "b".into(), + } + .is_verified() + ); + } } diff --git a/crates/auths-witness-node/Cargo.toml b/crates/auths-witness-node/Cargo.toml new file mode 100644 index 00000000..d86d1263 --- /dev/null +++ b/crates/auths-witness-node/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "auths-witness-node" +description = "Operator orchestration for running a hardened witness node: embedded standup manifest, key custody policy, and the node's health surface — composes the platform witness/KERI/verifier crates, reimplements no protocol" +version.workspace = true +edition = "2024" +publish = false +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = ["auths", "keri", "witness", "operator", "standup"] +categories = ["cryptography", "network-programming"] + +[lib] +name = "auths_witness_node" +path = "src/lib.rs" + +[dependencies] +# Composition over the platform's PUBLIC crate APIs — depending on these IS the +# integration. The node crate orchestrates operator standup around the hardened +# witness server; it reimplements no receipt / key-state / CESR / SAID logic. +auths-witness.workspace = true +auths-keri.workspace = true +auths-verifier = { workspace = true, features = ["native"] } + +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# OS CSPRNG for minting the node's stable signing-identity seed at first boot. +# Only the OS-backed `OsRng` is used (the workspace bans `thread_rng`/`random`). +rand = { workspace = true } +hex = "0.4" + +[dev-dependencies] +tempfile = "3" +# The build-attestation verdict composes the async platform verifier; its tests +# need a runtime to drive it. +tokio = { workspace = true, features = ["rt", "macros"] } + +[lints] +workspace = true diff --git a/crates/auths-witness-node/src/build.rs b/crates/auths-witness-node/src/build.rs new file mode 100644 index 00000000..31964dcc --- /dev/null +++ b/crates/auths-witness-node/src/build.rs @@ -0,0 +1,226 @@ +//! The proof a node serves of which binary it runs — the operator-facing seam. +//! +//! A witness operator is vouching for the network; an operator must in turn be +//! *vouchable* — a relying party has to be able to confirm the node runs the +//! binary the platform shipped, not a silently-swapped one. This module owns the +//! operator-facing artifact that makes that confirmable: the node's +//! self-measurement of its own binary, paired with the signed build attestation +//! the operator produced over the released binary (`auths artifact sign`). +//! +//! It owns no protocol. The decision — does the attestation's signature hold, +//! and does it attest the digest the node measured of itself? — is made by the +//! platform verifier ([`auths_verifier::verify_build_attestation_offline`]), +//! composed here. The node crate only frames the served artifact and renders the +//! verdict an operator reads. + +use auths_verifier::OfflineBuildVerdict; +use auths_verifier::core::Attestation; +use serde::{Deserialize, Serialize}; + +/// The `/build` document a node serves: its own measurement of the binary it +/// runs, paired with the signed attestation that vouches for it. +/// +/// Parse-don't-validate: a returned `BuildAttestation` is fully formed (the +/// `attestation` parsed into the verifier's [`Attestation`] type), so nothing +/// downstream re-checks its shape — `verify` only decides the *trust* question. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAttestation { + /// Version string the running binary reports. + pub version: String, + /// SHA-256 (hex) the node measured of its own on-disk binary at startup. + pub running_digest: String, + /// The signed build attestation (`auths artifact sign` output) the operator + /// produced over the released binary. + pub attestation: Attestation, +} + +/// What an operator (or any relying party) learns from checking a node's build +/// proof. `Trusted` is the only arm that means "this node provably runs the +/// attested binary"; every other arm names exactly what went wrong, so a forged +/// or unprovable build can never be read as a trusted one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NodeBuildVerdict { + /// The node provably runs the attested binary: the attestation's signature + /// holds and attests the very digest the node measured of itself. + Trusted { + /// The version the node reported. + version: String, + /// The digest both attested and self-measured (they agree). + digest: String, + }, + /// The attestation could not be interpreted as a signed build attestation. + Unreadable { + /// Why the attestation could not be read. + reason: String, + }, + /// The attestation's signature does not verify — altered, or not produced by + /// its claimed signer. + SignatureFailed { + /// The issuer the signature was checked against. + issuer: String, + }, + /// The signature is valid but the attestation attests a DIFFERENT binary + /// than the one running — a forged or mismatched attestation. + DigestMismatch { + /// The digest the attestation attests. + attested: String, + /// The digest the node measured of its running binary. + running: String, + }, +} + +impl NodeBuildVerdict { + /// Whether the node provably runs the attested binary (the only trusted arm). + pub fn is_trusted(&self) -> bool { + matches!(self, NodeBuildVerdict::Trusted { .. }) + } + + /// A single operator-facing line describing the verdict — no protocol + /// vocabulary, just "which binary does this node run, and can we trust it". + pub fn summary(&self) -> String { + match self { + NodeBuildVerdict::Trusted { version, digest } => format!( + "build verified: this node runs {version} (digest {}), signed and matching", + short(digest) + ), + NodeBuildVerdict::Unreadable { reason } => { + format!("build not verifiable: {reason}") + } + NodeBuildVerdict::SignatureFailed { issuer } => format!( + "build rejected: the build attestation's signature does not verify (signer {})", + short(issuer) + ), + NodeBuildVerdict::DigestMismatch { attested, running } => format!( + "build rejected: the attestation is for a different binary \ + (attested {}, running {}) — this node is not running what it attests", + short(attested), + short(running) + ), + } + } +} + +/// Shorten a long hex/DID to a readable head for one-line operator output. +fn short(s: &str) -> String { + if s.len() > 16 { + format!("{}…", &s[..16]) + } else { + s.to_string() + } +} + +impl BuildAttestation { + /// Parse a build document from its served JSON, failing loudly on malformed + /// input (parse-don't-validate). + /// + /// Args: + /// * `json`: the `/build` response bytes. + pub fn from_json(json: &[u8]) -> Result { + serde_json::from_slice(json) + } + + /// Decide whether this node provably runs the attested binary. + /// + /// Composes the platform verifier: the signature is checked against the + /// issuer's embedded key, and the attested digest is checked against the + /// node's self-measured `running_digest`. A forged attestation (a valid + /// signature over a different binary) lands on + /// [`NodeBuildVerdict::DigestMismatch`], never on `Trusted`. + pub async fn verify(&self) -> NodeBuildVerdict { + match auths_verifier::verify_build_attestation_offline( + &self.attestation, + &self.running_digest, + ) + .await + { + OfflineBuildVerdict::Verified { digest } => NodeBuildVerdict::Trusted { + version: self.version.clone(), + digest, + }, + OfflineBuildVerdict::Unreadable { reason } => NodeBuildVerdict::Unreadable { reason }, + OfflineBuildVerdict::SignatureFailed { issuer } => { + NodeBuildVerdict::SignatureFailed { issuer } + } + OfflineBuildVerdict::DigestMismatch { attested, running } => { + NodeBuildVerdict::DigestMismatch { attested, running } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A build document whose attestation carries NO digest payload — it parses + /// into a well-formed attestation (real key/signature shapes), but with + /// nothing to compare against the running binary it verifies to `Unreadable`, + /// never `Trusted`. (The signed-and-matching and forged arms are covered end + /// to end against a real `artifact sign` output in the integration suite.) + const NO_DIGEST_BUILD: &str = r#"{ + "version": "0.1.3", + "running_digest": "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", + "attestation": { + "version": 1, + "rid": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", + "issuer": "did:key:zDnaev8Ae55oCNc38Ha6gFkuYGG4zxH1quDCpd5veWrQUHf8C", + "subject": "did:key:zDnaev8Ae55oCNc38Ha6gFkuYGG4zxH1quDCpd5veWrQUHf8C", + "device_public_key": { + "curve": "p256", + "key": "03b92f4329b76bec0f02c28b37e16a4fd9803129d22943ff53d5b5472f123fd349" + }, + "device_signature": "944b5ba55f517db44abac03b27b31f4dd596e7a335af5082d09c43601ddd09565d1e3e6a1c21a28ae8ef5d1b405240ea3420f18e42a3300dade341d9a9ff767d", + "payload": { "artifact_type": "file" } + } + }"#; + + #[test] + fn parses_a_served_build_document() { + let build = BuildAttestation::from_json(NO_DIGEST_BUILD.as_bytes()).unwrap(); + assert_eq!(build.version, "0.1.3"); + assert_eq!( + build.running_digest, + "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881" + ); + } + + #[tokio::test] + async fn a_build_with_no_attested_digest_is_not_trusted() { + let build = BuildAttestation::from_json(NO_DIGEST_BUILD.as_bytes()).unwrap(); + let verdict = build.verify().await; + assert!(!verdict.is_trusted()); + assert!(matches!(verdict, NodeBuildVerdict::Unreadable { .. })); + } + + #[test] + fn verdict_summary_carries_no_protocol_vocabulary() { + // The operator-facing line must never leak protocol jargon — operators + // see "which binary, and can we trust it", never the wire vocabulary. + // Held to the one canonical rule (crate::scan_for_protocol_vocabulary), + // not a copy maintained here. + let verdicts = [ + NodeBuildVerdict::Trusted { + version: "0.1.3".into(), + digest: "7ce84d53b3b63323deadbeef".into(), + }, + NodeBuildVerdict::DigestMismatch { + attested: "296078e6633559c0aa".into(), + running: "7ce84d53b3b63323bb".into(), + }, + NodeBuildVerdict::SignatureFailed { + issuer: "did:key:zForged".into(), + }, + NodeBuildVerdict::Unreadable { + reason: "no digest".into(), + }, + ]; + for v in verdicts { + assert_eq!( + crate::scan_for_protocol_vocabulary(&v.summary()), + None, + "verdict summary leaked protocol vocabulary: {}", + v.summary() + ); + } + } +} diff --git a/crates/auths-witness-node/src/engine.rs b/crates/auths-witness-node/src/engine.rs new file mode 100644 index 00000000..0684067b --- /dev/null +++ b/crates/auths-witness-node/src/engine.rs @@ -0,0 +1,226 @@ +//! Shipped adapters for the standup ports. +//! +//! These are the only place in the crate that knows about the host: the +//! `docker` binary and a TCP socket. The orchestration in +//! [`crate::standup`] talks to them through the [`ContainerEngine`] and +//! [`HealthCheck`] traits and never sees either detail. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use crate::standup::{ContainerEngine, HealthCheck}; + +/// Drives Docker (Compose v2: `docker compose …`). +#[derive(Debug, Default, Clone, Copy)] +pub struct DockerEngine; + +impl DockerEngine { + fn docker(args: &[&str]) -> std::io::Result { + Command::new("docker").args(args).output() + } + + /// Distil an engine error blob to one actionable line: the last non-empty + /// line is the message Compose surfaces; operators never need the rest. + fn one_line(stdout: &[u8], stderr: &[u8]) -> String { + let blob = if stderr.is_empty() { stdout } else { stderr }; + String::from_utf8_lossy(blob) + .lines() + .map(str::trim) + .rfind(|l| !l.is_empty()) + .unwrap_or("container engine reported an unspecified failure") + .to_string() + } +} + +impl ContainerEngine for DockerEngine { + fn unavailable_reason(&self) -> Option { + match Self::docker(&["info"]) { + Ok(out) if out.status.success() => None, + Ok(_) => Some( + "the container engine is installed but not running — start it and retry" + .to_string(), + ), + Err(_) => Some( + "no container engine found — install Docker (or a compatible engine) and retry" + .to_string(), + ), + } + } + + fn compose_up(&self, project: &str, manifest_path: &Path) -> Result<(), String> { + let manifest = manifest_path.to_string_lossy(); + let out = Self::docker(&[ + "compose", + "-p", + project, + "-f", + &manifest, + "up", + "-d", + "--remove-orphans", + ]) + .map_err(|e| format!("could not run the container engine: {e}"))?; + if out.status.success() { + Ok(()) + } else { + // Keep the engine's own line (operators paste it into issues) but + // lead with what failed, so a terse engine message like "denied" + // (image not pullable) or "port is already allocated" reads as one + // actionable sentence on its own. + Err(format!( + "could not bring the node up: {}", + Self::one_line(&out.stdout, &out.stderr) + )) + } + } + + fn compose_down(&self, project: &str, manifest_path: &Path) -> Result<(), String> { + let manifest = manifest_path.to_string_lossy(); + let out = Self::docker(&["compose", "-p", project, "-f", &manifest, "down", "-v"]) + .map_err(|e| format!("could not run the container engine: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(Self::one_line(&out.stdout, &out.stderr)) + } + } +} + +/// Polls an `http://host:port/path` health endpoint over a raw socket — no HTTP +/// client dependency, because all this adapter needs to know is whether the +/// node answers `2xx` at its health path. +#[derive(Debug, Default, Clone, Copy)] +pub struct SocketHealthCheck; + +impl HealthCheck for SocketHealthCheck { + fn is_healthy(&self, url: &str) -> bool { + let Some((host, port, path)) = parse_http_url(url) else { + return false; + }; + http_get_ok(&host, port, &path).unwrap_or(false) + } +} + +/// Fetches an `http://host:port/path` endpoint's body over a raw socket — the +/// same no-dependency posture as [`SocketHealthCheck`], but returning the +/// response body (and whether the status was `2xx`) so a caller can read a small +/// JSON document a node serves (e.g. its build proof). +#[derive(Debug, Default, Clone, Copy)] +pub struct SocketHttpFetch; + +impl crate::standup::HttpFetch for SocketHttpFetch { + fn get(&self, url: &str) -> Result { + let (host, port, path) = + parse_http_url(url).ok_or_else(|| format!("not a plain-http url: {url}"))?; + http_get_body(&host, port, &path).map_err(|e| format!("could not reach {url}: {e}")) + } +} + +/// Parse `http://host:port/path` into its parts. Returns `None` for anything +/// that is not a plain-HTTP URL with an explicit port (which is all standup +/// ever produces). +fn parse_http_url(url: &str) -> Option<(String, u16, String)> { + let rest = url.strip_prefix("http://")?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = authority.rsplit_once(':')?; + let port: u16 = port.parse().ok()?; + Some((host.to_string(), port, path.to_string())) +} + +/// One blocking HTTP/1.0 GET; `Ok(true)` iff the status line is `2xx`. +fn http_get_ok(host: &str, port: u16, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect((host, port))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + let req = format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes())?; + let mut buf = Vec::with_capacity(256); + // The status line is the first line; reading the first chunk is enough. + let mut chunk = [0u8; 256]; + let n = stream.read(&mut chunk)?; + buf.extend_from_slice(&chunk[..n]); + let head = String::from_utf8_lossy(&buf); + let status_line = head.lines().next().unwrap_or(""); + // "HTTP/1.1 200 OK" → the code is the second token. + Ok(status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .map(|c| (200..300).contains(&c)) + .unwrap_or(false)) +} + +/// One blocking HTTP/1.0 GET returning the full response. `ok` is `true` iff the +/// status line was `2xx`; `body` is everything after the header block. Kept +/// dependency-free (raw socket) to match the health adapter — the node's build +/// proof is a small JSON document, so reading it whole is cheap. +fn http_get_body( + host: &str, + port: u16, + path: &str, +) -> std::io::Result { + let mut stream = TcpStream::connect((host, port))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + let req = format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes())?; + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw)?; + + // Split the head from the body at the first blank line (CRLFCRLF). + let split = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4); + let (head, body) = match split { + Some(i) => (&raw[..i], raw[i..].to_vec()), + None => (&raw[..], Vec::new()), + }; + let status_line = String::from_utf8_lossy(head) + .lines() + .next() + .unwrap_or("") + .to_string(); + let ok = status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .map(|c| (200..300).contains(&c)) + .unwrap_or(false); + Ok(crate::standup::HttpResponse { ok, body }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_standup_health_url() { + let (h, p, path) = parse_http_url("http://127.0.0.1:3333/health").unwrap(); + assert_eq!(h, "127.0.0.1"); + assert_eq!(p, 3333); + assert_eq!(path, "/health"); + } + + #[test] + fn rejects_non_http_urls() { + assert!(parse_http_url("https://x/health").is_none()); + assert!(parse_http_url("ftp://x").is_none()); + } + + #[test] + fn a_dead_port_is_not_healthy() { + // Nothing listens on this ephemeral port. + assert!(!SocketHealthCheck.is_healthy("http://127.0.0.1:1/health")); + } + + #[test] + fn one_line_takes_the_last_meaningful_line() { + let msg = DockerEngine::one_line(b"", b"noise\n\nError: port is already allocated\n"); + assert_eq!(msg, "Error: port is already allocated"); + } +} diff --git a/crates/auths-witness-node/src/lib.rs b/crates/auths-witness-node/src/lib.rs new file mode 100644 index 00000000..3e544513 --- /dev/null +++ b/crates/auths-witness-node/src/lib.rs @@ -0,0 +1,307 @@ +#![deny( + clippy::print_stdout, + clippy::print_stderr, + clippy::exit, + clippy::dbg_macro +)] +#![warn(missing_docs)] + +//! Operator orchestration for running a hardened witness node. +//! +//! This crate is the seam between a node *operator* (who wants one command to a +//! healthy, registered node) and the platform's protocol crates (which must be +//! correct for strangers). It owns the *operation*: the embedded standup +//! manifest (the released witness node), the node's key-custody policy, the +//! identity minted at first boot, and the node's operator-facing health surface. +//! +//! It owns no *protocol*. Every byte a stranger must verify — the receipt +//! format, the key-state notice, signature checking — comes from the platform's +//! public crate APIs, which this crate composes: +//! +//! * [`auths_witness`] — the hardened receipt server this node runs, and the +//! request-hardening constants ([`auths_witness::MAX_BODY_BYTES`] et al.) the +//! standup manifest is configured against. +//! * [`auths_keri`] — the key-state notice ([`auths_keri::KeyStateNotice`]) and +//! its wire version the node serves at its stable endpoint. +//! * [`auths_verifier`] — the witness-quorum policy +//! ([`auths_verifier::WitnessQuorum`]) the node exists to let relying parties +//! demand. +//! +//! If a standup task ever needs a protocol message the platform does not +//! expose, that is a missing *platform* API: add the public surface there, +//! never inline the bytes here. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +pub mod build; +pub mod engine; +pub mod receipt; +pub mod standup; +pub mod vocabulary; + +pub use build::{BuildAttestation, NodeBuildVerdict}; +pub use engine::{DockerEngine, SocketHealthCheck, SocketHttpFetch}; +pub use receipt::ReceiptBundle; +pub use standup::{ + ContainerEngine, HealthCheck, HttpFetch, HttpResponse, StandupError, StandupOutcome, stand_up, + tear_down, +}; +pub use vocabulary::{PROTOCOL_VOCABULARY, scan_for_protocol_vocabulary}; + +// Compose the platform's public protocol surface. These re-exports make the +// composition explicit and give the operator CLI one import path for the +// protocol types it renders, all sourced from the trust kernel. +pub use auths_keri::{KERI_KEY_STATE_VERSION, KSN_TYPE, KeyStateNotice, SignedKsn}; +pub use auths_verifier::{ + OfflineBuildVerdict, OfflineReceiptVerdict, SignedReceipt, WitnessQuorum, WitnessReceiptResult, + verify_build_attestation_offline, verify_receipt_offline, +}; +pub use auths_witness::{MAX_BODY_BYTES, MAX_CONCURRENT_REQUESTS, REQUEST_TIMEOUT}; + +/// The released, attested witness image a node runs. +/// +/// Standup deploys *released* binaries, never source builds: the operator runs +/// what the platform shipped, provably (the digest is what a build attestation +/// is checked against before boot). The default points at the platform's +/// hardened distroless witness image. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WitnessImage { + /// Fully-qualified image reference (registry/name:tag). + pub reference: String, + /// Container port the witness server binds inside the image. + pub container_port: u16, +} + +impl Default for WitnessImage { + fn default() -> Self { + Self { + // The canonical hardened witness image (see docs/deployment/witness). + reference: "ghcr.io/auths-dev/auths-witness:latest".to_string(), + container_port: 3333, + } + } +} + +/// How the node's stable signing identity is custodied at first boot. +/// +/// A deployed witness must have a stable, pinnable identity. The safe default +/// is a managed key (KMS/enclave) where the host provides one; a file-backed +/// key is a deliberate downgrade that an operator must acknowledge — it never +/// happens silently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum KeyCustody { + /// Key managed by a host KMS / secure enclave (the default, where available). + #[default] + Managed, + /// Key persisted to a file on the node — an explicit, acknowledged downgrade. + File, +} + +/// A request to stand up one witness node. +/// +/// This is the parsed, fully-formed operator intent — built once at the I/O +/// edge (the CLI), validated, then handed to the runtime. Nothing downstream +/// re-checks it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StandupRequest { + /// The released, attested image to run. + pub image: WitnessImage, + /// Host port the node's witness endpoint is published on. + pub host_port: u16, + /// Key custody for the node's stable identity. + pub custody: KeyCustody, + /// Where the node's receipts/keystore volume is mounted on the host. + pub data_dir: PathBuf, + /// Host path to the signed build attestation for the released image's binary + /// (`auths artifact sign` output). When set, standup mounts it into the node + /// and points the binary at it, so the node serves a `/build` proof of which + /// binary it runs. `None` stands a node up without that surface. + pub build_attestation: Option, +} + +impl StandupRequest { + /// A local-Docker standup on the default port with managed custody. + /// + /// Args: + /// * `data_dir`: host directory for the node's persistent volume. + pub fn local(data_dir: impl Into) -> Self { + Self { + image: WitnessImage::default(), + host_port: 3333, + custody: KeyCustody::default(), + data_dir: data_dir.into(), + build_attestation: None, + } + } + + /// The operator-facing health URL a freshly stood-up node answers on. + /// + /// Zero protocol vocabulary by construction — it is a plain HTTP health + /// endpoint an operator can open in a browser. + pub fn health_url(&self) -> String { + format!("http://127.0.0.1:{}/health", self.host_port) + } + + /// Render the embedded Compose manifest that brings the node up. + /// + /// The witness service is the platform's released image — the manifest + /// declares it `image:`, never `build:`, so standup is never a source + /// build. The body-size cap the server enforces + /// ([`auths_witness::MAX_BODY_BYTES`]) is surfaced here as the front-proxy + /// hint so the two limits cannot drift. + /// + /// The node's stable signing identity is injected at first boot (the + /// `WITNESS_SEED` environment value), so the node advertises the same + /// identity across restarts without a key file baked into the image. A + /// file-backed custody downgrade is surfaced in the manifest header so an + /// operator reading it sees the posture they acknowledged. + pub fn compose_manifest(&self) -> String { + let WitnessImage { + reference, + container_port, + } = &self.image; + let custody = match self.custody { + KeyCustody::Managed => "managed (KMS/enclave)", + KeyCustody::File => "file (acknowledged downgrade)", + }; + // When a build attestation is supplied, mount it read-only into the node + // and point the binary at it, so the node serves a `/build` proof of + // which binary it runs. The attestation is data the node serves, never a + // secret, so a plain read-only bind is right. + let (attestation_env, attestation_volume) = match &self.build_attestation { + Some(path) => ( + format!( + "\x20\x20\x20\x20\x20\x20AUTHS_WITNESS_BUILD_ATTESTATION: \"{ATTESTATION_CONTAINER_PATH}\"\n" + ), + format!( + "\x20\x20\x20\x20volumes:\n\ + \x20\x20\x20\x20\x20\x20- \"{host}:{ATTESTATION_CONTAINER_PATH}:ro\"\n", + host = path.display(), + ), + ), + None => (String::new(), String::new()), + }; + format!( + "# Embedded witness standup — one node, released image only.\n\ + # Never a source build; identity custody: {custody}.\n\ + # Max accepted body at the proxy mirrors the server cap: {max_body} bytes.\n\ + services:\n\ + \x20\x20witness:\n\ + \x20\x20\x20\x20image: {reference}\n\ + \x20\x20\x20\x20read_only: true\n\ + \x20\x20\x20\x20ports:\n\ + \x20\x20\x20\x20\x20\x20- \"127.0.0.1:{host_port}:{container_port}\"\n\ + \x20\x20\x20\x20environment:\n\ + \x20\x20\x20\x20\x20\x20AUTHS_WITNESS_SEED: \"${{WITNESS_SEED}}\"\n\ + {attestation_env}\ + \x20\x20\x20\x20command: [\"--bind\", \"0.0.0.0:{container_port}\", \"--curve\", \"ed25519\", \"--persist\", \"/data/receipts.db\"]\n\ + {attestation_volume}\ + \x20\x20\x20\x20tmpfs:\n\ + \x20\x20\x20\x20\x20\x20- /data\n", + custody = custody, + max_body = MAX_BODY_BYTES, + reference = reference, + host_port = self.host_port, + container_port = container_port, + attestation_env = attestation_env, + attestation_volume = attestation_volume, + ) + } +} + +/// The fixed in-container path the build attestation is mounted at and the node +/// reads from. One constant so the mount target and the env value cannot drift. +const ATTESTATION_CONTAINER_PATH: &str = "/build-attestation.json"; + +/// The KERI wire version of the key-state notice this node serves. +/// +/// Sourced from the platform, never redeclared here — the node serves exactly +/// what the trust kernel defines. +pub fn served_ksn_version() -> [u32; 2] { + KERI_KEY_STATE_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_standup_uses_managed_custody_and_default_image() { + let req = StandupRequest::local("/tmp/witness-data"); + assert_eq!(req.custody, KeyCustody::Managed); + assert_eq!(req.image, WitnessImage::default()); + assert_eq!(req.host_port, 3333); + } + + #[test] + fn health_url_has_no_protocol_vocabulary() { + // The operator-vocabulary rule lives in one place; this asserts the + // health URL the operator opens is held to it, not to a private copy. + let url = StandupRequest::local("/tmp/d").health_url(); + assert_eq!( + scan_for_protocol_vocabulary(&url), + None, + "health URL leaked protocol vocabulary" + ); + } + + #[test] + fn compose_manifest_is_released_image_never_source_build() { + let manifest = StandupRequest::local("/srv/witness").compose_manifest(); + assert!( + manifest.contains("image:"), + "manifest must declare a released image" + ); + assert!( + !manifest.contains("build:"), + "standup must never build from source" + ); + // The node mints its identity at first boot via an injected seed, never + // a key file baked into the image. + assert!( + manifest.contains("AUTHS_WITNESS_SEED"), + "the node identity must be injected at boot" + ); + // The proxy body cap is sourced from the platform server constant. + assert!(manifest.contains(&MAX_BODY_BYTES.to_string())); + // With no build attestation, the node serves no `/build` surface — the + // manifest neither mounts one nor points the binary at it. + assert!(!manifest.contains("AUTHS_WITNESS_BUILD_ATTESTATION")); + } + + #[test] + fn compose_manifest_mounts_a_supplied_build_attestation() { + let mut req = StandupRequest::local("/srv/witness"); + req.build_attestation = Some(PathBuf::from("/host/build.auths.json")); + let manifest = req.compose_manifest(); + // The node is pointed at the in-container attestation path … + assert!(manifest.contains("AUTHS_WITNESS_BUILD_ATTESTATION")); + assert!(manifest.contains(ATTESTATION_CONTAINER_PATH)); + // … and the host file is bind-mounted read-only to that path. + assert!(manifest.contains(&format!( + "/host/build.auths.json:{ATTESTATION_CONTAINER_PATH}:ro" + ))); + // Still a released image, never a source build. + assert!(manifest.contains("image:")); + assert!(!manifest.contains("build:")); + } + + #[test] + fn served_ksn_version_is_the_platform_version() { + assert_eq!(served_ksn_version(), KERI_KEY_STATE_VERSION); + } + + #[test] + fn quorum_type_is_the_verifier_type() { + // The node exists to let relying parties demand this quorum — the type + // is the verifier's, not a fork. + let q = WitnessQuorum { + required: 2, + verified: 0, + receipts: Vec::::new(), + }; + assert_eq!(q.required, 2); + } +} diff --git a/crates/auths-witness-node/src/receipt.rs b/crates/auths-witness-node/src/receipt.rs new file mode 100644 index 00000000..e1bb80ba --- /dev/null +++ b/crates/auths-witness-node/src/receipt.rs @@ -0,0 +1,111 @@ +//! The self-contained receipt artifact a node hands a third party. +//! +//! A witness receipt is only *corroboration* if someone who does not trust the +//! node can check it alone — on a clean machine, with no network and no +//! registry. This module owns the operator-facing *bundle* that makes that +//! possible: the witness's signed receipt paired with the witness's own +//! published identity. Everything a stranger needs to decide is in one file. +//! +//! It owns no protocol. The decision — does this signature verify against the +//! key the published identity embeds? — is made by the platform verifier +//! ([`auths_verifier::verify_receipt_offline`]), composed here. The node crate +//! only frames the artifact and renders the verdict. + +use auths_verifier::{OfflineReceiptVerdict, SignedReceipt, verify_receipt_offline}; +use serde::{Deserialize, Serialize}; + +/// A witness receipt paired with the witness's published identity — the +/// complete, self-contained artifact a third party verifies offline. +/// +/// The two fields together are sufficient and self-describing: the published +/// `identity` is a `did:key` that *embeds* the witness's verification key, so a +/// holder needs no directory lookup and no network call to recover the key the +/// `receipt` was signed with. The receipt body itself names only the +/// *controller* it attests (`receipt.receipt.i`), never the witness — so the +/// bundle cannot self-attest, and the identity is the single trust input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReceiptBundle { + /// The witness's signed receipt (spec `rct` body + detached signature). + pub receipt: SignedReceipt, + /// The witness's published `did:key:z…` identity (as advertised at its + /// health endpoint), embedding the key the receipt was signed with. Named + /// `witness` to match the platform's stored-receipt vocabulary — one word + /// for "who attested this". + pub witness: String, +} + +impl ReceiptBundle { + /// Parse a bundle from its JSON wire form, failing loudly on malformed input. + /// + /// Parse-don't-validate: a returned `ReceiptBundle` is fully formed; nothing + /// downstream re-checks its shape. + /// + /// Args: + /// * `json`: the bundle JSON bytes. + pub fn from_json(json: &[u8]) -> Result { + serde_json::from_slice(json) + } + + /// Verify this receipt offline against the published identity it carries. + /// + /// No network, no registry: the verdict is computed by the platform verifier + /// from the bundle's own bytes alone. The result is a parsed sum type, so a + /// caller renders exactly why a receipt failed without re-inspecting it. + pub fn verify_offline(&self) -> OfflineReceiptVerdict { + verify_receipt_offline(&self.receipt, &self.witness) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A genuine bundle captured from a real witness node: a signed receipt and + // the node's published did:key identity, which embeds the signing key. This + // is data (a real artifact), not a re-implementation of any protocol. + const GENUINE_BUNDLE: &str = r#"{ + "receipt": { + "receipt": { + "v": "KERI10JSON000000_", + "t": "rct", + "d": "EPVOwKzgOeQ2rD5nv_fXzD036LBYcIgyaD3AgD0ghToU", + "i": "EProbeControllerWITN2000000000000000000000000", + "s": "0" + }, + "signature": "3828f4b8c9156f3603060e3c71f38324bc968ea6547fe3144ee059f3219879e9dd824d49db705478d71c0b597d5bc30ff53f79841266ded9275782259f5b270c" + }, + "witness": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S" + }"#; + + #[test] + fn a_genuine_bundle_verifies_offline() { + let bundle = ReceiptBundle::from_json(GENUINE_BUNDLE.as_bytes()).unwrap(); + let verdict = bundle.verify_offline(); + assert!( + verdict.is_verified(), + "a genuine receipt + published identity must verify alone: {verdict:?}" + ); + } + + #[test] + fn a_bit_flipped_signature_is_rejected() { + let mut bundle = ReceiptBundle::from_json(GENUINE_BUNDLE.as_bytes()).unwrap(); + bundle.receipt.signature[0] ^= 0x01; + assert!( + !bundle.verify_offline().is_verified(), + "a tampered signature must not verify" + ); + } + + #[test] + fn a_foreign_identity_is_rejected() { + // The genuine receipt, but carried with a different witness's identity + // (one of the fixture's other nodes) — must not verify. + let mut bundle = ReceiptBundle::from_json(GENUINE_BUNDLE.as_bytes()).unwrap(); + bundle.witness = "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK".to_string(); + assert!( + !bundle.verify_offline().is_verified(), + "a receipt carried with the wrong witness identity must not verify" + ); + } +} diff --git a/crates/auths-witness-node/src/standup.rs b/crates/auths-witness-node/src/standup.rs new file mode 100644 index 00000000..835acb3f --- /dev/null +++ b/crates/auths-witness-node/src/standup.rs @@ -0,0 +1,379 @@ +//! Bringing one witness node up, for real. +//! +//! [`StandupRequest`](crate::StandupRequest) is the parsed operator *intent*; +//! this module is the *runtime* that acts on it: it materializes the embedded +//! manifest, asks a container engine to bring the node (and its monitor +//! sidecar) up, waits until the node answers its health endpoint, and hands the +//! operator back the URL — or fails with one actionable line and leaves nothing +//! half-standing. +//! +//! Ports and adapters: the orchestration here never shells out directly. It +//! drives a [`ContainerEngine`] port; the shipped adapter ([`DockerEngine`]) +//! is the only thing that knows about the `docker` binary. The health wait +//! talks to a [`HealthCheck`] port the same way. Swapping either (a different +//! engine, an in-process probe) never touches the bring-up logic. +//! +//! No source builds: the manifest declares the node's *released* image +//! (`image:`, never `build:`), so an operator runs what the platform shipped. +//! When that image cannot be obtained, bring-up fails honestly rather than +//! falling back to compiling one. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::StandupRequest; + +/// Why a standup could not complete. Each variant carries the single, +/// actionable sentence an operator should see — no stack traces, no partial +/// state left behind. +#[derive(Debug)] +pub enum StandupError { + /// No container engine is available on this host. + NoEngine { + /// The one-line, actionable remedy. + hint: String, + }, + /// The engine ran but could not bring the node up (image unavailable, + /// port already taken, …). Carries the engine's own first error line. + BringUpFailed { + /// The single actionable line distilled from the engine's output. + reason: String, + }, + /// The node was asked to come up but never answered its health endpoint + /// within the allotted window. + Unhealthy { + /// The health URL that stayed dark. + url: String, + /// How long we waited before giving up. + waited: Duration, + }, + /// The host filesystem could not be prepared for the node's data volume. + DataDir { + /// The path that could not be prepared. + path: PathBuf, + /// The underlying os error rendered to one line. + reason: String, + }, +} + +impl std::fmt::Display for StandupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StandupError::NoEngine { hint } => write!(f, "{hint}"), + StandupError::BringUpFailed { reason } => write!(f, "{reason}"), + StandupError::Unhealthy { url, waited } => write!( + f, + "node did not become healthy at {url} within {}s — nothing left running", + waited.as_secs() + ), + StandupError::DataDir { path, reason } => { + write!( + f, + "could not prepare data directory {}: {reason}", + path.display() + ) + } + } + } +} + +impl std::error::Error for StandupError {} + +/// A container engine that can bring a compose project up and tear it down. +/// +/// The port is intentionally narrow: the orchestrator only needs to start a +/// project from a manifest, stop it, and know whether the engine is usable at +/// all. Everything engine-specific lives behind the adapter. +pub trait ContainerEngine { + /// Is this engine usable right now (binary present AND daemon reachable)? + /// A `None` return means yes; `Some(hint)` is the one-line reason it is not, + /// phrased as a remedy. + fn unavailable_reason(&self) -> Option; + + /// Bring the project named `project` up from `manifest_path`, publishing on + /// the host as the manifest declares. Returns the first actionable error + /// line on failure. + fn compose_up(&self, project: &str, manifest_path: &Path) -> Result<(), String>; + + /// Tear the project down (idempotent — tearing down an absent project + /// succeeds), removing its containers so no partial state survives a failed + /// bring-up. + fn compose_down(&self, project: &str, manifest_path: &Path) -> Result<(), String>; +} + +/// A health endpoint poller. +pub trait HealthCheck { + /// Does `url` answer successfully right now? + fn is_healthy(&self, url: &str) -> bool; +} + +/// A response read from a node endpoint: whether the status was `2xx` and the +/// raw body bytes. +#[derive(Debug, Clone)] +pub struct HttpResponse { + /// Whether the HTTP status line was `2xx`. + pub ok: bool, + /// The response body bytes. + pub body: Vec, +} + +/// Fetches a small document a node serves (e.g. its build proof). +/// +/// A narrow port, like [`HealthCheck`]: the orchestration only needs to GET a +/// URL and read the body; the transport (raw socket, a real HTTP client, an +/// in-process stub) lives behind the adapter. +pub trait HttpFetch { + /// GET `url` and return the response, or one actionable error line. + fn get(&self, url: &str) -> Result; +} + +/// The result of a successful standup: the operator-facing health URL of a node +/// that is already answering there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandupOutcome { + /// The health URL the operator can open — proven live before this returns. + pub health_url: String, +} + +/// The stable compose project name for a node published on `port`. +/// +/// One node per host port; the project name is derived from the port so a +/// second node on a second port is a second project, and `down` targets exactly +/// the node `up` created. +fn project_name(port: u16) -> String { + format!("auths-witness-{port}") +} + +/// Mint a fresh 32-byte signing-identity seed from the OS CSPRNG, hex-encoded. +/// +/// This is the node's identity at first boot: 32 bytes is the witness signer's +/// seed width, and the hex form is what the node loads. Only the OS-backed +/// random source is used — never a thread-local or seeded PRNG. +fn mint_seed_hex() -> String { + use rand::RngCore; + let mut seed = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut seed); + hex::encode(seed) +} + +/// Bring one witness node up and return its proven-live health URL. +/// +/// The full bring-up, in order: prepare the data dir, refuse early if the +/// engine is unusable, write the manifest, bring the project up, then wait for +/// the node to answer its health endpoint. Any failure tears down whatever +/// started so the host is left clean, and returns the single actionable line. +/// +/// Args: +/// * `req`: the parsed standup intent. +/// * `engine`: the container engine adapter to drive. +/// * `health`: the health poller. +/// * `wait`: how long to wait for the node to answer before failing. +pub fn stand_up( + req: &StandupRequest, + engine: &dyn ContainerEngine, + health: &dyn HealthCheck, + wait: Duration, +) -> Result { + // Fail before touching anything if the engine is unusable. + if let Some(hint) = engine.unavailable_reason() { + return Err(StandupError::NoEngine { hint }); + } + + std::fs::create_dir_all(&req.data_dir).map_err(|e| StandupError::DataDir { + path: req.data_dir.clone(), + reason: e.to_string(), + })?; + + // A build attestation is bind-mounted, so its host path must be absolute and + // exist — resolve it here at the I/O edge and fail closed if it cannot be, + // rather than letting the engine surface an opaque mount error. + let mut req = req.clone(); + if let Some(path) = req.build_attestation.take() { + let resolved = std::fs::canonicalize(&path).map_err(|e| StandupError::DataDir { + path: path.clone(), + reason: format!("build attestation could not be resolved: {e}"), + })?; + req.build_attestation = Some(resolved); + } + let req = &req; + + let project = project_name(req.host_port); + let manifest_path = req.data_dir.join("standup.compose.yml"); + std::fs::write(&manifest_path, req.compose_manifest()).map_err(|e| StandupError::DataDir { + path: manifest_path.clone(), + reason: e.to_string(), + })?; + + // Mint the node's stable signing identity at first boot and pin it next to + // the manifest, so the node advertises the same identity across restarts + // (and an operator never has to think about a key file). Compose reads the + // value from this `.env` and injects it into the node — the manifest never + // carries the secret inline. A re-run reuses the existing identity rather + // than minting a second one. + let env_path = req.data_dir.join(".env"); + if !env_path.exists() { + std::fs::write(&env_path, format!("WITNESS_SEED={}\n", mint_seed_hex())).map_err(|e| { + StandupError::DataDir { + path: env_path.clone(), + reason: e.to_string(), + } + })?; + } + + if let Err(reason) = engine.compose_up(&project, &manifest_path) { + // Leave nothing half-standing. + let _ = engine.compose_down(&project, &manifest_path); + return Err(StandupError::BringUpFailed { reason }); + } + + let url = req.health_url(); + let deadline = Instant::now() + wait; + while Instant::now() < deadline { + if health.is_healthy(&url) { + return Ok(StandupOutcome { health_url: url }); + } + std::thread::sleep(Duration::from_millis(500)); + } + + // Came up but never answered — tear it down and report honestly. + let _ = engine.compose_down(&project, &manifest_path); + Err(StandupError::Unhealthy { url, waited: wait }) +} + +/// Tear down the node published on `port`, if any. Idempotent. +pub fn tear_down( + data_dir: &Path, + port: u16, + engine: &dyn ContainerEngine, +) -> Result<(), StandupError> { + if let Some(hint) = engine.unavailable_reason() { + return Err(StandupError::NoEngine { hint }); + } + let project = project_name(port); + let manifest_path = data_dir.join("standup.compose.yml"); + engine + .compose_down(&project, &manifest_path) + .map_err(|reason| StandupError::BringUpFailed { reason }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + /// A scripted engine: records calls, returns canned results. + struct FakeEngine { + unavailable: Option, + up_result: Result<(), String>, + calls: RefCell>, + } + + impl FakeEngine { + fn ok() -> Self { + Self { + unavailable: None, + up_result: Ok(()), + calls: RefCell::new(vec![]), + } + } + } + + impl ContainerEngine for FakeEngine { + fn unavailable_reason(&self) -> Option { + self.unavailable.clone() + } + fn compose_up(&self, project: &str, _m: &Path) -> Result<(), String> { + self.calls.borrow_mut().push(format!("up:{project}")); + self.up_result.clone() + } + fn compose_down(&self, project: &str, _m: &Path) -> Result<(), String> { + self.calls.borrow_mut().push(format!("down:{project}")); + Ok(()) + } + } + + struct AlwaysHealthy; + impl HealthCheck for AlwaysHealthy { + fn is_healthy(&self, _url: &str) -> bool { + true + } + } + struct NeverHealthy; + impl HealthCheck for NeverHealthy { + fn is_healthy(&self, _url: &str) -> bool { + false + } + } + + fn req() -> StandupRequest { + let dir = tempfile::tempdir().unwrap().keep(); + let mut r = StandupRequest::local(dir); + r.host_port = 3399; + r + } + + #[test] + fn healthy_node_yields_its_health_url() { + let r = req(); + let out = stand_up( + &r, + &FakeEngine::ok(), + &AlwaysHealthy, + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(out.health_url, r.health_url()); + } + + #[test] + fn missing_engine_fails_before_touching_the_host() { + let r = req(); + let engine = FakeEngine { + unavailable: Some("install a container engine".to_string()), + up_result: Ok(()), + calls: RefCell::new(vec![]), + }; + let err = stand_up(&r, &engine, &AlwaysHealthy, Duration::from_secs(1)).unwrap_err(); + assert!(matches!(err, StandupError::NoEngine { .. })); + // Never attempted to bring anything up. + assert!(engine.calls.borrow().is_empty()); + } + + #[test] + fn bring_up_failure_tears_down_so_nothing_is_left() { + let r = req(); + let engine = FakeEngine { + unavailable: None, + up_result: Err("port already taken".to_string()), + calls: RefCell::new(vec![]), + }; + let err = stand_up(&r, &engine, &AlwaysHealthy, Duration::from_secs(1)).unwrap_err(); + assert!(matches!(err, StandupError::BringUpFailed { .. })); + // A teardown followed the failed bring-up. + assert!(engine.calls.borrow().iter().any(|c| c.starts_with("down:"))); + } + + #[test] + fn came_up_but_never_healthy_is_a_distinct_failure_and_tears_down() { + let r = req(); + let engine = FakeEngine::ok(); + let err = stand_up(&r, &engine, &NeverHealthy, Duration::from_millis(50)).unwrap_err(); + assert!(matches!(err, StandupError::Unhealthy { .. })); + assert!(engine.calls.borrow().iter().any(|c| c.starts_with("down:"))); + } + + #[test] + fn project_name_is_per_port() { + assert_ne!(project_name(3333), project_name(3334)); + assert_eq!(project_name(3333), "auths-witness-3333"); + } + + #[test] + fn standup_error_renders_one_line() { + let e = StandupError::NoEngine { + hint: "one actionable line".to_string(), + }; + assert_eq!(e.to_string(), "one actionable line"); + assert!(!e.to_string().contains('\n')); + } +} diff --git a/crates/auths-witness-node/src/vocabulary.rs b/crates/auths-witness-node/src/vocabulary.rs new file mode 100644 index 00000000..a017b345 --- /dev/null +++ b/crates/auths-witness-node/src/vocabulary.rs @@ -0,0 +1,165 @@ +//! The operator-vocabulary rule, in one place. +//! +//! A node operator stands a witness up, checks on it, registers it, reads its +//! logs — and must never need the protocol's vocabulary to do any of it. The +//! words a relying party's *verifier* speaks (key event logs, key-state +//! notices, self-addressing identifiers, the CESR wire, signing thresholds and +//! the rest) are correct and necessary *inside* the trust kernel; they are +//! friction in an operator's face. This module owns the line between the two so +//! it lives in exactly one place: the canonical list of terms an operator must +//! never see, and the scanner that finds one in a string. +//! +//! One source of truth (quality constitution §3): the standup surface, the +//! crate's own tests, and the conformance suite's vocabulary probe all check +//! against [`PROTOCOL_VOCABULARY`] here — none of them carries its own copy, so +//! none of them can drift from this list. + +/// Protocol terms an operator must never encounter in the witness happy path. +/// +/// These are the trust kernel's wire and ceremony vocabulary — exact and +/// load-bearing for a verifier, pure friction for an operator who only wants a +/// node that is up and vouchable. The list is the contract the operator-facing +/// surface is held to; it is matched whole-word and case-insensitively (see +/// [`scan_for_protocol_vocabulary`]), so an operator string that merely +/// *contains* these letters inside a larger benign word is not a leak. +/// +/// Whole-word matching is why innocuous substrings are safe: `did:key:` and +/// `identity` carry "id"; "received" carries no listed term; the operator line +/// "this node is not running what it attests" carries none. A leak is a listed +/// term standing as its own word. +pub const PROTOCOL_VOCABULARY: &[&str] = &[ + // The event-log / key-state family. + "keri", + "kel", + "kerl", + "ksn", + "icp", + "rot", + "ixn", + "drt", + // Self-addressing & credential wire. + "said", + "saider", + "acdc", + "tel", + // The binary encoding. + "cesr", + "cigar", + // Key-material jargon a relying party speaks, not an operator. + "verkey", + "prefix", + "tholder", + "diger", + // The corroboration-policy term: an operator runs a node; "threshold" is the + // verifier's M-of-N language, never standup's. + "threshold", + // Out-of-band introduction — discovery wire, not an operator concept. + "oobi", +]; + +/// Find the first protocol term [`PROTOCOL_VOCABULARY`] that appears as a whole +/// word in `text`, case-insensitively. `None` means the text is operator-clean. +/// +/// "Whole word" means flanked by non-alphanumeric boundaries (or string ends), +/// so a listed term embedded in a larger identifier — `prefixed`, `said` inside +/// `unsaid` — is not a false positive; only the bare term is a leak. The match +/// is case-insensitive because operators never see cased jargon either. +/// +/// Args: +/// * `text`: any operator-facing string (a command's stdout/stderr line, a +/// rendered verdict, a help blurb). +pub fn scan_for_protocol_vocabulary(text: &str) -> Option<&'static str> { + let lowered = text.to_ascii_lowercase(); + let bytes = lowered.as_bytes(); + for &term in PROTOCOL_VOCABULARY { + let mut from = 0; + while let Some(rel) = lowered[from..].find(term) { + let start = from + rel; + let end = start + term.len(); + let left_ok = start == 0 || !is_word_byte(bytes[start - 1]); + let right_ok = end == bytes.len() || !is_word_byte(bytes[end]); + if left_ok && right_ok { + return Some(term); + } + from = start + 1; + } + } + None +} + +/// Whether a byte is part of a word (ASCII alphanumeric). Word boundaries are +/// anything else — whitespace, punctuation, string ends. +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_operator_strings_carry_no_protocol_vocabulary() { + // The exact lines the operator happy path prints today — every one must + // pass the rule it is held to. + let operator_lines = [ + "health: http://127.0.0.1:3333/health", + "healthy: http://127.0.0.1:3333/health", + "build verified: this node runs 0.1.3 (digest 7ce84d53b3b63323…), signed and matching", + "witness node torn down", + "opening signed registration for https://wit.example.org", + "streaming logs for witness node at ./witness-data", + "verified: this receipt was issued by did:key:z6Mkhr…", + "node did not become healthy at http://127.0.0.1:3333/health within 540s — nothing left running", + "this node is not running what it attests", + ]; + for line in operator_lines { + assert_eq!( + scan_for_protocol_vocabulary(line), + None, + "operator line leaked protocol vocabulary: {line}" + ); + } + } + + #[test] + fn a_leaked_term_is_found_whole_word_case_insensitively() { + assert_eq!( + scan_for_protocol_vocabulary("served the current KEL for this identity"), + Some("kel") + ); + assert_eq!( + scan_for_protocol_vocabulary("quorum threshold met (2 of 3)"), + Some("threshold") + ); + assert_eq!( + scan_for_protocol_vocabulary("verkey rotated"), + Some("verkey") + ); + } + + #[test] + fn benign_substrings_are_not_false_positives() { + // "prefix" is listed; "prefixed"/"prefixes" are operator-fine words that + // merely contain it — whole-word matching must not flag them. + assert_eq!(scan_for_protocol_vocabulary("the prefixed path"), None); + // "id" / "did" appear all over operator output and are not listed; a + // listed term inside a larger word ("unsaid") is not a bare leak. + assert_eq!(scan_for_protocol_vocabulary("did:key:z6Mkhr… unsaid"), None); + assert_eq!(scan_for_protocol_vocabulary("received and witnessed"), None); + } + + #[test] + fn the_denylist_is_nonempty_and_lowercase() { + // The scanner lowercases input; the list it compares against must be + // lowercase too, or a term could never match. + assert!(!PROTOCOL_VOCABULARY.is_empty()); + for &term in PROTOCOL_VOCABULARY { + assert_eq!( + term, + term.to_ascii_lowercase(), + "denylist term not lowercase: {term}" + ); + assert!(!term.is_empty()); + } + } +} diff --git a/crates/auths-witness/Cargo.toml b/crates/auths-witness/Cargo.toml index 896a56b4..439c2f54 100644 --- a/crates/auths-witness/Cargo.toml +++ b/crates/auths-witness/Cargo.toml @@ -17,6 +17,9 @@ categories = ["cryptography", "network-programming"] auths-core = { workspace = true, features = ["witness-server"] } auths-crypto.workspace = true anyhow = "1" +# The binary reads its signed build attestation (a JSON document) from the file +# the deployment env names, and hands it to auths-core verbatim to serve. +serde_json = "1" clap = { version = "4", features = ["derive", "env"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal"] } tracing = "0.1" diff --git a/crates/auths-witness/src/main.rs b/crates/auths-witness/src/main.rs index 7356634f..679668eb 100644 --- a/crates/auths-witness/src/main.rs +++ b/crates/auths-witness/src/main.rs @@ -9,13 +9,19 @@ use std::path::PathBuf; use anyhow::{Context, Result, anyhow}; use auths_core::witness::{ - WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer, + BuildProof, WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer, load_witness_signer, witness_signer_from_seed_hex, }; use auths_crypto::CurveType; use auths_witness::hardened_witness_app; use clap::Parser; +/// Environment variable naming the file that holds this binary's signed build +/// attestation (`auths artifact sign` output). When set, the node measures its +/// own running binary, pairs it with the signed attestation, and serves both at +/// `/build` so a relying party can prove which binary the node runs. +const BUILD_ATTESTATION_ENV: &str = "AUTHS_WITNESS_BUILD_ATTESTATION"; + /// Slim, hardened KERI rct-witness server. #[derive(Parser, Debug)] #[command(name = "auths-witness", version, about)] @@ -91,6 +97,39 @@ fn resolve_signer(args: &Args, curve: CurveType) -> Result Result> { + #[allow(clippy::disallowed_methods)] + // Boundary: the binary reads its own deployment env, exactly as it does for + // AUTHS_WITNESS_SEED. A container injects the attestation file path here. + let Some(path) = std::env::var_os(BUILD_ATTESTATION_ENV) else { + return Ok(None); + }; + let path = PathBuf::from(path); + let bytes = std::fs::read(&path).with_context(|| { + format!( + "{BUILD_ATTESTATION_ENV} points at {} but it could not be read", + path.display() + ) + })?; + let attestation: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| { + format!( + "the build attestation at {} is not valid JSON", + path.display() + ) + })?; + let proof = BuildProof::measure_self(env!("CARGO_PKG_VERSION"), attestation) + .context("could not measure the running binary for the build proof")?; + Ok(Some(proof)) +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -101,8 +140,11 @@ async fn main() -> Result<()> { let curve = parse_curve(&args.curve)?; let signer = resolve_signer(&args, curve)?; - let config = WitnessServerConfig::from_signer(args.persist.clone(), signer) + let mut config = WitnessServerConfig::from_signer(args.persist.clone(), signer) .map_err(|e| anyhow!("witness config: {e}"))?; + if let Some(proof) = resolve_build_proof()? { + config = config.with_build_proof(proof); + } let state = WitnessServerState::new(config).map_err(|e| anyhow!("witness state: {e}"))?; tracing::info!( diff --git a/docs/deployment/witness/Dockerfile b/docs/deployment/witness/Dockerfile index eb00de02..9deadb80 100644 --- a/docs/deployment/witness/Dockerfile +++ b/docs/deployment/witness/Dockerfile @@ -6,21 +6,36 @@ # (secret/volume or AUTHS_WITNESS_SEED), never baked into the image. FROM rust:1.93 AS build +ARG TARGETARCH WORKDIR /src -RUN rustup target add x86_64-unknown-linux-musl && apt-get update \ +# Install musl tooling and the static target for THIS build's architecture. +# Resolving the Rust triple from the Docker TARGETARCH keeps a single image +# definition correct on both amd64 and arm64 build hosts (no emulated cross). +RUN case "$TARGETARCH" in \ + amd64) echo x86_64-unknown-linux-musl ;; \ + arm64) echo aarch64-unknown-linux-musl ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac > /rust-target \ + && apt-get update \ && apt-get install -y --no-install-recommends musl-tools \ && rm -rf /var/lib/apt/lists/* +# Copy the source (including rust-toolchain.toml) FIRST, then add the static +# target to the toolchain the toolchain file actually selects — adding it to a +# different toolchain instance before the copy leaves the build without a musl +# std for the active toolchain (E0463: can't find crate for `core`). COPY . . +RUN rustup target add "$(cat /rust-target)" # Build only the slim witness binary; auths-core `default = []` keeps the # keychain / ssh-agent / pairing surface out of the dependency tree. RUN cargo build --release --locked \ - --target x86_64-unknown-linux-musl \ - -p auths-witness --bin auths-witness + --target "$(cat /rust-target)" \ + -p auths-witness --bin auths-witness \ + && cp "target/$(cat /rust-target)/release/auths-witness" /auths-witness FROM gcr.io/distroless/static-debian12:nonroot # Non-root (the distroless `nonroot` user is uid 65532). USER nonroot:nonroot -COPY --from=build /src/target/x86_64-unknown-linux-musl/release/auths-witness /usr/local/bin/auths-witness +COPY --from=build /auths-witness /usr/local/bin/auths-witness # Receipts DB + keystore live under a writable volume mounted at /data; the rest # of the rootfs is read-only (enforce with `--read-only` / k8s readOnlyRootFilesystem). diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 4f2a1b75..cbccb78a 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "zeroize", ] diff --git a/tests/conformance/.gitignore b/tests/conformance/.gitignore new file mode 100644 index 00000000..c00a80a5 --- /dev/null +++ b/tests/conformance/.gitignore @@ -0,0 +1,7 @@ +# Python / pytest / uv ephemera — never committed. +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +uv.lock +.uv/ diff --git a/tests/conformance/MANIFEST.yaml b/tests/conformance/MANIFEST.yaml new file mode 100644 index 00000000..13bd76cc --- /dev/null +++ b/tests/conformance/MANIFEST.yaml @@ -0,0 +1,80 @@ +# MANIFEST.yaml — provenance for every conformance fixture + vector. +# GENERATED by gen_vectors.py from keripy (the oracle). Do not hand-edit; +# re-run `python3 gen_vectors.py` and review the diff. keripy-version +# drift changes the sha256s and fails test_vectors_provenance.py. +# +# Per-entry: {path, surface, source: keripy, version, gen_command, sha256} + +vectors: +- path: fixtures/kel-ed25519.json + surface: ksn/did-webs/oobi + source: keripy + version: 1.3.4 + gen_command: eventing.incept(keys=[Verfer(bytes(0..32)).qb64], code=Blake3_256) + sha256: 367b102719bf4601629079071fb01c11194f7c3bc711b27d2175ae07b045748c +- path: fixtures/kel-p256.json + surface: did-webs + source: keripy + version: 1.3.4 + gen_command: eventing.incept(keys=[P256 Verfer(scalar=int(bytes(1..33))).qb64], code=Blake3_256) + sha256: 80739dc059a21d0647ad6b8c802ce2c9afa5f3ed1e1915be0ab9d105ca161d7c +- path: fixtures/ksn-ingest.json + surface: ksn-ingest + source: keripy + version: 1.3.4 + gen_command: 'eventing.state(...).asdict() # keripy-published ksn wire record' + sha256: 940894c36b2f6d119e92b88e24b3720bcfbf741a482e2ea0f0aa7a05afe43cab +- path: fixtures/acdc.json + surface: ipex + source: keripy + version: 1.3.4 + gen_command: proving.credential(schema, issuer, data={dt}, recipient, status=registry) + sha256: 467916809b8d7e0a2732dfa4573ed94e3e2b102b969eb0bd4141ffa41db8de0d +- path: vectors/ksn-emit.json + surface: ksn-emit + source: keripy + version: 1.3.4 + gen_command: eventing.state(pre,sn=0,pig='',dig,fn=0,eilk='icp',keys,eevt,sith='1',ndigs=[],toad=0,wits=[],cnfg=[],stamp=epoch)._asdict() + sha256: 940894c36b2f6d119e92b88e24b3720bcfbf741a482e2ea0f0aa7a05afe43cab +- path: vectors/ksn-ingest-resolved.json + surface: ksn-ingest + source: keripy + version: 1.3.4 + gen_command: subset {i,k,s} of eventing.state(...)._asdict() + sha256: a6a5fdd4c9b49310200752ad535099cc70ff89154f22e86dc932a510f913f99e +- path: vectors/did-webs-ed25519.json + surface: did-webs + source: keripy + version: 1.3.4 + gen_command: "gen_did_document(Ed25519\u2192OKP x-only JWK)" + sha256: 86715671a59fe60d105e45def2a3f0573ad4e65328e83e157cf391f4f40532ec +- path: vectors/did-webs-p256.json + surface: did-webs + source: keripy + version: 1.3.4 + gen_command: "gen_did_document(P-256\u2192EC x/y JWK)" + sha256: 5a2e86894bb7c4c0395431aa36a65b6c63e2fe84e38b7bd5131d434a01b91a06 +- path: vectors/oobi-loc-scheme.json + surface: oobi + source: keripy + version: 1.3.4 + gen_command: eventing.reply(route='/loc/scheme', data={eid,scheme,url}, stamp=epoch) + sha256: d77be84ca8ebc2730fd93955532c7431832f5db6a507ea1ca9f4d02fc53152c9 +- path: vectors/oobi-end-role.json + surface: oobi + source: keripy + version: 1.3.4 + gen_command: eventing.reply(route='/end/role/add', data={cid,role,eid}, stamp=epoch) + sha256: 4eb7e35a33e3789cef80c2da3ccdd2343fa85a2b051f08f2950648c8593ed3f6 +- path: vectors/ipex-grant.json + surface: ipex + source: keripy + version: 1.3.4 + gen_command: exchanging.exchange(route='/ipex/grant', payload={m:'',i:recp}, sender, embeds={acdc}, date=dt) + sha256: c1eb900196e4edd0638792829a4c31213e774b91219f3d2a6bd76f08159feaa0 +- path: vectors/ipex-admit.json + surface: ipex + source: keripy + version: 1.3.4 + gen_command: exchanging.exchange(route='/ipex/admit', payload={m:''}, sender=recp, dig=grant.said, date=dt) + sha256: 6ba49d823995c7087a534e740c6c7a1567c53ac8aab6f547ea5ea12f2cd1061b diff --git a/tests/conformance/README.md b/tests/conformance/README.md new file mode 100644 index 00000000..99d3637b --- /dev/null +++ b/tests/conformance/README.md @@ -0,0 +1,102 @@ +# keripy conformance suite + +Proves the **auths** KERI CLI is byte-for-byte interoperable with the KERI +reference implementation, **keripy 1.3.4**. For each KERI surface, identical +fixed inputs are fed to both keripy and the auths CLI, and the outputs are +compared as canonical JSON. + +## What it proves + +For every surface the suite asserts **two** things: + +1. **Live cross-check** — `auths CLI output == keripy oracle output` + (`oracle.py`, pure keripy). This is the real interop claim, computed fresh on + every run from the installed keripy. +2. **Drift / provenance** — `auths CLI output == the frozen vector` in + `vectors/`, and (`test_vectors_provenance.py`) the frozen vectors reproduce + the keripy oracle byte-for-byte. So the goldens are provably keripy's, and a + keripy-version drift fails loudly. + +No expected value is ever hand-copied from auths output: every expected SAID and +field is produced by keripy code (`oracle.py`), using the exact recipes the auths +Rust modules document (`auths-keri/src/{oobi,ipex,did_webs}.rs`). + +## Surfaces and match type + +| # | Surface | auths command | keripy oracle | Match | +|---|---------|---------------|---------------|-------| +| 1 | ksn emit | `key-state --from-kel` | `eventing.state(...)._asdict()` | byte-exact (canonical JSON); `dt` pinned to epoch on both sides | +| 2 | ksn ingest | `key-state --ingest` | subset `{i,k,s}` of `eventing.state(...)` | structural — auths prints a normalized internal view; gated on resolved key-state `(i,k,s)` + last-event SAID | +| 3 | did:webs (Ed25519) | `did-webs --from-kel --domain` | `gen_did_document` (OKP x-only JWK) | byte-exact | +| 3b | did:webs (P-256) | `did-webs --from-kel --domain` | `gen_did_document` (EC x/y JWK) | byte-exact | +| 4 | oobi endpoint | `oobi endpoint --from-kel --authority --url` | `eventing.reply('/loc/scheme')` + `eventing.reply('/end/role/add')` | byte-exact incl. SAIDs; `dt` pinned to epoch | +| 5 | ipex grant | `ipex grant --acdc --sender --recipient` | `exchanging.exchange('/ipex/grant', embeds={acdc})` | byte-exact incl. top-level `d`, embedded ACDC, and embeds-section `e.d` | +| 6 | ipex admit | `ipex admit --grant --sender` | `exchanging.exchange('/ipex/admit', dig=grant.said)` | byte-exact incl. `d` and prior `p` | + +### Normalization notes (and why) + +The comparison helper `canon()` only sorts object keys and compacts whitespace — +it never drops or rewrites a value, so any real type/value difference still +fails. The only places where the two sides are deliberately made to agree: + +- **`dt` (ksn emit, oobi)** — keripy's `state()`/`reply()` default the timestamp + to `now()`; auths defaults `--dt` to the epoch. We pin BOTH to the epoch (the + auths default; `stamp=`/`date=` on keripy), so no field is normalized away. +- **`a.dt` (ipex ACDC)** — keripy's `proving.credential` stamps the subject `dt` + with `now()` unless given; we pass `data={"dt": ...}` to fix it. auths is + given the same fixed-`dt` ACDC and `--dt`. +- **ksn ingest shape** — auths `--ingest` prints `{prefix, current_keys, + sequence, last_event_said, ...}` (a resolved internal view), not the wire ksn. + The test maps those to `(i, k, s)` and compares; this surface is gated on the + *resolved key-state*, not on echoing the wire bytes. This is the one + structural-rather-than-byte-exact surface. + +All other surfaces are byte-exact under canonical JSON. + +## How to run + +From the auths repo root, with keripy 1.3.4 importable: + +```bash +python3 -m pytest tests/conformance -v +``` + +The auths binary is located via `$AUTHS_BIN`, else the release build at +`target/release/auths`, else `auths` on `PATH`. If none is found the suite +*skips* with a clear message (build with `cargo build --release -p auths`). + +Every auths invocation is passed `--repo `; an autouse session fixture +asserts `~/.auths` is never created or modified by the run. + +## Regenerating vectors + +The frozen inputs (`fixtures/`) and golden outputs (`vectors/`) plus their +provenance (`MANIFEST.yaml`) are generated from keripy: + +```bash +cd tests/conformance && python3 gen_vectors.py +``` + +`test_vectors_provenance.py` then guarantees the checked-in files match what +keripy produces, byte-for-byte. Regenerate only on an intentional keripy version +bump and review the diff. + +## Version pin + +- **keripy 1.3.4** (`python3 -c "import keri; print(keri.__version__)"`). +- Deterministic key material: Ed25519 raw = `bytes(range(32))`; recipient + Ed25519 raw = `bytes((b+5)%256 for b in range(32))`; P-256 scalar = + `int(bytes(range(1,33)))`. AID for the primary key: + `EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J`. + +## Honest scope + +- **Byte-exact (canonical JSON):** ksn emit, did:webs (Ed25519 + P-256), oobi + `/loc/scheme` + `/end/role/add` (SAIDs included), ipex grant (incl. embedded + ACDC + embeds-section SAID), ipex admit (incl. prior SAID). +- **Structural:** ksn ingest — gated on the resolved key-state `(i, k, s)` and + the last-event SAID, because auths emits a normalized view rather than the wire + ksn. +- **Out of scope for this gate:** no network, no Docker, no signify-ts/KERIA live + peers. Signatures and witnessing are covered by the separate interop harness, + not here. This suite is keripy-only and offline. diff --git a/tests/conformance/conftest.py b/tests/conformance/conftest.py new file mode 100644 index 00000000..1ea7b3c4 --- /dev/null +++ b/tests/conformance/conftest.py @@ -0,0 +1,156 @@ +"""Fixtures for the keripy conformance suite. + +Locates the auths binary, gives each test an isolated `--repo` tmpdir, and +provides helpers to run auths and parse its JSON. The suite NEVER touches +`~/.auths`: every auths invocation is passed `--repo `, and a +session-scoped guard fixture asserts `~/.auths` is not created or mutated by the +run. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +# The release binary the spike used; overridable via AUTHS_BIN. +_DEFAULT_BIN = ( + Path(__file__).resolve().parents[2] / "target" / "release" / "auths" +) + + +def _find_binary() -> Path | None: + if path := os.environ.get("AUTHS_BIN"): + p = Path(path) + if p.exists(): + return p + if _DEFAULT_BIN.exists(): + return _DEFAULT_BIN + if found := shutil.which("auths"): + return Path(found) + return None + + +@dataclass +class CLIResult: + """The result of running an auths CLI command.""" + + returncode: int + stdout: str + stderr: str + + def ok(self) -> "CLIResult": + assert self.returncode == 0, ( + f"auths exited {self.returncode}\nstdout:\n{self.stdout}\n" + f"stderr:\n{self.stderr}" + ) + return self + + @property + def json(self) -> dict: + """Parse stdout as a single JSON object.""" + return json.loads(self.stdout) + + @property + def json_lines(self) -> list[dict]: + """Parse stdout as newline-delimited JSON objects (skipping blanks).""" + return [ + json.loads(line) + for line in self.stdout.splitlines() + if line.strip() + ] + + +@pytest.fixture(scope="session") +def auths_bin() -> Path: + """Path to the auths binary (skip the suite cleanly if it's not built).""" + path = _find_binary() + if path is None: + pytest.skip( + "auths binary not found — set AUTHS_BIN or build " + "(cargo build --release -p auths)" + ) + return path + + +@pytest.fixture(scope="session", autouse=True) +def _guard_user_auths_dir(): + """Fail loudly if the suite ever creates or mutates ~/.auths. + + Records a fingerprint of ~/.auths before any test runs and re-checks it after + the whole session. Every auths call in this suite passes --repo, so the real + user store must be byte-identical across the run. + """ + home_auths = Path.home() / ".auths" + + def fingerprint() -> tuple[bool, list[str]]: + if not home_auths.exists(): + return (False, []) + entries = sorted( + f"{p.relative_to(home_auths)}:{p.stat().st_mtime_ns}:{p.stat().st_size}" + for p in home_auths.rglob("*") + if p.is_file() + ) + return (True, entries) + + before = fingerprint() + yield + after = fingerprint() + assert after == before, ( + "~/.auths was created or modified by the conformance suite — every " + "auths invocation MUST pass --repo .\n" + f"before: existed={before[0]} files={len(before[1])}\n" + f"after: existed={after[0]} files={len(after[1])}" + ) + + +@pytest.fixture +def repo_dir(tmp_path) -> Path: + """A fresh per-test `--repo` directory (never ~/.auths).""" + d = tmp_path / "repo" + d.mkdir() + return d + + +@pytest.fixture +def run_auths(auths_bin, repo_dir): + """Run `auths --repo ` and capture output. + + `--repo` is appended automatically (unless already present) so no test can + accidentally fall back to ~/.auths. + """ + + def _run(args: list[str], *, check: bool = True) -> CLIResult: + full = list(args) + if "--repo" not in full: + full += ["--repo", str(repo_dir)] + proc = subprocess.run( + [str(auths_bin), *full], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "NO_COLOR": "1"}, + ) + result = CLIResult(proc.returncode, proc.stdout, proc.stderr) + if check: + result.ok() + return result + + return _run + + +@pytest.fixture +def write_json(tmp_path): + """Write an object as JSON to a tmp file and return its path.""" + + def _write(name: str, obj) -> Path: + p = tmp_path / name + p.write_text(json.dumps(obj, indent=2)) + return p + + return _write diff --git a/tests/conformance/fixtures/acdc.json b/tests/conformance/fixtures/acdc.json new file mode 100644 index 00000000..041be565 --- /dev/null +++ b/tests/conformance/fixtures/acdc.json @@ -0,0 +1 @@ +{"a":{"d":"EM87KRWNVWdwTbahWe3X5pV-sspJ0cQcTYV0VBC2GDZ1","dt":"2024-01-01T00:00:00.000000+00:00","i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS"},"d":"EPEa_K4Qzu4pDckEIiGeTONlWy2db2a3tXcuksOHPSLl","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","v":"ACDC10JSON00017a_"} diff --git a/tests/conformance/fixtures/kel-ed25519.json b/tests/conformance/fixtures/kel-ed25519.json new file mode 100644 index 00000000..346cf185 --- /dev/null +++ b/tests/conformance/fixtures/kel-ed25519.json @@ -0,0 +1 @@ +[{"a":[],"b":[],"bt":"0","c":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"kt":"1","n":[],"nt":"0","s":"0","t":"icp","v":"KERI10JSON0000fd_"}] diff --git a/tests/conformance/fixtures/kel-p256.json b/tests/conformance/fixtures/kel-p256.json new file mode 100644 index 00000000..461cc12c --- /dev/null +++ b/tests/conformance/fixtures/kel-p256.json @@ -0,0 +1 @@ +[{"a":[],"b":[],"bt":"0","c":[],"d":"EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","i":"EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","k":["1AAJAlFcPW6545a5BNP-yn9U_c0MwemXvzddylFa0KbDtANf"],"kt":"1","n":[],"nt":"0","s":"0","t":"icp","v":"KERI10JSON000101_"}] diff --git a/tests/conformance/fixtures/ksn-ingest.json b/tests/conformance/fixtures/ksn-ingest.json new file mode 100644 index 00000000..383326ab --- /dev/null +++ b/tests/conformance/fixtures/ksn-ingest.json @@ -0,0 +1 @@ +{"b":[],"bt":"0","c":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","di":"","dt":"1970-01-01T00:00:00+00:00","ee":{"ba":[],"br":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0"},"et":"icp","f":"0","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"kt":"1","n":[],"nt":"0","p":"","s":"0","vn":[1,0]} diff --git a/tests/conformance/gen_vectors.py b/tests/conformance/gen_vectors.py new file mode 100644 index 00000000..6cd99afe --- /dev/null +++ b/tests/conformance/gen_vectors.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Regenerate canonical input fixtures + frozen golden outputs FROM keripy 1.3.4. + +Writes two trees plus a provenance manifest: + - fixtures/*.json : the deterministic INPUTS both keripy and the auths CLI + consume (KELs, ksn records, ACDC). + - vectors/*.json : the frozen EXPECTED OUTPUTS computed by the keripy oracle + (ksn state, did:webs docs, oobi replies, ipex exn). These + are the drift/provenance anchors: test_vectors_provenance.py + asserts re-running this script reproduces them byte-for-byte. + - MANIFEST.yaml : per-file {path, surface, source: keripy, version, + gen_command, sha256} (mirrors interop/vectors/MANIFEST.yaml). + +Every value is deterministic (fixed key material, explicit timestamps), so the +checked-in files and their sha256s are stable across regenerations. The expected +outputs are produced ONLY by keripy oracle code in oracle.py — never copied from +auths output. + +Run: python3 gen_vectors.py +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import oracle as o + +HERE = Path(__file__).resolve().parent +FIX = HERE / "fixtures" +VEC = HERE / "vectors" +KERI_VERSION = __import__("keri").__version__ + + +def _canon_bytes(obj) -> bytes: + """Canonical JSON bytes: sorted keys, compact separators, trailing newline.""" + return ( + json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + + +def main() -> None: + FIX.mkdir(parents=True, exist_ok=True) + VEC.mkdir(parents=True, exist_ok=True) + entries: list[dict] = [] + + def emit(tree: Path, rel: str, obj, surface: str, gen: str) -> None: + path = tree / rel + path.parent.mkdir(parents=True, exist_ok=True) + data = _canon_bytes(obj) + path.write_bytes(data) + entries.append( + { + "path": f"{tree.name}/{rel}", + "surface": surface, + "source": "keripy", + "version": KERI_VERSION, + "gen_command": gen, + "sha256": hashlib.sha256(data).hexdigest(), + } + ) + + # ── Inception fixtures (shared inputs) ────────────────────────────────── + icp = o.icp_ed() + icp2 = o.icp_ed2() + icp_p = o.icp_p256() + sender, recipient = icp.pre, icp2.pre + + emit(FIX, "kel-ed25519.json", o.kel_json(icp), "ksn/did-webs/oobi", + "eventing.incept(keys=[Verfer(bytes(0..32)).qb64], code=Blake3_256)") + emit(FIX, "kel-p256.json", o.kel_json(icp_p), "did-webs", + "eventing.incept(keys=[P256 Verfer(scalar=int(bytes(1..33))).qb64], code=Blake3_256)") + emit(FIX, "ksn-ingest.json", o.ksn_ingest_input(icp), "ksn-ingest", + "eventing.state(...).asdict() # keripy-published ksn wire record") + emit(FIX, "acdc.json", o.ipex_acdc(sender, recipient), "ipex", + "proving.credential(schema, issuer, data={dt}, recipient, status=registry)") + + # ── Golden outputs (keripy oracle) ────────────────────────────────────── + emit(VEC, "ksn-emit.json", o.ksn_state(icp), "ksn-emit", + "eventing.state(pre,sn=0,pig='',dig,fn=0,eilk='icp',keys,eevt,sith='1'," + "ndigs=[],toad=0,wits=[],cnfg=[],stamp=epoch)._asdict()") + emit(VEC, "ksn-ingest-resolved.json", o.ksn_ingest_expected(icp), "ksn-ingest", + "subset {i,k,s} of eventing.state(...)._asdict()") + emit(VEC, "did-webs-ed25519.json", + o.did_webs(icp, o.ed_verfer(), "example.com"), "did-webs", + "gen_did_document(Ed25519→OKP x-only JWK)") + emit(VEC, "did-webs-p256.json", + o.did_webs(icp_p, o.p256_verfer(), "example.com"), "did-webs", + "gen_did_document(P-256→EC x/y JWK)") + emit(VEC, "oobi-loc-scheme.json", + o.oobi_loc_scheme(icp, "http://127.0.0.1:5642/"), "oobi", + "eventing.reply(route='/loc/scheme', data={eid,scheme,url}, stamp=epoch)") + emit(VEC, "oobi-end-role.json", o.oobi_end_role(icp), "oobi", + "eventing.reply(route='/end/role/add', data={cid,role,eid}, stamp=epoch)") + emit(VEC, "ipex-grant.json", o.ipex_grant(sender, recipient), "ipex", + "exchanging.exchange(route='/ipex/grant', payload={m:'',i:recp}, " + "sender, embeds={acdc}, date=dt)") + emit(VEC, "ipex-admit.json", o.ipex_admit(sender, recipient), "ipex", + "exchanging.exchange(route='/ipex/admit', payload={m:''}, sender=recp, " + "dig=grant.said, date=dt)") + + # ── MANIFEST ──────────────────────────────────────────────────────────── + import yaml + + header = ( + "# MANIFEST.yaml — provenance for every conformance fixture + vector.\n" + "# GENERATED by gen_vectors.py from keripy (the oracle). Do not hand-edit;\n" + "# re-run `python3 gen_vectors.py` and review the diff. keripy-version\n" + "# drift changes the sha256s and fails test_vectors_provenance.py.\n" + "#\n" + "# Per-entry: {path, surface, source: keripy, version, gen_command, sha256}\n\n" + ) + (HERE / "MANIFEST.yaml").write_text( + header + yaml.safe_dump({"vectors": entries}, sort_keys=False, width=10_000) + ) + + print(f"wrote {len(entries)} files from keripy {KERI_VERSION}") + for e in entries: + print(f" {e['path']:<32} {e['sha256'][:12]}…") + + +if __name__ == "__main__": + main() diff --git a/tests/conformance/oracle.py b/tests/conformance/oracle.py new file mode 100644 index 00000000..fccb3430 --- /dev/null +++ b/tests/conformance/oracle.py @@ -0,0 +1,278 @@ +"""keripy oracle — the canonical expected output for every conformance surface. + +Every function here is a *pure keripy* computation. It never reads auths output +and never hand-copies a SAID: the expected value for each surface is produced by +calling the KERI reference implementation (keripy 1.3.4) with the SAME fixed, +deterministic inputs the auths CLI is fed. The conformance tests then assert that +`auths_cli_output == oracle_output`, which is the live byte-for-byte cross-check. + +All key material is fixed (no randomness, no clock): the Ed25519 raw key is +`bytes(range(32))`, the P-256 scalar is `int(bytes(range(1, 33)))`, and every +timestamp is passed explicitly. This mirrors interop/harness/gen_vectors.py. + +The keripy recipe each function uses is documented in the auths Rust sources: + - key-state / ksn ⇔ keri.core.eventing.state(...)._asdict() + - did:webs ⇔ did-webs-resolver gen_did_document (OKP/EC JWK) + - oobi /loc /end ⇔ keri.core.eventing.reply(route=..., data=..., stamp=...) + - ipex grant / admit ⇔ keri.peer.exchanging.exchange(route=..., ...) +""" + +from __future__ import annotations + +import base64 +import json + +from keri.core import coring, eventing +from keri.core.coring import MtrDex +from keri.peer import exchanging +from keri.vc import proving + +# ── Fixed, deterministic key material ─────────────────────────────────────── +ED_RAW = bytes(range(32)) # 0x00..0x1f — the spike's Ed25519 raw key. +# A second Ed25519 key for the IPEX recipient AID (deterministic offset). +ED2_RAW = bytes((b + 5) % 256 for b in range(32)) +# The fixed registry/schema SAIDs used by the auths ipex.rs reference vector. +IPEX_REGISTRY = "EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o" +IPEX_SCHEMA = "EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC" +# Fixed timestamps. Epoch is the auths CLI default for --dt; the oobi/ipex +# surfaces stamp with microsecond precision (keripy/auths both use that form). +EPOCH_KSN = "1970-01-01T00:00:00+00:00" +EPOCH_US = "1970-01-01T00:00:00.000000+00:00" +ACDC_DT = "2024-01-01T00:00:00.000000+00:00" + + +def ed_verfer() -> coring.Verfer: + """The deterministic Ed25519 verfer (controller key).""" + return coring.Verfer(raw=ED_RAW, code=MtrDex.Ed25519) + + +def ed2_verfer() -> coring.Verfer: + """The deterministic Ed25519 verfer for the IPEX recipient AID.""" + return coring.Verfer(raw=ED2_RAW, code=MtrDex.Ed25519) + + +def p256_verfer() -> coring.Verfer: + """A deterministic, valid P-256 verfer (compressed SEC1 point).""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + + scalar = int.from_bytes(bytes(range(1, 33)), "big") + priv = ec.derive_private_key(scalar, ec.SECP256R1()) + raw = priv.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint + ) + return coring.Verfer(raw=raw, code=MtrDex.ECDSA_256r1) + + +# ── Inception / KEL fixtures (the inputs both sides consume) ───────────────── +def icp_ed() -> eventing.Serder: + """Self-addressing Ed25519 inception (the auths AID model: i == d == SAID).""" + return eventing.incept(keys=[ed_verfer().qb64], code=MtrDex.Blake3_256) + + +def icp_ed2() -> eventing.Serder: + """Self-addressing inception for the IPEX recipient AID.""" + return eventing.incept(keys=[ed2_verfer().qb64], code=MtrDex.Blake3_256) + + +def icp_p256() -> eventing.Serder: + """Self-addressing P-256 inception.""" + return eventing.incept(keys=[p256_verfer().qb64], code=MtrDex.Blake3_256) + + +def kel_json(icp: eventing.Serder) -> list[dict]: + """A KEL file body: a JSON array of keripy event.raw objects (the spike form).""" + return [json.loads(icp.raw.decode())] + + +# ── Surface 1: ksn emit ───────────────────────────────────────────────────── +def ksn_state(icp: eventing.Serder, *, stamp: str = EPOCH_KSN) -> dict: + """keripy KeyStateRecord (`ksn` wire shape) for a single-icp KEL. + + Mirrors auths `key-state --from-kel`. keripy's `state(...)` defaults `dt` to + `now()`; auths defaults `--dt` to the epoch, so we pin `stamp` to the epoch + to keep the comparison deterministic (the only field that would otherwise + diverge). Every other field (vn, i, s, p, d, f, et, kt, k, nt, n, bt, b, c, + ee, di) is keripy's own output. + """ + keys = [json.loads(icp.raw.decode())["k"][0]] + ksr = eventing.state( + pre=icp.pre, + sn=0, + pig="", + dig=icp.said, + fn=0, + eilk="icp", + keys=keys, + eevt=eventing.StateEstEvent(s="0", d=icp.said, br=[], ba=[]), + sith="1", + ndigs=[], + toad=0, + wits=[], + cnfg=[], + stamp=stamp, + ) + return ksr._asdict() + + +# ── Surface 2: ksn ingest ─────────────────────────────────────────────────── +def ksn_ingest_input(icp: eventing.Serder) -> dict: + """A keripy-produced ksn JSON record (the thing a peer publishes).""" + return ksn_state(icp, stamp=EPOCH_KSN) + + +def ksn_ingest_expected(icp: eventing.Serder) -> dict: + """The resolved key-state (i, k, s) a peer should recover from the ksn. + + auths `--ingest` prints a normalized internal view, not the wire ksn; the + gate asserts the *resolved key-state* matches: prefix==i, current_keys==k, + sequence==s. Those three are derived here straight from keripy. + """ + rec = ksn_state(icp, stamp=EPOCH_KSN) + return {"i": rec["i"], "k": rec["k"], "s": rec["s"]} + + +# ── Surface 3: did:webs ───────────────────────────────────────────────────── +def _b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def did_webs(icp: eventing.Serder, verfer: coring.Verfer, domain: str) -> dict: + """The did:webs DID document for an AID. + + Mirrors the did-webs-resolver `gen_did_document` / `generate_json_web_key_vm`: + field order id, verificationMethod, service, alsoKnownAs; Ed25519 → OKP + x-only JWK; P-256 → EC x/y JWK. The verkey qb64 is keripy's; the JWK + coordinates are derived from the same raw public key keripy holds. + """ + aid = icp.pre + did = f"did:webs:{domain}:{aid}" + vk = verfer.qb64 + + if verfer.code == MtrDex.Ed25519: + jwk = {"kty": "OKP", "kid": vk, "crv": "Ed25519", "x": _b64u(verfer.raw)} + elif verfer.code == MtrDex.ECDSA_256r1: + from cryptography.hazmat.primitives.asymmetric import ec + + # Decompress the SEC1 point keripy carries to recover x and y. + pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), verfer.raw) + nums = pub.public_numbers() + jwk = { + "kty": "EC", + "kid": vk, + "crv": "P-256", + "x": _b64u(nums.x.to_bytes(32, "big")), + "y": _b64u(nums.y.to_bytes(32, "big")), + } + else: + raise ValueError(f"unsupported verkey code {verfer.code}") + + return { + "id": did, + "verificationMethod": [ + { + "id": f"#{vk}", + "type": "JsonWebKey", + "controller": did, + "publicKeyJwk": jwk, + } + ], + "service": [], + "alsoKnownAs": [f"did:keri:{aid}"], + } + + +# ── Surface 4: oobi endpoint ──────────────────────────────────────────────── +def oobi_url(icp: eventing.Serder, authority: str, scheme: str = "http") -> str: + """The OOBI URL auths emits for the controller role (keripy OOBI_RE shape).""" + return f"{scheme}://{authority}/oobi/{icp.pre}/controller" + + +def oobi_loc_scheme(icp: eventing.Serder, url: str, *, stamp: str = EPOCH_US) -> dict: + """The `/loc/scheme` rpy reply, via keripy `eventing.reply` (SAID included).""" + rpy = eventing.reply( + route="/loc/scheme", + data={"eid": icp.pre, "scheme": "http", "url": url}, + stamp=stamp, + ) + return json.loads(rpy.raw.decode()) + + +def oobi_end_role(icp: eventing.Serder, *, stamp: str = EPOCH_US) -> dict: + """The `/end/role/add` rpy reply (controller authorizes itself), via keripy.""" + rpy = eventing.reply( + route="/end/role/add", + data={"cid": icp.pre, "role": "controller", "eid": icp.pre}, + stamp=stamp, + ) + return json.loads(rpy.raw.decode()) + + +# ── Surfaces 5 & 6: ipex grant / admit ────────────────────────────────────── +def ipex_acdc(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: + """A deterministic ACDC `{v,d,i,ri,s,a:{d,i,dt}}` matching the auths shape. + + keripy `proving.credential` saidifies the `a` block (nested `a.d`) exactly as + auths does. Passing `data={"dt": dt}` makes `a.dt` deterministic (keripy + otherwise stamps it with now()). Empty attributes otherwise. + """ + acdc = proving.credential( + schema=IPEX_SCHEMA, + issuer=sender, + data={"dt": dt}, + recipient=recipient, + status=IPEX_REGISTRY, + ) + return json.loads(acdc.raw.decode()) + + +def _acdc_serder(sender: str, recipient: str, *, dt: str = ACDC_DT): + return proving.credential( + schema=IPEX_SCHEMA, + issuer=sender, + data={"dt": dt}, + recipient=recipient, + status=IPEX_REGISTRY, + ) + + +def ipex_grant(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: + """keripy IPEX grant `exn` (route /ipex/grant), via `exchanging.exchange`. + + Recipe (from auths ipex.rs): exchange(route="/ipex/grant", + payload={m:"", i:recipient}, sender=sender, embeds={acdc:}, date=dt). + The top-level `d` SAID and the embeds section SAID `e.d` are keripy's own. + """ + acdc = _acdc_serder(sender, recipient, dt=dt) + exn, _ = exchanging.exchange( + route="/ipex/grant", + payload={"m": "", "i": recipient}, + sender=sender, + embeds={"acdc": acdc.raw}, + date=dt, + ) + return json.loads(exn.raw.decode()) + + +def ipex_admit(sender: str, recipient: str, *, dt: str = ACDC_DT) -> dict: + """keripy IPEX admit `exn` (route /ipex/admit), via `exchanging.exchange`. + + Recipe (from auths ipex.rs): exchange(route="/ipex/admit", payload={m:""}, + sender=recipient, dig=, date=dt). Prior `p` = the grant SAID. + """ + acdc = _acdc_serder(sender, recipient, dt=dt) + grant, _ = exchanging.exchange( + route="/ipex/grant", + payload={"m": "", "i": recipient}, + sender=sender, + embeds={"acdc": acdc.raw}, + date=dt, + ) + admit, _ = exchanging.exchange( + route="/ipex/admit", + payload={"m": ""}, + sender=recipient, + dig=grant.said, + date=dt, + ) + return json.loads(admit.raw.decode()) diff --git a/tests/conformance/pyproject.toml b/tests/conformance/pyproject.toml new file mode 100644 index 00000000..9654f509 --- /dev/null +++ b/tests/conformance/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "auths-keripy-conformance" +version = "0.1.0" +description = "Byte-for-byte conformance of the auths KERI CLI against keripy 1.3.4" +# keri==1.3.4 pulls hio, which requires Python >=3.12.6; pinning below 3.14 +# keeps uv from selecting hio>=0.7.17 (which demands 3.14). Without this the +# `uv run` resolver used in CI is unsatisfiable. +requires-python = ">=3.12.6,<3.14" + +[tool.uv] +package = false + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-timeout>=2.1", + "keri==1.3.4", + "cryptography", + "pyyaml", +] + +[tool.pytest.ini_options] +testpaths = ["."] +markers = [ + "requires_binary: marks tests that require the compiled auths binary", +] +strict_markers = true +# `timeout` is honored when pytest-timeout is installed (see dev deps); harmless +# otherwise. Per-subprocess timeouts are also enforced in conftest's run_auths. +timeout = 60 diff --git a/tests/conformance/test_keripy_conformance.py b/tests/conformance/test_keripy_conformance.py new file mode 100644 index 00000000..958b80ab --- /dev/null +++ b/tests/conformance/test_keripy_conformance.py @@ -0,0 +1,229 @@ +"""keripy conformance — the live gate. + +For each KERI surface, assert TWO things: + 1. live cross-check : auths CLI output == keripy oracle output (oracle.py) + 2. drift/provenance : auths CLI output == the frozen vector in vectors/ + +Both comparisons are CANONICAL JSON (sorted keys, normalized via `canon`). When +the comparison is byte-exact the raw stdout already matches; `canon` only +reorders keys so a JSON object-ordering difference never masks a real one. Any +field that must be *normalized away* (because the two sides legitimately differ) +is documented inline with the WHY. + +The auths AID for the fixed Ed25519 key is +EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J (the spike's value). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import oracle as o + +HERE = Path(__file__).resolve().parent +VEC = HERE / "vectors" + + +def canon(obj) -> str: + """Canonical JSON string: keys sorted recursively, compact separators. + + Numbers and strings keep their JSON forms; this only removes object-key + ordering and whitespace as sources of spurious mismatch. Both the auths + output and the keripy oracle output are real JSON, so this is a faithful + structural-equality check that still catches any value/type difference. + """ + return json.dumps(obj, sort_keys=True, separators=(",", ":")) + + +def load_vector(name: str) -> dict: + return json.loads((VEC / name).read_text()) + + +# ── Surface 1: ksn emit ────────────────────────────────────────────────────── +def test_ksn_emit(run_auths, write_json): + """keripy KEL → `auths key-state --from-kel` == keripy eventing.state(...). + + Byte-exact / structural: every field matches keripy's KeyStateRecord. The + only field that would diverge is `dt` — keripy `state()` defaults it to + now(); auths defaults --dt to the epoch. We pin BOTH to the epoch (oracle + stamp=epoch, auths default --dt=epoch), so no field is normalized away here. + """ + icp = o.icp_ed() + kel = write_json("kel.json", o.kel_json(icp)) + + out = run_auths(["key-state", "--from-kel", str(kel), "--json"]).json + expected = o.ksn_state(icp) # stamp defaults to epoch + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("ksn-emit.json")) + + +# ── Surface 2: ksn ingest ──────────────────────────────────────────────────── +def test_ksn_ingest(run_auths, write_json): + """keripy ksn JSON → `auths key-state --ingest` resolves the same (i, k, s). + + auths `--ingest` prints a normalized internal view (prefix/current_keys/ + sequence/last_event_said), NOT the wire ksn shape, so we map those fields to + the ksn (i, k, s) and compare. This is structural by design: the ingest + surface is gated on the RESOLVED key-state, not on echoing the wire bytes. + """ + icp = o.icp_ed() + ksn = write_json("ksn.json", o.ksn_ingest_input(icp)) + + out = run_auths(["key-state", "--ingest", str(ksn), "--json"]).json + resolved = { + "i": out["prefix"], + "k": out["current_keys"], + "s": str(out["sequence"]), + } + expected = o.ksn_ingest_expected(icp) + + assert canon(resolved) == canon(expected) + assert canon(resolved) == canon(load_vector("ksn-ingest-resolved.json")) + # And the resolved last-event SAID equals the ingested ksn's `d`. + assert out["last_event_said"] == o.ksn_ingest_input(icp)["d"] + + +# ── Surface 3: did:webs (Ed25519) ──────────────────────────────────────────── +def test_did_webs_ed25519(run_auths, write_json): + """keripy Ed25519 KEL → `auths did-webs` == keripy gen_did_document (OKP JWK). + + Byte-exact: id, verificationMethod (OKP x-only JWK), service, alsoKnownAs all + match. Nothing normalized away. + """ + icp = o.icp_ed() + kel = write_json("kel.json", o.kel_json(icp)) + + out = run_auths( + ["did-webs", "--from-kel", str(kel), "--domain", "example.com", "--json"] + ).json + expected = o.did_webs(icp, o.ed_verfer(), "example.com") + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("did-webs-ed25519.json")) + + +# ── Surface 3b: did:webs (P-256) ───────────────────────────────────────────── +def test_did_webs_p256(run_auths, write_json): + """keripy P-256 KEL → `auths did-webs` == keripy gen_did_document (EC JWK). + + Byte-exact: the EC JWK carries x and y derived from the same SEC1 compressed + point keripy holds. Nothing normalized away. + """ + icp = o.icp_p256() + kel = write_json("kel-p256.json", o.kel_json(icp)) + + out = run_auths( + ["did-webs", "--from-kel", str(kel), "--domain", "example.com", "--json"] + ).json + expected = o.did_webs(icp, o.p256_verfer(), "example.com") + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("did-webs-p256.json")) + + +# ── Surface 4: oobi endpoint ───────────────────────────────────────────────── +def test_oobi_endpoint(run_auths, write_json): + """keripy KEL → `auths oobi endpoint` /loc/scheme + /end/role/add == keripy reply(). + + Byte-exact including SAIDs. The auths `--json` stdout is the OOBI URL on the + first line followed by the two `rpy` reply JSON lines; we parse all three. + The dt is pinned to the epoch (microsecond form) on both sides. + """ + icp = o.icp_ed() + kel = write_json("kel.json", o.kel_json(icp)) + url = "http://127.0.0.1:5642/" + + res = run_auths( + [ + "oobi", "endpoint", + "--from-kel", str(kel), + "--authority", "127.0.0.1:5642", + "--url", url, + "--json", + ] + ) + lines = res.stdout.splitlines() + oobi_url_line = lines[0].strip() + replies = [json.loads(line) for line in lines[1:] if line.strip()] + loc = next(r for r in replies if r["r"] == "/loc/scheme") + end = next(r for r in replies if r["r"] == "/end/role/add") + + assert oobi_url_line == o.oobi_url(icp, "127.0.0.1:5642") + + assert canon(loc) == canon(o.oobi_loc_scheme(icp, url)) + assert canon(loc) == canon(load_vector("oobi-loc-scheme.json")) + + assert canon(end) == canon(o.oobi_end_role(icp)) + assert canon(end) == canon(load_vector("oobi-end-role.json")) + + +# ── Surface 5: ipex grant ──────────────────────────────────────────────────── +def test_ipex_grant(run_auths, write_json): + """keripy ACDC → `auths ipex grant` == keripy ipexGrantExn/exchange. + + Byte-exact: the top-level `exn` SAID (`d`), the embedded ACDC (with its own + saidified `a.d`), and the embeds-section SAID (`e.d`) all match keripy. dt is + pinned (--dt on auths == date= on keripy). + """ + icp, icp2 = o.icp_ed(), o.icp_ed2() + sender, recipient = icp.pre, icp2.pre + acdc = write_json("acdc.json", o.ipex_acdc(sender, recipient)) + + out = run_auths( + [ + "ipex", "grant", + "--acdc", str(acdc), + "--sender", sender, + "--recipient", recipient, + "--dt", o.ACDC_DT, + "--json", + ] + ).json + expected = o.ipex_grant(sender, recipient) + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("ipex-grant.json")) + # The embeds-section SAID specifically must match (the load-bearing detail). + assert out["e"]["d"] == expected["e"]["d"] + + +# ── Surface 6: ipex admit ──────────────────────────────────────────────────── +def test_ipex_admit(run_auths, write_json): + """That grant → `auths ipex admit` == keripy ipexAdmitExn/exchange. + + Byte-exact: the admit `exn` SAID (`d`) and its prior (`p` = the grant SAID) + match keripy. We first produce the grant via the CLI, then admit it. + """ + icp, icp2 = o.icp_ed(), o.icp_ed2() + sender, recipient = icp.pre, icp2.pre + acdc = write_json("acdc.json", o.ipex_acdc(sender, recipient)) + + grant_res = run_auths( + [ + "ipex", "grant", + "--acdc", str(acdc), + "--sender", sender, + "--recipient", recipient, + "--dt", o.ACDC_DT, + "--json", + ] + ) + grant_path = write_json("grant.json", json.loads(grant_res.stdout)) + + out = run_auths( + [ + "ipex", "admit", + "--grant", str(grant_path), + "--sender", recipient, + "--dt", o.ACDC_DT, + "--json", + ] + ).json + expected = o.ipex_admit(sender, recipient) + + assert canon(out) == canon(expected) + assert canon(out) == canon(load_vector("ipex-admit.json")) + # The prior must thread the grant's SAID (the IPEX loop-closing detail). + assert out["p"] == json.loads(grant_res.stdout)["d"] diff --git a/tests/conformance/test_vectors_provenance.py b/tests/conformance/test_vectors_provenance.py new file mode 100644 index 00000000..2d666ae2 --- /dev/null +++ b/tests/conformance/test_vectors_provenance.py @@ -0,0 +1,103 @@ +"""Provenance — the frozen vectors are provably keripy's. + +Re-runs gen_vectors.py's generation logic IN PROCESS (via the keripy oracle) and +asserts the freshly computed bytes equal the checked-in fixtures/ and vectors/ +files, byte-for-byte. If keripy changes a derivation (or someone hand-edits a +vector, or the version pin drifts), the sha256 changes and this fails — proving +the goldens were produced by keripy 1.3.4 and nothing else. + +It also validates MANIFEST.yaml: every entry's recorded sha256 matches the file +on disk, and the recorded keripy version equals the installed one. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +import yaml + +import oracle as o + +HERE = Path(__file__).resolve().parent +KERI_VERSION = __import__("keri").__version__ + + +def _canon_bytes(obj) -> bytes: + """Must match gen_vectors._canon_bytes exactly.""" + return (json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n").encode() + + +def _expected_files() -> dict[str, bytes]: + """The full set of {relpath: bytes} gen_vectors.py would write, from keripy.""" + icp, icp2, icp_p = o.icp_ed(), o.icp_ed2(), o.icp_p256() + sender, recipient = icp.pre, icp2.pre + return { + # fixtures (inputs) + "fixtures/kel-ed25519.json": _canon_bytes(o.kel_json(icp)), + "fixtures/kel-p256.json": _canon_bytes(o.kel_json(icp_p)), + "fixtures/ksn-ingest.json": _canon_bytes(o.ksn_ingest_input(icp)), + "fixtures/acdc.json": _canon_bytes(o.ipex_acdc(sender, recipient)), + # vectors (outputs) + "vectors/ksn-emit.json": _canon_bytes(o.ksn_state(icp)), + "vectors/ksn-ingest-resolved.json": _canon_bytes(o.ksn_ingest_expected(icp)), + "vectors/did-webs-ed25519.json": _canon_bytes( + o.did_webs(icp, o.ed_verfer(), "example.com") + ), + "vectors/did-webs-p256.json": _canon_bytes( + o.did_webs(icp_p, o.p256_verfer(), "example.com") + ), + "vectors/oobi-loc-scheme.json": _canon_bytes( + o.oobi_loc_scheme(icp, "http://127.0.0.1:5642/") + ), + "vectors/oobi-end-role.json": _canon_bytes(o.oobi_end_role(icp)), + "vectors/ipex-grant.json": _canon_bytes(o.ipex_grant(sender, recipient)), + "vectors/ipex-admit.json": _canon_bytes(o.ipex_admit(sender, recipient)), + } + + +@pytest.mark.parametrize("rel", list(_expected_files().keys())) +def test_vector_reproduces_byte_for_byte(rel): + """Each checked-in fixture/vector == freshly regenerated keripy bytes.""" + expected = _expected_files()[rel] + on_disk = (HERE / rel).read_bytes() + assert on_disk == expected, ( + f"{rel} drifted from keripy {KERI_VERSION}; re-run `python3 gen_vectors.py` " + "and review the diff (or a keripy version pin changed)" + ) + + +def test_manifest_matches_disk(): + """Every MANIFEST entry's sha256 matches the file, version matches keripy.""" + manifest = yaml.safe_load((HERE / "MANIFEST.yaml").read_text()) + entries = manifest["vectors"] + assert entries, "MANIFEST.yaml has no entries" + + for e in entries: + path = HERE / e["path"] + assert path.exists(), f"MANIFEST references missing file {e['path']}" + digest = hashlib.sha256(path.read_bytes()).hexdigest() + assert digest == e["sha256"], ( + f"{e['path']} sha256 mismatch: manifest {e['sha256']}, disk {digest}" + ) + assert e["source"] == "keripy" + assert e["version"] == KERI_VERSION, ( + f"{e['path']} recorded keripy {e['version']} but {KERI_VERSION} is installed" + ) + + +def test_manifest_covers_all_vector_files(): + """No fixture/vector file is missing from the MANIFEST (provenance gap).""" + manifest = yaml.safe_load((HERE / "MANIFEST.yaml").read_text()) + recorded = {e["path"] for e in manifest["vectors"]} + on_disk = { + f"{d}/{p.name}" + for d in ("fixtures", "vectors") + for p in (HERE / d).glob("*.json") + } + assert on_disk == recorded, ( + f"MANIFEST out of sync with disk; missing={on_disk - recorded} " + f"extra={recorded - on_disk}" + ) diff --git a/tests/conformance/vectors/did-webs-ed25519.json b/tests/conformance/vectors/did-webs-ed25519.json new file mode 100644 index 00000000..b587178e --- /dev/null +++ b/tests/conformance/vectors/did-webs-ed25519.json @@ -0,0 +1 @@ +{"alsoKnownAs":["did:keri:EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J"],"id":"did:webs:example.com:EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","service":[],"verificationMethod":[{"controller":"did:webs:example.com:EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","id":"#DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","publicKeyJwk":{"crv":"Ed25519","kid":"DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","kty":"OKP","x":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"},"type":"JsonWebKey"}]} diff --git a/tests/conformance/vectors/did-webs-p256.json b/tests/conformance/vectors/did-webs-p256.json new file mode 100644 index 00000000..4468efc8 --- /dev/null +++ b/tests/conformance/vectors/did-webs-p256.json @@ -0,0 +1 @@ +{"alsoKnownAs":["did:keri:EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K"],"id":"did:webs:example.com:EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","service":[],"verificationMethod":[{"controller":"did:webs:example.com:EBsDyB7uU0uNYtO_t8qYIocgYIld5JCC62TCZclHYt0K","id":"#1AAJAlFcPW6545a5BNP-yn9U_c0MwemXvzddylFa0KbDtANf","publicKeyJwk":{"crv":"P-256","kid":"1AAJAlFcPW6545a5BNP-yn9U_c0MwemXvzddylFa0KbDtANf","kty":"EC","x":"UVw9brnjlrkE0_7Kf1T9zQzB6Ze_N13KUVrQpsO0A18","y":"RTa-OlDzGPv5pUdZAqIhUCvvDVfgjFOyzApW8X2fk1Q"},"type":"JsonWebKey"}]} diff --git a/tests/conformance/vectors/ipex-admit.json b/tests/conformance/vectors/ipex-admit.json new file mode 100644 index 00000000..64950e48 --- /dev/null +++ b/tests/conformance/vectors/ipex-admit.json @@ -0,0 +1 @@ +{"a":{"m":""},"d":"EGIoEIE8A2HIclGXWHxZV0rC0ESOfHrykyMRObPvGg03","dt":"2024-01-01T00:00:00.000000+00:00","e":{},"i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS","p":"EO_tOX6rN3E_LEMGbiK88hOtdWMyFhmD1NaY3KlCnkEY","q":{},"r":"/ipex/admit","rp":"","t":"exn","v":"KERI10JSON000119_"} diff --git a/tests/conformance/vectors/ipex-grant.json b/tests/conformance/vectors/ipex-grant.json new file mode 100644 index 00000000..0f9a3339 --- /dev/null +++ b/tests/conformance/vectors/ipex-grant.json @@ -0,0 +1 @@ +{"a":{"i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS","m":""},"d":"EO_tOX6rN3E_LEMGbiK88hOtdWMyFhmD1NaY3KlCnkEY","dt":"2024-01-01T00:00:00.000000+00:00","e":{"acdc":{"a":{"d":"EM87KRWNVWdwTbahWe3X5pV-sspJ0cQcTYV0VBC2GDZ1","dt":"2024-01-01T00:00:00.000000+00:00","i":"EIL3BI6FNMvZKzQw4W_VPPW9amBlIbxVQxENDAUYgjOS"},"d":"EPEa_K4Qzu4pDckEIiGeTONlWy2db2a3tXcuksOHPSLl","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","v":"ACDC10JSON00017a_"},"d":"EDU_pMrI77AvMvdzZ9BBrs98VUZNP1pBSLYQVX8WhXdZ"},"i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","p":"","q":{},"r":"/ipex/grant","rp":"","t":"exn","v":"KERI10JSON0002d4_"} diff --git a/tests/conformance/vectors/ksn-emit.json b/tests/conformance/vectors/ksn-emit.json new file mode 100644 index 00000000..383326ab --- /dev/null +++ b/tests/conformance/vectors/ksn-emit.json @@ -0,0 +1 @@ +{"b":[],"bt":"0","c":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","di":"","dt":"1970-01-01T00:00:00+00:00","ee":{"ba":[],"br":[],"d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0"},"et":"icp","f":"0","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"kt":"1","n":[],"nt":"0","p":"","s":"0","vn":[1,0]} diff --git a/tests/conformance/vectors/ksn-ingest-resolved.json b/tests/conformance/vectors/ksn-ingest-resolved.json new file mode 100644 index 00000000..a192a71f --- /dev/null +++ b/tests/conformance/vectors/ksn-ingest-resolved.json @@ -0,0 +1 @@ +{"i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"s":"0"} diff --git a/tests/conformance/vectors/oobi-end-role.json b/tests/conformance/vectors/oobi-end-role.json new file mode 100644 index 00000000..704b0604 --- /dev/null +++ b/tests/conformance/vectors/oobi-end-role.json @@ -0,0 +1 @@ +{"a":{"cid":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","eid":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","role":"controller"},"d":"EA_36cdbznGcFrcATtYbt0RIm5txScKHZnHbCxVnqYsj","dt":"1970-01-01T00:00:00.000000+00:00","r":"/end/role/add","t":"rpy","v":"KERI10JSON000116_"} diff --git a/tests/conformance/vectors/oobi-loc-scheme.json b/tests/conformance/vectors/oobi-loc-scheme.json new file mode 100644 index 00000000..7fb132ab --- /dev/null +++ b/tests/conformance/vectors/oobi-loc-scheme.json @@ -0,0 +1 @@ +{"a":{"eid":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","scheme":"http","url":"http://127.0.0.1:5642/"},"d":"EIIHrrm_0YOEV0kRDFTa-cuVMr0xabjfRojg2Mwv7wxj","dt":"1970-01-01T00:00:00.000000+00:00","r":"/loc/scheme","t":"rpy","v":"KERI10JSON0000fa_"}