Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/spec/delegation-chain-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,37 @@ attenuated rather than merely claimed.
unresolved delegator key as `hop_signature_invalid`. No published vector exercised that
path, so no earlier result changes.

- **Verified hardening against known Ed25519 verification-edge-case classes
(2026-10-18, preventive, no vulnerability found).** `hop_signature` is this spec's only
*live* Ed25519 check in this repo — everywhere else a signature's validity is a
declared, externally-resolved fact (`peer-reconciliation-ref-v1`'s `co_signer`,
`anchoring-precedence-ref-v1`'s `admission_invariant`), not computed by this repo's own
code. That makes this the one place a weak verification library would actually matter.
Prompted by robertolocatelli81-dev/Noûs finding, the same day and unrelated to this
repo, that identity-element Ed25519 keys (`R`=identity, `S`=0) forge a signature that
verifies for *any* message under a naive RFC 8032 implementation, and that some
libraries accept non-canonically-encoded points/scalars (`x402-foundation/tsc#4`;
`novifinancial/ed25519-speccheck` classes 0–11). This was not assumed safe because
PyNaCl/libsodium is "generally known to be strict" — it was reproduced:
- `examples/conformance/delegation-chain-ref/small-order-key-vectors.json`,
`small-order-identity-key-universal-forgery-001`: a real 2-hop chain, through this
spec's own `hop_signing_preimage()`, where the second hop's delegator key *is* the
identity element and its `hop_signature` *is* the textbook universal-forgery
signature. `hop_signature_invalid`, correctly — confirmed through this repo's own
code path, not an isolated library unit test.
- `examples/conformance/delegation-chain-ref/ed25519-hardening/`: the full published
`novifinancial/ed25519-speccheck` suite (12 vectors, Apache-2.0, vendored verbatim at
commit `65519336fda78a3d016e947df6d82848aca0c9da`, byte-verified via `git hash-object`
against the GitHub blob) run against this repo's pinned PyNaCl version
(`requirements.txt`). 12/12 match the published LibSodium column — small-order
`A`/`R` (classes 0–2), out-of-range `S` (6–7), and non-canonical point encodings for
both `R` and `A` (8–11, the two classes Noûs named) are all rejected; only class 3
(a benign mixed-order edge case every major library accepts) passes, as expected.
- **This is a property of the pinned dependency, not of this spec's own logic** — a
future PyNaCl/libsodium upgrade, or a reimplementation in another language with a
different Ed25519 library, is not guaranteed to inherit it. `check_hardening.py`
exists to be rerun after any such change, not only once.

---

## Replay guard (additive, optional)
Expand Down Expand Up @@ -341,3 +372,7 @@ The leaf record carries both its direct `delegation_ref` (the authorization from
- `revocation_ref` (per-hop invalidation): [`docs/spec/revocation-ref.md`](./revocation-ref.md)
- `composition_ref` (lifecycle composition at leaf): [`docs/spec/composition-ref.md`](./composition-ref.md)
- TrailRecord schema: [`docs/MYCELIUM_TRAILS_REFERENCE.md`](../MYCELIUM_TRAILS_REFERENCE.md)
- `hop_signature` Ed25519 hardening source: `novifinancial/ed25519-speccheck`
(Apache-2.0), vendored at `examples/conformance/delegation-chain-ref/ed25519-hardening/`;
finding that prompted the self-audit: robertolocatelli81-dev/Noûs,
`x402-foundation/tsc#4`, 2026-10-18.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
`novifinancial-ed25519-speccheck-cases.json` in this directory is vendored
verbatim from https://github.com/novifinancial/ed25519-speccheck, commit
`65519336fda78a3d016e947df6d82848aca0c9da` (file: `cases.json`).

Verified byte-identical to that commit's GitHub blob via `git hash-object`
matching the recorded blob SHA `8686dcb7eef8b6abe36ca8fa9bb10de112e63774`
for that path at that commit, not re-serialized.

That repository is licensed under the Apache License, Version 2.0 (no
separate NOTICE file exists upstream to reproduce). Per its README:

> The authors of this code are Kostas Chalkias ([@kchalkias]), François
> Garillot ([@huitseeker]) and Valeria Nikolaenko ([@valerini]).
>
> Special thanks go to Yolan Romailler, Rajath Shanbag and Rob Starkey for
> contributing test vector results.

The `EXPECTED_LIBSODIUM_ACCEPTS` set in `check_hardening.py` is transcribed
from that same commit's `results.md` condition table (the "LibSodium"
row) — not re-derived by us. This directory tests whether this repo's own
pinned PyNaCl (`../requirements.txt`) still matches that published,
third-party-audited result; it is not a claim about the test vectors'
own correctness, which is novifinancial's work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
Regression check: does this repo's pinned Ed25519 verification library
(PyNaCl, see ../requirements.txt) still reject the known classes of
Ed25519 signature-verification edge cases it is expected to reject?

Built 2026-10-18, preventive, no external trigger: robertolocatelli81-dev/
Noûs found, the same day and unrelated to this repo, that identity-element
keys (R=identity, S=0) forge a valid-looking signature for ANY message
under naive RFC 8032 verification, and that non-canonical point/scalar
encodings (ed25519-speccheck classes 0 and 11) are accepted by some
libraries (x402-foundation/tsc#4). This repo's only LIVE (not
declared/external) Ed25519 verification is `hop_signature` in
delegation-chain-ref.md, via PyNaCl -- everywhere else (peer-reconciliation
-ref-v1's co_signer, anchoring-precedence-ref-v1's admission_invariant)
signature validity is a declared, externally-resolved fact, not computed by
this repo's own code. This check closes the "not verified explicit" gap:
it does not assume PyNaCl/libsodium happens to be strict, it reproduces the
real published test vectors against the real pinned library in this repo's
own environment and checks the result matches the documented LibSodium
behavior.

Source (vendored verbatim, Apache-2.0): `novifinancial/ed25519-speccheck`,
commit `65519336fda78a3d016e947df6d82848aca0c9da`. `cases.json` in this
directory is byte-identical to that commit's file (verified via
`git hash-object`, matches the GitHub API's blob sha
8686dcb7eef8b6abe36ca8fa9bb10de112e63774 for the same path at that commit).
The expected LibSodium column below is transcribed from that commit's
`results.md` condition table -- this script does not re-derive it, it
checks our own library against it.

