wai: every Ed25519 signature verified strictly, by one verifier (SPEC §7) #186

Merged
dcharlot merged 14 commits from wai/strict-ed25519 into main 2026-10-08 01:50:19 -04:00
66 changed files with 1126 additions and 443 deletions

View file

@ -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/**'

View file

@ -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'

View file

@ -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;

View file

@ -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

View file

@ -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`.

View file

@ -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)

View file

@ -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)

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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`.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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`

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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`

View file

@ -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

View file

@ -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.

View file

@ -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<VerifyingKey, SigError> {
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).

View file

@ -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).

View file

@ -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",

View file

@ -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"
}
}
]
}

View file

@ -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

View file

@ -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",

View file

@ -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:[email protected]","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}

View file

@ -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:[email protected]","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}

View file

@ -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:[email protected]","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}

View file

@ -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")

View file

@ -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"
}
]
}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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:

View file

@ -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",

View file

@ -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 (`<name>/`, 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<Vec<String>> {
}
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"))?;

View file

@ -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");
}

View file

@ -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"]

View file

@ -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());
}
}

View file

@ -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<VerifyingKey, Reason> {
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");

View file

@ -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 {

View file

@ -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());
}
}

View file

@ -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))?;

View file

@ -508,6 +508,28 @@ fn vectors() -> Vec<V> {
// 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
}

View file

@ -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.

View file

@ -1297,6 +1297,23 @@ fn vectors() -> Vec<V> {
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",

View file

@ -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

View file

@ -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<Value, String> {

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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<u8>) -> [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<R: Clone>(
good: &R,
set: impl Fn(&mut R, [u8; 32], [u8; 64]),
payload: impl Fn(&R) -> Vec<u8>,
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::<Vec<_>>().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;

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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<u8> {
/// The bytes the signature covers, under the key the receipt names.
pub fn signing_payload(&self) -> Vec<u8> {
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());
}
}

View file

@ -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());
}
}

View file

@ -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<Seal, WorldError> {
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<Vec<AssetEntry>, 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()),
);
}
}

View file

@ -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<u8> {
@ -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),
}
}
}
}

View file

@ -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());
}
}