From c65b68a2f70dbe5a87f01f5b27948cf752cca5f6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 6 Oct 2026 18:06:47 -0400 Subject: [PATCH 01/10] wai-rs: every Ed25519 check outside OER/2 goes through sig::verify_strict Sixteen sites still decoded the key with VerifyingKey::from_bytes and checked with the lenient verify, which accepts a key of small order (under the identity, (R, S) = (B, 1) verifies over any message), a second spelling of a point (y = p + j is reduced), and an R of small order. Each now calls crate::sig::verify_strict: the video, SSAI, key-release, channel and package receipts (v1 and v2), the provenance, ternary and the five media receipts, and the world's receipt, op signature and container seal. A package names a bad key apart from a bad signature under receipt-signature; an op's bad key is still BadActorKey. claim_receipt's own copy of the rule (signer_key, verify_ed25519_strict) is gone: claims, JWP groups and the key log call the same routine, and its key test moved into sig.rs. sig builds under a new `ed25519` feature that is only the dependency, which world and oer enable, so the helper exists exactly when ed25519-dalek does and asks for nothing else. sig::forgeries builds, for any signing payload, the three signatures a lenient verifier accepts and the rule refuses; every moved site has a test that its receipt refuses all three. oer.rs is untouched: OER/2's settled rule refuses the same keys but not R of small order. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/Cargo.toml | 8 +- wai/wai-rs/src/audio_scene/receipt.rs | 16 +- wai/wai-rs/src/claim_receipt.rs | 114 ++---------- wai/wai-rs/src/film/receipt.rs | 14 +- wai/wai-rs/src/haptic/receipt.rs | 15 +- wai/wai-rs/src/jwp_delivery.rs | 8 +- wai/wai-rs/src/key_log.rs | 11 +- wai/wai-rs/src/lib.rs | 7 +- wai/wai-rs/src/package.rs | 12 +- wai/wai-rs/src/package/tests.rs | 20 +++ wai/wai-rs/src/provenance.rs | 15 +- wai/wai-rs/src/score/receipt.rs | 15 +- wai/wai-rs/src/sig.rs | 247 ++++++++++++++++++++++---- wai/wai-rs/src/splat4d/receipt.rs | 15 +- wai/wai-rs/src/ternary_receipt.rs | 18 +- wai/wai-rs/src/video_receipt.rs | 20 ++- wai/wai-rs/src/wai_video_channel.rs | 15 +- wai/wai-rs/src/wai_video_keys.rs | 18 +- wai/wai-rs/src/wai_video_package.rs | 16 +- wai/wai-rs/src/wai_video_ssai.rs | 17 +- wai/wai-rs/src/world/container.rs | 44 ++++- wai/wai-rs/src/world/op.rs | 41 ++++- wai/wai-rs/src/world/receipt.rs | 17 +- 23 files changed, 521 insertions(+), 202 deletions(-) diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 1ed8a29ce..4959ca69a 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -148,9 +148,13 @@ neural = ["neural_int", "dep:ort", "dep:ndarray", "dep:zstd-safe"] # regenerate-at-sink. Reuses the ONNX runtime for the reference CLIP embedder; # the generator is always sink-supplied. Off by default like `neural`. semantic = ["codecs", "dep:ort", "dep:ndarray"] +# Ed25519 verified as WAI verifies every signature (SPEC §7 *Signatures*, `src/sig.rs`): +# the dependency on its own, so the one verifier builds exactly when ed25519-dalek does +# and asks for no feature its caller does not need. Every signing feature enables it. +ed25519 = ["dep:ed25519-dalek"] # Interactive worlds (extensions/interactive-worlds): media that consumes # input. Pure-Rust, no ML dep chain — the sim is closed-form fixed-point. -world = ["dep:blake3", "dep:ed25519-dalek"] +world = ["dep:blake3", "ed25519"] # Object-based spatial audio (extensions/spatial-audio): the deterministic # Fx mixdown engine. Reuses the world `Fx` floor + blake3; no codec/ML deps # (sources arrive as decoded mono PCM — decoding the wai.audio.* assets is @@ -175,7 +179,7 @@ provenance = ["world"] # receipts that grade what a unit of work cost (projected/simulated/derived/measured), # content-address and chain it, and sign it. Conformance is reason-set equality with # the reference verifier and the portable C kernel over oer-conformance/. -oer = ["dep:blake3", "dep:ed25519-dalek", "dep:num-bigint", "serde_json/float_roundtrip"] +oer = ["dep:blake3", "ed25519", "dep:num-bigint", "serde_json/float_roundtrip"] # jwp-receipts §2.1's object content hash (src/jwp_hash.rs), on its own so any # receipt family that names an object can compute it with BLAKE3 alone. jwp_hash = ["dep:blake3"] diff --git a/wai/wai-rs/src/audio_scene/receipt.rs b/wai/wai-rs/src/audio_scene/receipt.rs index 862b5b01a..120568eee 100644 --- a/wai/wai-rs/src/audio_scene/receipt.rs +++ b/wai/wai-rs/src/audio_scene/receipt.rs @@ -14,7 +14,7 @@ use crate::energy_class::{ check_label, label_coheres, labelled_domain, write_label, AcquisitionError, EnergyProvenance, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:audio-receipt\x01"; const DOMAIN_BLOCK: &[u8] = b"wai:audio-block\x01"; @@ -141,8 +141,7 @@ impl SceneReceipt { if merkle_root(&leaves) != self.merkle_root { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Label the figure with its acquisition class and re-sign @@ -245,4 +244,15 @@ mod tests { assert!(r0.verify() && r1.verify()); assert_ne!(r0.receipt_hash(), r1.receipt_hash()); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let (scene, src) = scene_and_sources(); + let blocks = scene.block_hashes(&src, "stereo", 96).unwrap(); + let r = SceneReceipt::build(&key(9), "did:web:wai#audio", scene.scene_hash(), 0, blocks, 480, 1234, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/claim_receipt.rs b/wai/wai-rs/src/claim_receipt.rs index 911b6fe38..8c6914870 100644 --- a/wai/wai-rs/src/claim_receipt.rs +++ b/wai/wai-rs/src/claim_receipt.rs @@ -115,7 +115,7 @@ use crate::energy_class::{ use crate::energy_figure::FigureView; use crate::energy_report::{Attribution, BracketSummary, BracketValues, BuildProfile, BuildRecord, ReportDefect}; use std::collections::BTreeMap; -use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use serde_json::{Map, Value}; use std::fmt::Write as _; @@ -2498,8 +2498,8 @@ impl ClaimReceipt { /// Ed25519 rule, then the class rules. pub fn verify(&self) -> Result<(), Rejection> { self.check_shape()?; - verify_ed25519_strict(&self.signer_pubkey, &self.signing_input()?, &self.sig) - .map_err(|r| Rejection::at(3, r))?; + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_input()?, &self.sig) + .map_err(|e| Rejection::at(3, Reason::of_signature(e)))?; self.body .check_class_rules(self.subject_type, &self.subject) .map_err(|r| Rejection::at(4, r)) @@ -2804,50 +2804,19 @@ impl ClaimReceipt { } } -/// §3.1 step 3's key checks: `signer_pubkey` decodes under RFC 8032 §5.1.3 and is not of -/// small order; `bad_signer_key` otherwise. -/// -/// `VerifyingKey::from_bytes` is more lenient than RFC 8032 §5.1.3 in two ways: it reduces -/// a `y` coordinate ≥ p modulo p, and it accepts `x` = 0 with the sign bit set. RFC 8032 -/// refuses both, which leaves every point exactly one encoding. A key therefore decodes -/// under RFC 8032 exactly when it decodes here and re-encodes to the same 32 bytes. -/// Without this check a `y` written as p + j would be a second spelling of the point with -/// `y` = j, and would reach the signature check where RFC 8032 decoding has already -/// refused the key: two verifiers, two reasons. -/// -/// A key of small order (one whose order divides 8) is refused because the verification -/// equation can then hold for a signature made without any private key. Under the -/// identity key, every `R` = [s]B with `S` = s verifies. -pub(crate) fn signer_key(bytes: &[u8; 32]) -> Result { - let key = VerifyingKey::from_bytes(bytes).map_err(|_| Reason::BadSignerKey)?; - if key.to_edwards().compress().to_bytes() != *bytes || key.is_weak() { - return Err(Reason::BadSignerKey); +/// §3.1 step 3's Ed25519 rule is WAI's one rule (SPEC §7 *Signatures*, [`crate::sig`]): +/// a key that is not the canonical encoding of a point of large order is +/// `bad_signer_key`; `S` ≥ L, `R` of small order, and an equation that does not hold with +/// `R` compared byte for byte are `signature_verify_failed`. A JWP group signature in +/// either form (`crate::jwp_delivery`) and the documents of `crate::key_log` call the same +/// routine and name its two outcomes with their own reasons. +impl Reason { + pub(crate) fn of_signature(e: crate::sig::SigError) -> Self { + match e { + crate::sig::SigError::BadKey => Reason::BadSignerKey, + crate::sig::SigError::BadSignature => Reason::SignatureVerifyFailed, + } } - Ok(key) -} - -/// §3.1 step 3's Ed25519 rule over `msg`: the key decodes from its one canonical -/// encoding and is not of small order (`bad_signer_key`, the checks of [`signer_key`]); -/// then `S` < L, `R` is not of small order, and the cofactorless equation holds with `R` -/// compared byte for byte (`signature_verify_failed`). One routine, so a claim, a JWP -/// group signature in either group form, legacy or labelled (jwp-receipts §3 step 2, -/// `crate::jwp_delivery`), and the documents of `crate::key_log` (key-log revisions, -/// module grants and statuses, jwp-receipts §7) are held to the same rule, and every -/// conforming verifier accepts the same signatures. -pub(crate) fn verify_ed25519_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), Reason> { - let key = signer_key(pubkey)?; - // S < L (RFC 8032 §5.1.7), checked here as well as inside `verify_strict`: the - // library's own range check shrinks to a top-three-bits test when any crate in a - // build turns on its `legacy_compatibility` feature, and S + L passes that test. - let s: [u8; 32] = sig[32..].try_into().expect("a signature is 64 bytes"); - if bool::from(curve25519_dalek::Scalar::from_canonical_bytes(s).is_none()) { - return Err(Reason::SignatureVerifyFailed); - } - // `verify_strict` does the rest of the rule: R not of small order, and the - // cofactorless equation checked by comparing the encoding of [S]B − [k]A with R byte - // for byte (so a non-canonical R never verifies). Its own small-order test on the key - // cannot fire here; `signer_key` refused those first. - key.verify_strict(msg, &Signature::from_bytes(sig)).map_err(|_| Reason::SignatureVerifyFailed) } /// An RFC 3339 `date-time` in UTC, to the second, for `seconds` since the Unix epoch: @@ -3804,7 +3773,7 @@ mod corpus { fn off_curve_key() -> [u8; 32] { (1u8..0x7f) .map(|i| [i; 32]) - .find(|k| VerifyingKey::from_bytes(k).is_err()) + .find(|k| ed25519_dalek::VerifyingKey::from_bytes(k).is_err()) .expect("half of all y values are off the curve") } @@ -6109,57 +6078,6 @@ mod tests { } } - /// §3.1 step 3's key rule over every encoding RFC 8032 §5.1.3 refuses that curve25519's - /// decompression accepts — y = p + j for j = 0 … 18, and x = 0 with the sign bit set — - /// and over the eight points of small order. - #[test] - fn a_key_is_refused_unless_canonical_and_of_large_order() { - let mut lenient = 0; - for j in 0u8..=18 { - let mut b = [0xff; 32]; - b[0] = 0xed + j; // p + j, little-endian - b[31] = 0x7f; - if VerifyingKey::from_bytes(&b).is_ok() { - lenient += 1; - assert_eq!(signer_key(&b).err(), Some(Reason::BadSignerKey), "y = p + {j}"); - // The same point, spelt canonically, is refused only for being of small order. - let mut c = [0u8; 32]; - c[0] = j; - let canonical = VerifyingKey::from_bytes(&c).expect("the same point"); - assert_eq!(signer_key(&c).is_ok(), !canonical.is_weak(), "y = {j}"); - } - } - assert!(lenient > 2, "the leniency this rule closes is real: {lenient} encodings"); - let mut sign_set = [0u8; 32]; - sign_set[0] = 1; - sign_set[31] = 0x80; // x = 0 (y = 1) with the sign bit set - let mut minus_one = [0xff; 32]; - minus_one[0] = 0xec; // y = p - 1, x = 0, sign bit set - for b in [sign_set, minus_one] { - assert!(VerifyingKey::from_bytes(&b).is_ok()); - assert_eq!(signer_key(&b).err(), Some(Reason::BadSignerKey)); - } - let small_order = [ - "0100000000000000000000000000000000000000000000000000000000000000", - "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", - "0000000000000000000000000000000000000000000000000000000000000000", - "0000000000000000000000000000000000000000000000000000000000000080", - "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", - "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", - "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", - "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", - ]; - for h in small_order { - let b: [u8; 32] = unhex(h).unwrap().try_into().unwrap(); - let key = VerifyingKey::from_bytes(&b).expect("a curve point"); - assert_eq!(key.to_edwards().compress().to_bytes(), b, "{h} is canonical"); - assert!(key.is_weak(), "{h} is of small order"); - assert_eq!(signer_key(&b).err(), Some(Reason::BadSignerKey), "{h}"); - } - // A genuine key passes. - assert!(signer_key(&k(1).verifying_key().to_bytes()).is_ok()); - } - #[test] fn claims_chain_per_signer() { let body = || energy_body(9, "ModelBased", "derived"); diff --git a/wai/wai-rs/src/film/receipt.rs b/wai/wai-rs/src/film/receipt.rs index 2bc9b6832..f14ab1eeb 100644 --- a/wai/wai-rs/src/film/receipt.rs +++ b/wai/wai-rs/src/film/receipt.rs @@ -13,7 +13,7 @@ use crate::energy_class::{ check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, EnergyProvenance, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:film-receipt\x01"; @@ -111,8 +111,7 @@ impl FilmReceipt { if merkle_root(&leaves) != self.merkle_root { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Label the figure with its acquisition class and re-sign @@ -210,6 +209,15 @@ mod tests { assert_eq!(r1.parent_receipt_hash, Some(r0.receipt_hash())); assert_ne!(r0.receipt_hash(), r1.receipt_hash()); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let r = FilmReceipt::build(&key(9), "did:web:wai#film", [0xab; 32], 0, frames(), 5, 8080, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } impl Film { diff --git a/wai/wai-rs/src/haptic/receipt.rs b/wai/wai-rs/src/haptic/receipt.rs index f3a08a566..d828aafd0 100644 --- a/wai/wai-rs/src/haptic/receipt.rs +++ b/wai/wai-rs/src/haptic/receipt.rs @@ -12,7 +12,7 @@ use crate::energy_class::{ check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, EnergyProvenance, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:haptic-receipt\x01"; const DOMAIN_BLOCK: &[u8] = b"wai:haptic-rblock\x01"; @@ -126,8 +126,7 @@ impl HapticReceipt { if merkle_root(&leaves) != self.merkle_root { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Label the figure with its acquisition class and re-sign @@ -239,4 +238,14 @@ mod tests { assert_eq!(r1.parent_receipt_hash, Some(r0.receipt_hash())); assert_ne!(r0.receipt_hash(), r1.receipt_hash()); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let s = sig(); + let r = HapticReceipt::build(&key(9), "did:web:wai#haptic", s.signal_hash(), 0, s.channel_hashes(), 100, 1234, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs index c6d9b1007..f1c9f33b0 100644 --- a/wai/wai-rs/src/jwp_delivery.rs +++ b/wai/wai-rs/src/jwp_delivery.rs @@ -43,7 +43,7 @@ //! This crate seals group receipts only to generate its conformance corpus; //! [`GroupBuilder`] is not a publishing path. -use crate::claim_receipt::{verify_ed25519_strict, ClaimBody, ClaimReceipt, Reason as ClaimReason}; +use crate::claim_receipt::{ClaimBody, ClaimReceipt}; use crate::energy_binding::Coverage; use crate::energy_class::{ check_label, json_fields, label_from_json, AcquisitionError, AcquisitionKind, CounterFiltering, EnergyProvenance, @@ -454,9 +454,9 @@ impl GroupReceipt { /// 3's strict rule (`signature_verify_failed`), then a labelled group's own /// checks (`group_label_invalid`). pub fn verify(&self) -> Result<(), DeliveryError> { - verify_ed25519_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).map_err(|r| match r { - ClaimReason::BadSignerKey => DeliveryError::at(1, Reason::BadSignerKey), - _ => DeliveryError::at(2, Reason::SignatureVerifyFailed), + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).map_err(|e| match e { + crate::sig::SigError::BadKey => DeliveryError::at(1, Reason::BadSignerKey), + crate::sig::SigError::BadSignature => DeliveryError::at(2, Reason::SignatureVerifyFailed), })?; if let Some(l) = &self.label { l.check(self.joules_total).map_err(|_| DeliveryError::at(2, Reason::GroupLabelInvalid))?; diff --git a/wai/wai-rs/src/key_log.rs b/wai/wai-rs/src/key_log.rs index 3fe66aba4..dea0c2257 100644 --- a/wai/wai-rs/src/key_log.rs +++ b/wai/wai-rs/src/key_log.rs @@ -36,8 +36,7 @@ //! and identity against values `signer-conformance/signer_verify.py` computed. use crate::claim_receipt::{ - hex, is_lower_hex, jcs, parse_strict, rfc3339, signer_key, term_ok, verify_ed25519_strict, unhex, ClaimReceipt, - MAX_INTEGER, + hex, is_lower_hex, jcs, parse_strict, rfc3339, term_ok, unhex, ClaimReceipt, MAX_INTEGER, }; use crate::signer_id::{self, Level}; use ed25519_dalek::{Signer, SigningKey}; @@ -340,7 +339,7 @@ impl KeyEntry { /// The constraints §7.2 states with the entry table. fn check(&self) -> Result<(), Reason> { let bad = Err(Reason::KeyLogMalformed); - if !signer_id::key_id_ok(&self.id) || signer_key(&self.key).is_err() { + if !signer_id::key_id_ok(&self.id) || crate::sig::strict_key(&self.key).is_err() { return bad; } if self.may_sign.is_empty() @@ -544,7 +543,7 @@ impl Revision { if self.origin != origin { return Err(Reason::OriginMismatch); } - verify_ed25519_strict(&self.root, &self.signing_input(), &self.sig).map_err(|_| Reason::KeyLogSignatureInvalid) + crate::sig::verify_strict(&self.root, &self.signing_input(), &self.sig).map_err(|_| Reason::KeyLogSignatureInvalid) } /// Whether this revision is a rotation of `parent`: signed by another root, the one @@ -1366,7 +1365,7 @@ impl ModuleGrant { sig: s("sig").ok().as_deref().and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(bad)?, }; g.check()?; - verify_ed25519_strict(&g.signer_pubkey, &g.signing_input(), &g.sig).map_err(|_| bad)?; + crate::sig::verify_strict(&g.signer_pubkey, &g.signing_input(), &g.sig).map_err(|_| bad)?; Ok(g) } @@ -1486,7 +1485,7 @@ impl KeyStatus { self.origin == rev.origin && self.head == rev.revision_hash() && self.seq == rev.seq - && verify_ed25519_strict(&rev.root, &self.signing_input(), &self.sig).is_ok() + && crate::sig::verify_strict(&rev.root, &self.signing_input(), &self.sig).is_ok() } /// Sign a status for `head` under its root. diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 1eb2b013e..413c169b7 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -312,9 +312,10 @@ pub mod energy_binding; pub mod c2pa_emit; // Ed25519 verified strictly (RFC 8032 §5.1.7 with a canonical key of large order, S < L, -// and R of large order compared byte for byte): the one routine every receipt family's -// signature check is to move onto. Needs ed25519-dalek, which `world` and `oer` bring. -#[cfg(any(feature = "world", feature = "oer"))] +// and R of large order compared byte for byte, SPEC §7 *Signatures*): the one routine +// every signature check in this crate calls. Built whenever ed25519-dalek is, under the +// `ed25519` feature every signing feature enables. +#[cfg(feature = "ed25519")] pub mod sig; // RFC 8785 canonical JSON over the subset WAI signs and digests (integers up to diff --git a/wai/wai-rs/src/package.rs b/wai/wai-rs/src/package.rs index d897f082e..ee93ca38f 100644 --- a/wai/wai-rs/src/package.rs +++ b/wai/wai-rs/src/package.rs @@ -26,7 +26,7 @@ //! //! Integers are big-endian, as in every receipt of the family. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use serde_json::{json, Map, Value}; use crate::energy_class::{ @@ -447,10 +447,12 @@ impl PackageReceiptV2 { } check_parent(self.parent, &parsed)?; self.check_energy()?; - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return err("receipt-signature", "signer_pubkey is not an Ed25519 key"); - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).map_err(|_| PackageReason::new("receipt-signature", "the signature does not verify")) + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).map_err(|e| { + PackageReason::new("receipt-signature", match e { + crate::sig::SigError::BadKey => "signer_pubkey is not a canonical Ed25519 key of large order", + crate::sig::SigError::BadSignature => "the signature does not verify", + }) + }) } fn json_value(&self) -> Result { diff --git a/wai/wai-rs/src/package/tests.rs b/wai/wai-rs/src/package/tests.rs index 7d6e8a166..1193a1091 100644 --- a/wai/wai-rs/src/package/tests.rs +++ b/wai/wai-rs/src/package/tests.rs @@ -313,3 +313,23 @@ fn the_v2_figure_view_and_its_measurement_claim() { let uview = (&u, ug.as_slice()).figure_view().unwrap(); assert!(meter_report::emit(&uview, None, None, crate::energy_report::BuildRecord::of_this_build(), "model", &key(1), "x", None, "2026-10-06T00:00:00Z").is_err()); } + +/// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a +/// non-canonical spelling of one, and an R of small order are each `receipt-signature`, +/// though a lenient check accepts each. +#[test] +fn the_signature_is_verified_strictly() { + let (r, ix) = seal((0, None), Parent::None); + let good = refs(&ix); + let set = |r: &mut PackageReceiptV2, k, s| (r.signer_pubkey, r.sig) = (k, s); + crate::sig::assert_refuses_forgeries(&r, set, |r| r.signing_payload(), |r| r.verify_detailed(&good).is_ok()); + for f in crate::sig::forgeries(|k| { + let mut g = r.clone(); + set(&mut g, *k, [0; 64]); + g.signing_payload() + }) { + let mut g = r.clone(); + set(&mut g, f.key, f.sig); + assert_eq!(g.verify_detailed(&good).map_err(|e| e.code), Err("receipt-signature"), "{}", f.name); + } +} diff --git a/wai/wai-rs/src/provenance.rs b/wai/wai-rs/src/provenance.rs index 30b731f0e..6937786a5 100644 --- a/wai/wai-rs/src/provenance.rs +++ b/wai/wai-rs/src/provenance.rs @@ -40,7 +40,7 @@ use crate::energy_class::{ write_optional, AcquisitionError, EnergyProvenance, }; use crate::world::receipt::merkle_root; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:asset-receipt\x01"; const DOMAIN_STEP: &[u8] = b"wai:prov-step\x01"; @@ -247,8 +247,7 @@ impl AssetReceipt { if !self.steps.iter().all(|s| label_coheres(s.joules_micro, s.energy_provenance.as_ref())) { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm the receipt vouches for exactly this asset envelope: @@ -458,4 +457,14 @@ mod tests { assert_eq!(r1.parent_receipt_hash, Some(r0.receipt_hash())); assert_ne!(r0.receipt_hash(), r1.receipt_hash()); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let (steps, asset) = chain(); + let r = AssetReceipt::build(&key(9), "did:web:wai#prov", asset, "image", "wai.image.jpeg", 0, steps, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/score/receipt.rs b/wai/wai-rs/src/score/receipt.rs index b14df0d41..0b51efcd0 100644 --- a/wai/wai-rs/src/score/receipt.rs +++ b/wai/wai-rs/src/score/receipt.rs @@ -21,7 +21,7 @@ use crate::energy_class::{ check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, EnergyProvenance, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:score-receipt\x01"; const DOMAIN_BLOCK: &[u8] = b"wai:score-rblock\x01"; @@ -146,8 +146,7 @@ impl ScoreReceipt { if merkle_root(&leaves) != self.merkle_root { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Label the figure with its acquisition class and re-sign @@ -274,4 +273,14 @@ mod tests { assert_eq!(r1.parent_receipt_hash, Some(r0.receipt_hash())); assert_ne!(r0.receipt_hash(), r1.receipt_hash()); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let s = score(); + let r = ScoreReceipt::build(&key(9), "did:web:wai#score", s.score_hash(), 0, s.segment_hashes(), 20000, 4321, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-rs/src/sig.rs index 461359334..ed9028166 100644 --- a/wai/wai-rs/src/sig.rs +++ b/wai/wai-rs/src/sig.rs @@ -1,6 +1,8 @@ -//! Ed25519 verification as WAI verifies it (RFC 8032 §5.1.7, strictly): one routine, so -//! every receipt family that moves onto it accepts exactly the signatures every other -//! conforming verifier accepts. +//! Ed25519 verification as WAI verifies it (RFC 8032 §5.1.7, strictly; SPEC §7 +//! *Signatures*): one routine, so every receipt family accepts exactly the signatures every +//! other conforming verifier accepts. Every signature check in this crate calls it, except +//! OER/2's (`crate::oer`), whose rule oer-receipts §5 settles: it refuses the same keys and +//! leaves `R` to RFC 8032. //! //! The rule, in order: //! @@ -78,16 +80,152 @@ pub fn verify_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<() key.verify_strict(msg, &Signature::from_bytes(sig)).map_err(|_| SigError::BadSignature) } +/// The identity point, canonically encoded (y = 1). +pub const IDENTITY: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b +}; + +/// The identity spelled with y = p + 1: a decoder that reduces `y` reads the identity. +pub const IDENTITY_Y_NOT_REDUCED: [u8; 32] = { + let mut b = [0xffu8; 32]; + b[0] = 0xee; + b[31] = 0x7f; + b +}; + +/// The base point B, encoded. As a key its discrete logarithm is 1. +pub const BASE_POINT: [u8; 32] = { + let mut b = [0x66u8; 32]; + b[0] = 0x58; + b +}; + +/// `bytes` (little-endian, any length) mod L, by binary long division. +fn mod_l(bytes: &[u8]) -> [u8; 32] { + let mut r = [0u8; 33]; + for i in (0..bytes.len() * 8).rev() { + let mut carry = (bytes[i / 8] >> (i % 8)) & 1; + for b in r.iter_mut() { + let top = *b >> 7; + *b = (*b << 1) | carry; + carry = top; + } + let lx = |j: usize| if j < 32 { L[j] } else { 0 }; + if (0..33).rev().find(|&j| r[j] != lx(j)).is_none_or(|j| r[j] > lx(j)) { + let mut borrow = 0i16; + for j in 0..33 { + let v = r[j] as i16 - lx(j) as i16 - borrow; + r[j] = v.rem_euclid(256) as u8; + borrow = i16::from(v < 0); + } + } + } + r[..32].try_into().expect("the remainder is below L") +} + +/// A signature a lenient verifier accepts and [`verify_strict`] refuses, for the key a +/// receipt names: what a test or a conformance corpus uses to show a receipt family +/// verifies strictly. None of them needs a private key. +#[derive(Clone, Debug)] +pub struct Forgery { + /// A short name a corpus can build a vector name from. + pub name: &'static str, + /// What it is, in a sentence a corpus can carry. + pub about: &'static str, + /// The key the receipt names. + pub key: [u8; 32], + /// The signature it carries. + pub sig: [u8; 64], + /// Why [`verify_strict`] refuses it. + pub refusal: SigError, +} + +/// The three forgeries over `payload(key)`, the bytes a receipt signs once its key is +/// `key`. Each satisfies the cofactorless equation with `R` compared byte for byte, so +/// ed25519-dalek's lenient `verify` accepts it, which this asserts; each fails exactly one +/// step of the rule: +/// +/// - `key_identity`: the key is the identity and (R, S) = (B, 1), which verifies over any +/// message (step 2); +/// - `key_identity_y_not_reduced`: the same, with the identity spelled y = p + 1 (step 1, +/// and step 2 under a decoder that reduces `y`); +/// - `r_small_order`: the key is B, whose discrete logarithm is 1, `R` is the identity and +/// `S` = k, so [S]B = R + [k]B (step 4: `R` of small order). +pub fn forgeries(payload: impl Fn(&[u8; 32]) -> Vec) -> [Forgery; 3] { + use ed25519_dalek::Verifier; + use sha2::{Digest, Sha512}; + let sig = |r: [u8; 32], s: [u8; 32]| { + let mut g = [0u8; 64]; + g[..32].copy_from_slice(&r); + g[32..].copy_from_slice(&s); + g + }; + let r_small = { + let m = payload(&BASE_POINT); + let k = mod_l(&Sha512::new().chain_update(IDENTITY).chain_update(BASE_POINT).chain_update(&m).finalize()); + sig(IDENTITY, k) + }; + let all = [ + Forgery { + name: "key_identity", + about: "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order", + key: IDENTITY, + sig: sig(BASE_POINT, IDENTITY), + refusal: SigError::BadKey, + }, + Forgery { + name: "key_identity_y_not_reduced", + about: "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, and the encoding is not canonical", + key: IDENTITY_Y_NOT_REDUCED, + sig: sig(BASE_POINT, IDENTITY), + refusal: SigError::BadKey, + }, + Forgery { + name: "r_small_order", + about: "the base point as the key (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order", + key: BASE_POINT, + sig: r_small, + refusal: SigError::BadSignature, + }, + ]; + for f in &all { + let m = payload(&f.key); + let lenient = VerifyingKey::from_bytes(&f.key).and_then(|k| k.verify(&m, &Signature::from_bytes(&f.sig))); + assert!(lenient.is_ok(), "{}: a lenient verifier accepts the forgery", f.name); + assert_eq!(verify_strict(&f.key, &m, &f.sig), Err(f.refusal), "{}: the strict rule refuses it", f.name); + } + all +} + +/// For a signing site's tests: `good` verifies, and each of [`forgeries`], set into it by +/// `set` (the key and the signature it carries), is refused by `verifies`. `payload` is +/// the bytes the site signs, computed after its key is set, since a payload may name it. +#[cfg(test)] +pub(crate) fn assert_refuses_forgeries( + good: &R, + set: impl Fn(&mut R, [u8; 32], [u8; 64]), + payload: impl Fn(&R) -> Vec, + verifies: impl Fn(&R) -> bool, +) { + assert!(verifies(good), "the site verifies what it signed"); + let with = |key: [u8; 32], sig: [u8; 64]| { + let mut r = good.clone(); + set(&mut r, key, sig); + r + }; + for f in forgeries(|key| payload(&with(*key, [0; 64]))) { + assert!(!verifies(&with(f.key, f.sig)), "{} is refused: {}", f.name, f.about); + } +} + #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; - /// The base point B, encoded. - const B: [u8; 32] = [ - 0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, - 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, - ]; + const B: [u8; 32] = BASE_POINT; fn le(mut n: [u8; 32], add: u8) -> [u8; 32] { let mut carry = add as u16; @@ -138,6 +276,76 @@ mod tests { assert_eq!(verify_strict(&signed, b"any", &forged), Err(SigError::BadKey)); } + /// Steps 1 and 2 over every encoding RFC 8032 §5.1.3 refuses that curve25519's + /// decompression accepts — y = p + j for j = 0 … 18, and x = 0 with the sign bit set — + /// and over the eight points of small order. + #[test] + fn a_key_is_refused_unless_canonical_and_of_large_order() { + let mut lenient = 0; + for j in 0u8..=18 { + let mut b = [0xff; 32]; + b[0] = 0xed + j; // p + j, little-endian + b[31] = 0x7f; + if VerifyingKey::from_bytes(&b).is_ok() { + lenient += 1; + assert_eq!(strict_key(&b).err(), Some(SigError::BadKey), "y = p + {j}"); + // The same point, spelt canonically, is refused only for being of small order. + let mut c = [0u8; 32]; + c[0] = j; + let canonical = VerifyingKey::from_bytes(&c).expect("the same point"); + assert_eq!(strict_key(&c).is_ok(), !canonical.is_weak(), "y = {j}"); + } + } + assert!(lenient > 2, "the leniency this rule closes is real: {lenient} encodings"); + let mut sign_set = [0u8; 32]; + sign_set[0] = 1; + sign_set[31] = 0x80; // x = 0 (y = 1) with the sign bit set + let mut minus_one = [0xff; 32]; + minus_one[0] = 0xec; // y = p - 1, x = 0, sign bit set + for b in [sign_set, minus_one] { + assert!(VerifyingKey::from_bytes(&b).is_ok()); + assert_eq!(strict_key(&b).err(), Some(SigError::BadKey)); + } + let small_order = [ + "0100000000000000000000000000000000000000000000000000000000000000", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", + ]; + for h in small_order { + let b: [u8; 32] = (0..32).map(|i| u8::from_str_radix(&h[2 * i..2 * i + 2], 16).unwrap()).collect::>().try_into().unwrap(); + let key = VerifyingKey::from_bytes(&b).expect("a curve point"); + assert_eq!(key.to_edwards().compress().to_bytes(), b, "{h} is canonical"); + assert!(key.is_weak(), "{h} is of small order"); + assert_eq!(strict_key(&b).err(), Some(SigError::BadKey), "{h}"); + } + // A genuine key passes. + assert!(strict_key(&SigningKey::from_bytes(&[1; 32]).verifying_key().to_bytes()).is_ok()); + } + + /// Each forgery passes the lenient check and fails exactly the step it names, over a + /// message that names its key as a receipt's signing payload does; the constants are + /// the points they say. + #[test] + fn the_forgeries_pass_a_lenient_check_and_fail_their_step() { + let fs = forgeries(|k| [b"a receipt signed under ".as_slice(), k].concat()); + let got: Vec<_> = fs.iter().map(|f| (f.name, f.refusal)).collect(); + assert_eq!( + got, + [("key_identity", SigError::BadKey), ("key_identity_y_not_reduced", SigError::BadKey), ("r_small_order", SigError::BadSignature)] + ); + assert_eq!(IDENTITY_Y_NOT_REDUCED, le(p(), 1)); + assert!(VerifyingKey::from_bytes(&IDENTITY).unwrap().is_weak()); + assert_eq!(VerifyingKey::from_bytes(&IDENTITY_Y_NOT_REDUCED).unwrap().to_edwards().compress().to_bytes(), IDENTITY); + // B is a canonical key of large order: the r_small_order forgery above holds under it + // with S = k, which says its discrete logarithm is 1. + assert!(strict_key(&BASE_POINT).is_ok()); + } + #[test] fn a_non_canonical_key_of_large_order_is_refused() { // A point of large order whose y is below 19 has the second spelling y + p, @@ -177,29 +385,6 @@ mod tests { assert!(!below_l(&L) && below_l(&s)); } - /// `bytes` (little-endian, any length) mod L, by binary long division. - fn mod_l(bytes: &[u8]) -> [u8; 32] { - let mut r = [0u8; 33]; - for i in (0..bytes.len() * 8).rev() { - let mut carry = (bytes[i / 8] >> (i % 8)) & 1; - for b in r.iter_mut() { - let top = *b >> 7; - *b = (*b << 1) | carry; - carry = top; - } - let lx = |j: usize| if j < 32 { L[j] } else { 0 }; - if (0..33).rev().find(|&j| r[j] != lx(j)).is_none_or(|j| r[j] > lx(j)) { - let mut borrow = 0i16; - for j in 0..33 { - let v = r[j] as i16 - lx(j) as i16 - borrow; - r[j] = v.rem_euclid(256) as u8; - borrow = i16::from(v < 0); - } - } - } - r[..32].try_into().unwrap() - } - #[test] fn a_small_order_or_non_canonical_r_is_refused() { use ed25519_dalek::Verifier; diff --git a/wai/wai-rs/src/splat4d/receipt.rs b/wai/wai-rs/src/splat4d/receipt.rs index 88f343500..1c894b7b1 100644 --- a/wai/wai-rs/src/splat4d/receipt.rs +++ b/wai/wai-rs/src/splat4d/receipt.rs @@ -12,7 +12,7 @@ use crate::energy_class::{ check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, EnergyProvenance, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:splat4d-receipt\x01"; @@ -119,8 +119,7 @@ impl Splat4dReceipt { if merkle_root(&leaves) != self.merkle_root { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Label the figure with its acquisition class and re-sign @@ -229,4 +228,14 @@ mod tests { assert_eq!(r1.parent_receipt_hash, Some(r0.receipt_hash())); assert_ne!(r0.receipt_hash(), r1.receipt_hash()); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let c = clip(); + let r = Splat4dReceipt::build(&key(9), "did:web:wai#splat4d", c.clip_hash(), 0, c.frame_hashes(), 5, 9999, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/ternary_receipt.rs b/wai/wai-rs/src/ternary_receipt.rs index 225e72eef..816401b75 100644 --- a/wai/wai-rs/src/ternary_receipt.rs +++ b/wai/wai-rs/src/ternary_receipt.rs @@ -25,7 +25,7 @@ use crate::energy_class::{ AcquisitionError, EnergyProvenance, }; use crate::world::receipt::merkle_root; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:ternary-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:ternary-receipt-id\x01"; @@ -153,11 +153,7 @@ impl TernaryReceipt { if merkle_root(&self.leaves()) != self.merkle_root { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// The strong, portable check: recover the stack from the `WQTT` payload, @@ -342,4 +338,14 @@ mod tests { assert!(child.verify()); assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let stack = encode_residual(&stream(256), 8, 2, 4, 3); + let r = TernaryReceipt::seal(&key(1), "did:key:test", &stack, 4200, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/video_receipt.rs b/wai/wai-rs/src/video_receipt.rs index a35364324..a88f56675 100644 --- a/wai/wai-rs/src/video_receipt.rs +++ b/wai/wai-rs/src/video_receipt.rs @@ -39,7 +39,7 @@ //! dependency set; pulling blake3 and ed25519 into it would break the matrix //! that makes the paragraph above true. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use serde_json::Value; use crate::int_video::{decode, DecodedClip}; @@ -306,8 +306,7 @@ impl VideoReceipt { if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// The independent check: re-decode the `WIV1` payload and confirm this receipt @@ -435,3 +434,18 @@ impl VideoReceipt { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let clip = DecodedClip { frames: vec![vec![7u8; 4 * 4 * 3], vec![9u8; 4 * 4 * 3]], h: 4, w: 4 }; + let r = VideoReceipt::build(&SigningKey::from_bytes(&[7; 32]), "did:example:meter", [0xcd; 32], &clip, 0, None, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } +} diff --git a/wai/wai-rs/src/wai_video_channel.rs b/wai/wai-rs/src/wai_video_channel.rs index cbb23822d..315c4b6d9 100644 --- a/wai/wai-rs/src/wai_video_channel.rs +++ b/wai/wai-rs/src/wai_video_channel.rs @@ -25,7 +25,7 @@ //! measurement — `joules_micro`, its acquisition class and the class's evidence — //! is measured-attested: signed, and not re-measurable. Reuses the manifest floor. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use crate::quantum_ops::GrantRef; use crate::energy_class::{figure_and_class_cohere, write_optional}; @@ -236,8 +236,7 @@ impl ChannelReceipt { if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// The program-as-run: the schedule entries on air in this window. @@ -350,4 +349,14 @@ mod tests { assert!(r.verify()); assert_eq!(hex(&r.receipt_hash()), "4850a8245f9d3e62227a56791e68689dfa7a566e7d18665bf5dc89521a8e2841"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let g = GrantRef::unbounded("video.channel"); + let r = ChannelReceipt::seal(&key(1), "did:key:chan", "fast-42", schedule(), 8_000, 13_000, 6_000, Some(crate::energy_class::test_on_chip()), g, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/wai_video_keys.rs b/wai/wai-rs/src/wai_video_keys.rs index 8b25db176..42d689b48 100644 --- a/wai/wai-rs/src/wai_video_keys.rs +++ b/wai/wai-rs/src/wai_video_keys.rs @@ -23,7 +23,7 @@ //! manifest module's `EnergyProvenance` and the world Merkle floor's Ed25519 + `GrantRef`; links //! to a manifest via `parent_receipt_hash`. Reuse, not reinvention. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use crate::quantum_ops::GrantRef; use crate::energy_class::{figure_and_class_cohere, write_optional}; @@ -260,8 +260,7 @@ impl KeyReleaseReceipt { if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm the released key this receipt commits to is the one the caller @@ -381,4 +380,17 @@ mod tests { assert!(r.verify()); assert_eq!(hex(&r.receipt_hash()), "12c976fb6f742aeefb1f71af9a7b4eca01be5ba726bd80e1a8ed22cf1ac28249"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let r = KeyReleaseReceipt::seal( + &key(1), "did:key:license", [0xAB; 16], "asset-abc", DrmSystem::Widevine, // allow-name: DRM system identifier + "svod:tier2", 1000, 2000, CEK, 300, Some(crate::energy_class::test_on_chip()), + grant("svod:tier2"), None, + ); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/wai_video_package.rs b/wai/wai-rs/src/wai_video_package.rs index bf2a13879..8af4006b8 100644 --- a/wai/wai-rs/src/wai_video_package.rs +++ b/wai/wai-rs/src/wai_video_package.rs @@ -22,7 +22,7 @@ //! manifest module's `EnergyProvenance` and the world Merkle floor's Ed25519 + `GrantRef`. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use crate::quantum_ops::GrantRef; use crate::energy_class::{figure_and_class_cohere, write_optional}; @@ -252,8 +252,7 @@ impl PackageReceipt { if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Whether the segment at `index` in the attested map has this content — @@ -392,4 +391,15 @@ mod tests { assert!(r.verify()); assert_eq!(hex(&r.receipt_hash()), "71a9da59a3202abb2c3fd22ee692b1485a9c674bb8c311f4c82455e6be29ae2b"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let segs = segment(&source(), 2000); + let g = GrantRef::unbounded("video.package"); + let r = PackageReceipt::seal(&key(1), "did:key:pkg", "asset-abc", segs, 12_000, Some(crate::energy_class::test_on_chip()), g, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/wai_video_ssai.rs b/wai/wai-rs/src/wai_video_ssai.rs index 0ab3a9ffd..4fa982711 100644 --- a/wai/wai-rs/src/wai_video_ssai.rs +++ b/wai/wai-rs/src/wai_video_ssai.rs @@ -26,7 +26,7 @@ //! manifest receipt via `parent_receipt_hash`: the ad break belongs to a //! specific attested manifest. Reuse, not reinvention. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use crate::quantum_ops::GrantRef; // The decision policy is pure integer logic and lives apart from this @@ -220,8 +220,7 @@ impl SsaiReceipt { if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// The billable impressions — those that started rendering. @@ -341,4 +340,16 @@ mod tests { assert!(r.verify()); assert_eq!(hex(&r.receipt_hash()), "7f24dda0e0b85bc01d27fbb98b701c8ed0ff84cde280a24d653f43963f2f25c2"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let mut imps = decide(&pod(), 2, 30_000, 80_000); + imps[0].quartiles_reached = 5; + let g = GrantRef { grant_hash: [7u8; 32], capability: "video.ssai".into(), joule_ceiling_micro: 1_000_000, funds_ceiling: Some(80_000) }; + let r = SsaiReceipt::seal(&key(1), "did:key:ssai", "avail-1", [9u8; 32], pod_hash(&pod()), imps, 3, 500, Some(crate::energy_class::test_on_chip()), g, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } diff --git a/wai/wai-rs/src/world/container.rs b/wai/wai-rs/src/world/container.rs index 831351bb3..05eadeb98 100644 --- a/wai/wai-rs/src/world/container.rs +++ b/wai/wai-rs/src/world/container.rs @@ -14,7 +14,7 @@ use super::contract::SimContract; use super::op::CausalOp; use super::{DOMAIN_SEAL, WorldError}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; pub const MAGIC_WWLD: &[u8; 4] = b"WWLD"; @@ -191,9 +191,9 @@ impl WorldContainer { pub fn verify_seal(&self, publisher: &VerifyingKey) -> Result { let seal = self.seal.ok_or(WorldError::BadSeal("no seal section"))?; let msg = self.seal_preimage(seal.final_tick, &seal.final_hash); - publisher - .verify(&msg, &Signature::from_bytes(&seal.signature)) - .map_err(|_| WorldError::BadSignature)?; + // The key's bytes as it was decoded from, so a non-canonical spelling the library + // read is refused here like any other (SPEC §7 *Signatures*). + crate::sig::verify_strict(publisher.as_bytes(), &msg, &seal.signature).map_err(|_| WorldError::BadSignature)?; Ok(seal) } } @@ -236,3 +236,39 @@ fn decode_assets(bytes: &[u8]) -> Result, WorldError> { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::sim::CAP_SIM_KINEMATIC0; + use crate::world::{NUMERIC_FIXED64, RESOLVE_LWW}; + + /// The seal's signature is verified strictly (SPEC §7 *Signatures*), under the + /// publisher key's bytes as they were decoded: a key of small order, a non-canonical + /// spelling of one, and an R of small order are each refused, though a lenient check + /// accepts each. + #[test] + fn the_seal_is_verified_strictly() { + let contract = SimContract { + tick_rate: 60, + numeric: NUMERIC_FIXED64.into(), + rng_alg: "xoshiro256++".into(), + rng_seed: [0x42; 32], + sim: CAP_SIM_KINEMATIC0.into(), + resolve_default: RESOLVE_LWW.into(), + checkpoint_every: 100, + }; + let publisher = SigningKey::from_bytes(&[9; 32]); + let mut c = WorldContainer { contract_bytes: contract.to_canonical_json(), contract, graph_bytes: vec![], assets: vec![], ops: vec![], seal: None }; + c.seal(&publisher, 10, [7; 32]); + crate::sig::assert_refuses_forgeries( + &(c, publisher.verifying_key().to_bytes()), + |g: &mut (WorldContainer, [u8; 32]), k, s| { + g.1 = k; + g.0.seal.as_mut().expect("sealed").signature = s; + }, + |g| g.0.seal_preimage(10, &[7; 32]), + |g| VerifyingKey::from_bytes(&g.1).is_ok_and(|k| g.0.verify_seal(&k).is_ok()), + ); + } +} diff --git a/wai/wai-rs/src/world/op.rs b/wai/wai-rs/src/world/op.rs index b364d1154..437904778 100644 --- a/wai/wai-rs/src/world/op.rs +++ b/wai/wai-rs/src/world/op.rs @@ -16,7 +16,7 @@ //! ``` use super::{DOMAIN_NODE, DOMAIN_OP, WorldError}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; /// Op identity: `(actor public key, actor-local Lamport counter)`. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] @@ -98,11 +98,12 @@ impl CausalOp { /// Verify the signature against the op's own actor key. pub fn verify(&self) -> Result<(), WorldError> { - let key = VerifyingKey::from_bytes(&self.actor).map_err(|_| WorldError::BadActorKey)?; let mut msg = DOMAIN_OP.to_vec(); msg.extend_from_slice(&self.preimage()); - key.verify(&msg, &Signature::from_bytes(&self.signature)) - .map_err(|_| WorldError::BadSignature) + crate::sig::verify_strict(&self.actor, &msg, &self.signature).map_err(|e| match e { + crate::sig::SigError::BadKey => WorldError::BadActorKey, + crate::sig::SigError::BadSignature => WorldError::BadSignature, + }) } pub fn to_bytes(&self) -> Vec { @@ -190,3 +191,35 @@ impl<'a> Cursor<'a> { Ok(u64::from_le_bytes(self.take_array::<8>()?)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::sim::{kin_mutation, pack_fx}; + use crate::world::{mint_node_id, Fx}; + + /// An op's signature is verified strictly (SPEC §7 *Signatures*): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each; a key's defect is `BadActorKey`, R's + /// `BadSignature`. + #[test] + fn the_signature_is_verified_strictly() { + let a = SigningKey::from_bytes(&[1; 32]); + let op = CausalOp::sign(&a, 1, 1, vec![], mint_node_id(&a.verifying_key().to_bytes(), 1), kin_mutation::CREATE, pack_fx(&[Fx::ZERO; 7])); + let set = |o: &mut CausalOp, k, s| (o.actor, o.signature) = (k, s); + let msg = |o: &CausalOp| [DOMAIN_OP, &o.preimage()].concat(); + crate::sig::assert_refuses_forgeries(&op, set, msg, |o| o.verify().is_ok()); + for f in crate::sig::forgeries(|k| { + let mut o = op.clone(); + set(&mut o, *k, [0; 64]); + msg(&o) + }) { + let mut o = op.clone(); + set(&mut o, f.key, f.sig); + match f.refusal { + crate::sig::SigError::BadKey => assert!(matches!(o.verify(), Err(WorldError::BadActorKey)), "{}", f.name), + crate::sig::SigError::BadSignature => assert!(matches!(o.verify(), Err(WorldError::BadSignature)), "{}", f.name), + } + } + } +} diff --git a/wai/wai-rs/src/world/receipt.rs b/wai/wai-rs/src/world/receipt.rs index b7c229755..68776d97a 100644 --- a/wai/wai-rs/src/world/receipt.rs +++ b/wai/wai-rs/src/world/receipt.rs @@ -30,7 +30,7 @@ use crate::energy_class::{ check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, EnergyProvenance, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; /// JWP Merkle domain separators (jwp-receipts §2.2), reused verbatim — /// this profile builds the *same* tree shape JWP defines. @@ -269,11 +269,7 @@ impl SessionReceipt { if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { return false; } - let Ok(key) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - key.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm this receipt commits to exactly the given ops: rebatch @@ -477,4 +473,13 @@ mod tests { // parses as valid JSON let _: serde_json::Value = serde_json::from_str(&j).unwrap(); } + + /// The signature is verified strictly (SPEC §7 *Signatures*): a key of small order, a + /// non-canonical spelling of one, and an R of small order are each refused, though a + /// lenient check accepts each. + #[test] + fn the_signature_is_verified_strictly() { + let r = SessionReceipt::build(&k(9), "did:key:test", [0xab; 32], 0, &sample_ops(), &cps(), 12_345, 999, None); + crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } -- 2.54.0 (Apple Git-157) From c91ee372497c3c4a7b470c4962633de365437268 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 6 Oct 2026 18:09:04 -0400 Subject: [PATCH 02/10] sig::forgeries asserts only that a lenient check accepts each forgery The strict refusal is the caller's to show, so a test of a signing site fails at the site, not inside the constructor, when the rule lets one through. sig's own test checks each forgery's refusal. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/sig.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-rs/src/sig.rs index ed9028166..48a102190 100644 --- a/wai/wai-rs/src/sig.rs +++ b/wai/wai-rs/src/sig.rs @@ -144,8 +144,9 @@ pub struct Forgery { /// The three forgeries over `payload(key)`, the bytes a receipt signs once its key is /// `key`. Each satisfies the cofactorless equation with `R` compared byte for byte, so -/// ed25519-dalek's lenient `verify` accepts it, which this asserts; each fails exactly one -/// step of the rule: +/// ed25519-dalek's lenient `verify` accepts it, which this asserts. That [`verify_strict`] +/// refuses each is left to the caller to show, so a test of a signing site fails at the +/// site when the site, or the rule, lets one through. Each fails one step of the rule: /// /// - `key_identity`: the key is the identity and (R, S) = (B, 1), which verifies over any /// message (step 2); @@ -194,7 +195,6 @@ pub fn forgeries(payload: impl Fn(&[u8; 32]) -> Vec) -> [Forgery; 3] { let m = payload(&f.key); let lenient = VerifyingKey::from_bytes(&f.key).and_then(|k| k.verify(&m, &Signature::from_bytes(&f.sig))); assert!(lenient.is_ok(), "{}: a lenient verifier accepts the forgery", f.name); - assert_eq!(verify_strict(&f.key, &m, &f.sig), Err(f.refusal), "{}: the strict rule refuses it", f.name); } all } @@ -332,7 +332,11 @@ mod tests { /// the points they say. #[test] fn the_forgeries_pass_a_lenient_check_and_fail_their_step() { - let fs = forgeries(|k| [b"a receipt signed under ".as_slice(), k].concat()); + let payload = |k: &[u8; 32]| [b"a receipt signed under ".as_slice(), k].concat(); + let fs = forgeries(payload); + for f in &fs { + assert_eq!(verify_strict(&f.key, &payload(&f.key), &f.sig), Err(f.refusal), "{}", f.name); + } let got: Vec<_> = fs.iter().map(|f| (f.name, f.refusal)).collect(); assert_eq!( got, -- 2.54.0 (Apple Git-157) From f312e2cafa5a00fb7e788734c82a547c5773cae4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:22:27 -0400 Subject: [PATCH 03/10] Python verifiers: one strict Ed25519 rule, _ed25519.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claim_verify.py made the key, order and range checks itself and left the equation to `cryptography`; http_verify.py had its own RFC 8032 implementation; and pack_verify.py called `cryptography` alone, which accepts a key of small order, a second spelling of a key, and an R of small order. All three, and the JWP and signer verifiers through claim_verify, now call claim-conformance/_ed25519.py, written from RFC 8032 with the standard library (as http_verify's was): canonical A of large order, S < L, R decoded and not of small order, and the cofactorless equation with R compared byte for byte. It reports a bad key apart from a bad signature, keeps http_verify's --mutate small-order-key and small-order-r, and checks itself against RFC 8032's first two vectors and the three forgeries. Every committed corpus verifies unchanged: no vector's expectation moves. The OER reference keeps its own rule (oer-receipts §5). Co-Authored-By: Claude Opus 5.5 --- wai/claim-conformance/README.md | 16 ++- wai/claim-conformance/_ed25519.py | 182 ++++++++++++++++++++++++ wai/claim-conformance/claim_verify.py | 83 +++-------- wai/http-conformance/README.md | 7 +- wai/http-conformance/http_verify.py | 84 +---------- wai/pack-conformance/README.md | 6 +- wai/pack-conformance/pack_verify.py | 20 ++- wai/signer-conformance/signer_verify.py | 3 +- 8 files changed, 231 insertions(+), 170 deletions(-) create mode 100644 wai/claim-conformance/_ed25519.py diff --git a/wai/claim-conformance/README.md b/wai/claim-conformance/README.md index dbb2b8c0d..2fd8acd81 100644 --- a/wai/claim-conformance/README.md +++ b/wai/claim-conformance/README.md @@ -324,8 +324,10 @@ decoding. Called on each vector's key, signature and signing input (`cryptograph 50.0.1 over OpenSSL 3.6.4, Python 3.14.7, macOS on arm64), it accepted five of the six that satisfy the equation — both non-canonical spellings of the identity and the three vectors of the second group — and of the seven refused only `S` + L and the `y` = p + j -vector. `claim_verify.py` therefore makes step 3's decoding, order and range checks -itself, and leaves only the equation to `cryptography`. +vector. `claim_verify.py` therefore checks no signature with `cryptography`: step 3 is +`_ed25519.py`, WAI's one Ed25519 rule (SPEC §7 *Signatures*) written from RFC 8032's +definitions with the standard library, which the verifiers of the JWP, signer, HTTP and +packaging corpora call too. ## Running @@ -333,7 +335,7 @@ itself, and leaves only the equation to `cryptography`. # wai-rs: the canonical fixture, byte-identical regeneration, and every vector cargo run --no-default-features --features receipt_claims --bin wai_claim_vectors -- verify ../claim-conformance -# the second verifier (needs Python 3 and `cryptography`) +# the second verifier (needs Python 3 and `cryptography`, with which it signs the fixture) python3 claim_verify.py # the two verifiers' verdicts on generated inputs the corpus does not hold @@ -405,16 +407,16 @@ checks close that: - **The canonical fixture.** An `OnChipCounter` claim given with members out of order at three levels, whitespace, and `\u` escapes in the input text. Its canonical bytes, signature and link hash were computed by `claim_verify.py fixture` — Python's JSON - serialiser with members ordered by UTF-16 code units, `cryptography`'s Ed25519, and + serialiser with members ordered by UTF-16 code units, `cryptography`'s Ed25519 signing, and BLAKE3 written from its specification — and are pinned as literals in `wai-rs` (`check_canonical_form`), which `wai_claim_vectors verify` checks first. Reversing the member order, or changing the signing domain, fails it. - **The second verifier.** `claim_verify.py` reports every vector's declared outcome and every pinned link hash, including a claim whose canonical form spans three BLAKE3 chunks, and checks that the fixture it computes equals the literals in `wai-rs`. Its - §3.1 step 3 checks other than the equation — RFC 8032 key decoding, the order of the - key and of `R`, and `S` < L (which `cryptography` also checks) — are written from the - curve's definition; the equation is `cryptography`'s. Its acquisition-class rule is + §3.1 step 3 — RFC 8032 key decoding, the order of the key and of `R`, `S` < L and the + cofactorless equation — is `_ed25519.py`, written from the curve's definition. Its + acquisition-class rule is written from energy-measurement §2.3–§2.5, where `wai-rs` admits the class through `crate::energy_class`. diff --git a/wai/claim-conformance/_ed25519.py b/wai/claim-conformance/_ed25519.py new file mode 100644 index 000000000..df0873bbd --- /dev/null +++ b/wai/claim-conformance/_ed25519.py @@ -0,0 +1,182 @@ +"""Ed25519 verification as WAI verifies every signature (SPEC §7 *Signatures*), written +from RFC 8032's definitions with Python's integers and nothing beyond the standard +library: the one routine the Python verifiers of WAI's corpora share. It shares no code +with wai-rs (`wai::sig`). + +The rule, in order: + +1. the key A decodes, and is the one canonical encoding of its point: y < p, and x = 0 + never carries the sign bit (RFC 8032 §5.1.3); +2. A is not of small order (its order divides 8): under such a key the equation can + hold for a signature made without any private key; +3. S < L (RFC 8032 §5.1.7); +4. R decodes, is not of small order, and [S]B = R + [k]A holds with [S]B - [k]A encoded + and compared with R byte for byte (the cofactorless equation), k = SHA-512(R || A || + M) mod L over the bytes of R and A as given. + +`verify` returns None when every step holds, "bad_key" when step 1 or 2 fails and +"bad_signature" otherwise, so a verifier names the two outcomes with its own reasons. + +The OER/2 reference verifier does not use this: oer-receipts §5 settles its own rule, +which refuses the same keys and leaves R to RFC 8032. +""" +import hashlib + +P = 2 ** 255 - 19 +L = 2 ** 252 + 27742317777372353535851937790883648493 +D = -121665 * pow(121666, P - 2, P) % P +_SQRT_M1 = pow(2, (P - 1) // 4, P) + +# The checks a verifier's own self-test may leave out, by name, to show its corpus +# catches the defect; never left out otherwise. +SKIPPABLE = ("small-order-key", "small-order-r") + + +def _recover_x(y, sign): + if y >= P: + return None + x2 = (y * y - 1) * pow(D * y * y + 1, P - 2, P) % P + if x2 == 0: + return None if sign else 0 + x = pow(x2, (P + 3) // 8, P) + if (x * x - x2) % P: + x = x * _SQRT_M1 % P + if (x * x - x2) % P: + return None + return P - x if (x & 1) != sign else x + + +def decode(b): + """RFC 8032 §5.1.3: the point `b` encodes, in extended coordinates (X, Y, Z, T), or + None when it is not the canonical encoding of a point (y >= p, x = 0 with the sign + bit set, or no x).""" + if len(b) != 32: + return None + y = int.from_bytes(b, "little") + sign, y = y >> 255, y & ((1 << 255) - 1) + x = _recover_x(y, sign) + return None if x is None else (x, y, 1, x * y % P) + + +def _add(p, q): + """Twisted Edwards addition (a = -1) in extended coordinates; complete on this curve.""" + a = (p[1] - p[0]) * (q[1] - q[0]) % P + b = (p[1] + p[0]) * (q[1] + q[0]) % P + c = 2 * p[3] * q[3] * D % P + d = 2 * p[2] * q[2] % P + e, f, g, h = b - a, d - c, d + c, b + a + return (e * f % P, g * h % P, f * g % P, e * h % P) + + +def _neg(p): + return (P - p[0], p[1], p[2], P - p[3]) + + +def _identity(p): + return p[0] % P == 0 and (p[1] - p[2]) % P == 0 + + +def encode(p): + zi = pow(p[2], P - 2, P) + x, y = p[0] * zi % P, p[1] * zi % P + return (y | ((x & 1) << 255)).to_bytes(32, "little") + + +_BY = 4 * pow(5, P - 2, P) % P +B = (_recover_x(_BY, 0), _BY, 1, _recover_x(_BY, 0) * _BY % P) +IDENTITY = (0, 1, 1, 0) + + +def small_order(p): + """Whether [8]p is the identity: p's order divides 8.""" + for _ in range(3): + p = _add(p, p) + return _identity(p) + + +def strict_key(pk): + """Steps 1 and 2: whether `pk` is the canonical encoding of a point of large order.""" + a = decode(pk) + return a is not None and not small_order(a) + + +def _double_mul(s, p, k, q): + """[s]p + [k]q, one doubling per bit (Shamir's trick).""" + pq = _add(p, q) + acc = IDENTITY + for i in range(max(s.bit_length(), k.bit_length()) - 1, -1, -1): + acc = _add(acc, acc) + bs, bk = (s >> i) & 1, (k >> i) & 1 + if bs and bk: + acc = _add(acc, pq) + elif bs: + acc = _add(acc, p) + elif bk: + acc = _add(acc, q) + return acc + + +def verify(pk, msg, sig, skip=()): + """The whole rule over `msg`: None when every step holds, else "bad_key" (steps 1 + and 2) or "bad_signature" (steps 3 and 4). `skip` names checks of SKIPPABLE to leave + out, for a verifier's self-test only.""" + if len(pk) != 32: + return "bad_key" + a = decode(pk) + if a is None or (small_order(a) and "small-order-key" not in skip): + return "bad_key" + if len(sig) != 64: + return "bad_signature" + s = int.from_bytes(sig[32:], "little") + if s >= L: + return "bad_signature" + r = decode(sig[:32]) + if r is None or (small_order(r) and "small-order-r" not in skip): + return "bad_signature" + k = int.from_bytes(hashlib.sha512(sig[:32] + pk + msg).digest(), "little") % L + if encode(_double_mul(s, B, k, _neg(a))) != sig[:32]: + return "bad_signature" + return None + + +def verifies(pk, msg, sig, skip=()): + return verify(pk, msg, sig, skip) is None + + +def _self_test(): + """RFC 8032 §7.1 TEST 1 and TEST 2, and the three signatures a lenient verifier + accepts and this rule refuses; a list of the checks that fail.""" + fails = [] + t1_pk = bytes.fromhex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a") + t1_sig = bytes.fromhex("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b") + t2_pk = bytes.fromhex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c") + t2_sig = bytes.fromhex("92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da" + "085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00") + if verify(t1_pk, b"", t1_sig) is not None or verify(t2_pk, b"\x72", t2_sig) is not None: + fails.append("rfc8032-vectors") + if verify(t2_pk, b"\x73", t2_sig) != "bad_signature": + fails.append("rfc8032-wrong-message") + one = (1).to_bytes(32, "little") + b_enc = encode(B) + long_identity = (P + 1).to_bytes(32, "little") + forged = b_enc + one # (R, S) = (B, 1): [1]B = B + [k]O under the identity + if verify(one, b"any", forged) != "bad_key" or verify(long_identity, b"any", forged) != "bad_key": + fails.append("identity-key") + if verify(one, b"any", forged, skip=("small-order-key",)) is not None: + fails.append("identity-key-is-a-forgery") + k = int.from_bytes(hashlib.sha512(one + b_enc + b"m").digest(), "little") % L + r_small = one + k.to_bytes(32, "little") # under B (scalar 1): [k]B = O + [k]B + if verify(b_enc, b"m", r_small) != "bad_signature" or verify(b_enc, b"m", r_small, skip=("small-order-r",)) is not None: + fails.append("small-order-r") + s_plus_l = t1_sig[:32] + (int.from_bytes(t1_sig[32:], "little") + L).to_bytes(32, "little") + if verify(t1_pk, b"", s_plus_l) != "bad_signature": + fails.append("s-not-reduced") + return fails + + +if __name__ == "__main__": + import sys + f = _self_test() + print(("PASS" if not f else "FAIL") + " Ed25519 strict rule self-test " + ",".join(f)) + sys.exit(1 if f else 0) diff --git a/wai/claim-conformance/claim_verify.py b/wai/claim-conformance/claim_verify.py index f44277ce6..7ed26bf91 100644 --- a/wai/claim-conformance/claim_verify.py +++ b/wai/claim-conformance/claim_verify.py @@ -6,11 +6,11 @@ computed with Python's integers. Written from the extension text. It shares no code with wai-rs (`crate::claim_receipt`), which generates the corpus: the canonical form is Python's JSON serialiser with members -ordered by UTF-16 code units, BLAKE3 is written below from its specification, Ed25519's -equation is the `cryptography` package's and step 3's other checks (key decoding, the -order of the key and of R, S < L) are written below from the curve's definition, and OER -receipts named by an `energy` claim are read, verified and their figures read by the OER -reference verifier (`../oer-conformance/oer2_verify.py`), under oer-receipts' own rules. +ordered by UTF-16 code units, BLAKE3 is written from its specification (`_blake3.py`), +Ed25519 is WAI's strict rule written from RFC 8032 (`_ed25519.py`, SPEC §7 *Signatures*; +the verifiers of the other corpora call it too), and OER receipts named by an `energy` +claim are read, verified and their figures read by the OER reference verifier +(`../oer-conformance/oer2_verify.py`), under oer-receipts' own rules. python3 claim_verify.py [vectors.json] verify every vector and the canonical fixture python3 claim_verify.py fixture print the fixture's canonical bytes, @@ -40,7 +40,6 @@ sys.path.insert(0, os.path.join(HERE, "..", "staged-conformance")) import staged_verify as SV # noqa: E402 (the strict JSON and manifest reader http_verify.py uses too) sys.path.insert(0, HERE) -from cryptography.exceptions import InvalidSignature # noqa: E402 from cryptography.hazmat.primitives import serialization # noqa: E402 from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: E402 @@ -51,71 +50,21 @@ from _blake3 import blake3 # noqa: E402 (written from its specification; share # ------------------------------------------------ Ed25519 (RFC 8032), §3.1 step 3's rule # -# RFC 8032 verifiers differ on two choices that decide whether a signature verifies: -# whether keys and R of small order are refused (RFC 8032's procedure does not refuse -# them), and whether the cofactored or the cofactorless equation is checked (§5.1.7 states -# the first and allows the second). §3.1 step 3 fixes both, and restates RFC 8032's own decoding rule -# (one encoding per point) and range rule (S < L). Every check but the equation is made -# here, from the curve's definition: the `cryptography` package decodes keys more -# leniently than RFC 8032 §5.1.3 and accepts keys and R of small order. It does check -# S < L, which is made here as well. The equation itself is left to it. +# §3.1 step 3 is WAI's one rule for every signature (SPEC §7 *Signatures*): the key is the +# canonical encoding of a point of large order, S < L, R is not of small order, and the +# cofactorless equation holds with R compared byte for byte. `_ed25519.py` implements it +# from RFC 8032's definitions; the `cryptography` package alone would accept keys that are +# not canonical encodings and keys and R of small order. -_P = 2 ** 255 - 19 -_L = 2 ** 252 + 27742317777372353535851937790883648493 -_D = (-121665 * pow(121666, _P - 2, _P)) % _P -_SQRT_M1 = pow(2, (_P - 1) // 4, _P) - - -def point_decode(b: bytes): - """RFC 8032 §5.1.3: the affine point (x, y), or None where decoding fails — including - y >= p and x = 0 with the sign bit set, so a point has exactly one encoding.""" - y = int.from_bytes(b, "little") - sign, y = y >> 255, y & ((1 << 255) - 1) - if y >= _P: - return None - u, v = (y * y - 1) % _P, (_D * y * y + 1) % _P - x2 = u * pow(v, _P - 2, _P) % _P - if x2 == 0: - return (0, y) if sign == 0 else None - x = pow(x2, (_P + 3) // 8, _P) - if (x * x - x2) % _P: - x = x * _SQRT_M1 % _P - if (x * x - x2) % _P: - return None - if x & 1 != sign: - x = _P - x - return (x, y) - - -def _add(p, q): - """Twisted Edwards addition (a = -1), complete on this curve.""" - (x1, y1), (x2, y2) = p, q - t = _D * x1 * x2 * y1 * y2 % _P - x3 = (x1 * y2 + y1 * x2) * pow(1 + t, _P - 2, _P) % _P - y3 = (y1 * y2 + x1 * x2) * pow(1 - t, _P - 2, _P) % _P - return (x3, y3) - - -def small_order(pt) -> bool: - """Whether [8]pt is the identity: the point's order divides 8.""" - for _ in range(3): - pt = _add(pt, pt) - return pt == (0, 1) +import _ed25519 as ED # noqa: E402 def check_signature(pk: bytes, sig: bytes, msg: bytes): - """§3.1 step 3, in its order: the key decodes and is not of small order - (`bad_signer_key`); then S < L, R is not a point of small order, and the cofactorless - equation holds with R compared byte for byte (`signature_verify_failed`).""" - a = point_decode(pk) - need(a is not None and not small_order(a), 3, "bad_signer_key") - r = point_decode(sig[:32]) - need(int.from_bytes(sig[32:], "little") < _L, 3, "signature_verify_failed") - need(r is None or not small_order(r), 3, "signature_verify_failed") - try: - ed25519.Ed25519PublicKey.from_public_bytes(pk).verify(sig, msg) - except (InvalidSignature, ValueError): - raise Reject(3, "signature_verify_failed") + """§3.1 step 3, in its order: the key (`bad_signer_key`), then S < L, R, and the + equation (`signature_verify_failed`).""" + why = ED.verify(pk, msg, sig) + need(why != "bad_key", 3, "bad_signer_key") + need(why is None, 3, "signature_verify_failed") # ------------------------------------------------------------- canonical form (RFC 8785) diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index 31a54dda6..e10430b56 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -11,9 +11,10 @@ reference adaptation policies. Two implementations check every case and must agr - `wai_http_vectors verify .` (also `wai http vectors verify .`), in `wai-rs`, built with the features `http` and `video_manifest` (the playlist receipts); - `python3 http_verify.py .`, written from the extension text and SPEC §7.1, sharing no - code with `wai-rs`. It needs only the Python standard library (its Ed25519 check of the - playlist receipts is written from RFC 8032), and reads - `../claim-conformance/_blake3.py` (BLAKE3 from its specification) and + code with `wai-rs`. It needs only the Python standard library, and reads + `../claim-conformance/_blake3.py` (BLAKE3 from its specification), + `../claim-conformance/_ed25519.py` (WAI's strict Ed25519 rule, SPEC §7 *Signatures*, + written from RFC 8032; it checks the playlist receipts) and `../registry/capabilities.json` (the published registry export). ## Files diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index 0ed418ed1..4809d14ed 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -2547,86 +2547,16 @@ def jcs(v): return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + jcs(v[k]) for k in keys) + "}" -# Ed25519 (RFC 8032 §5.1), verification only, from the RFC's definitions, strictly as -# WAI verifies it: the key and R decoded with y < p (and no sign bit on x = 0), neither of -# small order, S < L, and [S]B = R + [k]A checked by encoding [S]B − [k]A and comparing it -# with R byte for byte. -_P = 2 ** 255 - 19 -_L = 2 ** 252 + 27742317777372353535851937790883648493 -_D = -121665 * pow(121666, _P - 2, _P) % _P -_I = pow(2, (_P - 1) // 4, _P) - - -def _recover_x(y, sign): - if y >= _P: - return None - x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P - if x2 == 0: - return None if sign else 0 - x = pow(x2, (_P + 3) // 8, _P) - if (x * x - x2) % _P: - x = x * _I % _P - if (x * x - x2) % _P: - return None - return _P - x if (x & 1) != sign else x - - -def _decode(b): - y = int.from_bytes(b, "little") - sign, y = y >> 255, y & ((1 << 255) - 1) - x = _recover_x(y, sign) - return None if x is None else (x, y, 1, x * y % _P) - - -def _add(p, q): - a = (p[1] - p[0]) * (q[1] - q[0]) % _P - b = (p[1] + p[0]) * (q[1] + q[0]) % _P - c = 2 * p[3] * q[3] * _D % _P - d = 2 * p[2] * q[2] % _P - e, f, g, h = b - a, d - c, d + c, b + a - return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) - - -def _mul(s, p): - q = (0, 1, 1, 0) - while s: - if s & 1: - q = _add(q, p) - p = _add(p, p) - s >>= 1 - return q - - -def _encode(p): - zi = pow(p[2], _P - 2, _P) - x, y = p[0] * zi % _P, p[1] * zi % _P - return (y | ((x & 1) << 255)).to_bytes(32, "little") - - -_BY = 4 * pow(5, _P - 2, _P) % _P -_B = (_recover_x(_BY, 0), _BY, 1, _recover_x(_BY, 0) * _BY % _P) - - -def _small_order(p): - """Whether [8]P is the identity: P's order divides 8.""" - q = _mul(8, p) - return q[0] % _P == 0 and (q[1] - q[2]) % _P == 0 +# Ed25519, strictly as WAI verifies every signature (SPEC §7 *Signatures*): the key and R +# decoded with y < p (and no sign bit on x = 0), neither of small order, S < L, and +# [S]B = R + [k]A checked by encoding [S]B − [k]A and comparing it with R byte for byte. +# `_ed25519.py` writes it from RFC 8032's definitions, with the standard library only; +# --mutate small-order-key and small-order-r leave out its two order checks. +import _ed25519 as ED # noqa: E402 def ed25519_verify(pk, msg, sig): - if len(pk) != 32 or len(sig) != 64: - return False - a, r = _decode(pk), _decode(sig[:32]) - if a is None or (_small_order(a) and MUTATE != "small-order-key"): - return False - if r is None or (_small_order(r) and MUTATE != "small-order-r"): - return False - s = int.from_bytes(sig[32:], "little") - if s >= _L: - return False - k = int.from_bytes(hashlib.sha512(sig[:32] + pk + msg).digest(), "little") % _L - neg_a = (_P - a[0], a[1], a[2], _P - a[3]) - return _encode(_add(_mul(s, _B), _mul(k, neg_a))) == sig[:32] + return ED.verifies(pk, msg, sig, skip=(MUTATE,)) RECEIPT_KNOWN = {"kind", "manifest_hash", "session_class_hash", "class", "segments", "merkle_root", "work_compose_ops", "viewers_served", diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index 9a656fe94..01c1b2f3e 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -93,7 +93,7 @@ Files are compared as bytes; `.gitattributes` turns off line-ending conversion. reason. `cargo test` runs the same check, with the scene cuts of each fixture stated as tests (`tests/scenecut.rs`). -- `pack_verify.py`, Python standard library and, for Ed25519, `cryptography` (TOML through `tomllib`, so 3.11 or +- `pack_verify.py`, Python standard library and, to sign what it re-derives, `cryptography` (TOML through `tomllib`, so 3.11 or later) and sharing no code with wai-pack, written from the extension: its own policy schema, constraints, canonical JSON and digest; its own Y4M and WAV readers; the fixtures regenerated from their definition; `wai.pack.scenecut/1` in its on-demand @@ -104,8 +104,8 @@ Files are compared as bytes; `.gitattributes` turns off line-ending conversion. `../claim-conformance/_blake3.py`, and the `WIV1` parser and decoder from `../int-payload-conformance/int_payload_verify.py`. For the packages it has its own stage index reader and rules, object rules (each payload parsed as its pinned format, - a `WIR1` reader included), version-1 and version-2 receipts (Ed25519 through the - `cryptography` package, imported only where a signature is checked), an AV1 stream + a `WIR1` reader included), version-1 and version-2 receipts (Ed25519 by WAI's strict + rule, SPEC §7 *Signatures*, from `../claim-conformance/_ed25519.py`), an AV1 stream and sequence header reader, the CMAF files, and every catalog: it verifies each golden package in §11.3's order, rebuilding the floor's files and re-rendering the catalogs byte for byte, gives every mutation its reason, checks the version-1 receipts, and diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index 656cee808..1f6785098 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -6,8 +6,9 @@ corpus. It takes BLAKE3 from ../claim-conformance/_blake3.py and the WIV1 parser keyframe-0 decoder from ../int-payload-conformance/int_payload_verify.py, both written from their specifications, reads the capability registry from its published export (../registry/capabilities.json), reads TOML with the standard library's tomllib (TOML -1.0), checks Ed25519 signatures (RFC 8032) with the `cryptography` package, imported only -where a signature is checked, and implements the rest: +1.0), checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's strict rule +(SPEC §7 *Signatures*) written from RFC 8032, signs its own re-derivations with the +`cryptography` package, imported only where it signs, and implements the rest: - the policy (§3): the value-kind walk, the schema with every default, the constraints and their refusal names, the effective policy as JSON, RFC 8785 canonical JSON and the @@ -73,6 +74,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) sys.path.insert(0, os.path.join(HERE, "..", "int-payload-conformance")) from _blake3 import blake3 # noqa: E402 +import _ed25519 as ED # noqa: E402 import int_payload_verify as IPV # noqa: E402 MAX_INTEGER = (1 << 53) - 1 @@ -1761,15 +1763,11 @@ def receipt_payload(j): def ed25519_verifies(pk, sig, msg): - """RFC 8032, through the `cryptography` package, imported here so the rest of the - verifier runs on the standard library alone.""" - from cryptography.exceptions import InvalidSignature - from cryptography.hazmat.primitives.asymmetric import ed25519 - try: - ed25519.Ed25519PublicKey.from_public_bytes(pk).verify(sig, msg) - return True - except (InvalidSignature, ValueError): - return False + """WAI's strict rule (SPEC §7 *Signatures*): a canonical key of large order, S < L, R + not of small order, and the cofactorless equation with R compared byte for byte. The + `cryptography` package alone accepts a key of small order, a second spelling of a key, + and an R of small order.""" + return ED.verifies(pk, msg, sig) def ed25519_from_seed(seed, msg): diff --git a/wai/signer-conformance/signer_verify.py b/wai/signer-conformance/signer_verify.py index fbfbfd143..f9aa398df 100644 --- a/wai/signer-conformance/signer_verify.py +++ b/wai/signer-conformance/signer_verify.py @@ -175,8 +175,7 @@ def verifies(pk: bytes, sig: bytes, msg: bytes) -> bool: def key_accepted(pk: bytes) -> bool: - a = CV.point_decode(pk) - return a is not None and not CV.small_order(a) + return CV.ED.strict_key(pk) def commitment(k: bytes) -> bytes: -- 2.54.0 (Apple Git-157) From c37c3e9ad66b9056e149d348ab787cbf1abc1c31 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:54:36 -0400 Subject: [PATCH 04/10] wai-sig: the strict Ed25519 rule as its own crate, and wai-quantum on it wai-quantum's receipts still checked signatures with VerifyingKey::from_bytes and the lenient verify, and could not call wai::sig: wai depends on wai-quantum, not the reverse. The rule moves out of wai-rs/src/sig.rs, unchanged, into the crate wai/wai-sig, which both depend on; `wai::sig` re-exports it, so every call site in wai-rs reads as before. wai-quantum's `_signing` feature brings it. Its twelve verify paths now call wai_sig::verify_strict: the calibration, job, mitigation, decode, compile and rearrange receipts, the QBOM and its labelled form, the phasor, circuit, SPD and resource receipts, and every Labelled. Each module has a test that its receipts refuse the three forgeries a lenient check accepts. assert_refuses_forgeries is public so both crates' tests use it. Lockfiles: the path crate added to every workspace that builds wai or wai-quantum (wai-rs, wai-quantum, wai-pack, wai-quantum-serve, wai-relay, wai-spz-gen, wai-web). wai-pack's lock also moves wai-quantum 0.3.36 to 0.3.37, which #176 left behind. CI: wai-conformance runs wai-sig's tests on every leg; every workflow that builds wai-rs or wai-quantum, and wai-http and wai-pack, which verify signatures through it, fire on wai/wai-sig and on claim-conformance/_ed25519.py. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 10 + .github/workflows/wai-energy-receipts.yml | 2 + .github/workflows/wai-http.yml | 10 +- .github/workflows/wai-pack.yml | 4 + .github/workflows/wai-web-wasm.yml | 2 + wai/wai-pack/Cargo.lock | 12 +- wai/wai-quantum-serve/Cargo.lock | 9 + wai/wai-quantum/Cargo.lock | 9 + wai/wai-quantum/Cargo.toml | 5 +- wai/wai-quantum/src/quantum_energy.rs | 15 +- wai/wai-quantum/src/quantum_ops.rs | 58 ++- wai/wai-quantum/src/quantum_phasor_meter.rs | 21 +- wai/wai-quantum/src/quantum_qbom.rs | 20 +- wai/wai-quantum/src/quantum_receipt.rs | 19 +- .../src/quantum_resource_receipt.rs | 16 +- wai/wai-quantum/src/quantum_spd_receipt.rs | 16 +- wai/wai-relay/Cargo.lock | 9 + wai/wai-rs/Cargo.lock | 10 + wai/wai-rs/Cargo.toml | 11 +- wai/wai-rs/src/lib.rs | 8 +- wai/wai-sig/Cargo.lock | 330 ++++++++++++++++++ wai/wai-sig/Cargo.toml | 13 + wai/{wai-rs/src/sig.rs => wai-sig/src/lib.rs} | 10 +- wai/wai-spz-gen/Cargo.lock | 9 + wai/wai-web/Cargo.lock | 10 + 25 files changed, 551 insertions(+), 87 deletions(-) create mode 100644 wai/wai-sig/Cargo.lock create mode 100644 wai/wai-sig/Cargo.toml rename wai/{wai-rs/src/sig.rs => wai-sig/src/lib.rs} (98%) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index feafd20cc..1ed4e024c 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -39,6 +39,8 @@ on: # wai-rs path-depends on wai-quantum, and the quantum corpora below are # verified through it: a change there changes what this workflow checks. - 'wai/wai-quantum/**' + # Every signature wai-rs and wai-quantum verify goes through wai-sig. + - 'wai/wai-sig/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' # The registry export and the two library tests that read files outside @@ -70,6 +72,8 @@ on: # wai-rs path-depends on wai-quantum, and the quantum corpora below are # verified through it: a change there changes what this workflow checks. - 'wai/wai-quantum/**' + # Every signature wai-rs and wai-quantum verify goes through wai-sig. + - 'wai/wai-sig/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' # The registry export and the two library tests that read files outside @@ -165,6 +169,12 @@ jobs: - name: Library unit tests (the fixed-point determinism floors) working-directory: wai/wai-rs run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery,staged,video_int,audio_int,splat_int,stage_measure,telemetry,video_package,http + # WAI's one Ed25519 rule (SPEC §7 *Signatures*): its own tests — each step refused + # alone, every encoding of a key the library reads that the rule refuses, and the + # three forgeries every signing site's test sets into its receipt. + - name: wai-sig (the strict Ed25519 rule) + working-directory: wai/wai-sig + run: cargo test --locked # The JPEG AI exact decoder reproduces across platforms only if no platform math # library is on its path. Two guards, each failing the step: clippy's # disallowed-methods (wai-rs/clippy.toml), which each module on the decode path diff --git a/.github/workflows/wai-energy-receipts.yml b/.github/workflows/wai-energy-receipts.yml index ed4e474ab..575dd5d9f 100644 --- a/.github/workflows/wai-energy-receipts.yml +++ b/.github/workflows/wai-energy-receipts.yml @@ -91,6 +91,7 @@ on: - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - 'wai/wai-quantum-serve/**' + - 'wai/wai-sig/**' - 'wai/extensions/**' - 'wai/SPEC.md' - 'wai/CHANGELOG.md' @@ -102,6 +103,7 @@ on: - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - 'wai/wai-quantum-serve/**' + - 'wai/wai-sig/**' - 'wai/extensions/**' - 'wai/SPEC.md' - 'wai/CHANGELOG.md' diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index 45f658044..6c12592ab 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -48,8 +48,9 @@ on: # The playlists render through hls_render and are sealed by wai_video_manifest. - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' - # Playlist receipts verify Ed25519 through the shared strict rule. - - 'wai/wai-rs/src/sig.rs' + # Playlist receipts verify Ed25519 through the shared strict rule, in Rust and Python. + - 'wai/wai-sig/**' + - 'wai/claim-conformance/_ed25519.py' - 'wai/wai-rs/Cargo.toml' - 'wai/http-conformance/**' - 'wai/cache-conformance/**' @@ -75,8 +76,9 @@ on: # The playlists render through hls_render and are sealed by wai_video_manifest. - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' - # Playlist receipts verify Ed25519 through the shared strict rule. - - 'wai/wai-rs/src/sig.rs' + # Playlist receipts verify Ed25519 through the shared strict rule, in Rust and Python. + - 'wai/wai-sig/**' + - 'wai/claim-conformance/_ed25519.py' - 'wai/wai-rs/Cargo.toml' - 'wai/http-conformance/**' - 'wai/cache-conformance/**' diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 2ce932959..a3b115a64 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -30,6 +30,8 @@ on: - 'wai/wai-rs/**' - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' + - 'wai/claim-conformance/_ed25519.py' + - 'wai/wai-sig/**' - 'wai/int-payload-conformance/int_payload_verify.py' - 'wai/registry/capabilities.json' - '.github/workflows/wai-pack.yml' @@ -40,6 +42,8 @@ on: - 'wai/wai-rs/**' - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' + - 'wai/claim-conformance/_ed25519.py' + - 'wai/wai-sig/**' - 'wai/int-payload-conformance/int_payload_verify.py' - 'wai/registry/capabilities.json' - '.github/workflows/wai-pack.yml' diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index e751c2afa..1e68de2bb 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -50,6 +50,7 @@ on: - 'wai/wai-web/**' - 'wai/wai-rs/**' - 'wai/wai-quantum/**' + - 'wai/wai-sig/**' - 'wai/neural-conformance/**' - 'wai/staged-conformance/**' - 'wai/registry/**' @@ -59,6 +60,7 @@ on: - 'wai/wai-web/**' - 'wai/wai-rs/**' - 'wai/wai-quantum/**' + - 'wai/wai-sig/**' - 'wai/neural-conformance/**' - 'wai/staged-conformance/**' - 'wai/registry/**' diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index e0160c91d..ce33fd859 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "serde_json", "sha2", "wai-quantum", + "wai-sig", ] [[package]] @@ -682,11 +683,20 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.36" +version = "0.3.37" dependencies = [ "blake3", "ed25519-dalek", "serde_json", + "wai-sig", +] + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", ] [[package]] diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 12d6a3727..8bc5b766c 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -594,6 +594,7 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", + "wai-sig", ] [[package]] @@ -605,6 +606,14 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index b24857177..41accf53c 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -420,6 +420,15 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", + "wai-sig", +] + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", ] [[package]] diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 9a09ec8ed..fdb2d9b43 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -25,13 +25,16 @@ blake3 = "1" serde_json = { version = "1", features = ["preserve_order"] } # Receipt signatures. Optional — only the sealing features need it. ed25519-dalek = { version = "2", optional = true } +# WAI's one Ed25519 rule (SPEC §7 *Signatures*), which every receipt's signature is +# verified by; the parent `wai` crate calls the same one. +wai-sig = { version = "0.1.0", path = "../wai-sig", optional = true } [features] default = ["quantum", "quantum_toolchain", "quantum_receipt"] # Internal: signature support, pulled in by every sealing feature. # (Mirrors the `world` feature in the parent `wai` crate.) -_signing = ["dep:ed25519-dalek"] +_signing = ["dep:ed25519-dalek", "dep:wai-sig"] # --- core simulation ------------------------------------------------------- quantum = [] diff --git a/wai/wai-quantum/src/quantum_energy.rs b/wai/wai-quantum/src/quantum_energy.rs index 9f9a8311e..135cee88d 100644 --- a/wai/wai-quantum/src/quantum_energy.rs +++ b/wai/wai-quantum/src/quantum_energy.rs @@ -50,7 +50,7 @@ //! receipts never carried it, and a new label must say which hardware evidence //! it has. -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use serde_json::{json, Map, Value}; /// A declared uncertainty: the standard relative uncertainty (k = 1) of the @@ -527,12 +527,10 @@ impl Labelled { if check_label(self.receipt.figure(), &self.energy_class).is_err() || !self.receipt.body_ok() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.receipt.signer_key()) else { - return false; - }; - k.verify( + wai_sig::verify_strict( + &self.receipt.signer_key(), &self.receipt.labelled_payload(Some(&self.energy_class)), - &Signature::from_bytes(&self.receipt.signature()), + &self.receipt.signature(), ) .is_ok() } @@ -732,10 +730,7 @@ impl LabelledQbom { if self.check().is_err() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.qbom.signer_pubkey) else { - return false; - }; - k.verify(&self.qbom.labelled_signing_payload(&self.classes), &Signature::from_bytes(&self.qbom.sig)) + wai_sig::verify_strict(&self.qbom.signer_pubkey, &self.qbom.labelled_signing_payload(&self.classes), &self.qbom.sig) .is_ok() } diff --git a/wai/wai-quantum/src/quantum_ops.rs b/wai/wai-quantum/src/quantum_ops.rs index e9019b4f8..8df85fcd5 100644 --- a/wai/wai-quantum/src/quantum_ops.rs +++ b/wai/wai-quantum/src/quantum_ops.rs @@ -27,7 +27,7 @@ //! Ed25519, reused. use crate::merkle::merkle_root; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; /// A signed attestation that a calibration run happened, with what result and /// energy cost, under what authorization. @@ -318,11 +318,7 @@ impl CalibrationReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm a fidelity claim binds to real bytes: recompute `content_hash` of @@ -542,11 +538,7 @@ impl JobReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// The cross-link check: this job claims it ran under `cal`'s device state. @@ -811,11 +803,7 @@ impl MitigationReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm the raw measured data binds to this receipt's `input_hash` — what @@ -1056,11 +1044,7 @@ impl DecodeReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm the correction bytes bind to this receipt's `correction_hash`. @@ -1277,11 +1261,7 @@ impl CompileReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } pub fn source_matches(&self, bytes: &[u8]) -> bool { @@ -1486,11 +1466,7 @@ impl RearrangeReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } pub fn plan_matches(&self, bytes: &[u8]) -> bool { @@ -1912,6 +1888,26 @@ mod tests { r.success = false; // flip the verdict assert!(!r.verify(), "tampering with the success verdict must break the receipt"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each. + #[test] + fn signatures_are_verified_strictly() { + macro_rules! strict { + ($r:expr) => { + wai_sig::assert_refuses_forgeries(&$r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()) + }; + } + let g = GrantRef::unbounded; + let cal = CalibrationReceipt::seal(&key(1), "did:key:lab", "example:lab3-device", "q3", content_hash(b"cfg"), evid(), 4_200, g("quantum.calibrate"), None); + strict!(cal); + strict!(JobReceipt::seal(&key(4), "lab", "example:qpu-a", content_hash(b""), content_hash(b""), Some(cal.receipt_hash()), 1000, 250, g("quantum.job.run"), None)); + strict!(MitigationReceipt::seal(&key(1), "did:key:lab", "wai.quantum.circuit", "zne.richardson", "ZZ", content_hash(b""), None, 744_178, 912_680, 20_971, 48_000, 900_000, g("quantum.mitigate"), None)); + strict!(DecodeReceipt::seal(&key(1), "did:key:lab", "bb:[[72,12]]", "relay-bp.min-sum", content_hash(b"h"), content_hash(b"s"), content_hash(b"c"), true, 3, 46_656, 900_000, g("quantum.decode"), None)); + strict!(CompileReceipt::seal(&key(1), "did:key:lab", "clifford-tableau", content_hash(b"src"), content_hash(b"tgt"), content_hash(b"cpl"), true, 3, 4096, 300_000, g("quantum.compile"), None)); + strict!(RearrangeReceipt::seal(&key(1), "did:key:lab", "hungarian-lsap", content_hash(b"i"), content_hash(b"t"), content_hash(b"p"), true, 41, 318, 450_000, g("quantum.rearrange"), None)); + } } crate::quantum_energy::labellable!(CalibrationReceipt, DOMAIN_CAL_ID); diff --git a/wai/wai-quantum/src/quantum_phasor_meter.rs b/wai/wai-quantum/src/quantum_phasor_meter.rs index 90006f197..1403e2d73 100644 --- a/wai/wai-quantum/src/quantum_phasor_meter.rs +++ b/wai/wai-quantum/src/quantum_phasor_meter.rs @@ -32,7 +32,7 @@ //! every other receipt here: the worlds Merkle + Ed25519, reused. use crate::merkle::merkle_root; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:phasor-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:phasor-receipt-id\x01"; @@ -228,11 +228,7 @@ impl PhasorReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Confirm a receipt vouches for *this* output: the result reproduces the @@ -416,6 +412,19 @@ mod tests { assert!(child.verify()); assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } + + /// The signature, and a labelled receipt's, is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each. + #[test] + fn signatures_are_verified_strictly() { + use crate::quantum_energy::{EnergyClass, Labellable, Labelled}; + let w = PhasorWork { n_features: 200, input_dim: 2, n_samples: 300 }; + let r = PhasorReceipt::seal(&key(1), "did:key:test", "wai.quantum.kernel", computation_id("wai.quantum.kernel", &[4.0, 7.0]), result_fingerprint(&[0.1, -0.2, 0.3]), w, 117_000, None); + wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + let l = Labelled::seal(r, EnergyClass::ModelBased, &key(1)).unwrap(); + wai_sig::assert_refuses_forgeries(&l, |l, k, s| (l.receipt.signer_pubkey, l.receipt.sig) = (k, s), |l| l.receipt.labelled_payload(Some(&l.energy_class)), |l| l.verify()); + } } crate::quantum_energy::labellable!(PhasorReceipt, DOMAIN_RECEIPT_ID); diff --git a/wai/wai-quantum/src/quantum_qbom.rs b/wai/wai-quantum/src/quantum_qbom.rs index 01ae51aac..06c8948e2 100644 --- a/wai/wai-quantum/src/quantum_qbom.rs +++ b/wai/wai-quantum/src/quantum_qbom.rs @@ -29,7 +29,7 @@ //! carries a single verifiable statement of everything it was made of. use crate::quantum_ops::content_hash; -use ed25519_dalek::{Signature, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::SigningKey; const DOMAIN_QBOM: &[u8] = b"wai:quantum-qbom\x01"; pub(crate) const DOMAIN_QBOM_ID: &[u8] = b"wai:quantum-qbom-id\x01"; @@ -215,11 +215,7 @@ impl Qbom { /// The manifest's own signature is valid. pub fn verify(&self) -> bool { - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// Given the actual stage receipts as `(receipt_hash, receipt_verifies)`, does @@ -399,4 +395,16 @@ mod tests { assert_eq!(back, q); assert!(back.verify()); } + + /// The bill's signature, and a labelled bill's, is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each. + #[test] + fn signatures_are_verified_strictly() { + let (q, _) = sample_qbom(); + wai_sig::assert_refuses_forgeries(&q, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |q| q.signing_payload(), |q| q.verify()); + let classes = vec![Some(crate::quantum_energy::EnergyClass::ModelBased), None]; + let l = crate::quantum_energy::LabelledQbom::seal(q, classes, &key(9)).unwrap(); + wai_sig::assert_refuses_forgeries(&l, |l, k, s| (l.qbom.signer_pubkey, l.qbom.sig) = (k, s), |l| l.qbom.labelled_signing_payload(&l.classes), |l| l.verify()); + } } diff --git a/wai/wai-quantum/src/quantum_receipt.rs b/wai/wai-quantum/src/quantum_receipt.rs index 3efbafe74..a2a56e7a2 100644 --- a/wai/wai-quantum/src/quantum_receipt.rs +++ b/wai/wai-quantum/src/quantum_receipt.rs @@ -34,7 +34,7 @@ use crate::quantum::{circuit_hash, Circuit, Measure, QuantumError}; use crate::merkle::merkle_root; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:quantum-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:quantum-receipt-id\x01"; @@ -219,11 +219,7 @@ impl QuantumReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) - .is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() } /// The strong, portable check: independently re-simulate `circuit` and @@ -423,6 +419,17 @@ mod tests { assert!(child.verify()); assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } + + /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each. + #[test] + fn signatures_are_verified_strictly() { + let mut c = Circuit::new(3); + c.h(0).cx(0, 1).cx(1, 2); + let r = QuantumReceipt::seal(&key(1), "did:key:test", &c, None, 117_000, None).unwrap(); + wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); + } } crate::quantum_energy::labellable!(QuantumReceipt, DOMAIN_RECEIPT_ID); diff --git a/wai/wai-quantum/src/quantum_resource_receipt.rs b/wai/wai-quantum/src/quantum_resource_receipt.rs index aa67682b4..cfbff0c64 100644 --- a/wai/wai-quantum/src/quantum_resource_receipt.rs +++ b/wai/wai-quantum/src/quantum_resource_receipt.rs @@ -28,7 +28,7 @@ use crate::quantum_resource::{ estimate_best, estimate_best_isa, estimate, estimate_isa, Budget, Estimate, EstimateConfig, EstimateError, Factory, InstructionSet, IsaCounts, LogicalCounts, PhysicalQubit, QecModel, Round, SearchLimits, UnitKind, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; use serde_json::{json, Value}; const DOMAIN_RECEIPT: &[u8] = b"wai:resource-receipt\x01"; @@ -294,10 +294,7 @@ impl ResourceReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.payload_with(None), &Signature::from_bytes(&self.sig)).is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.payload_with(None), &self.sig).is_ok() } /// The re-run half of [`Self::verify_computation`]. @@ -581,4 +578,13 @@ mod tests { assert!(labelled.verify()); assert!(!labelled.receipt.verify(), "the inner signature now covers the label"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each. + #[test] + fn signatures_are_verified_strictly() { + let r = ResourceReceipt::seal(&key(1), "sink-a", &ResourceJob::default(), None, unmetered).unwrap(); + wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.payload_with(None), |r| r.verify()); + } } diff --git a/wai/wai-quantum/src/quantum_spd_receipt.rs b/wai/wai-quantum/src/quantum_spd_receipt.rs index a8c1064c3..7586f6fe5 100644 --- a/wai/wai-quantum/src/quantum_spd_receipt.rs +++ b/wai/wai-quantum/src/quantum_spd_receipt.rs @@ -22,7 +22,7 @@ use crate::merkle::merkle_root; use crate::quantum_spd::{Op, Pauli, PauliString, RotCircuit, SpdConfig, SpdError, SpdResult, expectation}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:spd-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:spd-receipt-id\x01"; @@ -237,10 +237,7 @@ impl SpdReceipt { if !self.body_verifies() { return false; } - let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { - return false; - }; - k.verify(&self.payload_with(None), &Signature::from_bytes(&self.sig)).is_ok() + wai_sig::verify_strict(&self.signer_pubkey, &self.payload_with(None), &self.sig).is_ok() } /// The re-run half of [`Self::verify_computation`]. @@ -424,4 +421,13 @@ mod tests { assert!(labelled.verify()); assert!(!labelled.receipt.verify(), "the inner signature now covers the label"); } + + /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small + /// order, a non-canonical spelling of one, and an R of small order are each refused, + /// though a lenient check accepts each. + #[test] + fn signatures_are_verified_strictly() { + let r = SpdReceipt::seal(&key(1), "sink-a", &job(), None, unmetered).unwrap(); + wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.payload_with(None), |r| r.verify()); + } } diff --git a/wai/wai-relay/Cargo.lock b/wai/wai-relay/Cargo.lock index 846c6166d..d76e75921 100644 --- a/wai/wai-relay/Cargo.lock +++ b/wai/wai-relay/Cargo.lock @@ -1086,6 +1086,15 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-sig", +] + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", ] [[package]] diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 68a8339fe..5170ccbf3 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -3893,6 +3893,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "wai-quantum", + "wai-sig", "xz2", "zstd", "zstd-safe", @@ -3905,6 +3906,15 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", + "wai-sig", +] + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2 0.10.9", ] [[package]] diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 4959ca69a..553084822 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -83,6 +83,8 @@ c2pa = { version = "0.90", optional = true, default-features = false, # deterministic fixed-point simulation, replay-equivalence conformance. blake3 = { version = "1.8", optional = true } # state hashes, node minting, op/seal commitments; `hazmat` (1.8) for the HTTP head proof ed25519-dalek = { version = "2", optional = true } # op + seal signatures +# WAI's one Ed25519 rule (SPEC §7 *Signatures*), shared with wai-quantum; `crate::sig`. +wai-sig = { version = "0.1.0", path = "../wai-sig", optional = true } num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks # The curve ed25519-dalek is built on, named directly only by `receipt_claims`: its # conformance corpus builds the signatures jwp-receipts §3.1 step 3 must refuse (keys of @@ -148,10 +150,11 @@ neural = ["neural_int", "dep:ort", "dep:ndarray", "dep:zstd-safe"] # regenerate-at-sink. Reuses the ONNX runtime for the reference CLIP embedder; # the generator is always sink-supplied. Off by default like `neural`. semantic = ["codecs", "dep:ort", "dep:ndarray"] -# Ed25519 verified as WAI verifies every signature (SPEC §7 *Signatures*, `src/sig.rs`): -# the dependency on its own, so the one verifier builds exactly when ed25519-dalek does -# and asks for no feature its caller does not need. Every signing feature enables it. -ed25519 = ["dep:ed25519-dalek"] +# Ed25519 verified as WAI verifies every signature (SPEC §7 *Signatures*): the dependency +# and the one verifier (the `wai-sig` crate, `crate::sig`) on their own, so the verifier +# builds exactly when ed25519-dalek does and asks for no feature its caller does not need. +# Every signing feature enables it. +ed25519 = ["dep:ed25519-dalek", "dep:wai-sig"] # Interactive worlds (extensions/interactive-worlds): media that consumes # input. Pure-Rust, no ML dep chain — the sim is closed-form fixed-point. world = ["dep:blake3", "ed25519"] diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 413c169b7..23ce4b53e 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -313,10 +313,12 @@ pub mod c2pa_emit; // Ed25519 verified strictly (RFC 8032 §5.1.7 with a canonical key of large order, S < L, // and R of large order compared byte for byte, SPEC §7 *Signatures*): the one routine -// every signature check in this crate calls. Built whenever ed25519-dalek is, under the -// `ed25519` feature every signing feature enables. +// every signature check in this crate calls but OER/2's (oer-receipts §5), and +// wai-quantum's too. It is its own crate, `wai-sig`, so that wai-quantum, which `wai` +// depends on, can call it; built whenever ed25519-dalek is, under the `ed25519` feature +// every signing feature enables. #[cfg(feature = "ed25519")] -pub mod sig; +pub use wai_sig as sig; // RFC 8785 canonical JSON over the subset WAI signs and digests (integers up to // 2^53 − 1, no floats). Needs serde_json alone, so it is ungated: claims, key logs, diff --git a/wai/wai-sig/Cargo.lock b/wai/wai-sig/Cargo.lock new file mode 100644 index 000000000..3c0c69f46 --- /dev/null +++ b/wai/wai-sig/Cargo.lock @@ -0,0 +1,330 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" diff --git a/wai/wai-sig/Cargo.toml b/wai/wai-sig/Cargo.toml new file mode 100644 index 000000000..5e99e4034 --- /dev/null +++ b/wai/wai-sig/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wai-sig" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +description = "Ed25519 verified as WAI verifies every signature: a canonical key of large order, S < L, R not of small order, and the cofactorless equation with R compared byte for byte. The one routine `wai` and `wai-quantum` call." +repository = "https://git.transaction.science/Transaction-Science/open-standards" +homepage = "https://wai.transaction.science" + +[dependencies] +ed25519-dalek = "2" +sha2 = "0.10" diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-sig/src/lib.rs similarity index 98% rename from wai/wai-rs/src/sig.rs rename to wai/wai-sig/src/lib.rs index 48a102190..ee52992c8 100644 --- a/wai/wai-rs/src/sig.rs +++ b/wai/wai-sig/src/lib.rs @@ -1,8 +1,8 @@ //! Ed25519 verification as WAI verifies it (RFC 8032 §5.1.7, strictly; SPEC §7 //! *Signatures*): one routine, so every receipt family accepts exactly the signatures every -//! other conforming verifier accepts. Every signature check in this crate calls it, except -//! OER/2's (`crate::oer`), whose rule oer-receipts §5 settles: it refuses the same keys and -//! leaves `R` to RFC 8032. +//! other conforming verifier accepts. Every signature check in `wai` (as `wai::sig`) and in +//! `wai-quantum` calls it, except OER/2's (`wai::oer`), whose rule oer-receipts §5 settles: +//! it refuses the same keys and leaves `R` to RFC 8032. //! //! The rule, in order: //! @@ -202,8 +202,8 @@ pub fn forgeries(payload: impl Fn(&[u8; 32]) -> Vec) -> [Forgery; 3] { /// For a signing site's tests: `good` verifies, and each of [`forgeries`], set into it by /// `set` (the key and the signature it carries), is refused by `verifies`. `payload` is /// the bytes the site signs, computed after its key is set, since a payload may name it. -#[cfg(test)] -pub(crate) fn assert_refuses_forgeries( +/// Panics, naming the forgery, when one is accepted. +pub fn assert_refuses_forgeries( good: &R, set: impl Fn(&mut R, [u8; 32], [u8; 64]), payload: impl Fn(&R) -> Vec, diff --git a/wai/wai-spz-gen/Cargo.lock b/wai/wai-spz-gen/Cargo.lock index eef2758a9..c5ef3cffc 100644 --- a/wai/wai-spz-gen/Cargo.lock +++ b/wai/wai-spz-gen/Cargo.lock @@ -1006,6 +1006,15 @@ dependencies = [ "serde", "serde_json", "sha2", + "wai-sig", +] + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", ] [[package]] diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 6b4cc55b2..b937666ca 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -730,6 +730,7 @@ dependencies = [ "serde_json", "sha2", "wai-quantum", + "wai-sig", ] [[package]] @@ -739,6 +740,15 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", + "wai-sig", +] + +[[package]] +name = "wai-sig" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", ] [[package]] -- 2.54.0 (Apple Git-157) From 03d0df366c590391723933603001def32a986584 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:57:25 -0400 Subject: [PATCH 05/10] http falsify: the two signature patches break wai-sig, where the rule now lives Co-Authored-By: Claude Opus 5.5 --- .../falsify/sig-key-lenient.patch | 16 ++++++++-------- .../falsify/sig-verify-lenient.patch | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/wai/http-conformance/falsify/sig-key-lenient.patch b/wai/http-conformance/falsify/sig-key-lenient.patch index 804c776ae..3ad7289a2 100644 --- a/wai/http-conformance/falsify/sig-key-lenient.patch +++ b/wai/http-conformance/falsify/sig-key-lenient.patch @@ -1,10 +1,10 @@ # must fail: playlist_receipt_sig_key_identity playlist_receipt_sig_key_identity_long playlist_receipt_sig_r_small_order -# keys read as the library reads them, and the equation checked leniently (wai::sig) -diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-rs/src/sig.rs -index 46135933..01f85c39 100644 ---- a/wai/wai-rs/src/sig.rs -+++ b/wai/wai-rs/src/sig.rs -@@ -62,9 +62,6 @@ fn below_l(s: &[u8; 32]) -> bool { +# keys read as the library reads them, and the equation checked leniently (wai_sig) +diff --git a/wai/wai-sig/src/lib.rs b/wai/wai-sig/src/lib.rs +index ee52992c..4fa00223 100644 +--- a/wai/wai-sig/src/lib.rs ++++ b/wai/wai-sig/src/lib.rs +@@ -64,9 +64,6 @@ fn below_l(s: &[u8; 32]) -> bool { /// large order. pub fn strict_key(bytes: &[u8; 32]) -> Result { let key = VerifyingKey::from_bytes(bytes).map_err(|_| SigError::BadKey)?; @@ -14,7 +14,7 @@ index 46135933..01f85c39 100644 Ok(key) } -@@ -75,7 +72,7 @@ pub fn verify_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<() +@@ -77,7 +74,7 @@ pub fn verify_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<() if !below_l(s) { return Err(SigError::BadSignature); } @@ -22,4 +22,4 @@ index 46135933..01f85c39 100644 + ed25519_dalek::Verifier::verify(&key, msg, &Signature::from_bytes(sig)).map_err(|_| SigError::BadSignature) } - #[cfg(test)] + /// The identity point, canonically encoded (y = 1). diff --git a/wai/http-conformance/falsify/sig-verify-lenient.patch b/wai/http-conformance/falsify/sig-verify-lenient.patch index 2450c0fb0..53bbebcf3 100644 --- a/wai/http-conformance/falsify/sig-verify-lenient.patch +++ b/wai/http-conformance/falsify/sig-verify-lenient.patch @@ -1,10 +1,10 @@ # must fail: playlist_receipt_sig_r_small_order -# R of small order accepted (wai::sig::verify_strict checks the equation leniently) -diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-rs/src/sig.rs -index 46135933..ebe58160 100644 ---- a/wai/wai-rs/src/sig.rs -+++ b/wai/wai-rs/src/sig.rs -@@ -75,7 +75,7 @@ pub fn verify_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<() +# R of small order accepted (wai_sig::verify_strict checks the equation leniently) +diff --git a/wai/wai-sig/src/lib.rs b/wai/wai-sig/src/lib.rs +index ee52992c..f555da6a 100644 +--- a/wai/wai-sig/src/lib.rs ++++ b/wai/wai-sig/src/lib.rs +@@ -77,7 +77,7 @@ pub fn verify_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<() if !below_l(s) { return Err(SigError::BadSignature); } @@ -12,4 +12,4 @@ index 46135933..ebe58160 100644 + ed25519_dalek::Verifier::verify(&key, msg, &Signature::from_bytes(sig)).map_err(|_| SigError::BadSignature) } - #[cfg(test)] + /// The identity point, canonically encoded (y = 1). -- 2.54.0 (Apple Git-157) From 9b5854aa4c6ba9f1a3018657c456835664947099 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 10:16:21 -0400 Subject: [PATCH 06/10] Corpora: weak-key cases in every receipt family's corpus Each family whose corpus lacked them gets the three signatures a verifier that checks only the equation accepts and WAI's rule refuses (wai_sig::forgeries): the identity as the key with (R, S) = (B, 1), the identity spelled y = p + 1, and R of small order under the base point. Every case is new; no existing vector's expectation changes. - jwp: group_signer_key_identity_forged, group_signer_key_identity_y_not_reduced (step 1, bad_signer_key), group_signature_r_small_order (step 2). - signer: anchor_root_identity_forged, anchor_root_identity_y_not_reduced, anchor_root_signature_r_small_order (not anchored, key_log_signature_invalid). - pack: mutations receipt-key-identity, receipt-key-identity-y-not-reduced, receipt-r-small-order (receipt-signature); receipts/ v1-key-identity, v1-key-identity-y-not-reduced, v1-r-small-order, marked "signature": "refused", which wai-pack and pack_verify.py now read. - quantum-ops: cal_q3_ and job_bell_ key_identity, key_identity_y_not_reduced and r_small_order, "verifies": false, which wai_quantum_ops_vectors checks are refused and hash as pinned. claim (signer_key_identity, signer_key_identity_y_not_reduced, signature_r_small_order and others) and http (sig-key-identity, -long, sig-r-small-order) had them already. OER/2 keeps its own rule. PackageReceipt::signing_payload (v1) and the calibration and job receipts' signing_payload are public, so a corpus generator can sign over them; wai-quantum re-exports wai_sig beside ed25519_dalek. Co-Authored-By: Claude Opus 5.5 --- wai/jwp-conformance/vectors.json | 78 +++++++++++++++++++ wai/pack-conformance/README.md | 9 ++- wai/pack-conformance/mutations/expected.json | 27 +++++++ .../receipt/key-identity-y-not-reduced.json | 1 + .../mutations/receipt/key-identity.json | 1 + .../mutations/receipt/r-small-order.json | 1 + wai/pack-conformance/pack_verify.py | 12 +-- wai/pack-conformance/receipts/expected.json | 18 +++++ .../v1-key-identity-y-not-reduced.json | 1 + .../receipts/v1-key-identity.json | 1 + .../receipts/v1-r-small-order.json | 1 + .../expected_hashes.json | 38 ++++++++- .../vectors/cal_q3_key_identity.json | 1 + .../cal_q3_key_identity_y_not_reduced.json | 1 + .../vectors/cal_q3_r_small_order.json | 1 + .../vectors/job_bell_key_identity.json | 1 + .../job_bell_key_identity_y_not_reduced.json | 1 + .../vectors/job_bell_r_small_order.json | 1 + wai/signer-conformance/vectors.json | 36 +++++++++ wai/wai-pack/src/golden.rs | 40 ++++++++-- wai/wai-quantum/src/lib.rs | 5 ++ wai/wai-quantum/src/quantum_ops.rs | 8 +- wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs | 69 ++++++++++++++-- wai/wai-rs/src/jwp_delivery/corpus.rs | 22 ++++++ wai/wai-rs/src/key_log/corpus.rs | 17 ++++ wai/wai-rs/src/wai_video_package.rs | 3 +- 26 files changed, 370 insertions(+), 24 deletions(-) create mode 100644 wai/pack-conformance/mutations/receipt/key-identity-y-not-reduced.json create mode 100644 wai/pack-conformance/mutations/receipt/key-identity.json create mode 100644 wai/pack-conformance/mutations/receipt/r-small-order.json create mode 100644 wai/pack-conformance/receipts/v1-key-identity-y-not-reduced.json create mode 100644 wai/pack-conformance/receipts/v1-key-identity.json create mode 100644 wai/pack-conformance/receipts/v1-r-small-order.json create mode 100644 wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json create mode 100644 wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json create mode 100644 wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json create mode 100644 wai/quantum-ops-conformance/vectors/job_bell_key_identity.json create mode 100644 wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json create mode 100644 wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json diff --git a/wai/jwp-conformance/vectors.json b/wai/jwp-conformance/vectors.json index fbabd6faa..dcd00be2e 100644 --- a/wai/jwp-conformance/vectors.json +++ b/wai/jwp-conformance/vectors.json @@ -817,6 +817,84 @@ "reason": "parent_not_held" } }, + { + "name": "group_signer_key_identity_forged", + "about": "the identity point as signer_pubkey, with (R, S) = (B, 1): the equation holds over any payload, so a verifier that checks only the equation accepts it; the key is of small order", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "0100000000000000000000000000000000000000000000000000000000000000", + "signer_id": "did:example:publisher", + "sig": "58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "bad_signer_key" + } + }, + { + "name": "group_signer_key_identity_y_not_reduced", + "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, under which the equation holds; the encoding is not canonical", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "signer_id": "did:example:publisher", + "sig": "58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "bad_signer_key" + } + }, + { + "name": "group_signature_r_small_order", + "about": "signer_pubkey the base point, whose discrete logarithm is 1, R the identity and S = k: the equation holds, and R is of small order", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "5866666666666666666666666666666666666666666666666666666666666666", + "signer_id": "did:example:publisher", + "sig": "0100000000000000000000000000000000000000000000000000000000000000f43db9669cf0eb3913db18561bb4d5d64b9ed5c5ec5a425b1bc7351ae6e93904", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, { "name": "object_labelled_counter_accept", "about": "a labelled leaf: the figure, the counter's class and evidence, and the origin are under the group signature", diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index 01c1b2f3e..402aedcaa 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -46,9 +46,14 @@ digest and what it may ask a packager to emit (§3), how a source is cut into pi catalogs, the receipt and `LISTING`. The fixture's bumper plays twice, so its pieces are one object each time. - `mutations/` — stage indexes, objects and version-2 receipts, and `expected.json`: - each case's reason (§6.2–§6.4, §9.1), a well-formed case of each kind among them. + each case's reason (§6.2–§6.4, §9.1), a well-formed case of each kind among them, + and three receipts carrying a signature only a verifier that checks the equation + alone accepts (a key of small order, that key spelled with y = p + 1, an R of small + order), each `receipt-signature` (SPEC §7 *Signatures*). - `receipts/` — version-1 package receipts as JSON (§9.3), with each one's receipt - hash: the legacy receipt whose hash `wai-rs` pins, and an unmetered one. + hash: the legacy receipt whose hash `wai-rs` pins, an unmetered one, and the + unmetered one carrying each of those three signatures, marked + `"signature": "refused"`. - `catalogs/` — stage indexes no golden package has (refinements on more than one deadline), with the MoQ catalog each gives (§8.4). - `package-mutations/` — packages a verifier must refuse (§11.3), each the golden diff --git a/wai/pack-conformance/mutations/expected.json b/wai/pack-conformance/mutations/expected.json index aa002b3e1..2d9e302a9 100644 --- a/wai/pack-conformance/mutations/expected.json +++ b/wai/pack-conformance/mutations/expected.json @@ -412,6 +412,33 @@ ], "reason": "receipt-signature" }, + { + "name": "receipt-key-identity", + "kind": "receipt", + "receipt": "receipt/key-identity.json", + "indexes": [ + "receipt/index-v.wsix" + ], + "reason": "receipt-signature" + }, + { + "name": "receipt-key-identity-y-not-reduced", + "kind": "receipt", + "receipt": "receipt/key-identity-y-not-reduced.json", + "indexes": [ + "receipt/index-v.wsix" + ], + "reason": "receipt-signature" + }, + { + "name": "receipt-r-small-order", + "kind": "receipt", + "receipt": "receipt/r-small-order.json", + "indexes": [ + "receipt/index-v.wsix" + ], + "reason": "receipt-signature" + }, { "name": "receipt-unknown-member", "kind": "receipt", diff --git a/wai/pack-conformance/mutations/receipt/key-identity-y-not-reduced.json b/wai/pack-conformance/mutations/receipt/key-identity-y-not-reduced.json new file mode 100644 index 000000000..6f282ed7f --- /dev/null +++ b/wai/pack-conformance/mutations/receipt/key-identity-y-not-reduced.json @@ -0,0 +1 @@ +{"asset_id":"mutations","components":[{"id":"v","index":"index-v.wsix","index_digest":"354687c821d088507431959415d285bbb20d02526c614d2e3092adbd48a93d5e","media_class":"video","stage_root":"cc3482c7ed8d99b58f5fd11ae463919d09e27a853d1b3cd5bbfa4b5bedcaa2e3"}],"energy_provenance":null,"grant":{"capability":"wai.video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":0,"kind":"package","packager":"builtin:wai-pack@0.1.0","parent_kind":"none","parent_receipt_hash":null,"policy_digest":"0101010101010101010101010101010101010101010101010101010101010101","receipt_hash":"b5e67aba7b6cc74f5a36f281eb009acf7d46c956214bbd101f49c9354368f6fa","sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:z6MkghLt1e8m1fmANsdJJco3aCLV8Xnigr5UWwC3u5iZFPd3","signer_pubkey":"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f","stored_bytes":2312,"total_bytes":2746,"total_entries":6,"unique_objects":5,"version":2,"work_units":6} \ No newline at end of file diff --git a/wai/pack-conformance/mutations/receipt/key-identity.json b/wai/pack-conformance/mutations/receipt/key-identity.json new file mode 100644 index 000000000..a206d7564 --- /dev/null +++ b/wai/pack-conformance/mutations/receipt/key-identity.json @@ -0,0 +1 @@ +{"asset_id":"mutations","components":[{"id":"v","index":"index-v.wsix","index_digest":"354687c821d088507431959415d285bbb20d02526c614d2e3092adbd48a93d5e","media_class":"video","stage_root":"cc3482c7ed8d99b58f5fd11ae463919d09e27a853d1b3cd5bbfa4b5bedcaa2e3"}],"energy_provenance":null,"grant":{"capability":"wai.video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":0,"kind":"package","packager":"builtin:wai-pack@0.1.0","parent_kind":"none","parent_receipt_hash":null,"policy_digest":"0101010101010101010101010101010101010101010101010101010101010101","receipt_hash":"f14cce74876592260cb5840dbadd8046571994e54348c380d79f907a018cac34","sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:z6MkghLt1e8m1fmANsdJJco3aCLV8Xnigr5UWwC3u5iZFPd3","signer_pubkey":"0100000000000000000000000000000000000000000000000000000000000000","stored_bytes":2312,"total_bytes":2746,"total_entries":6,"unique_objects":5,"version":2,"work_units":6} \ No newline at end of file diff --git a/wai/pack-conformance/mutations/receipt/r-small-order.json b/wai/pack-conformance/mutations/receipt/r-small-order.json new file mode 100644 index 000000000..1060395d4 --- /dev/null +++ b/wai/pack-conformance/mutations/receipt/r-small-order.json @@ -0,0 +1 @@ +{"asset_id":"mutations","components":[{"id":"v","index":"index-v.wsix","index_digest":"354687c821d088507431959415d285bbb20d02526c614d2e3092adbd48a93d5e","media_class":"video","stage_root":"cc3482c7ed8d99b58f5fd11ae463919d09e27a853d1b3cd5bbfa4b5bedcaa2e3"}],"energy_provenance":null,"grant":{"capability":"wai.video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":0,"kind":"package","packager":"builtin:wai-pack@0.1.0","parent_kind":"none","parent_receipt_hash":null,"policy_digest":"0101010101010101010101010101010101010101010101010101010101010101","receipt_hash":"a3a74cbfe3f2b89252a924286d67e0a227ee492831fb575213652bef2754588c","sig":"0100000000000000000000000000000000000000000000000000000000000000ac25b83496c7c49c920828631dbfcdac9123da5cd7a9011e9cee5bf229b44d03","signer_id":"did:key:z6MkghLt1e8m1fmANsdJJco3aCLV8Xnigr5UWwC3u5iZFPd3","signer_pubkey":"5866666666666666666666666666666666666666666666666666666666666666","stored_bytes":2312,"total_bytes":2746,"total_entries":6,"unique_objects":5,"version":2,"work_units":6} \ No newline at end of file diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index 1f6785098..9db9b0dbf 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -2904,9 +2904,10 @@ def mutation_verdict(m, c, used): return e.code -def verify_v1_receipt(rep, name, b, want_hash): +def verify_v1_receipt(rep, name, b, want_hash, refused=False): """§9.3: a version-1 receipt read, its payload rebuilt, its hash, counts, segment map - root and signature.""" + root and signature; with `refused`, everything but a signature that every verifier + refuses (SPEC §7 *Signatures*).""" try: r = read_receipt(b) except Refused as e: @@ -2927,8 +2928,8 @@ def verify_v1_receipt(rep, name, b, want_hash): rep.check((j["total_segments"], j["unique_segments"], j["total_bytes"], j["stored_bytes"], j["work_segment_ops"]) == (len(segs), len(first), sum(s["byte_len"] for s in segs), sum(first.values()), len(segs)), f"{name}: the segment counts") - rep.check(ed25519_verifies(bytes.fromhex(j["signer_pubkey"]), bytes.fromhex(j["sig"]), r["payload"]), - f"{name}: the signature verifies") + rep.check(ed25519_verifies(bytes.fromhex(j["signer_pubkey"]), bytes.fromhex(j["sig"]), r["payload"]) != refused, + f"{name}: the signature " + ("is refused" if refused else "verifies")) def verify(d): @@ -3068,7 +3069,8 @@ def verify(d): rc = json.loads(load(os.path.join(d, "receipts", "expected.json"))) for c in rc["cases"]: - verify_v1_receipt(rep, f"receipts/{c['file']}", load(os.path.join(d, "receipts", c["file"])), c["receipt_hash"]) + verify_v1_receipt(rep, f"receipts/{c['file']}", load(os.path.join(d, "receipts", c["file"])), c["receipt_hash"], + c.get("signature") == "refused") rep.check(tree(os.path.join(d, "receipts")) == sorted([c["file"] for c in rc["cases"]] + ["expected.json"]), "receipts/: every receipt is a case") diff --git a/wai/pack-conformance/receipts/expected.json b/wai/pack-conformance/receipts/expected.json index 0ee9c2404..b5073b118 100644 --- a/wai/pack-conformance/receipts/expected.json +++ b/wai/pack-conformance/receipts/expected.json @@ -7,6 +7,24 @@ { "file": "v1-unmetered.json", "receipt_hash": "2e9a0a221436ae3b7161b67c0d3215d8bed8f4c2f1a0b27ad786762120f71f31" + }, + { + "file": "v1-key-identity.json", + "receipt_hash": "60731f7c63ea66eb14850f2578714ed83548914f5722ce1f1400416d03523106", + "signature": "refused", + "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order" + }, + { + "file": "v1-key-identity-y-not-reduced.json", + "receipt_hash": "9a0167d70cb2bb27b05431c05f4fa907ca47beff5fd21df80533f49b368922f3", + "signature": "refused", + "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, and the encoding is not canonical" + }, + { + "file": "v1-r-small-order.json", + "receipt_hash": "4b9ebabaf3d1cd22bf5ed0335840913c3162adc6d9315a587523c2654e0d6cdf", + "signature": "refused", + "about": "the base point as the key (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order" } ] } diff --git a/wai/pack-conformance/receipts/v1-key-identity-y-not-reduced.json b/wai/pack-conformance/receipts/v1-key-identity-y-not-reduced.json new file mode 100644 index 000000000..e9e762bab --- /dev/null +++ b/wai/pack-conformance/receipts/v1-key-identity-y-not-reduced.json @@ -0,0 +1 @@ +{"asset_id":"asset","energy_provenance":null,"grant":{"capability":"video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":0,"kind":"package","parent_receipt_hash":"0303030303030303030303030303030303030303030303030303030303030303","receipt_hash":"9a0167d70cb2bb27b05431c05f4fa907ca47beff5fd21df80533f49b368922f3","segment_map_root":"941704ebc6c0adc3394ebda28f50b3d6cf38cfc0fcd265a861d002365294829f","segments":[{"byte_len":16,"content_hash":"9f63cd63fcfb34a53f2113045123c0309bef7f8eb74dc1ccda2c17c1369558ab","duration_ms":2000,"index":0},{"byte_len":16,"content_hash":"6897f7480bfd8cc5aaefaf33912d5f643ec6eae4450b593e98cd2ec4de18bcf2","duration_ms":2000,"index":1},{"byte_len":16,"content_hash":"864d8e31f5b44bde825e01963d73d6047b7acfae6ddaa64aa85ff0a47995e176","duration_ms":2000,"index":2},{"byte_len":16,"content_hash":"6897f7480bfd8cc5aaefaf33912d5f643ec6eae4450b593e98cd2ec4de18bcf2","duration_ms":2000,"index":3}],"sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"o","signer_pubkey":"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f","stored_bytes":48,"total_bytes":64,"total_segments":4,"unique_segments":3,"version":1,"work_segment_ops":4} \ No newline at end of file diff --git a/wai/pack-conformance/receipts/v1-key-identity.json b/wai/pack-conformance/receipts/v1-key-identity.json new file mode 100644 index 000000000..142c3242e --- /dev/null +++ b/wai/pack-conformance/receipts/v1-key-identity.json @@ -0,0 +1 @@ +{"asset_id":"asset","energy_provenance":null,"grant":{"capability":"video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":0,"kind":"package","parent_receipt_hash":"0303030303030303030303030303030303030303030303030303030303030303","receipt_hash":"60731f7c63ea66eb14850f2578714ed83548914f5722ce1f1400416d03523106","segment_map_root":"941704ebc6c0adc3394ebda28f50b3d6cf38cfc0fcd265a861d002365294829f","segments":[{"byte_len":16,"content_hash":"9f63cd63fcfb34a53f2113045123c0309bef7f8eb74dc1ccda2c17c1369558ab","duration_ms":2000,"index":0},{"byte_len":16,"content_hash":"6897f7480bfd8cc5aaefaf33912d5f643ec6eae4450b593e98cd2ec4de18bcf2","duration_ms":2000,"index":1},{"byte_len":16,"content_hash":"864d8e31f5b44bde825e01963d73d6047b7acfae6ddaa64aa85ff0a47995e176","duration_ms":2000,"index":2},{"byte_len":16,"content_hash":"6897f7480bfd8cc5aaefaf33912d5f643ec6eae4450b593e98cd2ec4de18bcf2","duration_ms":2000,"index":3}],"sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"o","signer_pubkey":"0100000000000000000000000000000000000000000000000000000000000000","stored_bytes":48,"total_bytes":64,"total_segments":4,"unique_segments":3,"version":1,"work_segment_ops":4} \ No newline at end of file diff --git a/wai/pack-conformance/receipts/v1-r-small-order.json b/wai/pack-conformance/receipts/v1-r-small-order.json new file mode 100644 index 000000000..6a1488ddc --- /dev/null +++ b/wai/pack-conformance/receipts/v1-r-small-order.json @@ -0,0 +1 @@ +{"asset_id":"asset","energy_provenance":null,"grant":{"capability":"video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":0,"kind":"package","parent_receipt_hash":"0303030303030303030303030303030303030303030303030303030303030303","receipt_hash":"4b9ebabaf3d1cd22bf5ed0335840913c3162adc6d9315a587523c2654e0d6cdf","segment_map_root":"941704ebc6c0adc3394ebda28f50b3d6cf38cfc0fcd265a861d002365294829f","segments":[{"byte_len":16,"content_hash":"9f63cd63fcfb34a53f2113045123c0309bef7f8eb74dc1ccda2c17c1369558ab","duration_ms":2000,"index":0},{"byte_len":16,"content_hash":"6897f7480bfd8cc5aaefaf33912d5f643ec6eae4450b593e98cd2ec4de18bcf2","duration_ms":2000,"index":1},{"byte_len":16,"content_hash":"864d8e31f5b44bde825e01963d73d6047b7acfae6ddaa64aa85ff0a47995e176","duration_ms":2000,"index":2},{"byte_len":16,"content_hash":"6897f7480bfd8cc5aaefaf33912d5f643ec6eae4450b593e98cd2ec4de18bcf2","duration_ms":2000,"index":3}],"sig":"01000000000000000000000000000000000000000000000000000000000000002722c1477d52c8f68054b88dc39ea3b8958f5a6f15927aea8e8a7a6c3350e00a","signer_id":"o","signer_pubkey":"5866666666666666666666666666666666666666666666666666666666666666","stored_bytes":48,"total_bytes":64,"total_segments":4,"unique_segments":3,"version":1,"work_segment_ops":4} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/expected_hashes.json b/wai/quantum-ops-conformance/expected_hashes.json index aeb775d77..e9f6fc6b3 100644 --- a/wai/quantum-ops-conformance/expected_hashes.json +++ b/wai/quantum-ops-conformance/expected_hashes.json @@ -1,6 +1,6 @@ { "extension": "wai.quantum.calibration + wai.quantum.job", - "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job).", + "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job). A vector with verifies = false carries a signature only a verifier checking the equation alone accepts, which WAI's rule refuses (SPEC §7 Signatures).", "vectors": { "cal_q3": { "kind": "calibration", @@ -18,6 +18,42 @@ "receipt_hash": "08b142d29dadc466b294b89d2127f39ee9fa1aa0b6b5a4c6c5d226baf343b9e3", "verifies": true, "calibration_ref": "cal_q3" + }, + "cal_q3_key_identity": { + "kind": "calibration", + "receipt_hash": "46fc075efa52bb4be4cb877af923be5683d9910af6f7b1fa09232dbefc0b02d9", + "verifies": false, + "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order" + }, + "cal_q3_key_identity_y_not_reduced": { + "kind": "calibration", + "receipt_hash": "df688858fe3a2e0ec1f606848e568021a143129afb04f167bd023e1e3eabd6e6", + "verifies": false, + "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, and the encoding is not canonical" + }, + "cal_q3_r_small_order": { + "kind": "calibration", + "receipt_hash": "f9e8a77f63b194cf723091d1b012ccc31fa337379060c7e36517a325a3412a30", + "verifies": false, + "about": "the base point as the key (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order" + }, + "job_bell_key_identity": { + "kind": "job", + "receipt_hash": "a5d568ad0785f8bb087a99f7ec36eea0d2c3f74bc3b9e70b1d1bad1044e9da32", + "verifies": false, + "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order" + }, + "job_bell_key_identity_y_not_reduced": { + "kind": "job", + "receipt_hash": "ce178c904fe33e68d60c0ddf37ddd715fad5e57ae7d084e424adc7843ff09aa7", + "verifies": false, + "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, and the encoding is not canonical" + }, + "job_bell_r_small_order": { + "kind": "job", + "receipt_hash": "22311b855ac06e33b17da181f9e2c680a92377833e16964dc9152cddabf1b6d9", + "verifies": false, + "about": "the base point as the key (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order" } } } \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json b/wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json new file mode 100644 index 000000000..56cdf9b40 --- /dev/null +++ b/wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json @@ -0,0 +1 @@ +{"kind":"quantum-calibration","config_hash":"5105f919eddea0c18dba494abf6c5ea71654c291517cf5262275f15acfc46e79","device_id":"example:lab3-device","evidence":[{"blob_hash":"226f5f5166a539c2f865b4deb26c3fa110c5a741403abf60def2d50d1b84e6f0","kind":"randomized_benchmarking","summary":"1q RB fidelity 0.9992"}],"grant":{"capability":"quantum.calibrate","funds_ceiling":null,"grant_hash":"65e3c147b00bf7159cf77ea73683a8278a594e879020b8d1acea970d1b65530d","joule_ceiling_micro":5000000},"joules_micro":4200000,"merkle_root":"57f9b6a130abc017a67d5d6ac948e972b4e1ec22d78c4a99d9bca9c3245753ed","parent_receipt_hash":null,"receipt_hash":"46fc075efa52bb4be4cb877af923be5683d9910af6f7b1fa09232dbefc0b02d9","sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"0100000000000000000000000000000000000000000000000000000000000000","target":"q3"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json b/wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json new file mode 100644 index 000000000..899f291a0 --- /dev/null +++ b/wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json @@ -0,0 +1 @@ +{"kind":"quantum-calibration","config_hash":"5105f919eddea0c18dba494abf6c5ea71654c291517cf5262275f15acfc46e79","device_id":"example:lab3-device","evidence":[{"blob_hash":"226f5f5166a539c2f865b4deb26c3fa110c5a741403abf60def2d50d1b84e6f0","kind":"randomized_benchmarking","summary":"1q RB fidelity 0.9992"}],"grant":{"capability":"quantum.calibrate","funds_ceiling":null,"grant_hash":"65e3c147b00bf7159cf77ea73683a8278a594e879020b8d1acea970d1b65530d","joule_ceiling_micro":5000000},"joules_micro":4200000,"merkle_root":"57f9b6a130abc017a67d5d6ac948e972b4e1ec22d78c4a99d9bca9c3245753ed","parent_receipt_hash":null,"receipt_hash":"df688858fe3a2e0ec1f606848e568021a143129afb04f167bd023e1e3eabd6e6","sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f","target":"q3"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json b/wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json new file mode 100644 index 000000000..107e017c0 --- /dev/null +++ b/wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json @@ -0,0 +1 @@ +{"kind":"quantum-calibration","config_hash":"5105f919eddea0c18dba494abf6c5ea71654c291517cf5262275f15acfc46e79","device_id":"example:lab3-device","evidence":[{"blob_hash":"226f5f5166a539c2f865b4deb26c3fa110c5a741403abf60def2d50d1b84e6f0","kind":"randomized_benchmarking","summary":"1q RB fidelity 0.9992"}],"grant":{"capability":"quantum.calibrate","funds_ceiling":null,"grant_hash":"65e3c147b00bf7159cf77ea73683a8278a594e879020b8d1acea970d1b65530d","joule_ceiling_micro":5000000},"joules_micro":4200000,"merkle_root":"57f9b6a130abc017a67d5d6ac948e972b4e1ec22d78c4a99d9bca9c3245753ed","parent_receipt_hash":null,"receipt_hash":"f9e8a77f63b194cf723091d1b012ccc31fa337379060c7e36517a325a3412a30","sig":"0100000000000000000000000000000000000000000000000000000000000000e893492516ba0bb6498eccbb199d76bc9537d06aeea7c3ee6b3231eabf51c70f","signer_id":"did:key:demo-lab","signer_pubkey":"5866666666666666666666666666666666666666666666666666666666666666","target":"q3"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/job_bell_key_identity.json b/wai/quantum-ops-conformance/vectors/job_bell_key_identity.json new file mode 100644 index 000000000..fdfd9a25c --- /dev/null +++ b/wai/quantum-ops-conformance/vectors/job_bell_key_identity.json @@ -0,0 +1 @@ +{"kind":"quantum-job","backend_id":"example:qpu-a","calibration_ref":"7c53298929a1261959315c91f9540781b4bff0669b5f4b783becee93dbf5f8fa","circuit_hash":"3071b48c3078c1fa737e7a94360a0fdcf5242f1f9785315f4ab94e50bb8e3a17","grant":{"capability":"quantum.job.run","funds_ceiling":50,"grant_hash":"883c52e9a16898a65e6ae3b5b896a8a5ff4686d927b411e91c9a2a4c92ee6fc9","joule_ceiling_micro":1000000},"joules_micro":250,"merkle_root":"fb000c7a1aea53bc465c9168082a061ff692ab7375b0a498a1ffb95a974b9fec","parent_receipt_hash":null,"receipt_hash":"a5d568ad0785f8bb087a99f7ec36eea0d2c3f74bc3b9e70b1d1bad1044e9da32","result_hash":"88dcf1cff7a0733a50578ed6e9d3073f21cc8644daa3b48f0d71d87bc1d4592e","shots":1000,"sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"0100000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json b/wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json new file mode 100644 index 000000000..e7ec83a8d --- /dev/null +++ b/wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json @@ -0,0 +1 @@ +{"kind":"quantum-job","backend_id":"example:qpu-a","calibration_ref":"7c53298929a1261959315c91f9540781b4bff0669b5f4b783becee93dbf5f8fa","circuit_hash":"3071b48c3078c1fa737e7a94360a0fdcf5242f1f9785315f4ab94e50bb8e3a17","grant":{"capability":"quantum.job.run","funds_ceiling":50,"grant_hash":"883c52e9a16898a65e6ae3b5b896a8a5ff4686d927b411e91c9a2a4c92ee6fc9","joule_ceiling_micro":1000000},"joules_micro":250,"merkle_root":"fb000c7a1aea53bc465c9168082a061ff692ab7375b0a498a1ffb95a974b9fec","parent_receipt_hash":null,"receipt_hash":"ce178c904fe33e68d60c0ddf37ddd715fad5e57ae7d084e424adc7843ff09aa7","result_hash":"88dcf1cff7a0733a50578ed6e9d3073f21cc8644daa3b48f0d71d87bc1d4592e","shots":1000,"sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json b/wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json new file mode 100644 index 000000000..8f5eaece1 --- /dev/null +++ b/wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json @@ -0,0 +1 @@ +{"kind":"quantum-job","backend_id":"example:qpu-a","calibration_ref":"7c53298929a1261959315c91f9540781b4bff0669b5f4b783becee93dbf5f8fa","circuit_hash":"3071b48c3078c1fa737e7a94360a0fdcf5242f1f9785315f4ab94e50bb8e3a17","grant":{"capability":"quantum.job.run","funds_ceiling":50,"grant_hash":"883c52e9a16898a65e6ae3b5b896a8a5ff4686d927b411e91c9a2a4c92ee6fc9","joule_ceiling_micro":1000000},"joules_micro":250,"merkle_root":"fb000c7a1aea53bc465c9168082a061ff692ab7375b0a498a1ffb95a974b9fec","parent_receipt_hash":null,"receipt_hash":"22311b855ac06e33b17da181f9e2c680a92377833e16964dc9152cddabf1b6d9","result_hash":"88dcf1cff7a0733a50578ed6e9d3073f21cc8644daa3b48f0d71d87bc1d4592e","shots":1000,"sig":"0100000000000000000000000000000000000000000000000000000000000000bc6926ef83a9ad50f3e3fcffba920f36ec88e22dca7212fc2e7195bb43f86109","signer_id":"did:key:demo-lab","signer_pubkey":"5866666666666666666666666666666666666666666666666666666666666666"} \ No newline at end of file diff --git a/wai/signer-conformance/vectors.json b/wai/signer-conformance/vectors.json index 2a86f6814..239c826a0 100644 --- a/wai/signer-conformance/vectors.json +++ b/wai/signer-conformance/vectors.json @@ -2584,6 +2584,42 @@ }, "expected": "not_anchored reason=key_log_signature_invalid" }, + { + "name": "anchor_root_identity_forged", + "about": "the identity as root, with (R, S) = (B, 1): R is of large order, and the equation holds over any revision; the root is of small order", + "procedure": "anchor", + "context": { + "origin": "https://publisher.example", + "log": "{\"kind\":\"wai-key-log\",\"revisions\":[{\"grant_max_s\":43200,\"issued_at\":\"2026-09-01T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"d1\",\"key\":\"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367\",\"may_sign\":[\"delivery\",\"receipt\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"m1\",\"key\":\"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653\",\"may_sign\":[\"energy/OnChipCounter\",\"energy/ModelBased\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"op\",\"key\":\"d62f016a1efd1e4fdf793eb42cd84471e1ba9f0cf04d1287b5cc71f616287cb8\",\"may_sign\":[\"lawful-basis/declaration\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"modd\",\"key\":\"8320a51977d8c38ca8a4927c670df5821e449761945e15e9efb26a1509d230ea\",\"may_sign\":[\"module/decoder\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"37332d293232e6e91c92aaccdd9fe2295a77447c7f4cd9df7e306b602643f5ac\",\"origin\":\"https://publisher.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"0100000000000000000000000000000000000000000000000000000000000000\",\"seq\":0,\"sig\":\"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000\",\"status_max_age_s\":3600}]}", + "path": "fetched", + "acquired_at": "2026-09-25T00:00:00Z" + }, + "expected": "not_anchored reason=key_log_signature_invalid" + }, + { + "name": "anchor_root_identity_y_not_reduced", + "about": "the identity spelled y = p + 1 as root, with (R, S) = (B, 1): a decoder that reduces y reads the identity; the encoding is not canonical", + "procedure": "anchor", + "context": { + "origin": "https://publisher.example", + "log": "{\"kind\":\"wai-key-log\",\"revisions\":[{\"grant_max_s\":43200,\"issued_at\":\"2026-09-01T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"d1\",\"key\":\"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367\",\"may_sign\":[\"delivery\",\"receipt\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"m1\",\"key\":\"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653\",\"may_sign\":[\"energy/OnChipCounter\",\"energy/ModelBased\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"op\",\"key\":\"d62f016a1efd1e4fdf793eb42cd84471e1ba9f0cf04d1287b5cc71f616287cb8\",\"may_sign\":[\"lawful-basis/declaration\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"modd\",\"key\":\"8320a51977d8c38ca8a4927c670df5821e449761945e15e9efb26a1509d230ea\",\"may_sign\":[\"module/decoder\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"37332d293232e6e91c92aaccdd9fe2295a77447c7f4cd9df7e306b602643f5ac\",\"origin\":\"https://publisher.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f\",\"seq\":0,\"sig\":\"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000\",\"status_max_age_s\":3600}]}", + "path": "fetched", + "acquired_at": "2026-09-25T00:00:00Z" + }, + "expected": "not_anchored reason=key_log_signature_invalid" + }, + { + "name": "anchor_root_signature_r_small_order", + "about": "the base point as root (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order", + "procedure": "anchor", + "context": { + "origin": "https://publisher.example", + "log": "{\"kind\":\"wai-key-log\",\"revisions\":[{\"grant_max_s\":43200,\"issued_at\":\"2026-09-01T00:00:00Z\",\"keys\":[{\"compromised_since\":null,\"id\":\"d1\",\"key\":\"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367\",\"may_sign\":[\"delivery\",\"receipt\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"m1\",\"key\":\"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653\",\"may_sign\":[\"energy/OnChipCounter\",\"energy/ModelBased\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"op\",\"key\":\"d62f016a1efd1e4fdf793eb42cd84471e1ba9f0cf04d1287b5cc71f616287cb8\",\"may_sign\":[\"lawful-basis/declaration\"],\"status\":\"active\",\"until\":null},{\"compromised_since\":null,\"id\":\"modd\",\"key\":\"8320a51977d8c38ca8a4927c670df5821e449761945e15e9efb26a1509d230ea\",\"may_sign\":[\"module/decoder\"],\"status\":\"active\",\"until\":null}],\"kind\":\"wai-key-set\",\"next_root\":\"37332d293232e6e91c92aaccdd9fe2295a77447c7f4cd9df7e306b602643f5ac\",\"origin\":\"https://publisher.example\",\"prev\":null,\"revoked_modules\":[],\"root\":\"5866666666666666666666666666666666666666666666666666666666666666\",\"seq\":0,\"sig\":\"010000000000000000000000000000000000000000000000000000000000000025bb71609e49e43d53441bb54c5216aaf56400620aa38a2f8871ebbea267dc07\",\"status_max_age_s\":3600}]}", + "path": "fetched", + "acquired_at": "2026-09-25T00:00:00Z" + }, + "expected": "not_anchored reason=key_log_signature_invalid" + }, { "name": "anchor_stranger_root", "about": "a revision signed by a root the previous one neither uses nor committed to", diff --git a/wai/wai-pack/src/golden.rs b/wai/wai-pack/src/golden.rs index 73d0d8f0e..9a916f8c1 100644 --- a/wai/wai-pack/src/golden.rs +++ b/wai/wai-pack/src/golden.rs @@ -12,7 +12,8 @@ //! - `catalogs/`: indexes no golden package has (refinements on more than one deadline) //! with the MoQ catalog each gives (§8.4). //! - `receipts/`: version-1 package receipts as JSON (packaging §9.3), each with its -//! receipt hash: the legacy receipt whose hash `wai-rs` pins, and an unmetered one. +//! receipt hash: the legacy receipt whose hash `wai-rs` pins, an unmetered one, and +//! three whose signature every verifier refuses (SPEC §7 *Signatures*). //! - `package-mutations/`: packages a verifier must refuse (§11.3), each the golden //! altered and re-sealed with its key where the receipt or `LISTING` alone would catch //! it, held as an overlay on the golden (`/`, the files that differ or are new, @@ -424,6 +425,20 @@ pub fn mutations() -> (Files, Value) { r.sig[0] ^= 1; r.to_json() }, "receipt/index-v.wsix")); + // The rule every WAI signature is held to (SPEC §7 *Signatures*): three signatures over + // the good receipt's payload, under the key each names, that a verifier checking only + // the equation accepts (`wai::sig::forgeries`): a key of small order, the same key + // spelled with y = p + 1, and R of small order. + let forged = wai::sig::forgeries(|k| { + let mut r = ok.clone(); + r.signer_pubkey = *k; + r.signing_payload() + }); + for (f, name) in forged.iter().zip(["key-identity", "key-identity-y-not-reduced", "r-small-order"]) { + let mut r = ok.clone(); + (r.signer_pubkey, r.sig) = (f.key, f.sig); + receipts.push((name, "receipt-signature", r.to_json(), "receipt/index-v.wsix")); + } receipts.push(("unknown-member", "receipt-json", ok.to_json().replacen('{', "{\"extra\":1,", 1), "receipt/index-v.wsix")); // §9.1's reader: a member named twice, -0, a lone surrogate, null in place of a value, // energy_provenance left out, evidence beside no class. @@ -1005,7 +1020,9 @@ pub fn apply_overlay(g: &Files, overlay: &Files, name: &str, remove: &[String]) // ─────────────────────────────── version-1 receipts ─────────────────────────────── /// The version-1 receipts and their JSON: the legacy receipt `wai-rs` pins -/// (`71a9da59…`), and an unmetered one with a parent. +/// (`71a9da59…`), an unmetered one with a parent, and the unmetered one carrying each +/// of the three signatures a verifier checking only the equation accepts +/// (`wai::sig::forgeries`), which every verifier refuses (`"signature": "refused"`). #[allow(deprecated)] // the legacy label is the point of the first pub fn v1_receipts() -> (Files, Value) { use wai::wai_video_package::{segment, PackageReceipt}; @@ -1015,11 +1032,23 @@ pub fn v1_receipts() -> (Files, Value) { let unmetered = PackageReceipt::seal(&SigningKey::from_bytes(&[2; 32]), "o", "asset", segment(&src, 2000), 0, None, g, Some([3; 32])); let mut files = BTreeMap::new(); let mut cases = Vec::new(); - for (name, r) in [("v1-legacy", legacy), ("v1-unmetered", unmetered)] { + let forged = wai::sig::forgeries(|k| { + let mut r = unmetered.clone(); + r.signer_pubkey = *k; + r.signing_payload() + }); + for (name, r) in [("v1-legacy", legacy), ("v1-unmetered", unmetered.clone())] { let path = format!("{name}.json"); files.insert(path.clone(), r.to_json().expect("a JSON form").into_bytes()); cases.push(json!({"file": path, "receipt_hash": hex(&r.receipt_hash())})); } + for (f, name) in forged.iter().zip(["v1-key-identity", "v1-key-identity-y-not-reduced", "v1-r-small-order"]) { + let mut r = unmetered.clone(); + (r.signer_pubkey, r.sig) = (f.key, f.sig); + let path = format!("{name}.json"); + files.insert(path.clone(), r.to_json().expect("a JSON form").into_bytes()); + cases.push(json!({"file": path, "receipt_hash": hex(&r.receipt_hash()), "signature": "refused", "about": f.about})); + } (files, json!({"cases": cases})) } @@ -1141,9 +1170,10 @@ pub fn check(dir: &Path) -> std::io::Result> { } for c in expected["cases"].as_array().into_iter().flatten() { let f = c["file"].as_str().unwrap_or(""); - let ok = held.get(f).and_then(|b| std::str::from_utf8(b).ok()).and_then(|t| wai::wai_video_package::PackageReceipt::from_json(t).ok()).is_some_and(|r| r.verify() && hex(&r.receipt_hash()) == c["receipt_hash"]); + let refused = c["signature"] == "refused"; + let ok = held.get(f).and_then(|b| std::str::from_utf8(b).ok()).and_then(|t| wai::wai_video_package::PackageReceipt::from_json(t).ok()).is_some_and(|r| r.verify() != refused && hex(&r.receipt_hash()) == c["receipt_hash"]); if !ok { - out.push(format!("receipts/{f}: does not read back to a receipt that verifies with its hash")); + out.push(format!("receipts/{f}: does not read back to a receipt that {} with its hash", if refused { "is refused" } else { "verifies" })); } } let mut held = read_tree(&dir.join("catalogs"))?; diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index b95daf6f3..ceefef6a0 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -49,6 +49,11 @@ mod repro; #[cfg(feature = "_signing")] pub use ed25519_dalek; +/// Re-exported for the same reason: the one Ed25519 rule every receipt here is verified +/// by (WAI SPEC §7 *Signatures*), and the forgeries a test or a corpus shows it refuses. +#[cfg(feature = "_signing")] +pub use wai_sig; + #[cfg(feature = "quantum")] pub mod quantum; diff --git a/wai/wai-quantum/src/quantum_ops.rs b/wai/wai-quantum/src/quantum_ops.rs index 8df85fcd5..188df6e2b 100644 --- a/wai/wai-quantum/src/quantum_ops.rs +++ b/wai/wai-quantum/src/quantum_ops.rs @@ -231,7 +231,9 @@ impl CalibrationReceipt { v } - fn signing_payload(&self) -> Vec { + /// The bytes the signature covers: the unlabelled payload, under the key the receipt + /// names. + pub fn signing_payload(&self) -> Vec { self.payload_with(None) } @@ -448,7 +450,9 @@ impl JobReceipt { v } - fn signing_payload(&self) -> Vec { + /// The bytes the signature covers: the unlabelled payload, under the key the receipt + /// names. + pub fn signing_payload(&self) -> Vec { self.payload_with(None) } diff --git a/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs b/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs index 4b0017763..09987ee1f 100644 --- a/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs +++ b/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs @@ -84,6 +84,36 @@ fn job_bell(cal_ref: [u8; 32]) -> JobReceipt { ) } +/// The calibration and the job, each carrying the three signatures a verifier checking +/// only the equation accepts (`wai_sig::forgeries`) and WAI's rule refuses: a key of small +/// order, that key spelled with y = p + 1, and R of small order. Name, kind, JSON, receipt +/// hash and what it is. +fn refused(cal: &CalibrationReceipt, job: &JobReceipt) -> Vec<(String, &'static str, String, String, &'static str)> { + let mut out = Vec::new(); + let suffix = ["key_identity", "key_identity_y_not_reduced", "r_small_order"]; + let forged = wai_quantum::wai_sig::forgeries(|k| { + let mut r = cal.clone(); + r.signer_pubkey = *k; + r.signing_payload() + }); + for (f, s) in forged.iter().zip(suffix) { + let mut r = cal.clone(); + (r.signer_pubkey, r.sig) = (f.key, f.sig); + out.push((format!("cal_q3_{s}"), "calibration", r.to_json(), hex(&r.receipt_hash()), f.about)); + } + let forged = wai_quantum::wai_sig::forgeries(|k| { + let mut r = job.clone(); + r.signer_pubkey = *k; + r.signing_payload() + }); + for (f, s) in forged.iter().zip(suffix) { + let mut r = job.clone(); + (r.signer_pubkey, r.sig) = (f.key, f.sig); + out.push((format!("job_bell_{s}"), "job", r.to_json(), hex(&r.receipt_hash()), f.about)); + } + out +} + fn main() { let a: Vec = std::env::args().collect(); match (a.get(1).map(String::as_str), a.get(2)) { @@ -122,17 +152,22 @@ fn generate(dir: &Path) { ] { std::fs::write(vd.join(format!("{name}.json")), &json).expect("write vector"); } + let mut vectors = serde_json::json!({ + "cal_q3": { "kind": "calibration", "receipt_hash": hex(&cal_q3.receipt_hash()), "verifies": true }, + "cal_cz": { "kind": "calibration", "receipt_hash": hex(&cal_cz.receipt_hash()), + "verifies": true, "parent": "cal_q3" }, + "job_bell": { "kind": "job", "receipt_hash": hex(&job.receipt_hash()), + "verifies": true, "calibration_ref": "cal_q3" }, + }); + for (name, kind, json, hash, about) in refused(&cal_q3, &job) { + std::fs::write(vd.join(format!("{name}.json")), &json).expect("write vector"); + vectors[name.as_str()] = serde_json::json!({ "kind": kind, "receipt_hash": hash, "verifies": false, "about": about }); + } let manifest = serde_json::json!({ "extension": "wai.quantum.calibration + wai.quantum.job", - "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job).", - "vectors": { - "cal_q3": { "kind": "calibration", "receipt_hash": hex(&cal_q3.receipt_hash()), "verifies": true }, - "cal_cz": { "kind": "calibration", "receipt_hash": hex(&cal_cz.receipt_hash()), - "verifies": true, "parent": "cal_q3" }, - "job_bell": { "kind": "job", "receipt_hash": hex(&job.receipt_hash()), - "verifies": true, "calibration_ref": "cal_q3" }, - }, + "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job). A vector with verifies = false carries a signature only a verifier checking the equation alone accepts, which WAI's rule refuses (SPEC §7 Signatures).", + "vectors": vectors, }); std::fs::write( dir.join("expected_hashes.json"), @@ -177,6 +212,24 @@ fn verify(dir: &Path) { && job.verify_against_calibration(&cal_q3) // cross-link to the right state && !job.verify_against_calibration(&cal_cz)); // and NOT a different one + // Every vector marked verifies = false is refused, and is the receipt the manifest + // hashes: only its signature fails. + for (name, v) in manifest["vectors"].as_object().expect("vectors") { + if v["verifies"] != false { + continue; + } + let text = read(&format!("{name}.json")); + let (hash, verifies) = match v["kind"].as_str() { + Some("calibration") => CalibrationReceipt::from_json(&text).map(|r| (hex(&r.receipt_hash()), r.verify())), + Some("job") => JobReceipt::from_json(&text).map(|r| (hex(&r.receipt_hash()), r.verify())), + _ => None, + } + .unwrap_or_else(|| panic!("{name}: an unreadable receipt")); + let pass = !verifies && hash == want(name); + println!("{} {name} refused, receipt_hash {}", if pass { "PASS" } else { "FAIL" }, &hash[..16]); + ok &= pass; + } + if ok { println!("all vectors pass: signature + joule-ceiling + content-addressing + cross-link hold"); } else { diff --git a/wai/wai-rs/src/jwp_delivery/corpus.rs b/wai/wai-rs/src/jwp_delivery/corpus.rs index e3f041c1a..099b08735 100644 --- a/wai/wai-rs/src/jwp_delivery/corpus.rs +++ b/wai/wai-rs/src/jwp_delivery/corpus.rs @@ -282,6 +282,28 @@ fn vectors() -> Vec { json!({ "group": gl_json }), reject(Some(3), Reason::ParentNotHeld)), ]); + // §3.1 step 3's rule is the one every WAI signature is held to (SPEC §7 *Signatures*): + // three signatures over the mixed group's own payload, under the key each names, that a + // verifier checking only the equation accepts. + let forged = crate::sig::forgeries(|k| { + let mut g = gm.clone(); + g.signer_pubkey = *k; + g.signing_payload() + }); + let under = |f: &crate::sig::Forgery| { + let mut g = gm.clone(); + (g.signer_pubkey, g.sig) = (f.key, f.sig); + json!({ "group": g.to_json() }) + }; + out.extend([ + v("group_signer_key_identity_forged", "the identity point as signer_pubkey, with (R, S) = (B, 1): the equation holds over any payload, so a verifier that checks only the equation accepts it; the key is of small order", "group", + under(&forged[0]), reject(Some(1), Reason::BadSignerKey)), + v("group_signer_key_identity_y_not_reduced", "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, under which the equation holds; the encoding is not canonical", "group", + under(&forged[1]), reject(Some(1), Reason::BadSignerKey)), + v("group_signature_r_small_order", "signer_pubkey the base point, whose discrete logarithm is 1, R the identity and S = k: the equation holds, and R is of small order", "group", + under(&forged[2]), reject(Some(2), Reason::SignatureVerifyFailed)), + ]); + // ── objects (§3 steps 4-5) ── let obj = |g: &Value, o: Value, e: u8| json!({ "group": g, "object": o, "envelope": hexs(&envelope(e)) }); let edit = |o: &ObjectReceipt, f: &dyn Fn(&mut Value)| { diff --git a/wai/wai-rs/src/key_log/corpus.rs b/wai/wai-rs/src/key_log/corpus.rs index ca44e4138..0fa51cc5b 100644 --- a/wai/wai-rs/src/key_log/corpus.rs +++ b/wai/wai-rs/src/key_log/corpus.rs @@ -1297,6 +1297,23 @@ fn vectors() -> Vec { None, not_anchored("key_log_signature_invalid"), )); + // The rule every WAI signature is held to (SPEC §7 *Signatures*), one step at a time: + // three signatures over the revision's own signing input, under the root each names, + // that a verifier checking only the equation accepts. + let unsigned_root = unsigned(P, None, "2026-09-01T00:00:00Z", R1, l.r0.keys.clone()); + let with_root = |key: [u8; 32], sig: [u8; 64]| { + let mut r = unsigned_root.clone(); + (r.root, r.sig) = (key, sig); + r + }; + let forged = crate::sig::forgeries(|k| with_root(*k, [0; 64]).signing_input()); + for (f, name, about) in [ + (&forged[0], "anchor_root_identity_forged", "the identity as root, with (R, S) = (B, 1): R is of large order, and the equation holds over any revision; the root is of small order"), + (&forged[1], "anchor_root_identity_y_not_reduced", "the identity spelled y = p + 1 as root, with (R, S) = (B, 1): a decoder that reduces y reads the identity; the encoding is not canonical"), + (&forged[2], "anchor_root_signature_r_small_order", "the base point as root (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order"), + ] { + v.push(anchor_vec(name, about, P, log_json(&[with_root(f.key, f.sig)]), "fetched", None, not_anchored("key_log_signature_invalid"))); + } let st1 = Revision::seal_unchecked(&key(STRANGER), unsigned(P, Some(&l.r0), "2026-09-10T00:00:00Z", R1, l.r1.keys.clone())); v.push(anchor_vec( "anchor_stranger_root", diff --git a/wai/wai-rs/src/wai_video_package.rs b/wai/wai-rs/src/wai_video_package.rs index 8af4006b8..4bca73ade 100644 --- a/wai/wai-rs/src/wai_video_package.rs +++ b/wai/wai-rs/src/wai_video_package.rs @@ -141,7 +141,8 @@ pub struct PackageReceipt { } impl PackageReceipt { - fn signing_payload(&self) -> Vec { + /// The bytes the signature covers, under the key the receipt names. + pub fn signing_payload(&self) -> Vec { let mut o = DOMAIN_RECEIPT.to_vec(); o.extend_from_slice(&(self.asset_id.len() as u32).to_be_bytes()); o.extend_from_slice(self.asset_id.as_bytes()); -- 2.54.0 (Apple Git-157) From 0be54c6987c350724bc30c01175ded7af1960e56 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 14:33:18 -0400 Subject: [PATCH 07/10] Leave wai-quantum on its own verifier; the rule goes back into wai-rs wai-quantum belongs to another line of work that is shipping releases, and moving it was not needed to put every signature check written in wai-rs on one strict verifier. This undoes c37c3e9a and the quantum-ops part of 9b5854aa: wai/wai-sig is gone, wai-rs/src/sig.rs is the rule again (under the `ed25519` feature), and wai-quantum, its Cargo.toml and lock, every other workspace's lock, the quantum-ops corpus and wai_quantum_ops_vectors are as main has them. wai-quantum's lenient checks are reported to its owner instead. The JWP, signer and packaging weak-key cases stay; they verify through wai::sig. The two HTTP falsify patches are cut against wai-rs/src/sig.rs again, and wai-http and wai-pack fire on claim-conformance/_ed25519.py, which their Python verifiers read. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 10 - .github/workflows/wai-energy-receipts.yml | 2 - .github/workflows/wai-http.yml | 4 +- .github/workflows/wai-pack.yml | 2 - .github/workflows/wai-web-wasm.yml | 2 - .../falsify/sig-key-lenient.patch | 10 +- .../falsify/sig-verify-lenient.patch | 10 +- .../expected_hashes.json | 38 +- .../vectors/cal_q3_key_identity.json | 1 - .../cal_q3_key_identity_y_not_reduced.json | 1 - .../vectors/cal_q3_r_small_order.json | 1 - .../vectors/job_bell_key_identity.json | 1 - .../job_bell_key_identity_y_not_reduced.json | 1 - .../vectors/job_bell_r_small_order.json | 1 - wai/wai-pack/Cargo.lock | 12 +- wai/wai-quantum-serve/Cargo.lock | 9 - wai/wai-quantum/Cargo.lock | 9 - wai/wai-quantum/Cargo.toml | 5 +- wai/wai-quantum/src/lib.rs | 5 - wai/wai-quantum/src/quantum_energy.rs | 15 +- wai/wai-quantum/src/quantum_ops.rs | 66 ++-- wai/wai-quantum/src/quantum_phasor_meter.rs | 21 +- wai/wai-quantum/src/quantum_qbom.rs | 20 +- wai/wai-quantum/src/quantum_receipt.rs | 19 +- .../src/quantum_resource_receipt.rs | 16 +- wai/wai-quantum/src/quantum_spd_receipt.rs | 16 +- wai/wai-relay/Cargo.lock | 9 - wai/wai-rs/Cargo.lock | 10 - wai/wai-rs/Cargo.toml | 11 +- wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs | 69 +--- wai/wai-rs/src/lib.rs | 8 +- wai/{wai-sig/src/lib.rs => wai-rs/src/sig.rs} | 11 +- wai/wai-sig/Cargo.lock | 330 ------------------ wai/wai-sig/Cargo.toml | 13 - wai/wai-spz-gen/Cargo.lock | 9 - wai/wai-web/Cargo.lock | 10 - 36 files changed, 107 insertions(+), 670 deletions(-) delete mode 100644 wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json delete mode 100644 wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json delete mode 100644 wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json delete mode 100644 wai/quantum-ops-conformance/vectors/job_bell_key_identity.json delete mode 100644 wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json delete mode 100644 wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json rename wai/{wai-sig/src/lib.rs => wai-rs/src/sig.rs} (97%) delete mode 100644 wai/wai-sig/Cargo.lock delete mode 100644 wai/wai-sig/Cargo.toml diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 1ed4e024c..feafd20cc 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -39,8 +39,6 @@ on: # wai-rs path-depends on wai-quantum, and the quantum corpora below are # verified through it: a change there changes what this workflow checks. - 'wai/wai-quantum/**' - # Every signature wai-rs and wai-quantum verify goes through wai-sig. - - 'wai/wai-sig/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' # The registry export and the two library tests that read files outside @@ -72,8 +70,6 @@ on: # wai-rs path-depends on wai-quantum, and the quantum corpora below are # verified through it: a change there changes what this workflow checks. - 'wai/wai-quantum/**' - # Every signature wai-rs and wai-quantum verify goes through wai-sig. - - 'wai/wai-sig/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' # The registry export and the two library tests that read files outside @@ -169,12 +165,6 @@ jobs: - name: Library unit tests (the fixed-point determinism floors) working-directory: wai/wai-rs run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery,staged,video_int,audio_int,splat_int,stage_measure,telemetry,video_package,http - # WAI's one Ed25519 rule (SPEC §7 *Signatures*): its own tests — each step refused - # alone, every encoding of a key the library reads that the rule refuses, and the - # three forgeries every signing site's test sets into its receipt. - - name: wai-sig (the strict Ed25519 rule) - working-directory: wai/wai-sig - run: cargo test --locked # The JPEG AI exact decoder reproduces across platforms only if no platform math # library is on its path. Two guards, each failing the step: clippy's # disallowed-methods (wai-rs/clippy.toml), which each module on the decode path diff --git a/.github/workflows/wai-energy-receipts.yml b/.github/workflows/wai-energy-receipts.yml index 575dd5d9f..ed4e474ab 100644 --- a/.github/workflows/wai-energy-receipts.yml +++ b/.github/workflows/wai-energy-receipts.yml @@ -91,7 +91,6 @@ on: - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - 'wai/wai-quantum-serve/**' - - 'wai/wai-sig/**' - 'wai/extensions/**' - 'wai/SPEC.md' - 'wai/CHANGELOG.md' @@ -103,7 +102,6 @@ on: - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - 'wai/wai-quantum-serve/**' - - 'wai/wai-sig/**' - 'wai/extensions/**' - 'wai/SPEC.md' - 'wai/CHANGELOG.md' diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index 6c12592ab..654c7a96e 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -49,7 +49,7 @@ on: - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' # Playlist receipts verify Ed25519 through the shared strict rule, in Rust and Python. - - 'wai/wai-sig/**' + - 'wai/wai-rs/src/sig.rs' - 'wai/claim-conformance/_ed25519.py' - 'wai/wai-rs/Cargo.toml' - 'wai/http-conformance/**' @@ -77,7 +77,7 @@ on: - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' # Playlist receipts verify Ed25519 through the shared strict rule, in Rust and Python. - - 'wai/wai-sig/**' + - 'wai/wai-rs/src/sig.rs' - 'wai/claim-conformance/_ed25519.py' - 'wai/wai-rs/Cargo.toml' - 'wai/http-conformance/**' diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index a3b115a64..2fc67964a 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -31,7 +31,6 @@ on: - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' - 'wai/claim-conformance/_ed25519.py' - - 'wai/wai-sig/**' - 'wai/int-payload-conformance/int_payload_verify.py' - 'wai/registry/capabilities.json' - '.github/workflows/wai-pack.yml' @@ -43,7 +42,6 @@ on: - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' - 'wai/claim-conformance/_ed25519.py' - - 'wai/wai-sig/**' - 'wai/int-payload-conformance/int_payload_verify.py' - 'wai/registry/capabilities.json' - '.github/workflows/wai-pack.yml' diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 1e68de2bb..e751c2afa 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -50,7 +50,6 @@ on: - 'wai/wai-web/**' - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - - 'wai/wai-sig/**' - 'wai/neural-conformance/**' - 'wai/staged-conformance/**' - 'wai/registry/**' @@ -60,7 +59,6 @@ on: - 'wai/wai-web/**' - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - - 'wai/wai-sig/**' - 'wai/neural-conformance/**' - 'wai/staged-conformance/**' - 'wai/registry/**' diff --git a/wai/http-conformance/falsify/sig-key-lenient.patch b/wai/http-conformance/falsify/sig-key-lenient.patch index 3ad7289a2..8ff9d29f4 100644 --- a/wai/http-conformance/falsify/sig-key-lenient.patch +++ b/wai/http-conformance/falsify/sig-key-lenient.patch @@ -1,9 +1,9 @@ # must fail: playlist_receipt_sig_key_identity playlist_receipt_sig_key_identity_long playlist_receipt_sig_r_small_order -# keys read as the library reads them, and the equation checked leniently (wai_sig) -diff --git a/wai/wai-sig/src/lib.rs b/wai/wai-sig/src/lib.rs -index ee52992c..4fa00223 100644 ---- a/wai/wai-sig/src/lib.rs -+++ b/wai/wai-sig/src/lib.rs +# keys read as the library reads them, and the equation checked leniently (wai::sig) +diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-rs/src/sig.rs +index 48a10219..7a770fae 100644 +--- a/wai/wai-rs/src/sig.rs ++++ b/wai/wai-rs/src/sig.rs @@ -64,9 +64,6 @@ fn below_l(s: &[u8; 32]) -> bool { /// large order. pub fn strict_key(bytes: &[u8; 32]) -> Result { diff --git a/wai/http-conformance/falsify/sig-verify-lenient.patch b/wai/http-conformance/falsify/sig-verify-lenient.patch index 53bbebcf3..c9c008488 100644 --- a/wai/http-conformance/falsify/sig-verify-lenient.patch +++ b/wai/http-conformance/falsify/sig-verify-lenient.patch @@ -1,9 +1,9 @@ # must fail: playlist_receipt_sig_r_small_order -# R of small order accepted (wai_sig::verify_strict checks the equation leniently) -diff --git a/wai/wai-sig/src/lib.rs b/wai/wai-sig/src/lib.rs -index ee52992c..f555da6a 100644 ---- a/wai/wai-sig/src/lib.rs -+++ b/wai/wai-sig/src/lib.rs +# R of small order accepted (wai::sig::verify_strict checks the equation leniently) +diff --git a/wai/wai-rs/src/sig.rs b/wai/wai-rs/src/sig.rs +index 48a10219..dd32f7fc 100644 +--- a/wai/wai-rs/src/sig.rs ++++ b/wai/wai-rs/src/sig.rs @@ -77,7 +77,7 @@ pub fn verify_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<() if !below_l(s) { return Err(SigError::BadSignature); diff --git a/wai/quantum-ops-conformance/expected_hashes.json b/wai/quantum-ops-conformance/expected_hashes.json index e9f6fc6b3..aeb775d77 100644 --- a/wai/quantum-ops-conformance/expected_hashes.json +++ b/wai/quantum-ops-conformance/expected_hashes.json @@ -1,6 +1,6 @@ { "extension": "wai.quantum.calibration + wai.quantum.job", - "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job). A vector with verifies = false carries a signature only a verifier checking the equation alone accepts, which WAI's rule refuses (SPEC §7 Signatures).", + "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job).", "vectors": { "cal_q3": { "kind": "calibration", @@ -18,42 +18,6 @@ "receipt_hash": "08b142d29dadc466b294b89d2127f39ee9fa1aa0b6b5a4c6c5d226baf343b9e3", "verifies": true, "calibration_ref": "cal_q3" - }, - "cal_q3_key_identity": { - "kind": "calibration", - "receipt_hash": "46fc075efa52bb4be4cb877af923be5683d9910af6f7b1fa09232dbefc0b02d9", - "verifies": false, - "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order" - }, - "cal_q3_key_identity_y_not_reduced": { - "kind": "calibration", - "receipt_hash": "df688858fe3a2e0ec1f606848e568021a143129afb04f167bd023e1e3eabd6e6", - "verifies": false, - "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, and the encoding is not canonical" - }, - "cal_q3_r_small_order": { - "kind": "calibration", - "receipt_hash": "f9e8a77f63b194cf723091d1b012ccc31fa337379060c7e36517a325a3412a30", - "verifies": false, - "about": "the base point as the key (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order" - }, - "job_bell_key_identity": { - "kind": "job", - "receipt_hash": "a5d568ad0785f8bb087a99f7ec36eea0d2c3f74bc3b9e70b1d1bad1044e9da32", - "verifies": false, - "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order" - }, - "job_bell_key_identity_y_not_reduced": { - "kind": "job", - "receipt_hash": "ce178c904fe33e68d60c0ddf37ddd715fad5e57ae7d084e424adc7843ff09aa7", - "verifies": false, - "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, and the encoding is not canonical" - }, - "job_bell_r_small_order": { - "kind": "job", - "receipt_hash": "22311b855ac06e33b17da181f9e2c680a92377833e16964dc9152cddabf1b6d9", - "verifies": false, - "about": "the base point as the key (its discrete logarithm is 1), R the identity and S = k: the equation holds, and R is of small order" } } } \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json b/wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json deleted file mode 100644 index 56cdf9b40..000000000 --- a/wai/quantum-ops-conformance/vectors/cal_q3_key_identity.json +++ /dev/null @@ -1 +0,0 @@ -{"kind":"quantum-calibration","config_hash":"5105f919eddea0c18dba494abf6c5ea71654c291517cf5262275f15acfc46e79","device_id":"example:lab3-device","evidence":[{"blob_hash":"226f5f5166a539c2f865b4deb26c3fa110c5a741403abf60def2d50d1b84e6f0","kind":"randomized_benchmarking","summary":"1q RB fidelity 0.9992"}],"grant":{"capability":"quantum.calibrate","funds_ceiling":null,"grant_hash":"65e3c147b00bf7159cf77ea73683a8278a594e879020b8d1acea970d1b65530d","joule_ceiling_micro":5000000},"joules_micro":4200000,"merkle_root":"57f9b6a130abc017a67d5d6ac948e972b4e1ec22d78c4a99d9bca9c3245753ed","parent_receipt_hash":null,"receipt_hash":"46fc075efa52bb4be4cb877af923be5683d9910af6f7b1fa09232dbefc0b02d9","sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"0100000000000000000000000000000000000000000000000000000000000000","target":"q3"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json b/wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json deleted file mode 100644 index 899f291a0..000000000 --- a/wai/quantum-ops-conformance/vectors/cal_q3_key_identity_y_not_reduced.json +++ /dev/null @@ -1 +0,0 @@ -{"kind":"quantum-calibration","config_hash":"5105f919eddea0c18dba494abf6c5ea71654c291517cf5262275f15acfc46e79","device_id":"example:lab3-device","evidence":[{"blob_hash":"226f5f5166a539c2f865b4deb26c3fa110c5a741403abf60def2d50d1b84e6f0","kind":"randomized_benchmarking","summary":"1q RB fidelity 0.9992"}],"grant":{"capability":"quantum.calibrate","funds_ceiling":null,"grant_hash":"65e3c147b00bf7159cf77ea73683a8278a594e879020b8d1acea970d1b65530d","joule_ceiling_micro":5000000},"joules_micro":4200000,"merkle_root":"57f9b6a130abc017a67d5d6ac948e972b4e1ec22d78c4a99d9bca9c3245753ed","parent_receipt_hash":null,"receipt_hash":"df688858fe3a2e0ec1f606848e568021a143129afb04f167bd023e1e3eabd6e6","sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f","target":"q3"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json b/wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json deleted file mode 100644 index 107e017c0..000000000 --- a/wai/quantum-ops-conformance/vectors/cal_q3_r_small_order.json +++ /dev/null @@ -1 +0,0 @@ -{"kind":"quantum-calibration","config_hash":"5105f919eddea0c18dba494abf6c5ea71654c291517cf5262275f15acfc46e79","device_id":"example:lab3-device","evidence":[{"blob_hash":"226f5f5166a539c2f865b4deb26c3fa110c5a741403abf60def2d50d1b84e6f0","kind":"randomized_benchmarking","summary":"1q RB fidelity 0.9992"}],"grant":{"capability":"quantum.calibrate","funds_ceiling":null,"grant_hash":"65e3c147b00bf7159cf77ea73683a8278a594e879020b8d1acea970d1b65530d","joule_ceiling_micro":5000000},"joules_micro":4200000,"merkle_root":"57f9b6a130abc017a67d5d6ac948e972b4e1ec22d78c4a99d9bca9c3245753ed","parent_receipt_hash":null,"receipt_hash":"f9e8a77f63b194cf723091d1b012ccc31fa337379060c7e36517a325a3412a30","sig":"0100000000000000000000000000000000000000000000000000000000000000e893492516ba0bb6498eccbb199d76bc9537d06aeea7c3ee6b3231eabf51c70f","signer_id":"did:key:demo-lab","signer_pubkey":"5866666666666666666666666666666666666666666666666666666666666666","target":"q3"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/job_bell_key_identity.json b/wai/quantum-ops-conformance/vectors/job_bell_key_identity.json deleted file mode 100644 index fdfd9a25c..000000000 --- a/wai/quantum-ops-conformance/vectors/job_bell_key_identity.json +++ /dev/null @@ -1 +0,0 @@ -{"kind":"quantum-job","backend_id":"example:qpu-a","calibration_ref":"7c53298929a1261959315c91f9540781b4bff0669b5f4b783becee93dbf5f8fa","circuit_hash":"3071b48c3078c1fa737e7a94360a0fdcf5242f1f9785315f4ab94e50bb8e3a17","grant":{"capability":"quantum.job.run","funds_ceiling":50,"grant_hash":"883c52e9a16898a65e6ae3b5b896a8a5ff4686d927b411e91c9a2a4c92ee6fc9","joule_ceiling_micro":1000000},"joules_micro":250,"merkle_root":"fb000c7a1aea53bc465c9168082a061ff692ab7375b0a498a1ffb95a974b9fec","parent_receipt_hash":null,"receipt_hash":"a5d568ad0785f8bb087a99f7ec36eea0d2c3f74bc3b9e70b1d1bad1044e9da32","result_hash":"88dcf1cff7a0733a50578ed6e9d3073f21cc8644daa3b48f0d71d87bc1d4592e","shots":1000,"sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"0100000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json b/wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json deleted file mode 100644 index e7ec83a8d..000000000 --- a/wai/quantum-ops-conformance/vectors/job_bell_key_identity_y_not_reduced.json +++ /dev/null @@ -1 +0,0 @@ -{"kind":"quantum-job","backend_id":"example:qpu-a","calibration_ref":"7c53298929a1261959315c91f9540781b4bff0669b5f4b783becee93dbf5f8fa","circuit_hash":"3071b48c3078c1fa737e7a94360a0fdcf5242f1f9785315f4ab94e50bb8e3a17","grant":{"capability":"quantum.job.run","funds_ceiling":50,"grant_hash":"883c52e9a16898a65e6ae3b5b896a8a5ff4686d927b411e91c9a2a4c92ee6fc9","joule_ceiling_micro":1000000},"joules_micro":250,"merkle_root":"fb000c7a1aea53bc465c9168082a061ff692ab7375b0a498a1ffb95a974b9fec","parent_receipt_hash":null,"receipt_hash":"ce178c904fe33e68d60c0ddf37ddd715fad5e57ae7d084e424adc7843ff09aa7","result_hash":"88dcf1cff7a0733a50578ed6e9d3073f21cc8644daa3b48f0d71d87bc1d4592e","shots":1000,"sig":"58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000","signer_id":"did:key:demo-lab","signer_pubkey":"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"} \ No newline at end of file diff --git a/wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json b/wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json deleted file mode 100644 index 8f5eaece1..000000000 --- a/wai/quantum-ops-conformance/vectors/job_bell_r_small_order.json +++ /dev/null @@ -1 +0,0 @@ -{"kind":"quantum-job","backend_id":"example:qpu-a","calibration_ref":"7c53298929a1261959315c91f9540781b4bff0669b5f4b783becee93dbf5f8fa","circuit_hash":"3071b48c3078c1fa737e7a94360a0fdcf5242f1f9785315f4ab94e50bb8e3a17","grant":{"capability":"quantum.job.run","funds_ceiling":50,"grant_hash":"883c52e9a16898a65e6ae3b5b896a8a5ff4686d927b411e91c9a2a4c92ee6fc9","joule_ceiling_micro":1000000},"joules_micro":250,"merkle_root":"fb000c7a1aea53bc465c9168082a061ff692ab7375b0a498a1ffb95a974b9fec","parent_receipt_hash":null,"receipt_hash":"22311b855ac06e33b17da181f9e2c680a92377833e16964dc9152cddabf1b6d9","result_hash":"88dcf1cff7a0733a50578ed6e9d3073f21cc8644daa3b48f0d71d87bc1d4592e","shots":1000,"sig":"0100000000000000000000000000000000000000000000000000000000000000bc6926ef83a9ad50f3e3fcffba920f36ec88e22dca7212fc2e7195bb43f86109","signer_id":"did:key:demo-lab","signer_pubkey":"5866666666666666666666666666666666666666666666666666666666666666"} \ No newline at end of file diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index ce33fd859..e0160c91d 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -666,7 +666,6 @@ dependencies = [ "serde_json", "sha2", "wai-quantum", - "wai-sig", ] [[package]] @@ -683,20 +682,11 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.37" +version = "0.3.36" dependencies = [ "blake3", "ed25519-dalek", "serde_json", - "wai-sig", -] - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", ] [[package]] diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 8bc5b766c..12d6a3727 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -594,7 +594,6 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", - "wai-sig", ] [[package]] @@ -606,14 +605,6 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 41accf53c..b24857177 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -420,15 +420,6 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", - "wai-sig", -] - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", ] [[package]] diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index fdb2d9b43..9a09ec8ed 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -25,16 +25,13 @@ blake3 = "1" serde_json = { version = "1", features = ["preserve_order"] } # Receipt signatures. Optional — only the sealing features need it. ed25519-dalek = { version = "2", optional = true } -# WAI's one Ed25519 rule (SPEC §7 *Signatures*), which every receipt's signature is -# verified by; the parent `wai` crate calls the same one. -wai-sig = { version = "0.1.0", path = "../wai-sig", optional = true } [features] default = ["quantum", "quantum_toolchain", "quantum_receipt"] # Internal: signature support, pulled in by every sealing feature. # (Mirrors the `world` feature in the parent `wai` crate.) -_signing = ["dep:ed25519-dalek", "dep:wai-sig"] +_signing = ["dep:ed25519-dalek"] # --- core simulation ------------------------------------------------------- quantum = [] diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index ceefef6a0..b95daf6f3 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -49,11 +49,6 @@ mod repro; #[cfg(feature = "_signing")] pub use ed25519_dalek; -/// Re-exported for the same reason: the one Ed25519 rule every receipt here is verified -/// by (WAI SPEC §7 *Signatures*), and the forgeries a test or a corpus shows it refuses. -#[cfg(feature = "_signing")] -pub use wai_sig; - #[cfg(feature = "quantum")] pub mod quantum; diff --git a/wai/wai-quantum/src/quantum_energy.rs b/wai/wai-quantum/src/quantum_energy.rs index 135cee88d..9f9a8311e 100644 --- a/wai/wai-quantum/src/quantum_energy.rs +++ b/wai/wai-quantum/src/quantum_energy.rs @@ -50,7 +50,7 @@ //! receipts never carried it, and a new label must say which hardware evidence //! it has. -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde_json::{json, Map, Value}; /// A declared uncertainty: the standard relative uncertainty (k = 1) of the @@ -527,10 +527,12 @@ impl Labelled { if check_label(self.receipt.figure(), &self.energy_class).is_err() || !self.receipt.body_ok() { return false; } - wai_sig::verify_strict( - &self.receipt.signer_key(), + let Ok(k) = VerifyingKey::from_bytes(&self.receipt.signer_key()) else { + return false; + }; + k.verify( &self.receipt.labelled_payload(Some(&self.energy_class)), - &self.receipt.signature(), + &Signature::from_bytes(&self.receipt.signature()), ) .is_ok() } @@ -730,7 +732,10 @@ impl LabelledQbom { if self.check().is_err() { return false; } - wai_sig::verify_strict(&self.qbom.signer_pubkey, &self.qbom.labelled_signing_payload(&self.classes), &self.qbom.sig) + let Ok(k) = VerifyingKey::from_bytes(&self.qbom.signer_pubkey) else { + return false; + }; + k.verify(&self.qbom.labelled_signing_payload(&self.classes), &Signature::from_bytes(&self.qbom.sig)) .is_ok() } diff --git a/wai/wai-quantum/src/quantum_ops.rs b/wai/wai-quantum/src/quantum_ops.rs index 188df6e2b..e9019b4f8 100644 --- a/wai/wai-quantum/src/quantum_ops.rs +++ b/wai/wai-quantum/src/quantum_ops.rs @@ -27,7 +27,7 @@ //! Ed25519, reused. use crate::merkle::merkle_root; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; /// A signed attestation that a calibration run happened, with what result and /// energy cost, under what authorization. @@ -231,9 +231,7 @@ impl CalibrationReceipt { v } - /// The bytes the signature covers: the unlabelled payload, under the key the receipt - /// names. - pub fn signing_payload(&self) -> Vec { + fn signing_payload(&self) -> Vec { self.payload_with(None) } @@ -320,7 +318,11 @@ impl CalibrationReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// Confirm a fidelity claim binds to real bytes: recompute `content_hash` of @@ -450,9 +452,7 @@ impl JobReceipt { v } - /// The bytes the signature covers: the unlabelled payload, under the key the receipt - /// names. - pub fn signing_payload(&self) -> Vec { + fn signing_payload(&self) -> Vec { self.payload_with(None) } @@ -542,7 +542,11 @@ impl JobReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// The cross-link check: this job claims it ran under `cal`'s device state. @@ -807,7 +811,11 @@ impl MitigationReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// Confirm the raw measured data binds to this receipt's `input_hash` — what @@ -1048,7 +1056,11 @@ impl DecodeReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// Confirm the correction bytes bind to this receipt's `correction_hash`. @@ -1265,7 +1277,11 @@ impl CompileReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } pub fn source_matches(&self, bytes: &[u8]) -> bool { @@ -1470,7 +1486,11 @@ impl RearrangeReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } pub fn plan_matches(&self, bytes: &[u8]) -> bool { @@ -1892,26 +1912,6 @@ mod tests { r.success = false; // flip the verdict assert!(!r.verify(), "tampering with the success verdict must break the receipt"); } - - /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small - /// order, a non-canonical spelling of one, and an R of small order are each refused, - /// though a lenient check accepts each. - #[test] - fn signatures_are_verified_strictly() { - macro_rules! strict { - ($r:expr) => { - wai_sig::assert_refuses_forgeries(&$r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()) - }; - } - let g = GrantRef::unbounded; - let cal = CalibrationReceipt::seal(&key(1), "did:key:lab", "example:lab3-device", "q3", content_hash(b"cfg"), evid(), 4_200, g("quantum.calibrate"), None); - strict!(cal); - strict!(JobReceipt::seal(&key(4), "lab", "example:qpu-a", content_hash(b""), content_hash(b""), Some(cal.receipt_hash()), 1000, 250, g("quantum.job.run"), None)); - strict!(MitigationReceipt::seal(&key(1), "did:key:lab", "wai.quantum.circuit", "zne.richardson", "ZZ", content_hash(b""), None, 744_178, 912_680, 20_971, 48_000, 900_000, g("quantum.mitigate"), None)); - strict!(DecodeReceipt::seal(&key(1), "did:key:lab", "bb:[[72,12]]", "relay-bp.min-sum", content_hash(b"h"), content_hash(b"s"), content_hash(b"c"), true, 3, 46_656, 900_000, g("quantum.decode"), None)); - strict!(CompileReceipt::seal(&key(1), "did:key:lab", "clifford-tableau", content_hash(b"src"), content_hash(b"tgt"), content_hash(b"cpl"), true, 3, 4096, 300_000, g("quantum.compile"), None)); - strict!(RearrangeReceipt::seal(&key(1), "did:key:lab", "hungarian-lsap", content_hash(b"i"), content_hash(b"t"), content_hash(b"p"), true, 41, 318, 450_000, g("quantum.rearrange"), None)); - } } crate::quantum_energy::labellable!(CalibrationReceipt, DOMAIN_CAL_ID); diff --git a/wai/wai-quantum/src/quantum_phasor_meter.rs b/wai/wai-quantum/src/quantum_phasor_meter.rs index 1403e2d73..90006f197 100644 --- a/wai/wai-quantum/src/quantum_phasor_meter.rs +++ b/wai/wai-quantum/src/quantum_phasor_meter.rs @@ -32,7 +32,7 @@ //! every other receipt here: the worlds Merkle + Ed25519, reused. use crate::merkle::merkle_root; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:phasor-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:phasor-receipt-id\x01"; @@ -228,7 +228,11 @@ impl PhasorReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// Confirm a receipt vouches for *this* output: the result reproduces the @@ -412,19 +416,6 @@ mod tests { assert!(child.verify()); assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } - - /// The signature, and a labelled receipt's, is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small - /// order, a non-canonical spelling of one, and an R of small order are each refused, - /// though a lenient check accepts each. - #[test] - fn signatures_are_verified_strictly() { - use crate::quantum_energy::{EnergyClass, Labellable, Labelled}; - let w = PhasorWork { n_features: 200, input_dim: 2, n_samples: 300 }; - let r = PhasorReceipt::seal(&key(1), "did:key:test", "wai.quantum.kernel", computation_id("wai.quantum.kernel", &[4.0, 7.0]), result_fingerprint(&[0.1, -0.2, 0.3]), w, 117_000, None); - wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); - let l = Labelled::seal(r, EnergyClass::ModelBased, &key(1)).unwrap(); - wai_sig::assert_refuses_forgeries(&l, |l, k, s| (l.receipt.signer_pubkey, l.receipt.sig) = (k, s), |l| l.receipt.labelled_payload(Some(&l.energy_class)), |l| l.verify()); - } } crate::quantum_energy::labellable!(PhasorReceipt, DOMAIN_RECEIPT_ID); diff --git a/wai/wai-quantum/src/quantum_qbom.rs b/wai/wai-quantum/src/quantum_qbom.rs index 06c8948e2..01ae51aac 100644 --- a/wai/wai-quantum/src/quantum_qbom.rs +++ b/wai/wai-quantum/src/quantum_qbom.rs @@ -29,7 +29,7 @@ //! carries a single verifiable statement of everything it was made of. use crate::quantum_ops::content_hash; -use ed25519_dalek::SigningKey; +use ed25519_dalek::{Signature, SigningKey, Verifier, VerifyingKey}; const DOMAIN_QBOM: &[u8] = b"wai:quantum-qbom\x01"; pub(crate) const DOMAIN_QBOM_ID: &[u8] = b"wai:quantum-qbom-id\x01"; @@ -215,7 +215,11 @@ impl Qbom { /// The manifest's own signature is valid. pub fn verify(&self) -> bool { - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// Given the actual stage receipts as `(receipt_hash, receipt_verifies)`, does @@ -395,16 +399,4 @@ mod tests { assert_eq!(back, q); assert!(back.verify()); } - - /// The bill's signature, and a labelled bill's, is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small - /// order, a non-canonical spelling of one, and an R of small order are each refused, - /// though a lenient check accepts each. - #[test] - fn signatures_are_verified_strictly() { - let (q, _) = sample_qbom(); - wai_sig::assert_refuses_forgeries(&q, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |q| q.signing_payload(), |q| q.verify()); - let classes = vec![Some(crate::quantum_energy::EnergyClass::ModelBased), None]; - let l = crate::quantum_energy::LabelledQbom::seal(q, classes, &key(9)).unwrap(); - wai_sig::assert_refuses_forgeries(&l, |l, k, s| (l.qbom.signer_pubkey, l.qbom.sig) = (k, s), |l| l.qbom.labelled_signing_payload(&l.classes), |l| l.verify()); - } } diff --git a/wai/wai-quantum/src/quantum_receipt.rs b/wai/wai-quantum/src/quantum_receipt.rs index a2a56e7a2..3efbafe74 100644 --- a/wai/wai-quantum/src/quantum_receipt.rs +++ b/wai/wai-quantum/src/quantum_receipt.rs @@ -34,7 +34,7 @@ use crate::quantum::{circuit_hash, Circuit, Measure, QuantumError}; use crate::merkle::merkle_root; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:quantum-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:quantum-receipt-id\x01"; @@ -219,7 +219,11 @@ impl QuantumReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)) + .is_ok() } /// The strong, portable check: independently re-simulate `circuit` and @@ -419,17 +423,6 @@ mod tests { assert!(child.verify()); assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } - - /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small - /// order, a non-canonical spelling of one, and an R of small order are each refused, - /// though a lenient check accepts each. - #[test] - fn signatures_are_verified_strictly() { - let mut c = Circuit::new(3); - c.h(0).cx(0, 1).cx(1, 2); - let r = QuantumReceipt::seal(&key(1), "did:key:test", &c, None, 117_000, None).unwrap(); - wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify()); - } } crate::quantum_energy::labellable!(QuantumReceipt, DOMAIN_RECEIPT_ID); diff --git a/wai/wai-quantum/src/quantum_resource_receipt.rs b/wai/wai-quantum/src/quantum_resource_receipt.rs index cfbff0c64..aa67682b4 100644 --- a/wai/wai-quantum/src/quantum_resource_receipt.rs +++ b/wai/wai-quantum/src/quantum_resource_receipt.rs @@ -28,7 +28,7 @@ use crate::quantum_resource::{ estimate_best, estimate_best_isa, estimate, estimate_isa, Budget, Estimate, EstimateConfig, EstimateError, Factory, InstructionSet, IsaCounts, LogicalCounts, PhysicalQubit, QecModel, Round, SearchLimits, UnitKind, }; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde_json::{json, Value}; const DOMAIN_RECEIPT: &[u8] = b"wai:resource-receipt\x01"; @@ -294,7 +294,10 @@ impl ResourceReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.payload_with(None), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.payload_with(None), &Signature::from_bytes(&self.sig)).is_ok() } /// The re-run half of [`Self::verify_computation`]. @@ -578,13 +581,4 @@ mod tests { assert!(labelled.verify()); assert!(!labelled.receipt.verify(), "the inner signature now covers the label"); } - - /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small - /// order, a non-canonical spelling of one, and an R of small order are each refused, - /// though a lenient check accepts each. - #[test] - fn signatures_are_verified_strictly() { - let r = ResourceReceipt::seal(&key(1), "sink-a", &ResourceJob::default(), None, unmetered).unwrap(); - wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.payload_with(None), |r| r.verify()); - } } diff --git a/wai/wai-quantum/src/quantum_spd_receipt.rs b/wai/wai-quantum/src/quantum_spd_receipt.rs index 7586f6fe5..a8c1064c3 100644 --- a/wai/wai-quantum/src/quantum_spd_receipt.rs +++ b/wai/wai-quantum/src/quantum_spd_receipt.rs @@ -22,7 +22,7 @@ use crate::merkle::merkle_root; use crate::quantum_spd::{Op, Pauli, PauliString, RotCircuit, SpdConfig, SpdError, SpdResult, expectation}; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:spd-receipt\x01"; const DOMAIN_RECEIPT_ID: &[u8] = b"wai:spd-receipt-id\x01"; @@ -237,7 +237,10 @@ impl SpdReceipt { if !self.body_verifies() { return false; } - wai_sig::verify_strict(&self.signer_pubkey, &self.payload_with(None), &self.sig).is_ok() + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { + return false; + }; + k.verify(&self.payload_with(None), &Signature::from_bytes(&self.sig)).is_ok() } /// The re-run half of [`Self::verify_computation`]. @@ -421,13 +424,4 @@ mod tests { assert!(labelled.verify()); assert!(!labelled.receipt.verify(), "the inner signature now covers the label"); } - - /// The signature is verified strictly (SPEC §7 *Signatures*, `wai_sig`): a key of small - /// order, a non-canonical spelling of one, and an R of small order are each refused, - /// though a lenient check accepts each. - #[test] - fn signatures_are_verified_strictly() { - let r = SpdReceipt::seal(&key(1), "sink-a", &job(), None, unmetered).unwrap(); - wai_sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.payload_with(None), |r| r.verify()); - } } diff --git a/wai/wai-relay/Cargo.lock b/wai/wai-relay/Cargo.lock index d76e75921..846c6166d 100644 --- a/wai/wai-relay/Cargo.lock +++ b/wai/wai-relay/Cargo.lock @@ -1086,15 +1086,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "wai-sig", -] - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", ] [[package]] diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 5170ccbf3..68a8339fe 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -3893,7 +3893,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "wai-quantum", - "wai-sig", "xz2", "zstd", "zstd-safe", @@ -3906,15 +3905,6 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", - "wai-sig", -] - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2 0.10.9", ] [[package]] diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 553084822..4959ca69a 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -83,8 +83,6 @@ c2pa = { version = "0.90", optional = true, default-features = false, # deterministic fixed-point simulation, replay-equivalence conformance. blake3 = { version = "1.8", optional = true } # state hashes, node minting, op/seal commitments; `hazmat` (1.8) for the HTTP head proof ed25519-dalek = { version = "2", optional = true } # op + seal signatures -# WAI's one Ed25519 rule (SPEC §7 *Signatures*), shared with wai-quantum; `crate::sig`. -wai-sig = { version = "0.1.0", path = "../wai-sig", optional = true } num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks # The curve ed25519-dalek is built on, named directly only by `receipt_claims`: its # conformance corpus builds the signatures jwp-receipts §3.1 step 3 must refuse (keys of @@ -150,11 +148,10 @@ neural = ["neural_int", "dep:ort", "dep:ndarray", "dep:zstd-safe"] # regenerate-at-sink. Reuses the ONNX runtime for the reference CLIP embedder; # the generator is always sink-supplied. Off by default like `neural`. semantic = ["codecs", "dep:ort", "dep:ndarray"] -# Ed25519 verified as WAI verifies every signature (SPEC §7 *Signatures*): the dependency -# and the one verifier (the `wai-sig` crate, `crate::sig`) on their own, so the verifier -# builds exactly when ed25519-dalek does and asks for no feature its caller does not need. -# Every signing feature enables it. -ed25519 = ["dep:ed25519-dalek", "dep:wai-sig"] +# Ed25519 verified as WAI verifies every signature (SPEC §7 *Signatures*, `src/sig.rs`): +# the dependency on its own, so the one verifier builds exactly when ed25519-dalek does +# and asks for no feature its caller does not need. Every signing feature enables it. +ed25519 = ["dep:ed25519-dalek"] # Interactive worlds (extensions/interactive-worlds): media that consumes # input. Pure-Rust, no ML dep chain — the sim is closed-form fixed-point. world = ["dep:blake3", "ed25519"] diff --git a/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs b/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs index 09987ee1f..4b0017763 100644 --- a/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs +++ b/wai/wai-rs/src/bin/wai_quantum_ops_vectors.rs @@ -84,36 +84,6 @@ fn job_bell(cal_ref: [u8; 32]) -> JobReceipt { ) } -/// The calibration and the job, each carrying the three signatures a verifier checking -/// only the equation accepts (`wai_sig::forgeries`) and WAI's rule refuses: a key of small -/// order, that key spelled with y = p + 1, and R of small order. Name, kind, JSON, receipt -/// hash and what it is. -fn refused(cal: &CalibrationReceipt, job: &JobReceipt) -> Vec<(String, &'static str, String, String, &'static str)> { - let mut out = Vec::new(); - let suffix = ["key_identity", "key_identity_y_not_reduced", "r_small_order"]; - let forged = wai_quantum::wai_sig::forgeries(|k| { - let mut r = cal.clone(); - r.signer_pubkey = *k; - r.signing_payload() - }); - for (f, s) in forged.iter().zip(suffix) { - let mut r = cal.clone(); - (r.signer_pubkey, r.sig) = (f.key, f.sig); - out.push((format!("cal_q3_{s}"), "calibration", r.to_json(), hex(&r.receipt_hash()), f.about)); - } - let forged = wai_quantum::wai_sig::forgeries(|k| { - let mut r = job.clone(); - r.signer_pubkey = *k; - r.signing_payload() - }); - for (f, s) in forged.iter().zip(suffix) { - let mut r = job.clone(); - (r.signer_pubkey, r.sig) = (f.key, f.sig); - out.push((format!("job_bell_{s}"), "job", r.to_json(), hex(&r.receipt_hash()), f.about)); - } - out -} - fn main() { let a: Vec = std::env::args().collect(); match (a.get(1).map(String::as_str), a.get(2)) { @@ -152,22 +122,17 @@ fn generate(dir: &Path) { ] { std::fs::write(vd.join(format!("{name}.json")), &json).expect("write vector"); } - let mut vectors = serde_json::json!({ - "cal_q3": { "kind": "calibration", "receipt_hash": hex(&cal_q3.receipt_hash()), "verifies": true }, - "cal_cz": { "kind": "calibration", "receipt_hash": hex(&cal_cz.receipt_hash()), - "verifies": true, "parent": "cal_q3" }, - "job_bell": { "kind": "job", "receipt_hash": hex(&job.receipt_hash()), - "verifies": true, "calibration_ref": "cal_q3" }, - }); - for (name, kind, json, hash, about) in refused(&cal_q3, &job) { - std::fs::write(vd.join(format!("{name}.json")), &json).expect("write vector"); - vectors[name.as_str()] = serde_json::json!({ "kind": kind, "receipt_hash": hash, "verifies": false, "about": about }); - } let manifest = serde_json::json!({ "extension": "wai.quantum.calibration + wai.quantum.job", - "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job). A vector with verifies = false carries a signature only a verifier checking the equation alone accepts, which WAI's rule refuses (SPEC §7 Signatures).", - "vectors": vectors, + "note": "Ed25519 over BLAKE3 Merkle; verify = merkle + joule-ceiling + signature (+ cross-link for the job).", + "vectors": { + "cal_q3": { "kind": "calibration", "receipt_hash": hex(&cal_q3.receipt_hash()), "verifies": true }, + "cal_cz": { "kind": "calibration", "receipt_hash": hex(&cal_cz.receipt_hash()), + "verifies": true, "parent": "cal_q3" }, + "job_bell": { "kind": "job", "receipt_hash": hex(&job.receipt_hash()), + "verifies": true, "calibration_ref": "cal_q3" }, + }, }); std::fs::write( dir.join("expected_hashes.json"), @@ -212,24 +177,6 @@ fn verify(dir: &Path) { && job.verify_against_calibration(&cal_q3) // cross-link to the right state && !job.verify_against_calibration(&cal_cz)); // and NOT a different one - // Every vector marked verifies = false is refused, and is the receipt the manifest - // hashes: only its signature fails. - for (name, v) in manifest["vectors"].as_object().expect("vectors") { - if v["verifies"] != false { - continue; - } - let text = read(&format!("{name}.json")); - let (hash, verifies) = match v["kind"].as_str() { - Some("calibration") => CalibrationReceipt::from_json(&text).map(|r| (hex(&r.receipt_hash()), r.verify())), - Some("job") => JobReceipt::from_json(&text).map(|r| (hex(&r.receipt_hash()), r.verify())), - _ => None, - } - .unwrap_or_else(|| panic!("{name}: an unreadable receipt")); - let pass = !verifies && hash == want(name); - println!("{} {name} refused, receipt_hash {}", if pass { "PASS" } else { "FAIL" }, &hash[..16]); - ok &= pass; - } - if ok { println!("all vectors pass: signature + joule-ceiling + content-addressing + cross-link hold"); } else { diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 23ce4b53e..f8cfa66f0 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -313,12 +313,10 @@ pub mod c2pa_emit; // Ed25519 verified strictly (RFC 8032 §5.1.7 with a canonical key of large order, S < L, // and R of large order compared byte for byte, SPEC §7 *Signatures*): the one routine -// every signature check in this crate calls but OER/2's (oer-receipts §5), and -// wai-quantum's too. It is its own crate, `wai-sig`, so that wai-quantum, which `wai` -// depends on, can call it; built whenever ed25519-dalek is, under the `ed25519` feature -// every signing feature enables. +// every signature check written in this crate calls but OER/2's (oer-receipts §5). Built +// whenever ed25519-dalek is, under the `ed25519` feature every signing feature enables. #[cfg(feature = "ed25519")] -pub use wai_sig as sig; +pub mod sig; // RFC 8785 canonical JSON over the subset WAI signs and digests (integers up to // 2^53 − 1, no floats). Needs serde_json alone, so it is ungated: claims, key logs, diff --git a/wai/wai-sig/src/lib.rs b/wai/wai-rs/src/sig.rs similarity index 97% rename from wai/wai-sig/src/lib.rs rename to wai/wai-rs/src/sig.rs index ee52992c8..855e6f335 100644 --- a/wai/wai-sig/src/lib.rs +++ b/wai/wai-rs/src/sig.rs @@ -1,8 +1,9 @@ //! Ed25519 verification as WAI verifies it (RFC 8032 §5.1.7, strictly; SPEC §7 //! *Signatures*): one routine, so every receipt family accepts exactly the signatures every -//! other conforming verifier accepts. Every signature check in `wai` (as `wai::sig`) and in -//! `wai-quantum` calls it, except OER/2's (`wai::oer`), whose rule oer-receipts §5 settles: -//! it refuses the same keys and leaves `R` to RFC 8032. +//! other conforming verifier accepts. Every signature check written in this crate calls it, +//! except OER/2's (`crate::oer`), whose rule oer-receipts §5 settles: it refuses the same +//! keys and leaves `R` to RFC 8032. The quantum receipts this crate re-exports are checked +//! in the `wai-quantum` crate, which does not call it. //! //! The rule, in order: //! @@ -202,8 +203,8 @@ pub fn forgeries(payload: impl Fn(&[u8; 32]) -> Vec) -> [Forgery; 3] { /// For a signing site's tests: `good` verifies, and each of [`forgeries`], set into it by /// `set` (the key and the signature it carries), is refused by `verifies`. `payload` is /// the bytes the site signs, computed after its key is set, since a payload may name it. -/// Panics, naming the forgery, when one is accepted. -pub fn assert_refuses_forgeries( +#[cfg(test)] +pub(crate) fn assert_refuses_forgeries( good: &R, set: impl Fn(&mut R, [u8; 32], [u8; 64]), payload: impl Fn(&R) -> Vec, diff --git a/wai/wai-sig/Cargo.lock b/wai/wai-sig/Cargo.lock deleted file mode 100644 index 3c0c69f46..000000000 --- a/wai/wai-sig/Cargo.lock +++ /dev/null @@ -1,330 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "libc" -version = "0.2.190" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "zeroize" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" diff --git a/wai/wai-sig/Cargo.toml b/wai/wai-sig/Cargo.toml deleted file mode 100644 index 5e99e4034..000000000 --- a/wai/wai-sig/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "wai-sig" -version = "0.1.0" -edition = "2024" -rust-version = "1.97" -license = "Apache-2.0" -description = "Ed25519 verified as WAI verifies every signature: a canonical key of large order, S < L, R not of small order, and the cofactorless equation with R compared byte for byte. The one routine `wai` and `wai-quantum` call." -repository = "https://git.transaction.science/Transaction-Science/open-standards" -homepage = "https://wai.transaction.science" - -[dependencies] -ed25519-dalek = "2" -sha2 = "0.10" diff --git a/wai/wai-spz-gen/Cargo.lock b/wai/wai-spz-gen/Cargo.lock index c5ef3cffc..eef2758a9 100644 --- a/wai/wai-spz-gen/Cargo.lock +++ b/wai/wai-spz-gen/Cargo.lock @@ -1006,15 +1006,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "wai-sig", -] - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", ] [[package]] diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index b937666ca..6b4cc55b2 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -730,7 +730,6 @@ dependencies = [ "serde_json", "sha2", "wai-quantum", - "wai-sig", ] [[package]] @@ -740,15 +739,6 @@ dependencies = [ "blake3", "ed25519-dalek", "serde_json", - "wai-sig", -] - -[[package]] -name = "wai-sig" -version = "0.1.0" -dependencies = [ - "ed25519-dalek", - "sha2", ] [[package]] -- 2.54.0 (Apple Git-157) From cd6dcf41bb5dd3099a2ede1fdd72f588d5139d71 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 14:39:04 -0400 Subject: [PATCH 08/10] =?UTF-8?q?SPEC=20=C2=A77=20Signatures:=20the=20one?= =?UTF-8?q?=20Ed25519=20rule,=20cited=20by=20the=20receipt=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Ed25519 signature WAI defines is verified strictly, in order: a canonical key, a key not of small order, S < L, R not of small order, and the cofactorless equation with R compared byte for byte. OER/2's chain signature keeps the rule oer-receipts §5 settles. jwp-receipts §3.1 step 3, http-delivery's playlist receipt and packaging's receipt cite it instead of restating it; the worlds, avatars, feeds, spatial-audio, haptics, volumetric, score, film, provenance, ternary and quantum receipt texts cite it where they name their signature. CHANGELOG records the sweep. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 28 ++++++++++++++++++++++++ wai/SPEC.md | 29 +++++++++++++++++++++++++ wai/extensions/avatars.md | 3 ++- wai/extensions/haptics.md | 4 ++-- wai/extensions/http-delivery.md | 11 ++++------ wai/extensions/interactive-worlds.md | 6 ++++-- wai/extensions/jwp-receipts.md | 32 ++++++++-------------------- wai/extensions/linear-film.md | 2 +- wai/extensions/packaging.md | 3 ++- wai/extensions/provenance.md | 2 +- wai/extensions/quantum-ops.md | 3 ++- wai/extensions/quantum-sim.md | 3 ++- wai/extensions/score.md | 4 ++-- wai/extensions/spatial-audio.md | 3 ++- wai/extensions/state-feeds.md | 3 ++- wai/extensions/ternary-tt.md | 3 ++- wai/extensions/volumetric.md | 2 +- 17 files changed, 95 insertions(+), 46 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2335e2aa3..1c99cbbc5 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,34 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### One Ed25519 rule for every signature + +- **SPEC §7 *Signatures*.** Every Ed25519 signature WAI defines is verified strictly: the + key is its point's one canonical encoding and not of small order, `S` < L, `R` is not of + small order, and the cofactorless equation holds with `R` compared byte for byte. The + receipt extensions cite the rule instead of restating it; jwp-receipts §3.1 step 3 + keeps its two reasons, `bad_signer_key` for steps 1 and 2 and + `signature_verify_failed` for the rest. OER/2's chain signature keeps the rule + oer-receipts §5 settles. +- **One verifier in wai-rs.** `wai::sig::verify_strict` is the only Ed25519 check written + in the crate but OER/2's. The video, SSAI, key-release, channel and package receipts + (both versions), the provenance, ternary, spatial-audio, volumetric, film, score and + haptics receipts, and a world's session receipt, ops and seal moved onto it; before, + each read its key with the library's lenient decoder and checked the lenient + equation, which accepts a key of small order (under the identity, `(R, S) = (B, 1)` + verifies over every message), a second spelling of a key, and an `R` of small order. + Claims, JWP groups and the key log, which held the rule in a copy of their own, call + it too. It builds under the `ed25519` feature, the dependency alone. +- **One verifier in Python.** The claim, JWP, signer, HTTP and packaging verifiers share + `claim-conformance/_ed25519.py`, the rule written from RFC 8032 with the standard + library. `pack_verify.py` had checked signatures leniently. +- **Corpora.** Each receipt family's corpus carries the three signatures a verifier + checking only the equation accepts — the identity as the key with `(R, S) = (B, 1)`, + the identity spelled `y` = p + 1, and an `R` of small order under the base point — + and every conforming verifier refuses them: new cases in jwp-conformance, + signer-conformance and pack-conformance (`mutations/` and `receipts/`); claim- and + http-conformance had them. No existing vector's expectation changed. + ### Staged tracks over MoQ: a track per layer, a receipts track per track, delivery timeouts per hop, chains that recover - **Re-read against the drafts.** [moq-streaming-format](extensions/moq-streaming-format.md) diff --git a/wai/SPEC.md b/wai/SPEC.md index 83ab61760..9c93f3f83 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -1566,6 +1566,35 @@ not a digest of its output, and this rule does not reach it. A frozen digest SHOULD be re-derived on more than one instruction-set architecture before it is published. +### Signatures (normative) + +Every Ed25519 signature (RFC 8032, pure Ed25519) that WAI or one of its extensions +defines is verified strictly: on a receipt of any family, a claim, a key-log document, +a module grant or status, an op, and a world's seal. A verifier MUST refuse a signature +unless each of these holds, in this order: + +1. **The key is canonical.** The public key `A` is the one encoding of its point that + RFC 8032 §5.1.3 allows: `y` < p, and no sign bit when `x` is 0. A decoder that + reduces `y` modulo p, or reads the sign bit beside `x` = 0, reads a second spelling + of a point; that key is refused. +2. **The key is not of small order.** `A`'s order does not divide 8. Under such a key + the equation can hold for a signature made without any private key: under the + identity, `(R, S) = (B, 1)` verifies over every message. +3. **`S` < L**, the order of the base point B (RFC 8032 §5.1.7). +4. **`R` is not of small order.** `R` decodes to a point whose order does not divide 8. +5. **The cofactorless equation holds.** `[S]B − [k]A`, with + `k = SHA-512(R ‖ A ‖ M) mod L` over the bytes of `R` and `A` as the signature and the + key give them, encodes to `R` byte for byte; an `R` that is not its point's + canonical encoding never does. + +A key that fails step 1 or 2 is a defect of the key, and an extension MAY report it +apart from a defect of the signature (jwp-receipts §3.1 step 3: `bad_signer_key` and +`signature_verify_failed`). The rule fixes what RFC 8032 leaves to the verifier, so +every conforming verifier accepts the same signatures. + +OER/2 is the one exception: oer-receipts §5 settles its chain signature, which refuses +a key by steps 1 and 2 and checks `R` as RFC 8032 does. + ### Machine-checked totality (informative) The cross-architecture matrix establishes byte-exactness by **sampling**: it diff --git a/wai/extensions/avatars.md b/wai/extensions/avatars.md index 755beea6f..10fc16352 100644 --- a/wai/extensions/avatars.md +++ b/wai/extensions/avatars.md @@ -97,7 +97,8 @@ so they are legitimately left to presentation and excluded from the hash The interactive-worlds session receipt (`jwp-receipts.md`) applies unchanged: world identity over contract + tick-0 graph; the group's -objects are the op batches, Merkle-bound; one Ed25519 seal over world +objects are the op batches, Merkle-bound; one Ed25519 seal (verified as +SPEC §7 *Signatures* requires) over world hash + root + checkpoints + exact signed `work` + carried `joules_micro` (with its acquisition class as an optional signed label, [energy-measurement](energy-measurement.md) §2.8); diff --git a/wai/extensions/haptics.md b/wai/extensions/haptics.md index cdad2a544..3deb781f1 100644 --- a/wai/extensions/haptics.md +++ b/wai/extensions/haptics.md @@ -86,8 +86,8 @@ supplementary); there is no perceptual substitute to fall back *to*, so ## 6. Receipt (JWP profile) `signal_hash` is the identity; the group's objects are per-channel -reconstruction blocks, Merkle-bound; one Ed25519 signature over -signal_hash + root + `work` (= duration × channels) + carried +reconstruction blocks, Merkle-bound; one Ed25519 signature (verified as +SPEC §7 *Signatures* requires) over signal_hash + root + `work` (= duration × channels) + carried `joules_micro`; `parent_receipt_hash` chains — the worlds/audio receipt shape reused. The figure's acquisition class rides beside it as an optional signed label ([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index 1b907d1e0..3d3089cb1 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -1212,13 +1212,10 @@ reads back (Appendix G) and has an RFC 8785 form; the BLAKE3 of the rendering is `manifest_hash`; `work_compose_ops` is the number of segments and `viewers_served` is at least 1; the segment at position `i` has `index` `i`, and `ad_id` exactly when it is an ad; `joules_micro` and the class are both present or the figure is the unmetered 0; -and `sig` is an Ed25519 signature (RFC 8032) by `signer_pubkey` over the signed bytes, -verified strictly: `signer_pubkey` decodes (§5.1.3) and is the one encoding of its point -(`y` < p, and no sign bit when `x` is 0), and neither it nor `R` is of small order -(`[8]P` is not the identity); `S` < L; and the encoding of `[S]B − [k]A` equals `R` -byte for byte, so an `R` that is not its point's one encoding never verifies. A verifier -that accepts a key or an `R` this refuses accepts a signature any party can make: under -a key of small order, `(R, S) = (B, 1)` verifies over every message. +and `sig` is an Ed25519 signature by `signer_pubkey` over the signed bytes, verified as +SPEC §7 *Signatures* requires: a key that is not its point's one encoding or is of small +order, `S` ≥ L, an `R` of small order, or an equation that does not hold with `R` +compared byte for byte fails the receipt. ## Appendix I — Reading an MPD diff --git a/wai/extensions/interactive-worlds.md b/wai/extensions/interactive-worlds.md index 131e0d7cd..b0f6de019 100644 --- a/wai/extensions/interactive-worlds.md +++ b/wai/extensions/interactive-worlds.md @@ -159,7 +159,9 @@ seal_preimage = "wai:world-seal\x01" Hashing the three sections — rather than raw container bytes — makes the commitment independent of section-table order and padding: the -seal binds content, not framing. +seal binds content, not framing. The seal's signature, every op's +(§5.1) and the session receipt's (§8) are verified as SPEC §7 +*Signatures* requires. ### 4.1. Asset table — worlds are made of WAI objects @@ -474,7 +476,7 @@ unchanged): (source-side authoring/solver joules; sink-side joules append per the JWP sink-metering path). - One Ed25519 signature per group, Merkle-bound, relay-preserved - byte-for-byte — all inherited. + byte-for-byte — all inherited — verified as SPEC §7 *Signatures* requires. - The signing domain is the profile's own, `wai:world-receipt\x01` (`\x02` when the figure carries a label; energy-measurement.md §2.8), not the base group receipt's `moq-jwp:group-sign\x01`. diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index b2fcc8f15..accd003ea 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -1713,29 +1713,15 @@ order listed. decoder entry's grammar and tier, the switches' reasons and order, the missed presentations, the order of its stage sets, its counted bytes and its classical rendition. -3. **Signature.** Ed25519 (RFC 8032). This step restates RFC 8032's - own decoding and range rules, and fixes the two choices on which - RFC 8032 verifiers differ — whether a key or `R` of small order is - refused (RFC 8032's procedure does not refuse them; this step does), - and which verification equation is checked — so that every - conforming verifier accepts the same signatures. `signer_pubkey` is - `bad_signer_key` if it does not decode under RFC 8032 §5.1.3 — - which refuses a `y` coordinate ≥ p, and `x` = 0 with the sign bit - set, so every point has exactly one encoding — or if it decodes to a - point of small order (one whose order divides 8). `sig` is - `R` ‖ `S`, and it is `signature_verify_failed` unless `S` < L (the - order of the base point B, as RFC 8032 §5.1.7 requires), `R` is not - the encoding of a point of small order, and the encoding of - [S]B − [k]A equals `R` byte for byte, where A is the key's point and - k = SHA-512(`R` ‖ `signer_pubkey` ‖ §2.4.3's signing input) mod L. - That is the cofactorless equation. RFC 8032 §5.1.7 - states the cofactored one and allows the cofactorless one; they - disagree on some signatures whose points have a small-order - component, and this step uses the cofactorless one only. The key - checks run first. Under a key of small order the equation can hold - for a signature made without any private key — under the identity, - `R` = [s]B with `S` = s verifies for every message — so such a key - is refused before its signature is examined. +3. **Signature.** Ed25519 over §2.4.3's signing input, verified by the + rule every WAI signature is held to (SPEC §7 *Signatures*): a canonical + key not of small order, `S` < L, `R` not of small order, and the + cofactorless equation with `R` compared byte for byte. `signer_pubkey` + is `bad_signer_key` if it fails that rule's steps 1 or 2 — it is not + the one encoding of its point, or its point is of small order — and + `sig` is `signature_verify_failed` if it fails steps 3 to 5. The key + checks run first, so a key under which a signature needs no private + key is refused before its signature is examined. 4. **Class rules**, per §2.4.5–§2.4.12 and §2.6, each with its own reason (below). They run after the signature because a rule about what a signer said only means something once it is established who diff --git a/wai/extensions/linear-film.md b/wai/extensions/linear-film.md index cec0262df..1081bbdd7 100644 --- a/wai/extensions/linear-film.md +++ b/wai/extensions/linear-film.md @@ -135,7 +135,7 @@ synthesizable slice of "video," not a video codec. `film_hash` is the identity; the group's objects are the per-frame reconstruction blocks (`frame_hash` per frame), Merkle-bound; one Ed25519 -signature over film_hash + root + `work` (= frames) + carried +signature (verified as SPEC §7 *Signatures* requires) over film_hash + root + `work` (= frames) + carried `joules_micro` (the native `wai_meter` supplies a **measured** value); `parent_receipt_hash` chains — the worlds / volumetric receipt shape reused. The figure's acquisition class rides beside it as an optional signed label diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index 56dbdc04b..6c6bfae9d 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -785,7 +785,8 @@ and the class's signed encoding. `total_entries` counts every entry; `unique_obj the distinct content hashes over every component; `total_bytes` the entries' `byte_len`; `stored_bytes` the `byte_len` of each content hash's first entry; `work_units` equals `total_entries`. A receipt whose content hash comes at two lengths -has no counts. The signature is Ed25519 (RFC 8032) by `signer_pubkey`. +has no counts. The signature is Ed25519 by `signer_pubkey`, verified as SPEC §7 +*Signatures* requires; a signature or a key that rule refuses is `receipt-signature`. Its JSON form, `package.receipt.json`, is RFC 8785 canonical, hexadecimal lowercase, integers as numbers no greater than `2^53 − 1` (a packager refuses to seal anything diff --git a/wai/extensions/provenance.md b/wai/extensions/provenance.md index 54588ff33..663a9e169 100644 --- a/wai/extensions/provenance.md +++ b/wai/extensions/provenance.md @@ -64,7 +64,7 @@ step chain, and the total joules. It chains across edits via > be linked — each step's `input_hash` equals the previous step's > `output_hash`, and the final `output_hash` equals `asset_hash`; (c) > recompute the Merkle root from the step hashes; (d) verify the Ed25519 -> signature. A failure at any point is a failure — the asset is reported +> signature as SPEC §7 *Signatures* requires. A failure at any point is a failure — the asset is reported > unverified, never "probably fine". There is no tolerance parameter: the content hash matches or it does not. diff --git a/wai/extensions/quantum-ops.md b/wai/extensions/quantum-ops.md index a055a87eb..49804e95e 100644 --- a/wai/extensions/quantum-ops.md +++ b/wai/extensions/quantum-ops.md @@ -44,7 +44,8 @@ A signed record of one bring-up / tune-up run: lineage of device states `merkle_root` covers `[config_hash] ++ evidence leaves`; one Ed25519 signature -covers the whole payload. +covers the whole payload. Every receipt here is verified as SPEC §7 *Signatures* +requires. ### `wai.quantum.job` — `JobReceipt` diff --git a/wai/extensions/quantum-sim.md b/wai/extensions/quantum-sim.md index 26cb750ac..0cab4fbe2 100644 --- a/wai/extensions/quantum-sim.md +++ b/wai/extensions/quantum-sim.md @@ -203,7 +203,8 @@ is trusted the way it honestly can be.** n_qubits)` and it grows exponentially in qubits, so a sink cannot inflate or deflate the stated cost. `joules_micro` is *measured-attested*: only the signer can vouch for what its own silicon drew, and the same holds for the class that -labels it and for the values of any report about it (energy-measurement §6). One Ed25519 signature covers all of it +labels it and for the values of any report about it (energy-measurement §6). One Ed25519 signature, +verified as SPEC §7 *Signatures* requires, covers all of it over a Merkle root of the leaf hashes (`circuit`, `statevector`, `[histogram]`) — the worlds / provenance / film receipt shape reused, `parent_receipt_hash` chaining a derivation exactly as they do. diff --git a/wai/extensions/score.md b/wai/extensions/score.md index 9797fc7e8..660875bb5 100644 --- a/wai/extensions/score.md +++ b/wai/extensions/score.md @@ -89,8 +89,8 @@ HRTF render or the worlds renderer does not. ## 5. Receipt (JWP profile) `score_hash` is the identity; the group's objects are the mix PCM blocks -(per a fixed sample span), Merkle-bound; one Ed25519 signature over -score_hash + root + exact work (= duration × notes) + carried +(per a fixed sample span), Merkle-bound; one Ed25519 signature (verified as +SPEC §7 *Signatures* requires) over score_hash + root + exact work (= duration × notes) + carried `joules_micro`; `parent_receipt_hash` chains — the worlds/audio receipt shape reused. The figure's acquisition class rides beside it as an optional signed label ([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps diff --git a/wai/extensions/spatial-audio.md b/wai/extensions/spatial-audio.md index 5cdea58fc..54ccb6fd5 100644 --- a/wai/extensions/spatial-audio.md +++ b/wai/extensions/spatial-audio.md @@ -150,7 +150,8 @@ impl): - the group's objects = the rendered mix's **PCM blocks** (fixed span of frames), each `BLAKE3("wai:audio-block\x01" || block_index || i16le)`, **Merkle-bound** with JWP's exact leaf/node domains. -- one Ed25519 signature over a profile payload carrying `scene_hash`, +- one Ed25519 signature (verified as SPEC §7 *Signatures* requires) over a + profile payload carrying `scene_hash`, the Merkle root, the block list, `work_mac` (= output samples × objects mixed — exact, signed) and `joules_micro` (carried; the native `wai_world_meter` pattern supplies a measured figure, `0` if diff --git a/wai/extensions/state-feeds.md b/wai/extensions/state-feeds.md index dd538d4ab..573da8d66 100644 --- a/wai/extensions/state-feeds.md +++ b/wai/extensions/state-feeds.md @@ -99,7 +99,8 @@ state hash. The interactive-worlds session receipt (`jwp-receipts.md`) applies unchanged: world identity over contract + tick-0 graph; the group's -objects are the op batches, Merkle-bound; one Ed25519 seal over world +objects are the op batches, Merkle-bound; one Ed25519 seal (verified as +SPEC §7 *Signatures* requires) over world hash + root + checkpoints + exact signed `work` + carried `joules_micro` (with its acquisition class as an optional signed label, [energy-measurement](energy-measurement.md) §2.8); diff --git a/wai/extensions/ternary-tt.md b/wai/extensions/ternary-tt.md index 8ec8307e6..23a88ec01 100644 --- a/wai/extensions/ternary-tt.md +++ b/wai/extensions/ternary-tt.md @@ -118,7 +118,8 @@ two costs so each is trusted honestly: | `joules_micro` | measured marginal energy | **attested** by signature | `seal` decodes the stack (so `reconstruction_hash` is always the true -reconstruction); `verify` checks the Merkle root + Ed25519; `verify_reconstruction` +reconstruction); `verify` checks the Merkle root + Ed25519 (as SPEC §7 *Signatures* +requires); `verify_reconstruction` recovers the stack from the `WQTT` payload and confirms hash, shape, work, and reconstruction independently. Chains via `parent_receipt_hash`. The JWP Merkle + Ed25519 are reused verbatim — the same shape as `QuantumReceipt` diff --git a/wai/extensions/volumetric.md b/wai/extensions/volumetric.md index 871930463..65be50d33 100644 --- a/wai/extensions/volumetric.md +++ b/wai/extensions/volumetric.md @@ -97,7 +97,7 @@ than imply a compression claim the format does not make. `clip_hash` is the identity; the group's objects are per-frame reconstruction blocks (`frame_hash` per frame), Merkle-bound; one -Ed25519 signature over clip_hash + root + `work` (= n_frames × +Ed25519 signature (verified as SPEC §7 *Signatures* requires) over clip_hash + root + `work` (= n_frames × n_gaussians) + carried `joules_micro`; `parent_receipt_hash` chains — the worlds / audio / haptics receipt shape reused. The figure's acquisition class rides beside it as an optional signed label ([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps -- 2.54.0 (Apple Git-157) From 03ea3360ea5057368be70515d3611815fe8612d1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 15:01:30 -0400 Subject: [PATCH 09/10] jwp corpus: the weak-key cases go last, so the pre-branch prefix is unchanged the_pre_branch_vectors_are_an_unchanged_prefix pins the first 83 vectors by digest; the three new group cases sat inside them. They now follow the recovery vectors, and every existing vector keeps its place and its bytes. joule-moq's corpus test passes over the new ones. Co-Authored-By: Claude Opus 5.5 --- wai/jwp-conformance/vectors.json | 156 +++++++++++++------------- wai/wai-rs/src/jwp_delivery/corpus.rs | 44 ++++---- 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/wai/jwp-conformance/vectors.json b/wai/jwp-conformance/vectors.json index dcd00be2e..794ce2959 100644 --- a/wai/jwp-conformance/vectors.json +++ b/wai/jwp-conformance/vectors.json @@ -817,84 +817,6 @@ "reason": "parent_not_held" } }, - { - "name": "group_signer_key_identity_forged", - "about": "the identity point as signer_pubkey, with (R, S) = (B, 1): the equation holds over any payload, so a verifier that checks only the equation accepts it; the key is of small order", - "procedure": "group", - "input": { - "group": { - "kind": "group", - "track_alias": 7, - "group_id": 3, - "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", - "leaf_count": 5, - "joules_total": 2800, - "group_start": "2026-09-30T12:00:00Z", - "group_end": "2026-09-30T12:00:01Z", - "signer_pubkey": "0100000000000000000000000000000000000000000000000000000000000000", - "signer_id": "did:example:publisher", - "sig": "58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", - "parent_receipt_hash": null - } - }, - "expected": { - "outcome": "reject", - "step": 1, - "reason": "bad_signer_key" - } - }, - { - "name": "group_signer_key_identity_y_not_reduced", - "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, under which the equation holds; the encoding is not canonical", - "procedure": "group", - "input": { - "group": { - "kind": "group", - "track_alias": 7, - "group_id": 3, - "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", - "leaf_count": 5, - "joules_total": 2800, - "group_start": "2026-09-30T12:00:00Z", - "group_end": "2026-09-30T12:00:01Z", - "signer_pubkey": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", - "signer_id": "did:example:publisher", - "sig": "58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", - "parent_receipt_hash": null - } - }, - "expected": { - "outcome": "reject", - "step": 1, - "reason": "bad_signer_key" - } - }, - { - "name": "group_signature_r_small_order", - "about": "signer_pubkey the base point, whose discrete logarithm is 1, R the identity and S = k: the equation holds, and R is of small order", - "procedure": "group", - "input": { - "group": { - "kind": "group", - "track_alias": 7, - "group_id": 3, - "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", - "leaf_count": 5, - "joules_total": 2800, - "group_start": "2026-09-30T12:00:00Z", - "group_end": "2026-09-30T12:00:01Z", - "signer_pubkey": "5866666666666666666666666666666666666666666666666666666666666666", - "signer_id": "did:example:publisher", - "sig": "0100000000000000000000000000000000000000000000000000000000000000f43db9669cf0eb3913db18561bb4d5d64b9ed5c5ec5a425b1bc7351ae6e93904", - "parent_receipt_hash": null - } - }, - "expected": { - "outcome": "reject", - "step": 2, - "reason": "signature_verify_failed" - } - }, { "name": "object_labelled_counter_accept", "about": "a labelled leaf: the figure, the counter's class and evidence, and the origin are under the group signature", @@ -12566,6 +12488,84 @@ } ] } + }, + { + "name": "group_signer_key_identity_forged", + "about": "the identity point as signer_pubkey, with (R, S) = (B, 1): the equation holds over any payload, so a verifier that checks only the equation accepts it; the key is of small order", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "0100000000000000000000000000000000000000000000000000000000000000", + "signer_id": "did:example:publisher", + "sig": "58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "bad_signer_key" + } + }, + { + "name": "group_signer_key_identity_y_not_reduced", + "about": "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, under which the equation holds; the encoding is not canonical", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "signer_id": "did:example:publisher", + "sig": "58666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "bad_signer_key" + } + }, + { + "name": "group_signature_r_small_order", + "about": "signer_pubkey the base point, whose discrete logarithm is 1, R the identity and S = k: the equation holds, and R is of small order", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "5866666666666666666666666666666666666666666666666666666666666666", + "signer_id": "did:example:publisher", + "sig": "0100000000000000000000000000000000000000000000000000000000000000f43db9669cf0eb3913db18561bb4d5d64b9ed5c5ec5a425b1bc7351ae6e93904", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } } ] } diff --git a/wai/wai-rs/src/jwp_delivery/corpus.rs b/wai/wai-rs/src/jwp_delivery/corpus.rs index 099b08735..7ccc0bff0 100644 --- a/wai/wai-rs/src/jwp_delivery/corpus.rs +++ b/wai/wai-rs/src/jwp_delivery/corpus.rs @@ -282,28 +282,6 @@ fn vectors() -> Vec { json!({ "group": gl_json }), reject(Some(3), Reason::ParentNotHeld)), ]); - // §3.1 step 3's rule is the one every WAI signature is held to (SPEC §7 *Signatures*): - // three signatures over the mixed group's own payload, under the key each names, that a - // verifier checking only the equation accepts. - let forged = crate::sig::forgeries(|k| { - let mut g = gm.clone(); - g.signer_pubkey = *k; - g.signing_payload() - }); - let under = |f: &crate::sig::Forgery| { - let mut g = gm.clone(); - (g.signer_pubkey, g.sig) = (f.key, f.sig); - json!({ "group": g.to_json() }) - }; - out.extend([ - v("group_signer_key_identity_forged", "the identity point as signer_pubkey, with (R, S) = (B, 1): the equation holds over any payload, so a verifier that checks only the equation accepts it; the key is of small order", "group", - under(&forged[0]), reject(Some(1), Reason::BadSignerKey)), - v("group_signer_key_identity_y_not_reduced", "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, under which the equation holds; the encoding is not canonical", "group", - under(&forged[1]), reject(Some(1), Reason::BadSignerKey)), - v("group_signature_r_small_order", "signer_pubkey the base point, whose discrete logarithm is 1, R the identity and S = k: the equation holds, and R is of small order", "group", - under(&forged[2]), reject(Some(2), Reason::SignatureVerifyFailed)), - ]); - // ── objects (§3 steps 4-5) ── let obj = |g: &Value, o: Value, e: u8| json!({ "group": g, "object": o, "envelope": hexs(&envelope(e)) }); let edit = |o: &ObjectReceipt, f: &dyn Fn(&mut Value)| { @@ -530,6 +508,28 @@ fn vectors() -> Vec { // Last again, so every vector before them keeps its place: holding, re-anchoring // and joining (§3 step 3). out.extend(recovery_vectors()); + // Last again, so every vector before them keeps its place: §3.1 step 3's rule is the + // one every WAI signature is held to (SPEC §7 *Signatures*), and these are three + // signatures over the mixed group's own payload, under the key each names, that a + // verifier checking only the equation accepts. + let forged = crate::sig::forgeries(|k| { + let mut g = gm.clone(); + g.signer_pubkey = *k; + g.signing_payload() + }); + let under = |f: &crate::sig::Forgery| { + let mut g = gm.clone(); + (g.signer_pubkey, g.sig) = (f.key, f.sig); + json!({ "group": g.to_json() }) + }; + out.extend([ + v("group_signer_key_identity_forged", "the identity point as signer_pubkey, with (R, S) = (B, 1): the equation holds over any payload, so a verifier that checks only the equation accepts it; the key is of small order", "group", + under(&forged[0]), reject(Some(1), Reason::BadSignerKey)), + v("group_signer_key_identity_y_not_reduced", "the identity spelled y = p + 1, with (R, S) = (B, 1): a decoder that reduces y reads the identity, under which the equation holds; the encoding is not canonical", "group", + under(&forged[1]), reject(Some(1), Reason::BadSignerKey)), + v("group_signature_r_small_order", "signer_pubkey the base point, whose discrete logarithm is 1, R the identity and S = k: the equation holds, and R is of small order", "group", + under(&forged[2]), reject(Some(2), Reason::SignatureVerifyFailed)), + ]); out } -- 2.54.0 (Apple Git-157) From a52bcfeef6376d7c0c9bc7844b66b20a48b1626d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 15:12:59 -0400 Subject: [PATCH 10/10] wai-pack: the v1 receipts test reads the refused cases as refused the_receipts_json_round_trips required every receipt of receipts/ to verify; the three marked "signature": "refused" must not, and there are three. Co-Authored-By: Claude Opus 5.5 --- wai/wai-pack/tests/packs.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/wai/wai-pack/tests/packs.rs b/wai/wai-pack/tests/packs.rs index 2f665dc5a..8529b7f50 100644 --- a/wai/wai-pack/tests/packs.rs +++ b/wai/wai-pack/tests/packs.rs @@ -274,7 +274,9 @@ fn forged_body(view: &wai::energy_figure::FigureView) -> wai::claim_receipt::Cla } /// Test 18: the golden's receipt JSON is RFC 8785 canonical and reads back to itself; the -/// version-1 goldens read back to receipts that verify with their pinned hashes. +/// version-1 goldens read back to receipts that verify with their pinned hashes, but for +/// those marked `"signature": "refused"`, whose signature WAI's rule refuses (SPEC §7 +/// *Signatures*). #[test] fn the_receipts_json_round_trips() { let pkg = golden_package(); @@ -292,9 +294,10 @@ fn the_receipts_json_round_trips() { for c in expected["cases"].as_array().unwrap() { let t = std::str::from_utf8(&files[c["file"].as_str().unwrap()]).unwrap(); let r1 = wai::wai_video_package::PackageReceipt::from_json(t).unwrap(); - assert!(r1.verify()); + assert_eq!(r1.verify(), c["signature"] != "refused", "{}", c["file"]); assert_eq!(wai_pack::hex(&r1.receipt_hash()), c["receipt_hash"].as_str().unwrap()); assert_eq!(r1.to_json().unwrap(), t); } assert!(expected["cases"].as_array().unwrap().iter().any(|c| c["receipt_hash"] == "71a9da59a3202abb2c3fd22ee692b1485a9c674bb8c311f4c82455e6be29ae2b"), "the legacy pin is unchanged"); + assert_eq!(expected["cases"].as_array().unwrap().iter().filter(|c| c["signature"] == "refused").count(), 3, "a key of small order, its second spelling, an R of small order"); } -- 2.54.0 (Apple Git-157)