From 81e4141482610bfec2a5264aa31c4ba689e158da Mon Sep 17 00:00:00 2001 From: Achille Wasque Date: Mon, 18 May 2026 19:51:17 +0200 Subject: [PATCH] feat(gitlawb-attest): External Attestation v1 for ref-update certs --- Cargo.lock | 35 ++- Cargo.toml | 2 + crates/gitlawb-attest/Cargo.toml | 22 ++ crates/gitlawb-attest/src/attestation.rs | 311 +++++++++++++++++++++++ crates/gitlawb-attest/src/cert.rs | 162 ++++++++++++ crates/gitlawb-attest/src/error.rs | 38 +++ crates/gitlawb-attest/src/lib.rs | 60 +++++ crates/gitlawb-attest/src/verifier.rs | 249 ++++++++++++++++++ 8 files changed, 878 insertions(+), 1 deletion(-) create mode 100644 crates/gitlawb-attest/Cargo.toml create mode 100644 crates/gitlawb-attest/src/attestation.rs create mode 100644 crates/gitlawb-attest/src/cert.rs create mode 100644 crates/gitlawb-attest/src/error.rs create mode 100644 crates/gitlawb-attest/src/lib.rs create mode 100644 crates/gitlawb-attest/src/verifier.rs diff --git a/Cargo.lock b/Cargo.lock index 107ccfe..043444b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2590,7 +2590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3359,6 +3359,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "gitlawb-attest" +version = "0.3.8" +dependencies = [ + "base64", + "chrono", + "ed25519-dalek", + "multibase", + "rand 0.8.5", + "serde", + "serde_jcs", + "serde_json", + "sha2", + "thiserror 2.0.18", +] + [[package]] name = "gitlawb-core" version = "0.3.8" @@ -6196,6 +6212,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "schannel" version = "0.1.29" @@ -6372,6 +6394,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/Cargo.toml b/Cargo.toml index 74d8e79..49d9e7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/gitlawb-node", "crates/gl", "crates/git-remote-gitlawb", + "crates/gitlawb-attest", ] [workspace.package] @@ -20,6 +21,7 @@ tokio = { version = "1", features = ["full"] } # serialization serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_jcs = "0.2" # errors thiserror = "2" anyhow = "1" diff --git a/crates/gitlawb-attest/Cargo.toml b/crates/gitlawb-attest/Cargo.toml new file mode 100644 index 0000000..f8fcbd3 --- /dev/null +++ b/crates/gitlawb-attest/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gitlawb-attest" +description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +base64 = { workspace = true } +chrono = { workspace = true } +ed25519-dalek = { workspace = true } +multibase = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_jcs = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +rand = { workspace = true } diff --git a/crates/gitlawb-attest/src/attestation.rs b/crates/gitlawb-attest/src/attestation.rs new file mode 100644 index 0000000..ba15e21 --- /dev/null +++ b/crates/gitlawb-attest/src/attestation.rs @@ -0,0 +1,311 @@ +//! One typed, signed, cert-bound provenance blob. +//! +//! Fields: `type` (discriminator), `payload` (opaque, type-specific JSON), +//! `cert_hash` (binds to one ref-update cert), `signer` (`did:key`), and +//! `sig` (base64url-no-pad ed25519). +//! +//! The signature covers JCS-encoded `{type, payload, cert_hash}` per RFC 8785. +//! Type discriminators are slash-separated namespace + version strings +//! (`covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`); verifiers register +//! by exact match. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::error::{AttestError, Result}; + +/// A signed provenance blob attached to a ref-update cert. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Attestation { + /// Type discriminator, e.g. `covenant/exec/v1`. + #[serde(rename = "type")] + pub type_: String, + + /// Type-specific payload. The verifier reparses into its concrete shape. + pub payload: serde_json::Value, + + /// SHA-256 hex of the cert body. Binds this attestation to one cert. + pub cert_hash: String, + + /// `did:key` of the signer; the verifying key is recoverable from it. + pub signer: String, + + /// base64url-no-pad ed25519 signature over the JCS signing input. + pub sig: String, +} + +/// Type-specific payload. Implement on a struct that is `Serialize + +/// DeserializeOwned` to participate. +pub trait AttestationPayload: Serialize + DeserializeOwned + Send + Sync { + /// The discriminator string written on the wire. + fn payload_type() -> &'static str; +} + +#[derive(Serialize)] +struct SigningInput<'a> { + #[serde(rename = "type")] + type_: &'a str, + payload: &'a serde_json::Value, + cert_hash: &'a str, +} + +impl Attestation { + /// Sign a fresh attestation. `cert_hash_bytes` comes from + /// [`crate::cert::cert_hash`]. + pub fn sign( + signing_key: &SigningKey, + payload: P, + cert_hash_bytes: [u8; 32], + ) -> Result { + let type_ = P::payload_type().to_string(); + validate_type(&type_)?; + let payload_value = serde_json::to_value(payload)?; + let cert_hash_hex = hex_encode(&cert_hash_bytes); + + let bytes = canonical_signing_bytes(&type_, &payload_value, &cert_hash_hex)?; + let sig: Signature = signing_key.sign(&bytes); + + Ok(Self { + type_, + payload: payload_value, + cert_hash: cert_hash_hex, + signer: did_key_from_verifying_key(&signing_key.verifying_key()), + sig: B64U.encode(sig.to_bytes()), + }) + } + + /// Verify the signature and check that `cert_hash` matches + /// `expected_cert_hash`. Returns the recovered verifying key so the caller + /// can check it against an allowlist. + pub fn verify_signature(&self, expected_cert_hash: [u8; 32]) -> Result { + let expected_hex = hex_encode(&expected_cert_hash); + if self.cert_hash != expected_hex { + return Err(AttestError::CertHashMismatch); + } + + validate_type(&self.type_)?; + let bytes = canonical_signing_bytes(&self.type_, &self.payload, &self.cert_hash)?; + + let vk = verifying_key_from_did_key(&self.signer)?; + let sig_bytes: [u8; 64] = B64U + .decode(&self.sig) + .map_err(|e| AttestError::Signature(format!("base64url: {e}")))? + .try_into() + .map_err(|_| AttestError::Signature("signature must be 64 bytes".into()))?; + let sig = Signature::from_bytes(&sig_bytes); + vk.verify(&bytes, &sig) + .map_err(|e| AttestError::Signature(format!("ed25519: {e}")))?; + + Ok(vk) + } + + /// Reparse `payload` as `P`. Errors if the type discriminator does not + /// match `P::payload_type()`. + pub fn payload_as(&self) -> Result