case | condition | expected (LibSodium)
-----|-----------------------------------------------------|---------------------
0 | S=0, small-order A, small-order R (universal forgery) | reject
1 | small-order A only, 0<S<L | reject
2 | small-order R only, 0<S<L | reject
3 | mixed-order A and R, 0<S<L (benign; not a vulnerability) | ACCEPT
4 | mixed A/R, fails cofactorless verification | reject
5 | pre-reduced scalar, R has order L | reject
6 | S out of bounds (S > L) | reject
7 | S out of bounds (S >> L, no canonical serialization) | reject
8 | non-canonical R, reduced for hash | reject
9 | non-canonical R, not reduced for hash | reject
10 | non-canonical A, reduced for hash | reject
11 | non-canonical A, not reduced for hash | reject

Case 3 is the only expected ACCEPT -- it is not malformed in any of the
ways the other 11 cases are, and every library in results.md's table
accepts it except the two that also reject 0/1/2 (LibSodium, libra-crypto)
plus "Dalek strict". A check that expects all 12 to reject would be
checking for behavior PyNaCl does not (and should not) have.

Usage: python3 check_hardening.py (needs PyNaCl; pip install -r ../requirements.txt)
"""

import binascii
import json
import sys
from pathlib import Path

from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey

EXPECTED_LIBSODIUM_ACCEPTS = {3} # every other case (0,1,2,4-11) must reject


def main():
cases_path = Path(__file__).parent / "novifinancial-ed25519-speccheck-cases.json"
cases = json.loads(cases_path.read_text())

print(f"ed25519 hardening check — {len(cases)} vectors "
f"(novifinancial/ed25519-speccheck @ 65519336)\n")

passed = 0
failed = 0
for i, case in enumerate(cases):
msg = binascii.unhexlify(case["message"])
pub = binascii.unhexlify(case["pub_key"])
sig = binascii.unhexlify(case["signature"])

try:
VerifyKey(pub).verify(msg, sig)
accepted = True
except BadSignatureError:
accepted = False

should_accept = i in EXPECTED_LIBSODIUM_ACCEPTS
ok = accepted == should_accept
marker = "✓" if ok else "✗"
status = "PASS" if ok else "FAIL"
print(f" {marker} [{status}] case {i:2d}: "
f"{'accepted' if accepted else 'rejected'} "
f"(expected {'accept' if should_accept else 'reject'})")

if ok:
passed += 1
else:
failed += 1

print(f"\n{passed}/{len(cases)} passed", end="")
if failed:
print(f", {failed} failed -- our pinned PyNaCl/libsodium no longer "
f"matches the documented hardening. Do not treat delegation-chain-ref's "
f"hop_signature check as protected against these classes until this is "
f"investigated.")
sys.exit(1)
else:
print(" -- matches documented LibSodium behavior exactly.")


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"message":"8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6","pub_key":"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa","signature":"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a0000000000000000000000000000000000000000000000000000000000000000"},{"message":"9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79","pub_key":"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa","signature":"f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43a5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04"},{"message":"aebf3f2601a0c8c5d39cc7d8911642f740b78168218da8471772b35f9d35b9ab","pub_key":"f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43","signature":"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa8c4bd45aecaca5b24fb97bc10ac27ac8751a7dfe1baff8b953ec9f5833ca260e"},{"message":"9bd9f44f4dcc75bd531b56b2cd280b0bb38fc1cd6d1230e14861d861de092e79","pub_key":"cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d","signature":"9046a64750444938de19f227bb80485e92b83fdb4b6506c160484c016cc1852f87909e14428a7a1d62e9f22f3d3ad7802db02eb2e688b6c52fcd6648a98bd009"},{"message":"e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c","pub_key":"cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d","signature":"160a1cb0dc9c0258cd0a7d23e94d8fa878bcb1925f2c64246b2dee1796bed5125ec6bc982a269b723e0668e540911a9a6a58921d6925e434ab10aa7940551a09"},{"message":"e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec4011eaccd55b53f56c","pub_key":"cdb267ce40c5cd45306fa5d2f29731459387dbf9eb933b7bd5aed9a765b88d4d","signature":"21122a84e0b5fca4052f5b1235c80a537878b38f3142356b2c2384ebad4668b7e40bc836dac0f71076f9abe3a53f9c03c1ceeeddb658d0030494ace586687405"},{"message":"85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40","pub_key":"442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623","signature":"e96f66be976d82e60150baecff9906684aebb1ef181f67a7189ac78ea23b6c0e547f7690a0e2ddcd04d87dbc3490dc19b3b3052f7ff0538cb68afb369ba3a514"},{"message":"85e241a07d148b41e47d62c63f830dc7a6851a0b1f33ae4bb2f507fb6cffec40","pub_key":"442aad9f089ad9e14647b1ef9099a1ff4798d78589e66f28eca69c11f582a623","signature":"8ce5b96c8f26d0ab6c47958c9e68b937104cd36e13c33566acd2fe8d38aa19427e71f98a473474f2f13f06f97c20d58cc3f54b8bd0d272f42b695dd7e89a8c22"},{"message":"9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41","pub_key":"f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43","signature":"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03be9678ac102edcd92b0210bb34d7428d12ffc5df5f37e359941266a4e35f0f"},{"message":"9bedc267423725d473888631ebf45988bad3db83851ee85c85e241a07d148b41","pub_key":"f7badec5b8abeaf699583992219b7b223f1df3fbbea919844e3f7c554a43dd43","signature":"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffca8c5b64cd208982aa38d4936621a4775aa233aa0505711d8fdcfdaa943d4908"},{"message":"e96b7021eb39c1a163b6da4e3093dcd3f21387da4cc4572be588fafae23c155b","pub_key":"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","signature":"a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04"},{"message":"39a591f5321bbe07fd5a23dc2f39d025d74526615746727ceefd6e82ae65c06f","pub_key":"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","signature":"a9d55260f765261eb9b84e106f665e00b867287a761990d7135963ee0a7d59dca5bb704786be79fc476f91d3f3f89b03984d8068dcf1bb7dfc6637b45450ac04"}]
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"description": "Preventive self-audit, 2026-10-18, no external trigger against this repo: robertolocatelli81-dev/Noûs found the same day, unrelated to us, that identity-element Ed25519 keys (R=identity, S=0) forge a valid signature for ANY message under naive RFC 8032 verification (x402-foundation/tsc#4). delegation-chain-ref-v1's hop_signature is this repo's only LIVE Ed25519 check (everywhere else -- peer-reconciliation-ref-v1's co_signer, anchoring-precedence-ref-v1's admission_invariant -- signature validity is a declared, externally-resolved fact, not computed by our own code). This vector constructs the real attack inside a real 2-hop chain using this repo's own hop_signing_preimage(): hop 0 is a genuine signature from a freshly generated key (legit-root-guardian); hop 1's delegator key IS the identity point and hop 1's hop_signature IS the universal-forgery signature (R=identity, S=0) -- a signature that, per the naive RFC 8032 equation, verifies for literally any message, including this repo's own hop preimage. Our pinned PyNaCl (requirements.txt) rejects it: libsodium checks that the public key is not a small-order point before the verification equation runs at all, independent of the message. See ed25519-hardening/ in this directory for the broader regression check against the full published novifinancial/ed25519-speccheck suite (12 vectors, classes 0-11, including the non-canonical point/scalar encodings -- ed25519-speccheck classes 0 and 11 -- that were the other half of what Noûs flagged).",
"pubkeys": {
"legit-root-guardian": "oMXddvJUt/4/Mk0onc5opxMDZQ2kowC+9YzWmnG08EQ=",
"attacker-controlled-identity-key": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
},
"vectors": [
{
"id": "small-order-identity-key-universal-forgery-001",
"description": "hop 0 (legit-root-guardian -> attacker-controlled-identity-key) carries a genuine signature from a freshly generated Ed25519 keypair -- this hop is legitimate and verifies correctly on its own, confirming the rest of the vector is sound. hop 1's delegator, attacker-controlled-identity-key, is registered with the identity-element public key (0x01 followed by 31 zero bytes -- the Ed25519 neutral element), and hop 1's hop_signature is R=identity||S=0, the textbook 'universal forgery' signature that satisfies [S]B = R + [k]A for ANY message when A is the identity point, because both sides reduce to the identity regardless of k. If our verifier only checked the naive RFC 8032 equation, this hop would verify against literally any preimage. PyNaCl/libsodium instead rejects any signature over a small-order public key before reaching the verification equation, independent of the message -- confirmed here through this repo's own real hop_signing_preimage()/verify_hop_signature() code path, not an isolated crypto-library unit test.",
"expected": "FAIL",
"failure_mode": "hop_signature_invalid",
"failure_detail": "hop 1: delegator=attacker-controlled-identity-key's registered pubkey is the Ed25519 identity element; PyNaCl/libsodium rejects any signature against a small-order public key (BadSignatureError) regardless of the message or declared signature bytes.",
"chain_artifact": {
"chain_id": "chain-small-order-identity-key-001",
"hops": [
{
"delegator": "legit-root-guardian",
"delegatee": "attacker-controlled-identity-key",
"delegation_ref": "1111111111111111111111111111111111111111111111111111111111111111",
"scope": "mycelium:payment",
"hop_signature": "ncB24FLN37w1Ac/gTne0ZPooNjCKLng1RT9IXx80HtBWpCVvBAH7xcqzFpExqdJatWQfNYp16L4dV3GbaTbECw=="
},
{
"delegator": "attacker-controlled-identity-key",
"delegatee": "victim-executor",
"delegation_ref": "2222222222222222222222222222222222222222222222222222222222222222",
"scope": "mycelium:payment:refund",
"hop_signature": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
],
"leaf_action_ref": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"root_delegator": "legit-root-guardian",
"scope": "mycelium:payment",
"version": "delegation-chain-ref-v1"
},
"delegation_chain_ref": "c085726b449b04d7973d9170d12118c072a46c604572e317e144b770af7cb2d2"
}
],
"provenance": {
"authored_by": "giskard09 (argentum-core)",
"verification_mode": "asserted",
"independently_reproduced_by": []
}
}
2 changes: 1 addition & 1 deletion examples/conformance/delegation-chain-ref/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ def main():
total_passed = 0
total_failed = 0
for fname in ("vectors.json", "cross-org-vectors.json", "replay-vectors.json",
"key-completeness-vectors.json"):
"key-completeness-vectors.json", "small-order-key-vectors.json"):
p, f = run_file(base / fname)
total_passed += p
total_failed += f
Expand Down
Loading