From 4b222bd0664f95cf49d20884de1583f5dcae477f Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 8 Aug 2026 17:25:44 +0200 Subject: [PATCH 1/3] feat(dsse): extract DSSE into a no_std wsc-dsse crate (#218, REQ-24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wsc::dsse (DSSE envelope sign/verify) lived only in the 179-crate wsc crate, so an embedded/offline consumer that just wants DSSE had to pull ring/rustls/ureq/ x509-parser/p256/webpki-roots. Move src/lib/src/dsse.rs into a new standalone `wsc-dsse` crate whose only deps are base64/serde/serde_json/ed25519-compact, all no_std+alloc. - **no_std + embedded-proven:** `#![no_std]` + alloc; builds for thumbv7em-none-eabi (#187's Cortex-M target). ed25519-compact with default-features=false pulls NO getrandom/std — verify + deterministic sign are ungated; only keygen needs RNG, and tests use deterministic seeds. - **Compat preserved:** wsc re-exports `pub use wsc_dsse as dsse` so wsc::dsse::* still resolves; a typed DsseError replaces the WSError coupling, with `From for WSError` so composition/mod.rs compiles BYTE-UNCHANGED. Full public API preserved (0 items dropped). - **Publish/CI wired:** wsc-dsse added to scripts/publish.rs (before wsc, dependency order) + src/dsse/BUILD.bazel; the kani `dsse` matrix entry repointed pkg wsc->wsc-dsse (leaving it at wsc would match ZERO harnesses = vacuous green — the harnesses moved). Its pre-existing tolerate_failure (unwind WIP, same as format/wasm_module) is carried over unchanged, not newly added. wsc::dsse (this) and wsc_attestation::dsse (in-toto ResourceDescriptor) are distinct concerns; only the former moved. Oracles: wsc-dsse 13/0, wsc 606/0 (=619-13 moved), thumbv7em build + workspace build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012aR3Md1h46K9wAUWMQiESH --- .github/workflows/formal-verification.yml | 6 +- Cargo.lock | 11 ++ Cargo.toml | 1 + artifacts/dev/features.yaml | 14 ++ scripts/publish.rs | 6 +- src/dsse/BUILD.bazel | 26 +++ src/dsse/Cargo.toml | 18 ++ src/{lib/src/dsse.rs => dsse/src/lib.rs} | 206 ++++++++++++++-------- src/lib/BUILD.bazel | 1 + src/lib/Cargo.toml | 3 + src/lib/src/error.rs | 18 ++ src/lib/src/lib.rs | 7 +- verification/rocq/BUILD.bazel | 2 +- verification/rocq/README.md | 2 +- verification/rocq/pae.rs | 3 +- 15 files changed, 244 insertions(+), 80 deletions(-) create mode 100644 src/dsse/BUILD.bazel create mode 100644 src/dsse/Cargo.toml rename src/{lib/src/dsse.rs => dsse/src/lib.rs} (73%) diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index fc7e55d..ac450a5 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -84,7 +84,11 @@ jobs: # the scope of bounded model checking entirely. tolerate_failure: true - module: dsse - pkg: wsc + # The DSSE `compute_pae` proofs live in `wsc-dsse` after the carve + # (issue #218 / REQ-24), so the harness must be invoked against that + # crate — invoking `-p wsc` would match zero harnesses and the gate + # would report green without running anything. + pkg: wsc-dsse harness: dsse # WIP — CI observation post-rebase: dsse harnesses also hit # "unwinding assertion loop 0" at --default-unwind 4. The diff --git a/Cargo.lock b/Cargo.lock index 2ccaf9d..862cfe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4672,6 +4672,7 @@ dependencies = [ "wat", "webpki-roots", "wsc-attestation", + "wsc-dsse", "wsc-verify-core", "x509-parser", "zeroize", @@ -4714,6 +4715,16 @@ dependencies = [ "wsc", ] +[[package]] +name = "wsc-dsse" +version = "0.10.0" +dependencies = [ + "base64", + "ed25519-compact", + "serde", + "serde_json", +] + [[package]] name = "wsc-verify-core" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index ac24274..89fc9a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "src/attestation", "src/cli", "src/component", + "src/dsse", "src/lib", "src/verify-core", ] diff --git a/artifacts/dev/features.yaml b/artifacts/dev/features.yaml index ecf4afc..9a1666c 100644 --- a/artifacts/dev/features.yaml +++ b/artifacts/dev/features.yaml @@ -719,3 +719,17 @@ artifacts: created-by: ai-assisted model: claude-opus-4-8 timestamp: 2026-08-08T06:00:07Z + + - id: DD-13 + type: design-decision + title: DSSE extracted to a new no_std wsc-dsse crate, not into wsc-verify-core + status: draft + description: "wsc::dsse moves to a new wsc-dsse crate (base64/serde/serde_json/ed25519-compact, no_std+alloc) rather than into wsc-verify-core. Reason: verify-core is the witness-MC/DC-instrumented crate whose gaps are REQ-25 — adding 600 lines of DSSE + serde_json there would add decisions to the exact crate we're closing gaps in, self-interfering within v0.11.0. wsc re-exports wsc-dsse as wsc::dsse with From for WSError so internal consumers (composition/mod.rs) and the public API keep working." + tags: [dsse, no-std, crate-topology] + fields: + rationale: "Single-responsibility crate gives embedded/offline consumers (varve, #187 Cortex-M) minimal-dep DSSE without verify-core's wasm-parsing or perturbing its MC/DC gate; no_std+alloc from the start avoids a std-only rewrite for the on-target verifier." + release: v0.11.0 + provenance: + created-by: ai-assisted + model: claude-opus-4-8 + timestamp: 2026-08-08T15:04:57Z diff --git a/scripts/publish.rs b/scripts/publish.rs index ce87eb9..682e8e4 100644 --- a/scripts/publish.rs +++ b/scripts/publish.rs @@ -14,7 +14,7 @@ use std::time::Duration; // wsc-verify-core and wsc-attestation MUST precede wsc (wsc depends on both); // wsc-cli depends on wsc. wsc-component/wsc-crypto are not deps of any published // crate, so they are intentionally not published. -const CRATES_TO_PUBLISH: &[&str] = &["wsc-verify-core", "wsc-attestation", "wsc", "wsc-cli"]; +const CRATES_TO_PUBLISH: &[&str] = &["wsc-verify-core", "wsc-attestation", "wsc-dsse", "wsc", "wsc-cli"]; struct Workspace { version: String, @@ -55,6 +55,10 @@ fn main() { let attestation_crate = read_crate(Some(&ws), "./src/attestation/Cargo.toml".as_ref()); crates.push(attestation_crate); + // Add DSSE crate (leaf: wsc depends on it; must precede wsc) + let dsse_crate = read_crate(Some(&ws), "./src/dsse/Cargo.toml".as_ref()); + crates.push(dsse_crate); + // Add main library crate let lib_crate = read_crate(Some(&ws), "./src/lib/Cargo.toml".as_ref()); crates.push(lib_crate); diff --git a/src/dsse/BUILD.bazel b/src/dsse/BUILD.bazel new file mode 100644 index 0000000..dc045ed --- /dev/null +++ b/src/dsse/BUILD.bazel @@ -0,0 +1,26 @@ +"""DSSE (Dead Simple Signing Envelope) sign/verify for wsc. + +Carved out of `//src/lib:wsc` (issue #218 / REQ-24) as a `no_std` + `alloc` +crate so embedded/offline consumers can verify DSSE envelopes with only +base64/serde/serde_json/ed25519-compact — no registry, TLS or X.509. `wsc` +re-exports its public API as `wsc::dsse` for backwards compatibility. +""" + +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "wsc-dsse", + srcs = glob(["src/**/*.rs"]), + crate_name = "wsc_dsse", + edition = "2024", + deps = [ + "@wsc_deps//:base64", + "@wsc_deps//:ed25519-compact", + "@wsc_deps//:serde", + "@wsc_deps//:serde_json", + ], +) + +exports_files(["Cargo.toml"]) diff --git a/src/dsse/Cargo.toml b/src/dsse/Cargo.toml new file mode 100644 index 0000000..b879b60 --- /dev/null +++ b/src/dsse/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wsc-dsse" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "DSSE (Dead Simple Signing Envelope) sign/verify for wsc: a lightweight no_std + alloc crate so embedded/offline consumers can verify DSSE envelopes with just base64/serde/serde_json/ed25519-compact — no registry, TLS or X.509. Carved out of the wsc crate." +readme = "../../README.md" +keywords = ["dsse", "signatures", "attestation", "no-std"] +homepage = "https://github.com/pulseengine/sigil" +categories = ["cryptography", "no-std"] + +[dependencies] +serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +ed25519-compact = { version = "2.3", default-features = false } diff --git a/src/lib/src/dsse.rs b/src/dsse/src/lib.rs similarity index 73% rename from src/lib/src/dsse.rs rename to src/dsse/src/lib.rs index ca6fce2..25781d7 100644 --- a/src/lib/src/dsse.rs +++ b/src/dsse/src/lib.rs @@ -8,10 +8,15 @@ //! - Multi-signature support //! - Format-agnostic payload handling //! +//! This crate is `no_std` + `alloc`: embedded/offline consumers can verify +//! DSSE envelopes with only base64/serde/serde_json/ed25519-compact — no +//! registry, TLS or X.509. It is carved out of the `wsc` crate, which +//! re-exports it as `wsc::dsse` for backwards compatibility. +//! //! # Example //! //! ```ignore -//! use wsc::dsse::{DsseEnvelope, DsseSigner}; +//! use wsc_dsse::{DsseEnvelope, DsseSigner}; //! //! let payload = b"my attestation data"; //! let envelope = DsseEnvelope::sign( @@ -24,10 +29,66 @@ //! let verified_payload = envelope.verify(&verifier)?; //! ``` -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +#![no_std] + +extern crate alloc; + +#[cfg(test)] +extern crate std; + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use serde::{Deserialize, Serialize}; -use crate::error::WSError; +/// Errors returned by DSSE sign/verify operations. +/// +/// Typed replacement for `wsc`'s `WSError` so this crate carries no dependency +/// on the wider `wsc` error tree. `wsc` provides `From for WSError` +/// so existing callers keep compiling. +#[derive(Debug)] +pub enum DsseError { + /// A base64 payload or signature field could not be decoded. + InvalidBase64(String), + + /// No signature verified, or the envelope carried no signatures. + VerificationFailed, + + /// JSON serialization or deserialization failed. + Json(String), + + /// An argument was invalid (e.g. an empty signer list). + InvalidArgument, + + /// An Ed25519 key or signature was malformed. + CryptoError(ed25519_compact::Error), + + /// An otherwise-unclassified internal error. + InternalError(String), +} + +impl core::fmt::Display for DsseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + DsseError::InvalidBase64(msg) => write!(f, "{}", msg), + DsseError::VerificationFailed => write!(f, "No valid signatures"), + DsseError::Json(msg) => write!(f, "{}", msg), + DsseError::InvalidArgument => write!(f, "Invalid argument"), + DsseError::CryptoError(e) => write!(f, "Ed25519 signature function error: {}", e), + DsseError::InternalError(msg) => write!(f, "Internal error: [{}]", msg), + } + } +} + +impl core::error::Error for DsseError {} + +impl From for DsseError { + fn from(e: ed25519_compact::Error) -> Self { + DsseError::CryptoError(e) + } +} /// DSSE envelope containing a signed payload /// @@ -60,7 +121,7 @@ pub struct DsseSignature { /// Trait for signing DSSE payloads pub trait DsseSigner { /// Sign the PAE-encoded data and return the signature bytes - fn sign(&self, pae: &[u8]) -> Result, WSError>; + fn sign(&self, pae: &[u8]) -> Result, DsseError>; /// Return the key ID (optional) fn key_id(&self) -> Option { @@ -71,7 +132,7 @@ pub trait DsseSigner { /// Trait for verifying DSSE signatures pub trait DsseVerifier { /// Verify the signature over PAE-encoded data - fn verify(&self, pae: &[u8], signature: &[u8]) -> Result<(), WSError>; + fn verify(&self, pae: &[u8], signature: &[u8]) -> Result<(), DsseError>; } impl DsseEnvelope { @@ -86,7 +147,7 @@ impl DsseEnvelope { payload: &[u8], payload_type: &str, signer: &dyn DsseSigner, - ) -> Result { + ) -> Result { // Compute PAE (Pre-Authentication Encoding) let pae = compute_pae(payload_type, payload); @@ -96,7 +157,7 @@ impl DsseEnvelope { Ok(Self { payload: BASE64.encode(payload), payload_type: payload_type.to_string(), - signatures: vec![DsseSignature { + signatures: alloc::vec![DsseSignature { keyid: signer.key_id(), sig: BASE64.encode(sig_bytes), }], @@ -108,9 +169,9 @@ impl DsseEnvelope { payload: &[u8], payload_type: &str, signers: &[&dyn DsseSigner], - ) -> Result { + ) -> Result { if signers.is_empty() { - return Err(WSError::InvalidArgument); + return Err(DsseError::InvalidArgument); } let pae = compute_pae(payload_type, payload); @@ -142,15 +203,15 @@ impl DsseEnvelope { /// forged ones. If you need to verify that ALL signatures are valid (e.g., for /// multi-party signing where every signer must be trusted), use [`verify_all()`] /// instead. - pub fn verify(&self, verifier: &dyn DsseVerifier) -> Result, WSError> { + pub fn verify(&self, verifier: &dyn DsseVerifier) -> Result, DsseError> { if self.signatures.is_empty() { - return Err(WSError::VerificationFailed); + return Err(DsseError::VerificationFailed); } // Decode payload - let payload = BASE64.decode(&self.payload).map_err(|e| { - WSError::InternalError(format!("Invalid base64 payload: {}", e)) - })?; + let payload = BASE64 + .decode(&self.payload) + .map_err(|e| DsseError::InvalidBase64(format!("Invalid base64 payload: {}", e)))?; // Compute PAE let pae = compute_pae(&self.payload_type, &payload); @@ -159,7 +220,7 @@ impl DsseEnvelope { let mut verified = false; for sig in &self.signatures { let sig_bytes = BASE64.decode(&sig.sig).map_err(|e| { - WSError::InternalError(format!("Invalid base64 signature: {}", e)) + DsseError::InvalidBase64(format!("Invalid base64 signature: {}", e)) })?; if verifier.verify(&pae, &sig_bytes).is_ok() { @@ -169,7 +230,7 @@ impl DsseEnvelope { } if !verified { - return Err(WSError::VerificationFailed); + return Err(DsseError::VerificationFailed); } Ok(payload) @@ -178,20 +239,20 @@ impl DsseEnvelope { /// Verify all signatures in the envelope /// /// Returns error if any signature fails verification. - pub fn verify_all(&self, verifier: &dyn DsseVerifier) -> Result, WSError> { + pub fn verify_all(&self, verifier: &dyn DsseVerifier) -> Result, DsseError> { if self.signatures.is_empty() { - return Err(WSError::VerificationFailed); + return Err(DsseError::VerificationFailed); } - let payload = BASE64.decode(&self.payload).map_err(|e| { - WSError::InternalError(format!("Invalid base64 payload: {}", e)) - })?; + let payload = BASE64 + .decode(&self.payload) + .map_err(|e| DsseError::InvalidBase64(format!("Invalid base64 payload: {}", e)))?; let pae = compute_pae(&self.payload_type, &payload); for sig in &self.signatures { let sig_bytes = BASE64.decode(&sig.sig).map_err(|e| { - WSError::InternalError(format!("Invalid base64 signature: {}", e)) + DsseError::InvalidBase64(format!("Invalid base64 signature: {}", e)) })?; verifier.verify(&pae, &sig_bytes)?; @@ -206,31 +267,28 @@ impl DsseEnvelope { /// /// This does not verify signatures. Use only when verification /// is done separately or not required. - pub fn payload_bytes(&self) -> Result, WSError> { - BASE64.decode(&self.payload).map_err(|e| { - WSError::InternalError(format!("Invalid base64 payload: {}", e)) - }) + pub fn payload_bytes(&self) -> Result, DsseError> { + BASE64 + .decode(&self.payload) + .map_err(|e| DsseError::InvalidBase64(format!("Invalid base64 payload: {}", e))) } /// Serialize to JSON - pub fn to_json(&self) -> Result { - serde_json::to_string(self).map_err(|e| { - WSError::InternalError(format!("Failed to serialize DSSE envelope: {}", e)) - }) + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| DsseError::Json(format!("Failed to serialize DSSE envelope: {}", e))) } /// Serialize to pretty JSON - pub fn to_json_pretty(&self) -> Result { - serde_json::to_string_pretty(self).map_err(|e| { - WSError::InternalError(format!("Failed to serialize DSSE envelope: {}", e)) - }) + pub fn to_json_pretty(&self) -> Result { + serde_json::to_string_pretty(self) + .map_err(|e| DsseError::Json(format!("Failed to serialize DSSE envelope: {}", e))) } /// Deserialize from JSON - pub fn from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| { - WSError::InternalError(format!("Failed to parse DSSE envelope: {}", e)) - }) + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + .map_err(|e| DsseError::Json(format!("Failed to parse DSSE envelope: {}", e))) } /// Create an unsigned envelope (for testing or deferred signing) @@ -238,12 +296,12 @@ impl DsseEnvelope { Self { payload: BASE64.encode(payload), payload_type: payload_type.to_string(), - signatures: vec![], + signatures: Vec::new(), } } /// Add a signature to an existing envelope - pub fn add_signature(&mut self, signer: &dyn DsseSigner) -> Result<(), WSError> { + pub fn add_signature(&mut self, signer: &dyn DsseSigner) -> Result<(), DsseError> { let payload = self.payload_bytes()?; let pae = compute_pae(&self.payload_type, &payload); let sig_bytes = signer.sign(&pae)?; @@ -297,15 +355,15 @@ impl Ed25519DsseSigner { } /// Create from raw secret key bytes - pub fn from_bytes(bytes: &[u8], key_id: Option) -> Result { - let secret_key = ed25519_compact::SecretKey::from_slice(bytes) - .map_err(|e| WSError::CryptoError(e))?; + pub fn from_bytes(bytes: &[u8], key_id: Option) -> Result { + let secret_key = + ed25519_compact::SecretKey::from_slice(bytes).map_err(DsseError::CryptoError)?; Ok(Self { secret_key, key_id }) } } impl DsseSigner for Ed25519DsseSigner { - fn sign(&self, pae: &[u8]) -> Result, WSError> { + fn sign(&self, pae: &[u8]) -> Result, DsseError> { let signature = self.secret_key.sign(pae, None); Ok(signature.to_vec()) } @@ -327,21 +385,21 @@ impl Ed25519DsseVerifier { } /// Create from raw public key bytes - pub fn from_bytes(bytes: &[u8]) -> Result { - let public_key = ed25519_compact::PublicKey::from_slice(bytes) - .map_err(|e| WSError::CryptoError(e))?; + pub fn from_bytes(bytes: &[u8]) -> Result { + let public_key = + ed25519_compact::PublicKey::from_slice(bytes).map_err(DsseError::CryptoError)?; Ok(Self { public_key }) } } impl DsseVerifier for Ed25519DsseVerifier { - fn verify(&self, pae: &[u8], signature: &[u8]) -> Result<(), WSError> { - let sig = ed25519_compact::Signature::from_slice(signature) - .map_err(|e| WSError::CryptoError(e))?; + fn verify(&self, pae: &[u8], signature: &[u8]) -> Result<(), DsseError> { + let sig = + ed25519_compact::Signature::from_slice(signature).map_err(DsseError::CryptoError)?; self.public_key .verify(pae, &sig) - .map_err(|_| WSError::VerificationFailed) + .map_err(|_| DsseError::VerificationFailed) } } @@ -364,11 +422,19 @@ pub mod payload_types { mod tests { use super::*; - fn generate_test_keypair() -> (ed25519_compact::SecretKey, ed25519_compact::PublicKey) { - let kp = ed25519_compact::KeyPair::generate(); + // Deterministic test keypairs from fixed seeds: keeps the crate free of + // `getrandom` (ed25519-compact's `KeyPair::generate()` needs the `random` + // feature). Distinct seed bytes yield distinct keypairs. Tests run on the + // host with the std test harness even though the library is `no_std`. + fn keypair(seed_byte: u8) -> (ed25519_compact::SecretKey, ed25519_compact::PublicKey) { + let kp = ed25519_compact::KeyPair::from_seed(ed25519_compact::Seed::new([seed_byte; 32])); (kp.sk, kp.pk) } + fn generate_test_keypair() -> (ed25519_compact::SecretKey, ed25519_compact::PublicKey) { + keypair(1) + } + #[test] fn test_pae_computation() { let pae = compute_pae("application/example", b"hello"); @@ -390,11 +456,7 @@ mod tests { let verifier = Ed25519DsseVerifier::new(pk); let payload = b"test payload"; - let envelope = DsseEnvelope::sign( - payload, - payload_types::IN_TOTO, - &signer, - ).unwrap(); + let envelope = DsseEnvelope::sign(payload, payload_types::IN_TOTO, &signer).unwrap(); assert_eq!(envelope.payload_type, payload_types::IN_TOTO); assert_eq!(envelope.signatures.len(), 1); @@ -409,11 +471,7 @@ mod tests { let (sk, _pk) = generate_test_keypair(); let signer = Ed25519DsseSigner::new(sk, None); - let envelope = DsseEnvelope::sign( - b"test data", - "application/json", - &signer, - ).unwrap(); + let envelope = DsseEnvelope::sign(b"test data", "application/json", &signer).unwrap(); let json = envelope.to_json().unwrap(); let parsed = DsseEnvelope::from_json(&json).unwrap(); @@ -425,8 +483,8 @@ mod tests { #[test] fn test_multi_signature() { - let (sk1, pk1) = generate_test_keypair(); - let (sk2, pk2) = generate_test_keypair(); + let (sk1, pk1) = keypair(1); + let (sk2, pk2) = keypair(2); let signer1 = Ed25519DsseSigner::new(sk1, Some("key1".to_string())); let signer2 = Ed25519DsseSigner::new(sk2, Some("key2".to_string())); @@ -437,7 +495,8 @@ mod tests { b"multi-signed payload", "application/json", &[&signer1, &signer2], - ).unwrap(); + ) + .unwrap(); assert_eq!(envelope.signatures.len(), 2); @@ -448,17 +507,13 @@ mod tests { #[test] fn test_verify_fails_wrong_key() { - let (sk, _pk) = generate_test_keypair(); - let (_, other_pk) = generate_test_keypair(); + let (sk, _pk) = keypair(1); + let (_, other_pk) = keypair(2); let signer = Ed25519DsseSigner::new(sk, None); let wrong_verifier = Ed25519DsseVerifier::new(other_pk); - let envelope = DsseEnvelope::sign( - b"test", - "application/json", - &signer, - ).unwrap(); + let envelope = DsseEnvelope::sign(b"test", "application/json", &signer).unwrap(); assert!(envelope.verify(&wrong_verifier).is_err()); } @@ -595,6 +650,9 @@ mod proofs { fn proof_pae_length_prefix_prevents_ambiguity() { let pae_a = compute_pae("a", b""); let pae_b = compute_pae("", b"a"); - assert_ne!(pae_a, pae_b, "PAE ambiguity: different type/payload split produced same encoding"); + assert_ne!( + pae_a, pae_b, + "PAE ambiguity: different type/payload split produced same encoding" + ); } } diff --git a/src/lib/BUILD.bazel b/src/lib/BUILD.bazel index 23d5623..e8ce993 100644 --- a/src/lib/BUILD.bazel +++ b/src/lib/BUILD.bazel @@ -23,6 +23,7 @@ rust_library( ], deps = [ "//src/attestation:wsc-attestation", + "//src/dsse:wsc-dsse", "//src/verify-core:wsc-verify-core", "@wsc_deps//:anyhow", "@wsc_deps//:ct-codecs", diff --git a/src/lib/Cargo.toml b/src/lib/Cargo.toml index 72c1073..cf49b81 100644 --- a/src/lib/Cargo.toml +++ b/src/lib/Cargo.toml @@ -17,6 +17,9 @@ categories = ["cryptography", "wasm"] wsc-verify-core = { version = "0.10.0", path = "../verify-core" } # Re-export attestation types from minimal crate wsc-attestation = { version = "0.10.0", path = "../attestation" } +# DSSE sign/verify carved out as a no_std crate (issue #218 / REQ-24). +# wsc re-exports it as `wsc::dsse` for backwards compatibility. +wsc-dsse = { version = "0.10.0", path = "../dsse" } anyhow = "1.0.100" ct-codecs = "1.1.6" ed25519-compact = { version = "2.1.1", features = ["pem"] } diff --git a/src/lib/src/error.rs b/src/lib/src/error.rs index 98ef096..2f41860 100644 --- a/src/lib/src/error.rs +++ b/src/lib/src/error.rs @@ -197,6 +197,24 @@ impl From for WSError { } } +// Lift errors from the DSSE crate (`wsc-dsse`) into `WSError` so call sites in +// this crate (e.g. `composition::mod`) can keep using `?` through the carve +// boundary without any change to their logic. Preserves the pre-carve behavior: +// base64/JSON failures were previously bucketed into `WSError::InternalError`. +impl From for WSError { + fn from(err: wsc_dsse::DsseError) -> Self { + use wsc_dsse::DsseError as D; + match err { + D::InvalidBase64(s) => WSError::InternalError(s), + D::VerificationFailed => WSError::VerificationFailed, + D::Json(s) => WSError::InternalError(s), + D::InvalidArgument => WSError::InvalidArgument, + D::CryptoError(e) => WSError::CryptoError(e), + D::InternalError(s) => WSError::InternalError(s), + } + } +} + // WASI HTTP error conversion for wasm32-wasip2 target #[cfg(all(target_arch = "wasm32", target_os = "wasi"))] impl From for WSError { diff --git a/src/lib/src/lib.rs b/src/lib/src/lib.rs index 0130824..16f17af 100644 --- a/src/lib/src/lib.rs +++ b/src/lib/src/lib.rs @@ -94,7 +94,12 @@ pub mod format; /// Used as the wrapper for all embedded attestations, enabling extraction /// and verification with standard tooling (cosign, sigstore-rs, etc.). /// See: https://github.com/secure-systems-lab/dsse -pub mod dsse; +/// +/// Carved out into the standalone `no_std` `wsc-dsse` crate (issue #218 / +/// REQ-24) so embedded/offline consumers can verify DSSE envelopes without +/// wsc's registry/TLS/X.509 tree. Re-exported here so `wsc::dsse::*` still +/// resolves for existing callers. +pub use wsc_dsse as dsse; /// in-toto Statement v1.0 implementation /// diff --git a/verification/rocq/BUILD.bazel b/verification/rocq/BUILD.bazel index edb14ea..26c99b3 100644 --- a/verification/rocq/BUILD.bazel +++ b/verification/rocq/BUILD.bazel @@ -1,7 +1,7 @@ load("@rules_rocq_rust//coq_of_rust:defs.bzl", "rocq_rust_verified_library") # Translate PAE (Pre-Authentication Encoding) to Rocq via coq-of-rust (CV-22) -# Self-contained extraction of compute_pae from dsse.rs — no external deps. +# Self-contained extraction of compute_pae from src/dsse/src/lib.rs — no external deps. rocq_rust_verified_library( name = "pae_verified", rust_sources = ["pae.rs"], diff --git a/verification/rocq/README.md b/verification/rocq/README.md index 0cb6dcf..480cf29 100644 --- a/verification/rocq/README.md +++ b/verification/rocq/README.md @@ -10,7 +10,7 @@ This README is part of the 2026-04-30 audit honesty fix (finding C-3). ## What lives here today -- `pae.rs` — a Rust extraction of `compute_pae` from `src/lib/src/dsse.rs`, +- `pae.rs` — a Rust extraction of `compute_pae` from `src/dsse/src/lib.rs`, shaped for `coq-of-rust` translation. It is plain Rust with unit tests; it does not contain any Rocq (`.v`) source. - `BUILD.bazel` — declares a `rocq_rust_verified_library` target named diff --git a/verification/rocq/pae.rs b/verification/rocq/pae.rs index 335354d..637cadb 100644 --- a/verification/rocq/pae.rs +++ b/verification/rocq/pae.rs @@ -1,6 +1,7 @@ /// Pre-Authentication Encoding for DSSE (extracted for Rocq verification). /// -/// This is a self-contained extraction of the PAE function from dsse.rs, +/// This is a self-contained extraction of the PAE function from +/// src/dsse/src/lib.rs, /// suitable for coq-of-rust translation. /// Compute Pre-Authentication Encoding (PAE) per DSSE spec. From 8912f26285396faa365058f80c2b737a36d5db82 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 8 Aug 2026 17:36:27 +0200 Subject: [PATCH 2/3] ci(coverage): include wsc-dsse in llvm-cov after the carve (#218) The coverage job ran `cargo llvm-cov -p wsc`; after DSSE moved to its own crate, those lines are no longer in wsc, so codecov/patch flagged the moved code as uncovered even though its 13 tests moved with it. Add `-p wsc-dsse`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012aR3Md1h46K9wAUWMQiESH --- .github/workflows/rust.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 757df28..42d1e2a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -295,9 +295,12 @@ jobs: with: tool: cargo-llvm-cov - name: Generate coverage (LCOV + HTML) + # Include wsc-dsse: the DSSE code was carved out of wsc into its own crate + # (#218), so `-p wsc` alone no longer sees it and its moved tests wouldn't + # count — codecov would report the moved lines as uncovered. run: | - cargo llvm-cov -p wsc --lcov --output-path lcov.info - cargo llvm-cov -p wsc --html --output-dir coverage-html + cargo llvm-cov -p wsc -p wsc-dsse --lcov --output-path lcov.info + cargo llvm-cov -p wsc -p wsc-dsse --html --output-dir coverage-html - name: Upload LCOV to Codecov if: env.CODECOV_TOKEN != '' uses: codecov/codecov-action@v7 From e042e1ad979a5833b0116186063be75ab8109ee2 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 8 Aug 2026 17:54:10 +0200 Subject: [PATCH 3/3] test(dsse): cover from_bytes constructors + verify error paths (#218) Raise wsc-dsse coverage 86% -> 97% lines by testing the paths the moved code never exercised: Ed25519DsseSigner/Verifier::from_bytes (positive + malformed), verify/verify_all error branches (empty signatures, bad base64 payload/sig, one-bad-among-many), and DsseError Display. Each asserts the specific DsseError variant. 24 tests, all no_std deterministic-seed based (no getrandom). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012aR3Md1h46K9wAUWMQiESH --- src/dsse/src/lib.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/src/dsse/src/lib.rs b/src/dsse/src/lib.rs index 25781d7..42c4168 100644 --- a/src/dsse/src/lib.rs +++ b/src/dsse/src/lib.rs @@ -600,6 +600,197 @@ mod tests { let parsed = DsseEnvelope::from_json(&json).unwrap(); assert_eq!(parsed.payload, envelope.payload); } + + #[test] + fn test_signer_from_bytes_roundtrip() { + // Derive raw secret-key bytes from a deterministic keypair, then + // reconstruct the signer via from_bytes and verify end-to-end. + let (sk, pk) = keypair(7); + let sk_bytes = sk.to_vec(); + + let signer = + Ed25519DsseSigner::from_bytes(&sk_bytes, Some("from-bytes-key".to_string())).unwrap(); + assert_eq!(signer.key_id(), Some("from-bytes-key".to_string())); + + let verifier = Ed25519DsseVerifier::new(pk); + let envelope = DsseEnvelope::sign(b"from_bytes payload", "text/plain", &signer).unwrap(); + assert_eq!(envelope.verify(&verifier).unwrap(), b"from_bytes payload"); + } + + #[test] + fn test_signer_from_bytes_wrong_length() { + // A too-short secret key must surface as a typed CryptoError. + // (Match on the Result: Ed25519DsseSigner intentionally isn't Debug, + // so unwrap_err() is unavailable.) + let result = Ed25519DsseSigner::from_bytes(&[0u8; 10], None); + assert!( + matches!(result, Err(DsseError::CryptoError(_))), + "expected Err(CryptoError)" + ); + } + + #[test] + fn test_verifier_from_bytes_roundtrip() { + // Reconstruct the verifier from raw public-key bytes and verify a good + // signature. + let (sk, pk) = keypair(9); + let pk_bytes = pk.to_vec(); + + let signer = Ed25519DsseSigner::new(sk, None); + let verifier = Ed25519DsseVerifier::from_bytes(&pk_bytes).unwrap(); + + let envelope = DsseEnvelope::sign(b"verifier from_bytes", "text/plain", &signer).unwrap(); + assert_eq!(envelope.verify(&verifier).unwrap(), b"verifier from_bytes"); + } + + #[test] + fn test_verifier_from_bytes_malformed() { + let result = Ed25519DsseVerifier::from_bytes(&[0u8; 5]); + assert!( + matches!(result, Err(DsseError::CryptoError(_))), + "expected Err(CryptoError)" + ); + } + + #[test] + fn test_verify_empty_signatures() { + let (_sk, pk) = keypair(3); + let verifier = Ed25519DsseVerifier::new(pk); + + let envelope = DsseEnvelope::unsigned(b"no sigs here", "text/plain"); + let err = envelope.verify(&verifier).unwrap_err(); + assert!( + matches!(err, DsseError::VerificationFailed), + "expected VerificationFailed, got {err:?}" + ); + } + + #[test] + fn test_verify_bad_base64_payload() { + let (sk, pk) = keypair(4); + let signer = Ed25519DsseSigner::new(sk, None); + let verifier = Ed25519DsseVerifier::new(pk); + + let mut envelope = DsseEnvelope::sign(b"payload", "text/plain", &signer).unwrap(); + // Corrupt the payload with a character outside the base64 alphabet. + envelope.payload = "not*valid*base64".to_string(); + + let err = envelope.verify(&verifier).unwrap_err(); + assert!( + matches!(err, DsseError::InvalidBase64(_)), + "expected InvalidBase64, got {err:?}" + ); + } + + #[test] + fn test_verify_bad_base64_signature() { + let (sk, pk) = keypair(5); + let signer = Ed25519DsseSigner::new(sk, None); + let verifier = Ed25519DsseVerifier::new(pk); + + let mut envelope = DsseEnvelope::sign(b"payload", "text/plain", &signer).unwrap(); + // Valid base64 payload, but the signature field is not valid base64. + envelope.signatures[0].sig = "@@@not-base64@@@".to_string(); + + let err = envelope.verify(&verifier).unwrap_err(); + assert!( + matches!(err, DsseError::InvalidBase64(_)), + "expected InvalidBase64, got {err:?}" + ); + } + + #[test] + fn test_verify_all_bad_base64_payload() { + let (sk, pk) = keypair(6); + let signer = Ed25519DsseSigner::new(sk, None); + let verifier = Ed25519DsseVerifier::new(pk); + + let mut envelope = DsseEnvelope::sign(b"payload", "text/plain", &signer).unwrap(); + envelope.payload = "###".to_string(); + + let err = envelope.verify_all(&verifier).unwrap_err(); + assert!( + matches!(err, DsseError::InvalidBase64(_)), + "expected InvalidBase64, got {err:?}" + ); + } + + #[test] + fn test_verify_all_bad_base64_signature() { + let (sk, pk) = keypair(8); + let signer = Ed25519DsseSigner::new(sk, None); + let verifier = Ed25519DsseVerifier::new(pk); + + let mut envelope = DsseEnvelope::sign(b"payload", "text/plain", &signer).unwrap(); + envelope.signatures[0].sig = "!!!".to_string(); + + let err = envelope.verify_all(&verifier).unwrap_err(); + assert!( + matches!(err, DsseError::InvalidBase64(_)), + "expected InvalidBase64, got {err:?}" + ); + } + + #[test] + fn test_verify_all_one_bad_signature_among_several() { + // Two valid signers; corrupt the second signature so verify_all fails + // with VerificationFailed while verify() (1-of-N) still succeeds. + let (sk1, pk1) = keypair(10); + let (sk2, _pk2) = keypair(11); + let signer1 = Ed25519DsseSigner::new(sk1, None); + let signer2 = Ed25519DsseSigner::new(sk2, None); + + let mut envelope = + DsseEnvelope::sign_multi(b"multi", "text/plain", &[&signer1, &signer2]).unwrap(); + + // Replace the second signature with a valid-base64 but wrong signature + // (re-encode the first signer's sig, which won't verify under pk1 twice + // — actually flip it to a different valid signature over other data). + let bad = DsseEnvelope::sign(b"other data", "text/plain", &signer2).unwrap(); + envelope.signatures[1].sig = bad.signatures[0].sig.clone(); + + let verifier1 = Ed25519DsseVerifier::new(pk1); + + // verify() still succeeds: signer1's signature is valid for verifier1. + assert!(envelope.verify(&verifier1).is_ok()); + + // verify_all() fails: the tampered second signature does not verify. + let err = envelope.verify_all(&verifier1).unwrap_err(); + assert!( + matches!(err, DsseError::VerificationFailed), + "expected VerificationFailed, got {err:?}" + ); + } + + #[test] + fn test_dsse_error_display() { + let cases: [(DsseError, &str); 6] = [ + ( + DsseError::InvalidBase64("bad payload".to_string()), + "bad payload", + ), + (DsseError::VerificationFailed, "No valid signatures"), + (DsseError::Json("parse boom".to_string()), "parse boom"), + (DsseError::InvalidArgument, "Invalid argument"), + ( + DsseError::CryptoError(ed25519_compact::Error::InvalidPublicKey), + "Ed25519", + ), + ( + DsseError::InternalError("kaboom".to_string()), + "kaboom", + ), + ]; + + for (err, expected_substr) in cases { + let s = alloc::format!("{err}"); + assert!(!s.is_empty(), "Display produced empty string for {err:?}"); + assert!( + s.contains(expected_substr), + "Display {s:?} missing {expected_substr:?}" + ); + } + } } // ============================================================================