diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index 45f658044..654c7a96e 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. + # Playlist receipts verify Ed25519 through the shared strict rule, in Rust and Python. - 'wai/wai-rs/src/sig.rs' + - '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. + # Playlist receipts verify Ed25519 through the shared strict rule, in Rust and Python. - 'wai/wai-rs/src/sig.rs' + - '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..2fc67964a 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -30,6 +30,7 @@ on: - 'wai/wai-rs/**' - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' + - 'wai/claim-conformance/_ed25519.py' - 'wai/int-payload-conformance/int_payload_verify.py' - 'wai/registry/capabilities.json' - '.github/workflows/wai-pack.yml' @@ -40,6 +41,7 @@ on: - 'wai/wai-rs/**' - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' + - 'wai/claim-conformance/_ed25519.py' - 'wai/int-payload-conformance/int_payload_verify.py' - 'wai/registry/capabilities.json' - '.github/workflows/wai-pack.yml' diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 47cb3bd19..f0fa5e7a5 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. + ### The int_codec model and vectors: trained and coded on a generated signal - The int_codec model and vectors are now trained and coded on a generated signal; 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/claim-conformance/README.md b/wai/claim-conformance/README.md index 50f744ede..51b433236 100644 --- a/wai/claim-conformance/README.md +++ b/wai/claim-conformance/README.md @@ -326,8 +326,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 @@ -335,7 +337,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 @@ -407,16 +409,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/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 diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index 8af0498b9..b679626bf 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). **Provenance.** Generated here by `wai_http_vectors`, from a staged-delivery case and from the frozen sources `src/SOURCES.json` lists, each encoded once by an encoder of its format: pictures of the `int_mlicpp` vector's generated 64×64 pattern, AV1 colour-bar test patterns, and a clip of a square moving over a gradient. The test patterns and the clip are generated content whose generators are not in this tree. None is a recording or a photograph. diff --git a/wai/http-conformance/falsify/sig-key-lenient.patch b/wai/http-conformance/falsify/sig-key-lenient.patch index 804c776ae..8ff9d29f4 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 +index 48a10219..7a770fae 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 { +@@ -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..c9c008488 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 +index 48a10219..dd32f7fc 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<() +@@ -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). 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/jwp-conformance/vectors.json b/wai/jwp-conformance/vectors.json index fbabd6faa..794ce2959 100644 --- a/wai/jwp-conformance/vectors.json +++ b/wai/jwp-conformance/vectors.json @@ -12488,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/pack-conformance/README.md b/wai/pack-conformance/README.md index 97ff92333..8b42af2be 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -48,9 +48,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 @@ -95,7 +100,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 @@ -106,8 +111,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/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 656cee808..9db9b0dbf 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): @@ -2906,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: @@ -2929,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): @@ -3070,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/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: 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-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"); } 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/jwp_delivery/corpus.rs b/wai/wai-rs/src/jwp_delivery/corpus.rs index e3f041c1a..7ccc0bff0 100644 --- a/wai/wai-rs/src/jwp_delivery/corpus.rs +++ b/wai/wai-rs/src/jwp_delivery/corpus.rs @@ -508,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 } 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/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/lib.rs b/wai/wai-rs/src/lib.rs index 1eb2b013e..f8cfa66f0 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 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 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..855e6f335 100644 --- a/wai/wai-rs/src/sig.rs +++ b/wai/wai-rs/src/sig.rs @@ -1,6 +1,9 @@ -//! 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 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: //! @@ -78,16 +81,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. 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); +/// - `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); + } + 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 +277,80 @@ 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 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, + [("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 +390,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..4bca73ade 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}; @@ -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()); @@ -252,8 +253,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 +392,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()); + } }