{ + if self.type_ != P::payload_type() { + return Err(AttestError::Type(format!( + "expected '{}', got '{}'", + P::payload_type(), + self.type_ + ))); + } + Ok(serde_json::from_value(self.payload.clone())?) + } +} + +fn validate_type(t: &str) -> Result<()> { + if t.is_empty() { + return Err(AttestError::Type("empty discriminator".into())); + } + if t.contains(char::is_whitespace) { + return Err(AttestError::Type(format!( + "discriminator must not contain whitespace: '{t}'" + ))); + } + Ok(()) +} + +fn canonical_signing_bytes( + type_: &str, + payload: &serde_json::Value, + cert_hash_hex: &str, +) -> Result> { + let input = SigningInput { + type_, + payload, + cert_hash: cert_hash_hex, + }; + serde_jcs::to_vec(&input).map_err(|e| AttestError::Payload(format!("JCS encode: {e}"))) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01]; + +fn did_key_from_verifying_key(key: &VerifyingKey) -> String { + let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32); + buf.extend_from_slice(&ED25519_MULTICODEC); + buf.extend_from_slice(&key.to_bytes()); + let encoded = multibase::encode(multibase::Base::Base58Btc, &buf); + format!("did:key:{encoded}") +} + +fn verifying_key_from_did_key(did: &str) -> Result { + let method_id = did + .strip_prefix("did:key:") + .ok_or_else(|| AttestError::Did(format!("not a did:key: {did}")))?; + let (_base, bytes) = + multibase::decode(method_id).map_err(|e| AttestError::Did(format!("multibase: {e}")))?; + if bytes.len() != ED25519_MULTICODEC.len() + 32 + || bytes[..ED25519_MULTICODEC.len()] != ED25519_MULTICODEC + { + return Err(AttestError::Did("not an ed25519 did:key".into())); + } + let key_bytes: [u8; 32] = bytes[ED25519_MULTICODEC.len()..] + .try_into() + .expect("length checked above"); + VerifyingKey::from_bytes(&key_bytes) + .map_err(|e| AttestError::Did(format!("invalid ed25519 key: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use serde::{Deserialize, Serialize}; + + fn fresh() -> SigningKey { + SigningKey::generate(&mut OsRng) + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + struct DummyPayload { + agent: String, + commit: String, + } + + impl AttestationPayload for DummyPayload { + fn payload_type() -> &'static str { + "test/dummy/v1" + } + } + + fn sample_cert_hash() -> [u8; 32] { + let mut h = [0u8; 32]; + for (i, b) in h.iter_mut().enumerate() { + *b = i as u8; + } + h + } + + #[test] + fn sign_then_verify_roundtrip() { + let sk = fresh(); + let cert_hash = sample_cert_hash(); + let payload = DummyPayload { + agent: "did:key:z6MkTest".into(), + commit: "deadbeef".into(), + }; + let att = Attestation::sign(&sk, payload.clone(), cert_hash).unwrap(); + let vk = att.verify_signature(cert_hash).unwrap(); + assert_eq!(vk.to_bytes(), sk.verifying_key().to_bytes()); + assert_eq!(att.payload_as::().unwrap(), payload); + } + + #[test] + fn cross_cert_replay_fails() { + let sk = fresh(); + let cert_a = sample_cert_hash(); + let mut cert_b = sample_cert_hash(); + cert_b[0] ^= 0xff; + let payload = DummyPayload { + agent: "a".into(), + commit: "b".into(), + }; + let att = Attestation::sign(&sk, payload, cert_a).unwrap(); + let err = att.verify_signature(cert_b).unwrap_err(); + assert!(matches!(err, AttestError::CertHashMismatch)); + } + + #[test] + fn tampered_payload_fails_verify() { + let sk = fresh(); + let cert_hash = sample_cert_hash(); + let payload = DummyPayload { + agent: "a".into(), + commit: "b".into(), + }; + let mut att = Attestation::sign(&sk, payload, cert_hash).unwrap(); + att.payload = serde_json::json!({ "agent": "evil", "commit": "b" }); + let err = att.verify_signature(cert_hash).unwrap_err(); + assert!(matches!(err, AttestError::Signature(_))); + } + + #[test] + fn payload_as_wrong_type_errors() { + let sk = fresh(); + let cert_hash = sample_cert_hash(); + let att = Attestation::sign( + &sk, + DummyPayload { + agent: "a".into(), + commit: "b".into(), + }, + cert_hash, + ) + .unwrap(); + + #[derive(Debug, Serialize, Deserialize)] + struct Wrong { + #[allow(dead_code)] + x: String, + } + impl AttestationPayload for Wrong { + fn payload_type() -> &'static str { + "test/other/v1" + } + } + let err = att.payload_as::().unwrap_err(); + assert!(matches!(err, AttestError::Type(_))); + } + + #[test] + fn json_roundtrip_preserves_signature() { + let sk = fresh(); + let cert_hash = sample_cert_hash(); + let att = Attestation::sign( + &sk, + DummyPayload { + agent: "a".into(), + commit: "b".into(), + }, + cert_hash, + ) + .unwrap(); + let json = serde_json::to_string(&att).unwrap(); + let back: Attestation = serde_json::from_str(&json).unwrap(); + back.verify_signature(cert_hash).unwrap(); + } + + #[test] + fn empty_type_rejected_at_signing() { + #[derive(Serialize, Deserialize)] + struct Empty {} + impl AttestationPayload for Empty { + fn payload_type() -> &'static str { + "" + } + } + let sk = fresh(); + let err = Attestation::sign(&sk, Empty {}, sample_cert_hash()).unwrap_err(); + assert!(matches!(err, AttestError::Type(_))); + } +} diff --git a/crates/gitlawb-attest/src/cert.rs b/crates/gitlawb-attest/src/cert.rs new file mode 100644 index 0000000..82fd931 --- /dev/null +++ b/crates/gitlawb-attest/src/cert.rs @@ -0,0 +1,162 @@ +//! Envelope around a `gitlawb/ref-update/v1` cert plus optional attestations, +//! and the cert-hash helper attestations bind to. +//! +//! An empty envelope serializes to the same bytes as the underlying bare cert. +//! `cert_hash` strips `signatures` and `attestations`, JCS-encodes the rest, +//! and SHA-256s; the result is what every attestation binds to. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::attestation::Attestation; +use crate::error::Result; + +/// A ref-update cert with optional attestations. +/// +/// The cert is flattened into the top-level JSON, so an empty envelope +/// produces the same bytes as a bare cert and existing decoders ignore the +/// extra `attestations` field. The cert is held as `serde_json::Value` so +/// this crate has no dependency on `gitlawb-core::cert::RefUpdateCert`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttestedRefUpdateCert { + /// The cert body and signatures, flattened to the top level. + #[serde(flatten)] + pub cert: serde_json::Value, + + /// Attached attestations. Empty when absent on the wire. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attestations: Vec, +} + +impl AttestedRefUpdateCert { + /// Wrap a cert. No attestations are attached yet. + pub fn from_cert(cert: &T) -> Result { + Ok(Self { + cert: serde_json::to_value(cert)?, + attestations: Vec::new(), + }) + } + + /// The 32-byte cert hash every attached attestation must bind to. + pub fn cert_hash(&self) -> Result<[u8; 32]> { + cert_hash(&self.cert) + } + + /// Append an already-signed attestation. + pub fn attach(&mut self, attestation: Attestation) { + self.attestations.push(attestation); + } +} + +/// SHA-256 of the cert body. Strips `signatures` and `attestations`, then +/// JCS-encodes the remaining object (RFC 8785). JCS pins key order, number +/// formatting, and string escaping, so the hash is reproducible across +/// languages and JSON libraries. +pub fn cert_hash(cert: &serde_json::Value) -> Result<[u8; 32]> { + let mut body = cert.clone(); + if let Some(obj) = body.as_object_mut() { + obj.remove("signatures"); + obj.remove("attestations"); + } + let bytes = serde_jcs::to_vec(&body) + .map_err(|e| crate::error::AttestError::Payload(format!("JCS encode: {e}")))?; + let mut h = Sha256::new(); + h.update(&bytes); + let out = h.finalize(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&out); + Ok(arr) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn empty_envelope_serializes_as_bare_cert() { + let bare = json!({ + "type": "gitlawb/ref-update/v1", + "repo": "did:key:z6MkRepo", + "ref_name": "refs/heads/main", + "from": "0".repeat(64), + "to": "a".repeat(64), + "seq": 1, + "timestamp": "2026-01-01T00:00:00Z", + "nonce": "n1", + "signatures": [{"signer": "did:key:z6MkRepo", "sig": "abc"}] + }); + + let env = AttestedRefUpdateCert::from_cert(&bare).unwrap(); + let env_json: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); + + let bare_keys: std::collections::BTreeSet<&String> = + bare.as_object().unwrap().keys().collect(); + let env_keys: std::collections::BTreeSet<&String> = + env_json.as_object().unwrap().keys().collect(); + assert_eq!(bare_keys, env_keys); + } + + #[test] + fn bare_cert_parses_as_envelope_with_no_attestations() { + let bare = json!({ + "type": "gitlawb/ref-update/v1", + "repo": "did:key:z6MkRepo", + "ref_name": "refs/heads/main", + "from": "0".repeat(64), + "to": "a".repeat(64), + "seq": 1, + "timestamp": "2026-01-01T00:00:00Z", + "nonce": "n1", + "signatures": [{"signer": "did:key:z6MkRepo", "sig": "abc"}] + }); + let env: AttestedRefUpdateCert = serde_json::from_value(bare.clone()).unwrap(); + assert!(env.attestations.is_empty()); + assert_eq!(env.cert, bare); + } + + #[test] + fn cert_hash_is_stable_across_countersignatures() { + let base_body = json!({ + "type": "gitlawb/ref-update/v1", + "repo": "did:key:z6MkRepo", + "ref_name": "refs/heads/main", + "from": "0".repeat(64), + "to": "a".repeat(64), + "seq": 1, + "timestamp": "2026-01-01T00:00:00Z", + "nonce": "n1" + }); + + let mut one_sig = base_body.clone(); + one_sig["signatures"] = json!([{"signer": "x", "sig": "y"}]); + + let mut two_sigs = base_body.clone(); + two_sigs["signatures"] = json!([ + {"signer": "x", "sig": "y"}, + {"signer": "a", "sig": "b"} + ]); + + assert_eq!(cert_hash(&one_sig).unwrap(), cert_hash(&two_sigs).unwrap()); + } + + #[test] + fn cert_hash_changes_when_body_changes() { + let body_a = json!({ + "type": "gitlawb/ref-update/v1", + "repo": "did:key:z6MkRepo", + "ref_name": "refs/heads/main", + "from": "0".repeat(64), + "to": "a".repeat(64), + "seq": 1, + "timestamp": "2026-01-01T00:00:00Z", + "nonce": "n1", + "signatures": [] + }); + let mut body_b = body_a.clone(); + body_b["to"] = json!("b".repeat(64)); + + assert_ne!(cert_hash(&body_a).unwrap(), cert_hash(&body_b).unwrap()); + } +} diff --git a/crates/gitlawb-attest/src/error.rs b/crates/gitlawb-attest/src/error.rs new file mode 100644 index 0000000..1190723 --- /dev/null +++ b/crates/gitlawb-attest/src/error.rs @@ -0,0 +1,38 @@ +//! Error type for attestation operations. + +use thiserror::Error; + +/// Errors from building or verifying an attestation. +#[derive(Debug, Error)] +pub enum AttestError { + /// Discriminator failed validation (empty, whitespace, etc.). + #[error("attestation type: {0}")] + Type(String), + + /// Attestation was signed against a different cert. + #[error("cert hash mismatch")] + CertHashMismatch, + + /// Ed25519 signature failed verification. + #[error("signature: {0}")] + Signature(String), + + /// DID could not be parsed. + #[error("did: {0}")] + Did(String), + + /// Payload structure check failed. + #[error("payload: {0}")] + Payload(String), + + /// No verifier registered for the type and policy required one. + #[error("no verifier registered for type '{0}'")] + UnknownType(String), + + /// JSON failure. + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +/// Crate result alias. +pub type Result = std::result::Result; diff --git a/crates/gitlawb-attest/src/lib.rs b/crates/gitlawb-attest/src/lib.rs new file mode 100644 index 0000000..4a0b4d1 --- /dev/null +++ b/crates/gitlawb-attest/src/lib.rs @@ -0,0 +1,60 @@ +//! Pluggable provenance attachments for gitlawb ref-update certs. +//! +//! A `gitlawb/ref-update/v1` cert proves who pushed and where a ref moved to. +//! It does not say how the commit was produced. This crate adds an optional +//! `attestations` field that lets any provenance system (SLSA, Sigstore, +//! in-toto, agent runtimes) attach a typed, signed blob bound to the cert by +//! its hash. +//! +//! The cert format stays additive. A cert with no attestations serializes the +//! same bytes it does today, and an envelope with attestations deserializes +//! into existing `RefUpdateCert` code that ignores the extra field. +//! +//! ## Wire shape +//! +//! ```json +//! { +//! "type": "gitlawb/ref-update/v1", +//! // ...standard cert fields..., +//! "signatures": [...], +//! "attestations": [ +//! { +//! "type": "covenant/exec/v1", +//! "payload": { /* opaque, type-specific */ }, +//! "cert_hash": "", +//! "signer": "did:key:z6Mk...", +//! "sig": "" +//! } +//! ] +//! } +//! ``` +//! +//! ## Verification +//! +//! [`Registry`] looks up a verifier by type discriminator. [`Policy`] decides +//! what to do when no verifier matches: `AcceptKnown` (default) lets unknown +//! types pass without trust, `RequireAll` enforces an allowlist per repo, +//! `RejectUnknown` rejects everything unregistered. +//! +//! ## Canonical bytes +//! +//! Hashing and signing inputs are encoded with JCS (RFC 8785) so they +//! reproduce across implementations regardless of struct field order or JSON +//! library. + +#![deny(unsafe_code)] +#![deny(missing_docs)] + +pub mod attestation; +pub mod cert; +pub mod error; +pub mod verifier; + +pub use attestation::{Attestation, AttestationPayload}; +pub use cert::{cert_hash, AttestedRefUpdateCert}; +pub use error::{AttestError, Result}; +pub use verifier::{AttestationVerifier, Policy, Registry, VerifiedAttestation}; + +/// Version string for the attestation envelope. Bumped if the binding rules +/// change. +pub const ATTEST_ENVELOPE_VERSION: &str = "v1"; diff --git a/crates/gitlawb-attest/src/verifier.rs b/crates/gitlawb-attest/src/verifier.rs new file mode 100644 index 0000000..46c7b0a --- /dev/null +++ b/crates/gitlawb-attest/src/verifier.rs @@ -0,0 +1,249 @@ +//! Verifier trait, registry, and policy. +//! +//! A [`Registry`] maps a type discriminator to a verifier. [`Policy`] decides +//! what happens for types nothing is registered for: accept without trust, +//! reject, or require an allowlist. + +use std::collections::HashMap; + +use crate::attestation::Attestation; +use crate::error::{AttestError, Result}; + +/// Verifier for one attestation type. Implementors declare the discriminator +/// they handle and validate the payload's shape. The signature and cert-hash +/// binding are checked by [`Registry::verify`] before `verify_payload` runs. +pub trait AttestationVerifier: Send + Sync { + /// The discriminator this verifier handles. + fn type_(&self) -> &str; + + /// Validate payload structure. Signature is already verified. + fn verify_payload(&self, payload: &serde_json::Value) -> Result<()>; +} + +/// What to do for attestation types with no registered verifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Policy { + /// Unknown types pass but are flagged `fully_verified = false`. Default. + #[default] + AcceptKnown, + + /// Every entry in `required_types` must be present and fully verified. + RequireAll, + + /// Reject every attestation whose type is not registered. + RejectUnknown, +} + +/// Result of verifying one attestation. +#[derive(Debug, Clone)] +pub struct VerifiedAttestation { + /// Type discriminator. + pub type_: String, + /// Signer's `did:key`. + pub signer: String, + /// `true` when a verifier ran a payload check and accepted it. `false` + /// when the policy let an unknown type through without one. + pub fully_verified: bool, +} + +/// Verifiers keyed by type discriminator. +#[derive(Default)] +pub struct Registry { + by_type: HashMap>, + policy: Policy, + required_types: Vec, +} + +impl Registry { + /// Empty registry with the default policy. + pub fn new() -> Self { + Self::default() + } + + /// Add a verifier, replacing any prior one for the same type. + pub fn register(&mut self, verifier: Box) { + self.by_type.insert(verifier.type_().to_string(), verifier); + } + + /// Switch policy. + pub fn with_policy(mut self, policy: Policy) -> Self { + self.policy = policy; + self + } + + /// Type discriminators that must be present and verified under + /// `Policy::RequireAll`. Ignored under other policies. + pub fn require_types>(mut self, types: I) -> Self { + self.required_types = types.into_iter().collect(); + self + } + + /// Active policy. + pub fn policy(&self) -> Policy { + self.policy + } + + /// Verify signature, cert-hash binding, and (if a verifier is registered) + /// payload structure. + pub fn verify( + &self, + attestation: &Attestation, + expected_cert_hash: [u8; 32], + ) -> Result { + attestation.verify_signature(expected_cert_hash)?; + + let fully = match self.by_type.get(&attestation.type_) { + Some(v) => { + v.verify_payload(&attestation.payload)?; + true + } + None => match self.policy { + Policy::AcceptKnown => false, + Policy::RejectUnknown | Policy::RequireAll => { + return Err(AttestError::UnknownType(attestation.type_.clone())); + } + }, + }; + + Ok(VerifiedAttestation { + type_: attestation.type_.clone(), + signer: attestation.signer.clone(), + fully_verified: fully, + }) + } + + /// Verify a batch, then enforce `RequireAll`. + pub fn verify_all( + &self, + attestations: &[Attestation], + expected_cert_hash: [u8; 32], + ) -> Result> { + let mut verified = Vec::with_capacity(attestations.len()); + for a in attestations { + verified.push(self.verify(a, expected_cert_hash)?); + } + if self.policy == Policy::RequireAll { + for required in &self.required_types { + let present = verified + .iter() + .any(|v| &v.type_ == required && v.fully_verified); + if !present { + return Err(AttestError::UnknownType(format!( + "required type '{required}' missing or not fully verified" + ))); + } + } + } + Ok(verified) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::attestation::{Attestation, AttestationPayload}; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use serde::{Deserialize, Serialize}; + + fn fresh() -> SigningKey { + SigningKey::generate(&mut OsRng) + } + + #[derive(Serialize, Deserialize)] + struct Demo { + a: String, + } + impl AttestationPayload for Demo { + fn payload_type() -> &'static str { + "demo/v1" + } + } + + struct DemoVerifier; + impl AttestationVerifier for DemoVerifier { + fn type_(&self) -> &str { + "demo/v1" + } + fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> { + payload + .get("a") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|_| ()) + .ok_or_else(|| AttestError::Payload("missing or empty 'a'".into())) + } + } + + fn sample_hash() -> [u8; 32] { + let mut h = [0u8; 32]; + for (i, b) in h.iter_mut().enumerate() { + *b = (i + 1) as u8; + } + h + } + + fn signed_demo(sk: &SigningKey, cert_hash: [u8; 32], a: &str) -> Attestation { + Attestation::sign(sk, Demo { a: a.into() }, cert_hash).unwrap() + } + + #[test] + fn registry_accepts_registered_type() { + let sk = fresh(); + let cert_hash = sample_hash(); + let mut reg = Registry::new(); + reg.register(Box::new(DemoVerifier)); + let att = signed_demo(&sk, cert_hash, "ok"); + let v = reg.verify(&att, cert_hash).unwrap(); + assert!(v.fully_verified); + } + + #[test] + fn accept_known_lets_unknown_pass_unverified() { + let sk = fresh(); + let cert_hash = sample_hash(); + let reg = Registry::new().with_policy(Policy::AcceptKnown); + let att = signed_demo(&sk, cert_hash, "ok"); + let v = reg.verify(&att, cert_hash).unwrap(); + assert!(!v.fully_verified); + } + + #[test] + fn reject_unknown_blocks_unregistered_types() { + let sk = fresh(); + let cert_hash = sample_hash(); + let reg = Registry::new().with_policy(Policy::RejectUnknown); + let att = signed_demo(&sk, cert_hash, "ok"); + let err = reg.verify(&att, cert_hash).unwrap_err(); + assert!(matches!(err, AttestError::UnknownType(_))); + } + + #[test] + fn require_all_enforces_presence() { + let sk = fresh(); + let cert_hash = sample_hash(); + let mut reg = Registry::new() + .with_policy(Policy::RequireAll) + .require_types(["demo/v1".to_string()]); + reg.register(Box::new(DemoVerifier)); + + let err = reg.verify_all(&[], cert_hash).unwrap_err(); + assert!(matches!(err, AttestError::UnknownType(_))); + + let att = signed_demo(&sk, cert_hash, "ok"); + let v = reg.verify_all(&[att], cert_hash).unwrap(); + assert_eq!(v.len(), 1); + } + + #[test] + fn payload_check_failure_rejects() { + let sk = fresh(); + let cert_hash = sample_hash(); + let mut reg = Registry::new(); + reg.register(Box::new(DemoVerifier)); + // Sign an empty `a` so the signature verifies but the payload check fails. + let att = Attestation::sign(&sk, Demo { a: String::new() }, cert_hash).unwrap(); + let err = reg.verify(&att, cert_hash).unwrap_err(); + assert!(matches!(err, AttestError::Payload(_))); + } +}