Skip to main content

cortex_verifier/
witness.rs

1//! Independent witness types for the trusted evidence reducer.
2//!
3//! Each witness carries:
4//!
5//! - its `class` (which of the four ADR 0041 §4 witness classes it is),
6//! - its `authority_domain` (a disjointness tag, ADR 0013),
7//! - its `tier` (third-party, operator-owned, or local),
8//! - a timestamp (`asserted_at`) and the digest it claims to witness
9//!   (`asserted_subject_blake3`),
10//! - an in-memory `signature` payload (no I/O on the trust path),
11//! - a typed `payload` discriminated by `class`.
12//!
13//! No method on these types performs I/O, network, or filesystem reads. The
14//! CLI is responsible for loading the witness bytes and the verifier public
15//! key into memory before invoking [`crate::verify`].
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20/// One of the four ADR 0041 §4 witness classes. Each class binds a fixed
21/// `AuthorityDomain`; mismatches are rejected as `verifier.witness.authority_overlap`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum WitnessClass {
25    /// Signed Cortex ledger chain head (ADR 0022 envelope + Ed25519 head signature).
26    SignedLedgerChainHead,
27    /// External anchor crossing line from a disjoint-authority sink
28    /// (Mechanism A branch-protected, Mechanism B WORM, etc.).
29    ExternalAnchorCrossing,
30    /// Remote-CI conclusion blob signed by the CI provider's key.
31    RemoteCiConclusion,
32    /// SLSA-shaped reproducible-build provenance signed by a builder
33    /// identity distinct from the CI signer.
34    ReproducibleBuildProvenance,
35}
36
37impl WitnessClass {
38    /// Authority domain a well-formed witness of this class MUST advertise.
39    /// Per ADR 0013 §"Invariant: what 'external' means".
40    #[must_use]
41    pub const fn required_authority_domain(self) -> AuthorityDomain {
42        match self {
43            Self::SignedLedgerChainHead => AuthorityDomain::LocalSignedLedger,
44            Self::ExternalAnchorCrossing => AuthorityDomain::ExternalAnchorSink,
45            Self::RemoteCiConclusion => AuthorityDomain::RemoteCiProvider,
46            Self::ReproducibleBuildProvenance => AuthorityDomain::ReproducibleBuildProvider,
47        }
48    }
49
50    /// Stable lowercase wire string for this class. Used in
51    /// `Broken { edge: { detail } }` output.
52    #[must_use]
53    pub const fn wire_str(self) -> &'static str {
54        match self {
55            Self::SignedLedgerChainHead => "signed_ledger_chain_head",
56            Self::ExternalAnchorCrossing => "external_anchor_crossing",
57            Self::RemoteCiConclusion => "remote_ci_conclusion",
58            Self::ReproducibleBuildProvenance => "reproducible_build_provenance",
59        }
60    }
61}
62
63/// Disjointness tag for witness authority. Two witnesses sharing one of these
64/// variants cannot corroborate one another (ADR 0013 §"Invariant: what
65/// 'external' means" and ADR 0040 §"composition boundary").
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum AuthorityDomain {
69    /// Operator-controlled local signing key.
70    LocalSignedLedger,
71    /// External anchor sink with disjoint write authority.
72    ExternalAnchorSink,
73    /// Third-party CI provider's OIDC / signing identity.
74    RemoteCiProvider,
75    /// Third-party reproducible-build provider's signing identity.
76    ReproducibleBuildProvider,
77}
78
79impl AuthorityDomain {
80    /// Stable lowercase wire string for this domain.
81    #[must_use]
82    pub const fn wire_str(self) -> &'static str {
83        match self {
84            Self::LocalSignedLedger => "local_signed_ledger",
85            Self::ExternalAnchorSink => "external_anchor_sink",
86            Self::RemoteCiProvider => "remote_ci_provider",
87            Self::ReproducibleBuildProvider => "reproducible_build_provider",
88        }
89    }
90}
91
92/// Trust tier of the witness signer. Some witness classes require
93/// `ThirdParty` to satisfy ADR 0041 §"Tier sufficiency".
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum WitnessTier {
97    /// Local-only key (operator's own machine, not externally attested).
98    Local,
99    /// Operator-controlled key with declared scope, but not third-party.
100    OperatorOwned,
101    /// A third party signed under their own identity (e.g. GitHub OIDC,
102    /// SLSA builder).
103    ThirdParty,
104}
105
106impl WitnessTier {
107    /// Stable lowercase wire string for this tier.
108    #[must_use]
109    pub const fn wire_str(self) -> &'static str {
110        match self {
111            Self::Local => "local",
112            Self::OperatorOwned => "operator_owned",
113            Self::ThirdParty => "third_party",
114        }
115    }
116}
117
118/// Algorithm-discriminated signature payload for an independent witness.
119///
120/// ## Serialization and backward compatibility
121///
122/// New records are serialized with `#[serde(tag = "type")]`, producing JSON
123/// of the form `{"type": "ed25519", "public_key_bytes": "...", ...}`.
124///
125/// Existing serialized records in the JSONL ledger from before this enum was
126/// introduced are bare structs with no `"type"` field:
127/// `{"verifying_key": "...", "signature": "...", "signer_id": null}`.
128///
129/// The custom [`Deserialize`] implementation tries the tagged form first; on
130/// failure it falls back to [`LegacyEd25519Shape`], which accepts the old
131/// field names and maps them to the `Ed25519` variant. No migration of
132/// existing records is required — they round-trip transparently.
133///
134/// When either migration is triggered (a new witness class lands that cannot
135/// be expressed via the adapter-boundary translation in
136/// `crates/cortex-ledger/src/external_sink/rekor.rs`) or the verifier core
137/// needs to reason uniformly over algorithm families, re-evaluate whether the
138/// custom deserializer can be replaced by a `#[serde(untagged)]` fallback
139/// combining the enum with the legacy shape.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141#[serde(tag = "type", rename_all = "snake_case")]
142pub enum WitnessSignature {
143    /// Ed25519 signature — current Rekor SET shape. The default variant;
144    /// existing serialized records deserialize as this without migration.
145    Ed25519 {
146        /// 32-byte Ed25519 verifying key (hex-encoded in JSON).
147        #[serde(with = "hex_bytes_32")]
148        public_key_bytes: [u8; 32],
149        /// 64-byte Ed25519 signature over the canonical witness preimage
150        /// (see [`WitnessPayload::canonical_preimage`]).
151        #[serde(with = "hex_bytes_64")]
152        signature_bytes: [u8; 64],
153        /// Optional human-readable signer label (e.g.
154        /// `"https://token.actions.githubusercontent.com"`). Carried for
155        /// reporting; not part of the trust decision.
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        signer_id: Option<String>,
158    },
159    /// ECDSA P-256 signature — for Cosign/Sigstore artifacts. Verification is
160    /// not yet implemented at the verifier layer; the Rekor live adapter
161    /// handles P-256 verification at the adapter boundary
162    /// (`crates/cortex-ledger/src/external_sink/rekor.rs`).
163    EcdsaP256 {
164        /// DER-encoded public key.
165        #[serde(with = "hex_vec")]
166        public_key_der: Vec<u8>,
167        /// DER-encoded signature.
168        #[serde(with = "hex_vec")]
169        signature_der: Vec<u8>,
170    },
171    /// Operator self-signed witness — third authority axis that does not
172    /// require Rekor or OTS. Enables N≥2-distinct-operators quorum for
173    /// operators who cannot use public infrastructure.
174    SelfSigned {
175        /// Operator key identifier (from the authority_key_timeline).
176        key_id: String,
177        /// Raw signature bytes (algorithm determined by key_id lookup).
178        #[serde(with = "hex_vec")]
179        signature_bytes: Vec<u8>,
180    },
181}
182
183impl WitnessSignature {
184    /// Optional signer label for reporting. Only present for the `Ed25519`
185    /// variant; other variants carry identity via their own fields.
186    #[must_use]
187    pub fn signer_id(&self) -> Option<&str> {
188        match self {
189            Self::Ed25519 { signer_id, .. } => signer_id.as_deref(),
190            Self::EcdsaP256 { .. } | Self::SelfSigned { .. } => None,
191        }
192    }
193}
194
195// --- backward-compatible Deserialize ---------------------------------------
196//
197// Strategy: deserialize into a raw `serde_json::Value`, then try the tagged
198// form. If the `"type"` key is absent (legacy bare struct), map the old
199// `verifying_key` / `signature` / `signer_id` fields to `Ed25519`.
200
201impl<'de> Deserialize<'de> for WitnessSignature {
202    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
203        let raw = serde_json::Value::deserialize(deserializer)?;
204
205        // If the value has a "type" field, delegate to the tagged inner enum.
206        if raw.get("type").is_some() {
207            let inner: WitnessSignatureInner =
208                serde_json::from_value(raw).map_err(serde::de::Error::custom)?;
209            return Ok(Self::from(inner));
210        }
211
212        // Legacy bare struct: `verifying_key` (32-byte hex), `signature`
213        // (64-byte hex), `signer_id` (optional string). Produced by the
214        // pre-enum `WitnessSignature` struct before ADR 0041 amendment.
215        let legacy: LegacyEd25519Shape =
216            serde_json::from_value(raw).map_err(serde::de::Error::custom)?;
217        Ok(Self::Ed25519 {
218            public_key_bytes: legacy.verifying_key,
219            signature_bytes: legacy.signature,
220            signer_id: legacy.signer_id,
221        })
222    }
223}
224
225// We cannot derive `Deserialize` directly on `WitnessSignature` with
226// `#[serde(tag)]` because doing so would conflict with our custom impl above.
227// Instead we mirror the three variants in a private inner enum that does
228// derive `Deserialize` through the proc macro, then convert into the public
229// enum.
230
231#[derive(Deserialize)]
232#[serde(tag = "type", rename_all = "snake_case")]
233enum WitnessSignatureInner {
234    Ed25519 {
235        #[serde(with = "hex_bytes_32")]
236        public_key_bytes: [u8; 32],
237        #[serde(with = "hex_bytes_64")]
238        signature_bytes: [u8; 64],
239        #[serde(default)]
240        signer_id: Option<String>,
241    },
242    EcdsaP256 {
243        #[serde(with = "hex_vec")]
244        public_key_der: Vec<u8>,
245        #[serde(with = "hex_vec")]
246        signature_der: Vec<u8>,
247    },
248    SelfSigned {
249        key_id: String,
250        #[serde(with = "hex_vec")]
251        signature_bytes: Vec<u8>,
252    },
253}
254
255impl From<WitnessSignatureInner> for WitnessSignature {
256    fn from(inner: WitnessSignatureInner) -> Self {
257        match inner {
258            WitnessSignatureInner::Ed25519 {
259                public_key_bytes,
260                signature_bytes,
261                signer_id,
262            } => Self::Ed25519 {
263                public_key_bytes,
264                signature_bytes,
265                signer_id,
266            },
267            WitnessSignatureInner::EcdsaP256 {
268                public_key_der,
269                signature_der,
270            } => Self::EcdsaP256 {
271                public_key_der,
272                signature_der,
273            },
274            WitnessSignatureInner::SelfSigned {
275                key_id,
276                signature_bytes,
277            } => Self::SelfSigned {
278                key_id,
279                signature_bytes,
280            },
281        }
282    }
283}
284
285/// Pre-enum (legacy) bare `WitnessSignature` shape for backward-compatible
286/// deserialization of records written before the enum migration.
287#[derive(Deserialize)]
288struct LegacyEd25519Shape {
289    #[serde(with = "hex_bytes_32")]
290    verifying_key: [u8; 32],
291    #[serde(with = "hex_bytes_64")]
292    signature: [u8; 64],
293    #[serde(default)]
294    signer_id: Option<String>,
295}
296
297/// Typed witness payload, discriminated by class.
298///
299/// Each variant is the bag of fields that this witness class must convey.
300/// The fields are deliberately minimal — only what the verifier needs to
301/// reduce the input.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(tag = "kind", rename_all = "snake_case")]
304pub enum WitnessPayload {
305    /// Signed Cortex ledger chain head per ADR 0022.
306    SignedLedgerChainHead {
307        /// Hex-encoded chain head event hash (BLAKE3 over canonical bytes).
308        chain_head_hash: String,
309        /// Position of the chain head row (event count at signing time).
310        event_count: u64,
311    },
312    /// External anchor crossing line from a Mechanism A / B / C sink.
313    ExternalAnchorCrossing {
314        /// Hex-encoded `chain_head_hash` the anchor witnessed.
315        chain_head_hash: String,
316        /// Crossing event count per ADR 0013 anchor payload.
317        event_count: u64,
318        /// Sink kind identifier (e.g. `"branch_protection"`, `"worm_append"`).
319        sink_kind: String,
320    },
321    /// Remote CI conclusion: workflow id + commit SHA + conclusion timestamp.
322    RemoteCiConclusion {
323        /// Workflow run identifier (e.g. GitHub Actions run id as decimal).
324        workflow_run_id: String,
325        /// Git commit SHA whose CI run produced this conclusion.
326        commit_sha: String,
327        /// Conclusion timestamp as ISO 8601 (informational; freshness uses
328        /// [`IndependentWitness::asserted_at`]).
329        conclusion_timestamp: DateTime<Utc>,
330    },
331    /// SLSA-shaped reproducible-build provenance.
332    ReproducibleBuildProvenance {
333        /// Builder identity string, distinct from the remote CI signer.
334        builder_id: String,
335        /// Hex-encoded source digest (input bytes the builder consumed).
336        source_digest: String,
337        /// Hex-encoded output artifact digest.
338        artifact_digest: String,
339    },
340}
341
342impl WitnessPayload {
343    /// Class this payload belongs to. Used to verify the declared `class`
344    /// field against the payload shape.
345    #[must_use]
346    pub const fn class(&self) -> WitnessClass {
347        match self {
348            Self::SignedLedgerChainHead { .. } => WitnessClass::SignedLedgerChainHead,
349            Self::ExternalAnchorCrossing { .. } => WitnessClass::ExternalAnchorCrossing,
350            Self::RemoteCiConclusion { .. } => WitnessClass::RemoteCiConclusion,
351            Self::ReproducibleBuildProvenance { .. } => WitnessClass::ReproducibleBuildProvenance,
352        }
353    }
354
355    /// Canonical signing preimage. Caller signs this exact byte sequence with
356    /// the witness's verifying key; the verifier reconstructs and re-checks it.
357    ///
358    /// The format is a fixed UTF-8 line layout, prefixed with the class wire
359    /// string, the authority domain wire string, the asserted-subject digest,
360    /// and the ISO 8601 `asserted_at` stamp. This is enough to bind the
361    /// signature to the exact tuple `(class, domain, subject, time, payload)`.
362    #[must_use]
363    pub fn canonical_preimage(
364        &self,
365        domain: AuthorityDomain,
366        asserted_subject_blake3: &str,
367        asserted_at: DateTime<Utc>,
368    ) -> Vec<u8> {
369        let mut out = Vec::with_capacity(256);
370        out.extend_from_slice(b"cortex.verifier.witness.v1\n");
371        out.extend_from_slice(b"class=");
372        out.extend_from_slice(self.class().wire_str().as_bytes());
373        out.push(b'\n');
374        out.extend_from_slice(b"authority_domain=");
375        out.extend_from_slice(domain.wire_str().as_bytes());
376        out.push(b'\n');
377        out.extend_from_slice(b"asserted_subject_blake3=");
378        out.extend_from_slice(asserted_subject_blake3.as_bytes());
379        out.push(b'\n');
380        out.extend_from_slice(b"asserted_at=");
381        out.extend_from_slice(asserted_at.to_rfc3339().as_bytes());
382        out.push(b'\n');
383        match self {
384            Self::SignedLedgerChainHead {
385                chain_head_hash,
386                event_count,
387            } => {
388                out.extend_from_slice(b"chain_head_hash=");
389                out.extend_from_slice(chain_head_hash.as_bytes());
390                out.push(b'\n');
391                out.extend_from_slice(b"event_count=");
392                out.extend_from_slice(event_count.to_string().as_bytes());
393                out.push(b'\n');
394            }
395            Self::ExternalAnchorCrossing {
396                chain_head_hash,
397                event_count,
398                sink_kind,
399            } => {
400                out.extend_from_slice(b"chain_head_hash=");
401                out.extend_from_slice(chain_head_hash.as_bytes());
402                out.push(b'\n');
403                out.extend_from_slice(b"event_count=");
404                out.extend_from_slice(event_count.to_string().as_bytes());
405                out.push(b'\n');
406                out.extend_from_slice(b"sink_kind=");
407                out.extend_from_slice(sink_kind.as_bytes());
408                out.push(b'\n');
409            }
410            Self::RemoteCiConclusion {
411                workflow_run_id,
412                commit_sha,
413                conclusion_timestamp,
414            } => {
415                out.extend_from_slice(b"workflow_run_id=");
416                out.extend_from_slice(workflow_run_id.as_bytes());
417                out.push(b'\n');
418                out.extend_from_slice(b"commit_sha=");
419                out.extend_from_slice(commit_sha.as_bytes());
420                out.push(b'\n');
421                out.extend_from_slice(b"conclusion_timestamp=");
422                out.extend_from_slice(conclusion_timestamp.to_rfc3339().as_bytes());
423                out.push(b'\n');
424            }
425            Self::ReproducibleBuildProvenance {
426                builder_id,
427                source_digest,
428                artifact_digest,
429            } => {
430                out.extend_from_slice(b"builder_id=");
431                out.extend_from_slice(builder_id.as_bytes());
432                out.push(b'\n');
433                out.extend_from_slice(b"source_digest=");
434                out.extend_from_slice(source_digest.as_bytes());
435                out.push(b'\n');
436                out.extend_from_slice(b"artifact_digest=");
437                out.extend_from_slice(artifact_digest.as_bytes());
438                out.push(b'\n');
439            }
440        }
441        out
442    }
443}
444
445/// An independent witness, in the shape the verifier consumes. All fields are
446/// already verified at the deserialization boundary: missing keys, malformed
447/// hex, and unknown classes fail closed before [`crate::verify`] runs.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449pub struct IndependentWitness {
450    /// Witness class.
451    pub class: WitnessClass,
452    /// Authority domain advertised by this witness. MUST equal
453    /// `class.required_authority_domain()` or the verifier rejects with
454    /// `verifier.witness.authority_overlap`.
455    pub authority_domain: AuthorityDomain,
456    /// Trust tier of the signer.
457    pub tier: WitnessTier,
458    /// When the witness was asserted (signed). Compared to the caller-supplied
459    /// `now` for freshness.
460    pub asserted_at: DateTime<Utc>,
461    /// Lowercase BLAKE3 hex of the bytes this witness claims to attest.
462    /// MUST equal the producer-supplied `EvidenceInput::evidence_blake3` or
463    /// the verifier rejects with `verifier.witness.disagreement`.
464    pub asserted_subject_blake3: String,
465    /// Algorithm-discriminated signature material.
466    pub signature: WitnessSignature,
467    /// Typed payload for this witness class.
468    pub payload: WitnessPayload,
469}
470
471impl IndependentWitness {
472    /// Compute the canonical signing preimage this witness's signature must cover.
473    #[must_use]
474    pub fn canonical_preimage(&self) -> Vec<u8> {
475        self.payload.canonical_preimage(
476            self.authority_domain,
477            &self.asserted_subject_blake3,
478            self.asserted_at,
479        )
480    }
481}
482
483/// Lightweight summary returned in `VerifiedTrustState` for reporting.
484/// Contains the typed identity of each witness without leaking signature bytes
485/// into report JSON.
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct WitnessSummary {
488    /// Witness class (wire string).
489    pub class: String,
490    /// Authority domain (wire string).
491    pub authority_domain: String,
492    /// Tier (wire string).
493    pub tier: String,
494    /// Optional signer id (from `WitnessSignature::Ed25519 { signer_id }` or
495    /// the key_id for `SelfSigned`). Not present for `EcdsaP256`.
496    pub signer_id: Option<String>,
497    /// ISO 8601 timestamp when the witness was signed.
498    pub asserted_at: DateTime<Utc>,
499}
500
501impl WitnessSummary {
502    /// Build a summary from an [`IndependentWitness`].
503    #[must_use]
504    pub fn from_witness(witness: &IndependentWitness) -> Self {
505        let signer_id = match &witness.signature {
506            WitnessSignature::Ed25519 { signer_id, .. } => signer_id.clone(),
507            WitnessSignature::SelfSigned { key_id, .. } => Some(key_id.clone()),
508            WitnessSignature::EcdsaP256 { .. } => None,
509        };
510        Self {
511            class: witness.class.wire_str().to_string(),
512            authority_domain: witness.authority_domain.wire_str().to_string(),
513            tier: witness.tier.wire_str().to_string(),
514            signer_id,
515            asserted_at: witness.asserted_at,
516        }
517    }
518}
519
520// ---------------------------------------------------------------------------
521// SelfSignedKeyRegistry — operator-supplied key table for SelfSigned witnesses
522// ---------------------------------------------------------------------------
523
524/// Signature algorithm declared for a `SelfSignedKeyEntry`. The verifier
525/// dispatches on this to select the correct verification routine.
526#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum SelfSignedAlgorithm {
529    /// Ed25519 — 32-byte raw public key.
530    Ed25519,
531    /// ECDSA P-256 — DER-encoded SubjectPublicKeyInfo.
532    EcdsaP256,
533}
534
535/// A single entry in the operator-supplied key registry. Each entry binds a
536/// `key_id` string (matched against `WitnessSignature::SelfSigned { key_id }`)
537/// to an algorithm and the raw public-key bytes.
538#[derive(Debug, Clone, serde::Deserialize)]
539pub struct SelfSignedKeyEntry {
540    /// Identifier matching `WitnessSignature::SelfSigned { key_id }`.
541    pub key_id: String,
542    /// Algorithm family for this key.
543    pub algorithm: SelfSignedAlgorithm,
544    /// Hex-encoded raw public-key bytes.
545    ///
546    /// For `Ed25519`: 32 bytes (64 hex chars).
547    /// For `EcdsaP256`: DER-encoded SubjectPublicKeyInfo.
548    pub key_bytes_hex: String,
549}
550
551impl SelfSignedKeyEntry {
552    /// Decode `key_bytes_hex` into raw bytes.
553    pub fn key_bytes(&self) -> Result<Vec<u8>, String> {
554        hex_decode(&self.key_bytes_hex)
555    }
556}
557
558/// TOML wire shape used only during `SelfSignedKeyRegistry::load()`. The outer
559/// struct matches `{ keys = [...] }`.
560#[derive(serde::Deserialize)]
561struct KeyRegistryFile {
562    keys: Vec<SelfSignedKeyEntry>,
563}
564
565/// In-memory table of operator-supplied public keys for `SelfSigned` witness
566/// verification. Loaded once at CLI startup; the verifier is given a reference
567/// to the populated registry before the trust path runs.
568#[derive(Debug, Clone)]
569pub struct SelfSignedKeyRegistry {
570    entries: Vec<SelfSignedKeyEntry>,
571}
572
573impl SelfSignedKeyRegistry {
574    /// Return an empty registry (no `SelfSigned` witness will verify).
575    #[must_use]
576    pub fn empty() -> Self {
577        Self {
578            entries: Vec::new(),
579        }
580    }
581
582    /// Load a key registry from a TOML file on disk.
583    ///
584    /// Expected file format:
585    ///
586    /// ```toml
587    /// [[keys]]
588    /// key_id = "my-operator-key"
589    /// algorithm = "ed25519"
590    /// key_bytes_hex = "aabbcc..."  # 64 hex chars for Ed25519 (32 bytes)
591    ///
592    /// [[keys]]
593    /// key_id = "my-p256-key"
594    /// algorithm = "ecdsa_p256"
595    /// key_bytes_hex = "..."  # DER hex
596    /// ```
597    ///
598    /// Returns `Err` if the file cannot be read or fails to parse as the
599    /// expected TOML shape. Key-bytes are NOT decoded here; decoding happens
600    /// at verification time so a single malformed entry does not block
601    /// loading the rest.
602    pub fn load(path: &std::path::Path) -> Result<Self, String> {
603        let raw = std::fs::read_to_string(path).map_err(|e| {
604            format!(
605                "witness-key-registry: cannot read `{}`: {e}",
606                path.display()
607            )
608        })?;
609        let file: KeyRegistryFile = toml::from_str(&raw).map_err(|e| {
610            format!(
611                "witness-key-registry: `{}` did not parse as expected TOML: {e}",
612                path.display()
613            )
614        })?;
615        Ok(Self { entries: file.keys })
616    }
617
618    /// Look up a key entry by `key_id`. Returns `None` when the id is not
619    /// present in this registry.
620    #[must_use]
621    pub fn get(&self, key_id: &str) -> Option<&SelfSignedKeyEntry> {
622        self.entries.iter().find(|e| e.key_id == key_id)
623    }
624
625    /// Number of entries in the registry.
626    #[must_use]
627    pub fn len(&self) -> usize {
628        self.entries.len()
629    }
630
631    /// Whether the registry contains no entries.
632    #[must_use]
633    pub fn is_empty(&self) -> bool {
634        self.entries.is_empty()
635    }
636}
637
638// ---------------------------------------------------------------------------
639// Serde helpers: fixed-size byte arrays and dynamic vecs, hex-encoded in JSON
640// ---------------------------------------------------------------------------
641
642/// Serde module for 32-byte arrays serialized as lowercase hex strings.
643pub(crate) mod hex_bytes_32 {
644    use serde::{Deserialize, Deserializer, Serializer};
645
646    pub fn serialize<S: Serializer>(value: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error> {
647        serializer.serialize_str(&hex_encode(value))
648    }
649
650    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<[u8; 32], D::Error> {
651        let text = <String as Deserialize>::deserialize(deserializer)?;
652        let bytes = super::hex_decode(&text).map_err(serde::de::Error::custom)?;
653        if bytes.len() != 32 {
654            return Err(serde::de::Error::custom(format!(
655                "expected 32-byte hex string, got {}",
656                bytes.len()
657            )));
658        }
659        let mut out = [0u8; 32];
660        out.copy_from_slice(&bytes);
661        Ok(out)
662    }
663
664    fn hex_encode(bytes: &[u8]) -> String {
665        let mut out = String::with_capacity(bytes.len() * 2);
666        for b in bytes {
667            out.push(super::hex_nibble(b >> 4));
668            out.push(super::hex_nibble(b & 0x0F));
669        }
670        out
671    }
672}
673
674/// Serde module for 64-byte arrays serialized as lowercase hex strings.
675pub(crate) mod hex_bytes_64 {
676    use serde::{Deserialize, Deserializer, Serializer};
677
678    pub fn serialize<S: Serializer>(value: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error> {
679        serializer.serialize_str(&hex_encode(value))
680    }
681
682    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<[u8; 64], D::Error> {
683        let text = <String as Deserialize>::deserialize(deserializer)?;
684        let bytes = super::hex_decode(&text).map_err(serde::de::Error::custom)?;
685        if bytes.len() != 64 {
686            return Err(serde::de::Error::custom(format!(
687                "expected 64-byte hex string, got {}",
688                bytes.len()
689            )));
690        }
691        let mut out = [0u8; 64];
692        out.copy_from_slice(&bytes);
693        Ok(out)
694    }
695
696    fn hex_encode(bytes: &[u8]) -> String {
697        let mut out = String::with_capacity(bytes.len() * 2);
698        for b in bytes {
699            out.push(super::hex_nibble(b >> 4));
700            out.push(super::hex_nibble(b & 0x0F));
701        }
702        out
703    }
704}
705
706/// Serde module for `Vec<u8>` serialized as lowercase hex strings.
707pub(crate) mod hex_vec {
708    use serde::{Deserialize, Deserializer, Serializer};
709
710    pub fn serialize<S: Serializer>(value: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
711        let mut out = String::with_capacity(value.len() * 2);
712        for b in value {
713            out.push(super::hex_nibble(b >> 4));
714            out.push(super::hex_nibble(b & 0x0F));
715        }
716        serializer.serialize_str(&out)
717    }
718
719    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
720        let text = <String as Deserialize>::deserialize(deserializer)?;
721        super::hex_decode(&text).map_err(serde::de::Error::custom)
722    }
723}
724
725fn hex_nibble(nib: u8) -> char {
726    match nib {
727        0..=9 => (b'0' + nib) as char,
728        10..=15 => (b'a' + nib - 10) as char,
729        _ => unreachable!("nibble fits in 4 bits"),
730    }
731}
732
733fn hex_decode(text: &str) -> Result<Vec<u8>, String> {
734    if !text.len().is_multiple_of(2) {
735        return Err(format!("hex string length {} is not even", text.len()));
736    }
737    let mut out = Vec::with_capacity(text.len() / 2);
738    let bytes = text.as_bytes();
739    let mut i = 0;
740    while i < bytes.len() {
741        let high = hex_value(bytes[i])?;
742        let low = hex_value(bytes[i + 1])?;
743        out.push((high << 4) | low);
744        i += 2;
745    }
746    Ok(out)
747}
748
749fn hex_value(byte: u8) -> Result<u8, String> {
750    match byte {
751        b'0'..=b'9' => Ok(byte - b'0'),
752        b'a'..=b'f' => Ok(byte - b'a' + 10),
753        b'A'..=b'F' => Ok(byte - b'A' + 10),
754        _ => Err(format!("non-hex character {byte:#x?}")),
755    }
756}