Repository navigation
ic402 v2.16.1
Patch — Ed25519 verification agrees with RFC 8032. Identity.verifyCallerEd25519 and the
voucher check in Sessions.consumeVoucher keep their names and types,
selfAuthPrincipalOfEd25519 is untouched, STABLE_SCHEMA_VERSION stays 1, and no stable type
changed.
Both checks used mo:ed25519 0.1.0, which refused about 1 valid signature in 32, accepted a
malleated partner of each one it refused, and allocated ~245 MB per check. ic402 now verifies with
a vendored copy of ic-agent-mo's Ed25519 (src/ic402/Ed25519.mo, Apache-2.0, byte-identical to
upstream below an attribution header), and the mo:ed25519 dependency is gone.
Fixed
- Valid signatures were refused. In 0.1.0,
bitrightshift(0, n)returns 2^n − 1 instead of
0 (utils.mo:670,let bit = if (num > 0) false else true). Its base-point multiply walks
8-bit windows; when the window carried into the top is zero, the bug turns it into 255 and
adds −2^256·B. The result: every valid signature with S < 2^247 was refused, plus a thin band
in [2^247, 2^247 + 2^240) where no carry crosses byte 29. Over 400,000 uniformly drawn S that
is 3.10% — about 1 in 32, with the band adding about 1 in 7,100. - Some invalid signatures were accepted. For each refused (R, S), 0.1.0 accepted
(R, (S + 2^256) mod L) — a second "signature" on the same message, which OpenSSL and noble
reject. Confirmed 5 of 5 inside a deployed canister. ic402's own vouchers could not be
replayed this way (sequenceandcumulativeAmountare signed and must strictly increase),
but a consumer that keys replay or dedup on signature bytes should check what the old
verifier let in. - A bad R encoding trapped instead of returning false (the "KNOWN SHARP EDGE" in
verifyCallerEd25519's doc). Every malformed input now returns false. - Cost. One 0.1.0 verification allocated 244.7 MB and ran 4.96 billion instructions, just
under the 5 billion query limit. A fresh canister went from 8.1 MB to 337 MB after one call.
The vendored verifier allocates 8.4 MB and runs ~200–206 million, depending on the signature.
Changed
- The policy is stated and pinned: RFC 8032, strict and cofactorless. A and R must be
canonical encodings (y < p, the point exists, no negative zero); S must be below L; k is
hashed over the original bytes; and small-order public keys are refused. That is stricter
than OpenSSL on small-order and non-canonical public keys (ed25519-speccheck cases 0, 1 and
11) and agrees with it everywhere else. Why each case falls where it does is written beside it
intest/ed25519.test.mo. mo:ed25519removed frommops.toml.HttpHandler's hex encoder, the only other use,
is now local and byte-identical over all 256 byte values, so theic402Noncewire format is
unchanged.- Example canister: two diagnostic queries,
ed25519Verifyanded25519VerifyCost
(additive toexample.didand the client IDL).
Docs
- The voucher cost was wrong.
docs/costs-and-rails.mdput a session voucher at under 1M
cycles, and the README called sessions "5,000× cheaper". Measured for this release with the
doc's own method (40 vouchers through@ic402/client, local replica, 13-node pricing), one
voucher costs ~415M cycles, ~96% of it the signature check. Wasm64 execution is billed at 2
cycles per instruction, so that is the mainnet price on a 13-node subnet too. Under 0.1.0 it
was ~10B. The 5,000× was never cycles: it counted 10,000 per-call settlements against a
session's 2, and a session is really a fixed few transactions (deposit, settle, refund; plus
the payer's approve on ICP). In cycles, at 10,000 calls an EVM session is ~40× cheaper than
per-call EVM settlement locally (the ceiling; fewer calls save less), and more on mainnet.
On ICP a voucher can cost the canister more than a per-call settle; there, a session saves
the payer's per-transfer ledger fees.docs/costs-and-rails.mdhas the numbers and the
pricing floor: price session calls above ~$0.0006, the voucher check's cost, plus the legs
spread over the calls. The README, getting-started, mcp-install, the client README and the
demo drop the 5,000× and two-transaction claims, and the README, the client README and the
demo now state the voucher cost.
Notes
- The memory plateau is the GC, not the verifier. Repeated verifications on the example
canister grow its memory ~8.4 MB per call until the first collection (~67 MB of heap), after
which it sits at 202.75 MB. A no-crypto probe allocating 8.4 MB per call follows the same
curve to the same 202.75 MB, and one allocating 1 MB per call gets there at call 60 instead of
call 8. That is Motoko's incremental GC; less garbage per call only delays it. - If you import
mo:ed25519yourself, it has the same defects: itsverifyrefuses and
accepts as above, and itssignandgetPublicKeyare wrong about 1 time in 32 for the same
reason (3.14% of seeds get a wrong public key). Upstream has only ever published 0.1.0. - ic402 calls only
Ed25519.verify. The vendored file also carries upstream'ssignand
publicKey, which ic402's tests use; they are reachable asmo:ic402/Ed25519but are not part
of ic402's API, and they are not constant-time (nor was the package they replace).
Testing
test/ed25519.test.mo(58 tests,mops test): the RFC 8032 §7.1 vectors (verified, and
reproduced by the vendored signer), all 151 Wycheproof vectors by flag, the 12
ed25519-speccheck cases with the policy per case, 13 regression vectors from 0.1.0, three
policy vectors that each pin one check (y < p, and each half of the final comparison), the
small-order universal forgery, and totality over malformed lengths and encodings.test/ed25519.test.ts, inside the deployed canister: 2,000 fresh OpenSSL key/message pairs
(messages 0–300 bytes) plus one corruption of each must match OpenSSL; the run must reach
S < 2^247. It also runs Wycheproof, speccheck (where ic402 is stricter than OpenSSL, so the
differential cannot see it), the policy vectors, the regressions, and a budget of 400 M
instructions and 16 MB per verify. About 5 minutes on a local replica.- CI: an Ed25519 step in
test-integration, and a newed25519-fixtures-syncjob that pins the
vendored Wycheproof and speccheck files to their upstream bytes and fails ifVectors.mois
stale. Provenance:test/fixtures/ed25519/README.md. - Mutation-verified, every new test failing at least once against a mutant that compiles:
- All 58 Motoko tests, each run in isolation.
- The seven canister tests, against eight mutants built into the example and reinstalled:
- refusing S < 2^247 fails the differential and the regressions;
- accepting whatever decodes fails the differential's invalid direction;
- dropping S < L fails Wycheproof;
- dropping the small-order check fails speccheck;
- dropping y < p fails the policy vectors;
- 16 MB of extra allocation, or three times the multiply, fails the budget;
- an unreachable canister fails the gate under
IC402_REQUIRE_REPLICA=1.
mops testis about 6× faster, 45 s to 8 s, because the old tests signed with 0.1.0.