What was done, removed, ignored or simulated, dated. This is the source of the README's prior-work note and of the final presentation.
- The specification (
FIANSO-SPEC.md) is added, then amended with the Ledger documentation feedback requirement (section 7.6). It consolidates the preparation dossier kept inSTOOOKEEE/TTT(docs/carbs/). The earlier prototype stays where it is and is not reused. - The team device is a Ledger Stax, firmware 1.11 (
API_LEVEL27, the level of the SDK image), side-loading allowed. - Upstream imports, unmodified:
XRPLF/rippledalphanet at68bd02b647(the branch head, unchanged since 2026-10-16) andLedgerHQ/app-xrpmaster at7b5185ff. File counts match upstream (2300 and 6763).UPSTREAM.mdrecords them. - Workspace skeleton, CI, pull request template, architecture and decision records, README (pull requests #1 to #4). CI covers the TypeScript side only; the daemon and the Ledger app are not built in CI.
- The scope is widened to a post-quantum root on the device, a quantum exposure check with migration, and community tooling (a Device Management Kit adapter for
xrpl-connect, acreate-xrptemplate). The agent payment scene is reserved, not planned. - Licence: Apache-2.0 for the code written here.
- Installed locally and not committed: the Ripple DevRel DevEx capture hook.
- The daemon of this repository is built from
rippled/(Debug, with the existing Conan cache):xrpld version 3.4.1+DEBUG; the upstreamPasskeyListSetsuite passes on the imported tree (11 cases, 250 tests, 0 failures). Recipe indocs/BUILDING.md. - Not done yet: the Ledger app has not been built or loaded on the Stax; no spike (K1 to K8) had run at that point; the protocol codes had not been re-checked on the imported tree (spike K5 has run since: see "Plan check, spike K5 and review gate 1").
- Simulated: nothing at that point; the entries below say what is simulated.
Run on rippled/ as imported (git diff 516f6a5 HEAD -- rippled/ is empty, tree 3e3f112e), Debug build, Apple clang 17, arm64 macOS, where char is signed. No test was edited, skipped or removed.
./xrpld --unittest --unittest-jobs=6 (from rippled/build): 627.7 s, 280 suites, 5334 cases, 1,285,447 tests, 204 failures, and ValidatorSiteV2 did not complete ("The child process may have crashed"). Every suite with a failure was run again alone (./xrpld --unittest=xrpl.app.<Suite>):
| Suite | Failures (6 jobs) | Alone | First failure |
|---|---|---|---|
AMMBinned |
152 | 152 | tecINVARIANT_FAILED instead of tesSUCCESS at rippled/src/test/app/AMMBinned_test.cpp:313; the log says "Invariant failed: a MPToken was created" |
AMMCurves |
41 | 41 | tecPATH_DRY instead of tesSUCCESS at rippled/src/test/app/AMMCurves_test.cpp:1469 |
InvariantsVault |
2 | 2 | tecLIMIT_EXCEEDED instead of tesSUCCESS at rippled/src/test/app/invariants/InvariantsVault_test.cpp:3035 |
ValidatorList_Dilithium |
2 | 2 | reads a validator list from an absolute path on the upstream author's machine (rippled/src/test/app/ValidatorListDRandom_test.cpp:150-158) |
ValidatorListExpiration |
2 | 2 | "expires() returned nullopt" and "secp256k1 VL backward compatibility broken" (rippled/src/test/app/ValidatorListExpiration_test.cpp:204, :383) |
Delegate |
1 | 1 | the delegable-type counter: 71 counted, 60 pinned (rippled/src/test/app/Delegate_test.cpp:2846-2855) |
MPToken |
1 | 1 | rippled/src/test/app/MPToken_test.cpp:2095, in "MPT Issue Invalid in Transaction" |
SignatureVerification |
1 | 0 | passes alone |
ValidatorSite |
1 | 0 | "database is locked" with parallel jobs; passes alone |
ValidatorSiteV2 |
did not complete | 0 | passes alone (86 cases) |
201 failures repeat, in seven suites; the other three suites fail only when several jobs run at once. Of the upstream suites the specification names, PasskeyListSet, AccountDelete and PassKey pass and Delegate fails only its counter test, so for Delegate "green before and after" means "no new failure" (see docs/PLAN-CHECK.md). Suites the next tasks rely on pass: Batch, Beneficiary, Firewall, Sponsor, InvariantsBeneficiary, HashRouter, NetworkOPs, STTx, Feature, ServerDefinitions, LedgerEntry, AccountObjects, TxQMetaInfo, TxQPosNegFlows.
./xrpl_tests (the gtest binary, built with cmake --build . --target xrpl_tests): 1120 tests from 240 suites in 52 s, 1119 passed, 1 failed: VaultTests.BuilderFromSleRoundTrip ("Field 'DealingInterval' may not be explicitly set to default.", rippled/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp:258; sfDealingInterval is SoeDefault in rippled/include/xrpl/protocol/detail/ledger_entries.macro:643), the same when run alone; 3 upstream tests are disabled (DISABLED_ consensus simulations).
Header levelization (rippled's .github/scripts/levelization/generate.py, run on a scratch copy so that its results folder in the tree is left alone): the imported tree has one dependency that the committed results/ordering.txt lacks, test.server > xrpl.rdb, from rippled/src/test/server/Wallet_test.cpp:10. Not ours; left as it is.
These are the base failures. docs/base-failures.txt lists them one failing assertion per line (file, line and message); a later run counts a failure as ours when it adds a line there.
docs/PLAN-CHECK.mdlists, for every task in scope, the files we will touch and the upstream code we mirror, each with the fullpath:lineof the imported tree. Every citation was printed again and read (its section 6).- Spike K5, code part done. Every code of spec section 6.1 is free on the imported tree and in the
server_definitionsanswers of a standalone node of this build and of the public AlphaNet, which are identical (hash34614C84…C3C5). Nothing is renumbered. The device-track branch uses the same numbers for every field the app parses. The encode round trip of K5 waits forpackages/core(task W1), once N2 exposes the new types. - Deviation: "green before and after" is judged per assertion. Before any change, the upstream
Delegatesuite fails its delegable-type counter (71 counted, 60 pinned atrippled/src/test/app/Delegate_test.cpp:2846) andMPTokenfails its completeness assertion (rippled/src/test/app/MPToken_test.cpp:2095). The spec asks these suites to stay green; they are not green before any change, so a run counts as clean when it adds no line todocs/base-failures.txt. Re-pinning theDelegatecounter is not allowed: both session types areNotDelegableand leave it at 71. N2 adds aSigningSessionSetcase to theMPTokencompleteness test, so that our type is covered instead of hidden in a set that is already not empty. - Deviation: the device track (the Ledger app, loading it on the Stax, K4, K6, photographs of the screens) belongs to a teammate, the device-track owner (decision D5, partly settled). These goals build the protocol, the client, the device contract and a simulated device, and never touch a device.
- Deviation, planned: the passkey wallet (an account that signs with its passkey only, after
SetRegularKeyto the blackhole address andasfDisableMaster) is not in spec section 8.5. It is built with the client, and that spec section is updated in the pull request that adds it. - SIMULATED, P5 before any change. With a software WebAuthn authenticator (no device, no browser ceremony), a payment from an account, signed through WebAuthn by a P-256 passkey of its list, was refused by the unchanged tree with
invalidTransaction, "fails local checks: Invalid signature."./xrpld --unittest=PasskeyWebAuthnwith the draft test gave:#26 failed: apply (rippled/src/test/app/PasskeyWebAuthn_test.cpp:94): Got telENV_RPC_FAILED (Unit test RPC failure.); Expected tesSUCCESS,#27 failed: PasskeyWebAuthn_test.cpp(96), "1 suite, 2 cases, 65 tests total, 2 failures". The challenge it sent equals the signing data and holds bytes at or above0x80.charis signed on this machine. N1 records the fix. - Review gate 1, round 1 (an independent reviewer, model Opus, read-only, given
PLAN.md, the spec anddocs/PLAN-CHECK.md):VERDICT: CHANGES REQUIRED. Blocking: theMPTokencompleteness test would hide our type, and the "green before and after" reading needed this entry. Non-blocking: the activation of a P5 fix had to be argued in both directions and cover a thirdBADsite; N1 lands as a test pull request, then a fix pull request; rows for W3 and W7; N2 risks (generated code behind the macros, a hand-writtenjssname, transaction settings); thefeaturelisting of N9 is unverified; the citation command missed short references; two line numbers were off. Applied indocs/PLAN-CHECK.md, indocs/base-failures.txt(the failures per assertion) and in this entry. - Review gate 1, round 2:
VERDICT: APPROVED. Its non-blocking findings are applied before merging: the SIMULATED labels and the output above; the cost and the rules each caller uses, worded exactly; the WebAuthn condition narrowed to a P-256SigningPubKeywithsfPasskeySignature; every refusal next to an accepted control; the preconditions of the peer test; the suites of the fix pull request and the full run for code inapply.cpp,NetworkOPs.cpporPeerImp.cpp; raw messages indocs/base-failures.txt; small accuracy fixes. One is applied differently from its wording: the reviewer asked to document that a forced signature verdict is not honoured before the fix; it is honoured instead, with one more private router flag, because ledger replay would otherwise stop reproducing a pre-fix ledger that holds a WebAuthn transaction. Review gate 2 checks it on the fix pull request. - Process: the N1 test was written and run once on this machine while round 1 ran, to learn whether a fix was needed. Nothing was committed or opened as a pull request before the verdict.
SIMULATED: every result here comes from a software WebAuthn authenticator (rippled/src/test/jtx/webauthn.h). No device and no browser ceremony was used.
-
Test first. The authenticator and the refusals landed first (
test:pull request). Before any change, on this machine (charsigned), a valid WebAuthn payment was refused withinvalidTransaction, "fails local checks: Invalid signature." (output in "Plan check, spike K5 and review gate 1"). P5 is confirmed. -
Fix, a separate
fix:commit gated by a new amendment,fixPasskeyChallenge(top offeatures.macro,Supported::Yes,DefaultNo). With it, the challenge is compared with the signing data as twoSlices; without it, the old comparison stays. Only that comparison changes. -
Activation. While the amendment is disabled, a transaction signed through WebAuthn (a P-256
SigningPubKeywithsfPasskeySignature) bypasses the signature cache ofcheckValidity, so no verdict crosses the activation in either direction.NetworkOPsandPeerImpdo not mark itBADeither. A verdict forced byforceValidity(cluster relay, ledger replay, signature checks off) still holds: one more private router flag,PRIVATE9on the unused bit0x0020, records it.isSignatureVerdictCacheableholds the condition in one place. Cost: one WebAuthn verification percheckValiditycall for such a transaction, until the amendment is enabled. -
Tests (suites
PasskeyWebAuthnandPasskeyRelay):- a challenge with a byte at or above
0x80, under both amendment states; - every refusal next to an accepted control;
- the same signed blob refused before the amendment and accepted after it;
checkValiditywith one router and the rules of both eras;- a forced verdict on both sides of the fix;
- the condition itself;
NetworkOPs::processTransaction;- a copy relayed by an overlay test peer.
Before the amendment, a signature is refused where
charis signed and accepted where it is unsigned. The unsigned branch is UNVERIFIED here, since this machine has signedchar. - a challenge with a byte at or above
-
Also in the fix: an
API-CHANGELOG.mdentry for thesubmiterror condition, and one new levelization dependency,test.overlay > xrpl.core. -
infra/standalone.cfg(N9) has to enablefixPasskeyChallenge, otherwise, wherecharis signed as on this machine, every WebAuthn transaction fails on that node. The public AlphaNet serves the definitions of the imported tree (K5) and has no such amendment, so its answer to a WebAuthn transaction may depend on its validators' platforms (UNVERIFIED; spike K2 is not run). -
Review gate 2 (an independent reviewer, model Opus, read-only, on the fix pull request):
VERDICT: APPROVED. Its non-blocking findings are applied before merging: the comment of the guard after a malformed result now says why it matters (a held transaction reaches it under earlier rules); a test pins the narrowing to P-256 (sfPasskeySignaturenext to a secp256k1 key, and next to no key); the tests check every built transaction before using it, and the relay test checks the balance wherecharis unsigned;HashRouter.hreserves the bit0x20and says when to removePRIVATE9; the condition states that only the top level carriessfPasskeySignature; the API changelog says that a refused WebAuthn transaction is now answered "Invalid signature." on every submission before the amendment; this line is qualified to platforms wherecharis signed. The defect came with the upstream commit that added passkeys (XRPLF/rippledb30e9fc369, "featurePasskey"). -
Kept to the facts on purpose: no upstream pull request and no message about this defect (decision D7).
-
Not done at the time of this entry (later entries record what was done since): the Ledger app had not been loaded on the Stax and no spike beyond K5 had run; K5 itself was run (see the plan-check entry above).
-
Simulated in this entry: the software WebAuthn authenticator, as labelled above.
-
Passkey defect P6, verified on the public AlphaNet (FIANSO-SPEC 6.14): a second
PasskeyListSeton the same account fails withtefEXCEPTION(A3E31459…, after a firsttesSUCCESS8AC8B117…), so a passkey can be neither rotated nor revoked. Found by readingSetPasskeyList.cpp, then tested with a fresh faucet account and random P-256 keys (throw-away script). Not reported upstream (D7); a decision is needed on whether we carry a fix for W2. -
Key migration in one
Batchdoes not work: an all-or-nothingBatchwithSetRegularKey+asfDisableMaster, master-signed, returnstesSUCCESS(16A5CA91…) but applies neither; the sameBatchwithasfDefaultRippleapplies (828DAF98…). The post-quantum migration stays two clear-signed transactions. -
Post-quantum root on the Stax, first proof (spike K4 / L2). A throw-away app built from
LedgerHQ/app-boilerplatederived an ML-DSA-44 key on the device and signed on the public AlphaNet (network 24100): fresh accountrs3drHLeUPmQmnYNmeVGnFwY68xDnA5G8T;SetRegularKeyto the device's ML-DSA key (E3D53D69…, master key);AccountSet asfDisableMaster(80D644C8…, master key: the node answerstecNEED_MASTER_KEYwhen the regular key signs it); a 1 XRPPaymentsigned with ML-DSA-44 on the Stax and verified with@noble/post-quantumbefore submission,tesSUCCESS(B55E26D1E387C2D277A9EA8F7AE4AC1F98FEFE6A28796DB51F4EFD30710C698D); the old classical key then getstefMASTER_DISABLED. The spike screen showed the message size and a SHA-256 prefix, not the decoded transaction (blind), and derived the key from a BIP32 key (not allowed by section 7.7 of the spec); that spike is not part of this repository. -
The feature is moved into
ledger-app/(src/pq/, instructions0x10to0x12, Stax / Flex / Apex P only): SLIP-21 derivation (labelcarbs ML-DSA-44), low-RAM ML-DSA, working buffers overlaid on the transaction context (.bss≈ 25 KB instead of ≈ 31 KB). It builds for Stax and Nano S Plus. -
Loaded on the Stax and proven on the AlphaNet with the XRP app itself (host side still a local script, not the DMK): fresh account
rpxMtodcdjFGpssK5k7D4CixifFBCLS7eV, ML-DSA key accountrDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2(corrected 2026-10-25: the zero-seed key, whose secret is public, see below);SetRegularKey0CBD8C74…;asfDisableMasterB9EB8FB4…(master key); 1 XRPPaymentsigned with ML-DSA-44 on the device, verified with@noble/post-quantum,tesSUCCESS(7654683C8DBBD091941A6BBD369DA25F0E5888B2C5C21A1340DFDCD6C652436B); the classical key then getstefMASTER_DISABLED. The review screen is still blind (size and SHA-256 prefix). -
The ML-DSA review is now clear-signed: the device strips
STX\0and runs the transaction through the app's own parser, then shows the same review screen as a classical signature (blind-signing screen only when the parser asks for it; malformed data is refused with0x6A80). Proven on the AlphaNet: accountrGcT27HiGhoJWYRxqg9jcnPboGD4RGuLLm, same device ML-DSA accountrDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2(the SLIP-21 key is stable across reinstalls; corrected 2026-10-25: that was the symptom of the seed defect, the key wasKeyGen_internalof 32 zero bytes on every device, see "the ML-DSA root key was the zero-seed key" below), 1 XRPPaymentsigned with ML-DSA-44 after review,tesSUCCESS(46F4D08446F73FDF4DA648EB5E050B7BF0C8FA882B35B53B8D90E2BFD6F6F3EE). -
Friction met with the Ledger SDK and tools is logged in
docs/ledger-dx-feedback.md(fifteen entries today). -
L1, clear signing of
SigningSessionSet/SigningSessionDeleteinledger-app/(not yet on the Stax, no node with the amendment yet). All the SigningSessions numbers the app uses are insrc/xrp/signing_session.h(types 130 / 131,SessionKeyACCOUNT 34,SessionCapAMOUNT 47,SessionDurationUINT32 114,PermissionValueUINT32 52,tfAllTransactions0x00010000); they are free on the public AlphaNet'sserver_definitions(checked today) and must be checked against the amendment when it lands. Screen: "Open session" / "Close session", account, session key, "Valid for" in minutes, "Allows" (mode A flag or one row per permission), "Max spend", fee, and a final "Note: Cannot change keys, signers or the account". The permission names are generated fromrippled/'s macros (ledger-app/tools/gen_session_permissions.py, 83 values). An unknown permission or flag bit, or a duplicateFlags, forces blind signing; a truncated transaction, 11 permissions or a field claiming the "Note" row are refused.MAX_ARRAY_LENgoes from 8 to 10. -
Tests: 14 vectors encoded with
ripple-binary-codec(tests/standalone/testcases/30-…and31-…, a.jsonbeside each.raw); cmocka 4/4 groups pass, including exactBLIND_SIGN_REQUIREDfor the three blind cases; Ragger on the Speculos emulator, 7 clear-sign cases per device with signature check, snapshots for Stax, Flex, Nano S Plus, Nano X and Apex P; builds for the five devices with no warning. Simulated: the device screens are emulator snapshots, not the real Stax. -
On the real Stax (XRP app with this change and the ML-DSA root of #7, built together locally): a
SigningSessionSetin mode C (20 XRP, 10 minutes) for the device accountr4MG66fUzV75ZHbSiEAXGHAxANrwQTnGh8was reviewed and signed with the classical key, signature verified locally withripple-keypairs; aSigningSessionSetin mode P (Payment, OfferCreate, 5 minutes) for the post-quantum accountrDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2(corrected 2026-10-25: the zero-seed key, see below) was reviewed through the same screens and signed with the device's ML-DSA-44 key, signature verified with@noble/post-quantum. Not submitted: no node has the amendment yet, so the acceptance case (session opened on the node) is still open. The throw-away "carbs PQ" spike app was removed from the device.
Documentation only: no protocol, client or device code changed.
-
D13 and D14 are applied to the specification (§2, the TL;DR, §6.4, §6.14, case 20 of §6.15, §7.7, §8.2 to §8.5, §10.1, §10.3, §12 and appendix A), to
docs/ARCHITECTURE.md, and in ADR 0011. ADRs 0004 and 0008 are marked superseded in part; their text stays. -
D14, read in code. An ML-DSA-44 session key needs nothing specific in the amendment; §6.14 gives the
path:lineof each step. Whether the node accepts a transaction single-signed by an ML-DSA-44 key is UNVERIFIED in a test: the only upstream test that signs one asserts a duration and not the verdict (rippled/src/test/app/STTXSigning_test.cpp:46-73). Case 20 settles it in N4. -
Deviation: case 20 with ML-DSA-44 moves from N8 (stretch) to N4 (MVP), because D14 makes ML-DSA-44 session keys a requirement.
-
Deviation: §7.7 now follows the device track. It said that the app prepends
STX\0before an ML-DSA signature. In the device track's code, now inledger-app/, the host sends the whole signing data, prefix included, and the app strips the prefix to parse and display (ledger-app/src/pq/pq_root.c:139-151). The instructions0x10to0x12, the 250-byte chunks and the 2 048-byte limit come from the same code (ledger-app/src/pq/pq_root.h:12-23,ledger-app/src/apdu/global.h:71). -
Deviation: three demo profiles become two (§8.5). The former "Ledger" and "PQ" profiles merge into the Ledger account; the "Face" profile becomes the Mac account.
-
The numbers of §6.1 match the device header,
ledger-app/src/xrp/signing_session.h(from the device track, merged intodevelop):§6.1 item Spec signing_session.hSigningSessionSet130 TRANSACTION_SIGNING_SESSION_SET 130(14)SigningSessionDelete131 TRANSACTION_SIGNING_SESSION_DELETE 131(15)SessionKeyACCOUNT 34 XRP_ACCOUNT_SESSION_KEY 34(19)SessionCapAMOUNT 47 XRP_UINT64_SESSION_CAP 47(20)SessionDurationUINT32 114 XRP_UINT32_SESSION_DURATION 114(21)PermissionValue(reused)UINT32 52 XRP_UINT32_PERMISSION_VALUE 52(22)Permissions(reused)ARRAY 29 XRP_STARRAY_PERMISSIONS 29(23)Permission(reused)OBJECT 15 XRP_STOBJECT_PERMISSION 15(24)tfAllTransactions0x00010000TF_ALL_TRANSACTIONS 0x00010000u(27)The numbers in brackets are lines of that header. The app does not use
SessionSpent(an entry field), the entry type, the keylet or the result codes. Its id0xFDfor the synthetic "Note" row (line 32) is not an XRPL field: VL 253 is free on the public AlphaNet and in the imported tree, whose highest VL field is 54 (sfClientDataJSON,rippled/include/xrpl/protocol/detail/sfields.macro:548). -
Review gate, round 1 (on #16, #17 and #18 together): its findings on this part are applied.
- §6.14 also cites where the build asserts the 1 312-byte size, the signing side and the jtx signer.
- The D14 table now cites the full fee formula and states that no local check limits the size.
- UNVERIFIED item 1 now says the AlphaNet accepted device signatures. A new item covers
@noble/post-quantumsignatures, which no node has checked yet. - The case 20 refusals name their codes, and three cases are added.
- §7.7 gains the status words, the rule that the context is cleared when the instruction changes, the key derived on chunk 0, the signature length answer, and the devices the path is built for. The stale
LEDGER_ASSERTsentence is gone.
Documentation only, and a proposal: ADR 0010 is Proposed, and nothing in §6 of the spec changes until the owner adopts an option.
-
D15 in the spec: tickets serve multisig coordination (§2). §12 now says that a session-signed transaction may today consume any ticket of the account, and §15 lists the decision left open.
-
ADR 0010 gives three options, with their fields, flags and result codes, the invariant impact, the tests and what the device would need:
- a ticket budget mode;
- a session key co-signing as its account;
- tickets bound at opening.
It recommends option 3 and its rule for sessions without a range, and rejects option 2.
-
Every new code an option would need is free on the public AlphaNet (
server_definitionshash34614C84…C3C5, the hash of spike K5): UINT32 115 and 116, ACCOUNT 35,tef-169, and no flag defined forSigningSessionSet. It is free in the imported tree too, with the commands and output in the ADR. Nothing is allocated. -
Device-track watch:
git fetch --pruneshowsdevelopholding the three device-track branches (merge695e6df). Open drafts #7, #10 and #11 are those branches. No number inledger-app/src/xrp/signing_session.hchanges, so there is nothing to adapt. -
Review gate, round 1, on this part. ADR 0010 is fixed:
- the line that names
TicketCountin the app is corrected; - option 1 notes that mode A can also lock tickets, and gives the owner count when a
TicketCreateuses a ticket; - option 2 lists every code of the signer check;
- option 3 exempts the own close, rejects an overflowing range, names
terPRE_TICKET, says that opening does not check the tickets, and cites the code it would touch; - the "if adopted" list is completed, and the ADR gains its Decision and Consequences parts.
- the line that names
Documentation only.
- Headline. It stays "quantum-safe by default" (D11). The TL;DR now says so, and the default profile is the Ledger account (D13).
- No agent scene. D6 and D9 are marked superseded by D16, and §8.8 no longer reserves the agent.
- §16 "Limits and roadmap" is added to the spec, with a short table in
docs/ARCHITECTURE.md. Each limit has its reason, itspath:linewhere it comes from code, what bounds it today and what would lift it. The limits:- P-256 sessions on Mac accounts;
- extractible ML-DSA session keys and large transactions on Ledger accounts;
- the passkey list that cannot be replaced (P6);
- the WebAuthn challenge defect (P5);
- ML-DSA v1 on the device without side-channel countermeasures;
Batchcannot migrate keys atomically;- 36 KB of device RAM.
- Batch, read in code. The observation of the device track (an all-or-nothing
BatchwithasfDisableMasterreturnstesSUCCESSand applies nothing) matches the code. An inner transaction has noSigningPubKey(rippled/src/libxrpl/tx/transactors/system/Batch.cpp:279-285), andasfDisableMasterneeds the transaction's own key to be the master key (rippled/src/libxrpl/tx/transactors/account/AccountSet.cpp:246-256,:307-313). - Stale P5 text updated. §6.14 and §15 still called P5 unverified. They now give the N1 result (SIMULATED, a software authenticator) and the fix. §6.12 adds
fixPasskeyChallengeto the standalone features, asinfra/README.mdalready does. - Review gate, round 1 (
VERDICT: CHANGES REQUIRED, two blocking findings, both applied):- Row 6 of §16 no longer says that side-channel attacks need the device in hand. The design claims nothing against them, and timing seen by a compromised host is UNVERIFIED.
- The intro of §16 no longer says that no classical key can sign. The master key is disabled, as the AlphaNet's
tefMASTER_DISABLEDshows. The claim holds only while the account has no signer list, delegate or beneficiary key, and the ML-DSA-44 session key is a client policy that neither the ledger nor the device enforces. - The other blocking finding, a wrong line in ADR 0010, is fixed in that part.
- Also fixed: N9 lists six features; the P5 paragraph cites the current lines; the §13 row on P5 records that it happened;
README.mdsays "by default";docs/PLAN-CHECK.mdno longer calls the ML-DSA instructions proposals.
- Review gate, round 2:
VERDICT: APPROVED. It found every round 1 finding resolved and each new citation correct. Its two non-blocking findings were slips of the merge resolution in this entry, a duplicated bullet and two joined bullets; both are fixed before merging. It confirmed that these three pull requests do not change the N2 macro batch: §6.1, §6.2, §6.3, §6.10, §6.13 and appendix A are unchanged. The batch grows only if the owner adopts the ticket range of ADR 0010 before N2 (sfSessionFirstTicket, UINT32 115, andsfTicketCountonSigningSessionSetand the entry).
packages/coregets its public API with working code: definitions read fromserver_definitions(an answer carrying onlyhashis refused),encodeForSigning(STX\0 ‖ signing fields),encodeForDevice(the same fields without the prefix,SigningPubKeyset, noTxnSignature) andencodeTx, low-S strict DER for P-256, a WebCrypto P-256 session key generated non-extractible and fresh per session, ML-DSA-44 through@noble/post-quantum0.7.1 (pinned), classical keys throughripple-keypairs, passkey signing (PasskeySignature) with a pluggable authenticator,SigningSessionSet/SigningSessionDeletebuilders that requireLastLedgerSequence, a JSON-RPC and WebSocket node client limited to public methods, typed errors for the threetefSESSION_*codes, and the quantum module (checkExposure,buildPqMigration,runPqMigration).- Deviation: SIMULATED definitions. The node with the
SigningSessionsamendment does not exist yet, so@carbs/sessions/testingadds the identifiers of spec section 6.1 to the recorded public AlphaNetserver_definitions(hash34614C84…C3C5). It is the only place where the client writes protocol numbers by hand; a test checks them against every number ofledger-app/src/xrp/signing_session.h. Everything built on them is SIMULATED until the client reads the node's own answer (then a test compares the two). - Cross-check with the device track:
encodeForDevicereproduces byte for byte the seven valid vectors ofledger-app/tests/standalone/testcases/30-*and31-*(modes A, P, C, ten permissions, short duration, own close, close by a third party). ripple-binary-codec2.11.0 leavesPermissionValuewithout a type when it is built from custom definitions (constructor order,src/enums/xrpl-definitions-base.ts:94,:121,:145);parseDefinitionsbinds the types again after construction. Logged indocs/ledger-dx-feedback.md.- SIMULATED: the passkey tests use a software WebAuthn authenticator (no device, no browser ceremony); the ML-DSA signer is software.
- Template.
apps/webis the output ofcreate-xrp@0.5.0(Nuxt), generated outside the repository and copied in unmodified (PR #19, three commits merged with--rebase: import, reformat, wire-in).UPSTREAM.mdrecords the tarball checksum, thegitHeadand what the generator does, withdist/index.jsline numbers. The wire-in raised pnpm to 10.33.0 (the template demands >= 10.26) inpackageManagerand the CI together, moved the nestedpnpm-workspace.yamlsettings to the root, pinnedxrplto 5.3.0 and added atypecheckscript. - CSP.
script-src 'self'withoutunsafe-inline. Nuxt writes its runtime config as an inline<script>in the SPA shell, so a Nitro plugin moves it into/_carbs/inline/<hash>.js(dev server, Node server andnuxt generate; in the static build the file is written by theprerender:generatehook). Checked over HTTP only; no browser was driven. - Five screens (Account, Session, Actions, Attack, Quantum) behind
NodePort,DevicePort,WalletPortandExposurePort. SIMULATED: the node (lib/sim/node.ts, spec 6.5 and 6.13 in order), the Stax (lib/sim/device.ts, the screen lines of spec 7.3 for the session types; the lines forSetRegularKey,AccountSetandPaymentare stand-ins until the device track freezes them), the wallet (lib/sim/wallet.ts: no WebAuthn prompt, no WebCrypto) and the exposure check over a simulated history. The badge is visible wherever a result comes from the simulation. - Not simulated faithfully (said here so nobody relies on it): granular permissions (mode P takes whole transaction types only), tickets in use, Batch, Firewall, Beneficiary, sponsorship, multi-signing, the 256-ledger window beyond the
LastLedgerSequencecheck, real signatures (keyed digests, keys.ts), real account addresses. The simulated node also reproduces the verified passkey defect P6 (a secondPasskeyListSetraisestefEXCEPTION). - Deviation from spec 8.5 as written (three demo profiles Face / Ledger / PQ): the Account screen follows the two profiles of ADR 0011 / D13 (Mac account and Ledger account), merged in #16 after this work started. A Mac account can also run the passkey-only recipe (regular key to the blackhole address, master disabled).
- Bug found by the tests:
(Flags | 0x80000000)is negative in JavaScript, so the first envelope no longer matched the mode C whitelist; flags are now normalized to unsigned (>>> 0) in the envelopes and in the simulated node. - Exposure rules adjusted in the simulation: a disabled master key and an ML-DSA-44 regular key are reported as seen but not exposed (a post-quantum key on the ledger is no risk; a disabled master no longer authorizes). Whether a disabled master key can still re-enable itself on the real node is UNVERIFIED here.
- Pending on GOAL-2:
packages/coreis still empty ondevelop. Until it exports its builders andloadDefinitions(server_definitions), the screens build transaction JSON inlib/tx-builders.tsand never encode it; once core lands, every transaction the UI builds goes through it with the definitions loaded from the node, never the bundledxrpl.jsdefinitions (they do not know types 130 and 131).
- Networks. The template's Testnet and Devnet are gone.
apps/web/lib/networks.tshas AlphaNet (default, network id 24100), the local node (NUXT_PUBLIC_LOCAL_NODE_URL, defaultws://localhost:6006) and mainnet (read-only, Quantum screen only). The localStorage key is nowcarbs-network(the oldscaffold-xrp-networkis never read). Explorer links for transactions use the explorer of the selected network; under the simulated backend no link is shown, since a simulated hash is on no explorer. The mainnet endpoints were written from memory at first;xrplcluster.comansweredserver_info(read-only) on 2026-10-24, andlivenet.xrpl.orgis still UNVERIFIED (no request made to it). - Removed: the template's payment page and form, header, shell, account card, network selector, the
useWalletanduseNetworkSelectioncomposables, its payment helpers and their tests, its logo; its README, QUICKSTART and.env.local.example. Branding iscarbswith a placeholder mark (components/ui/BrandMark.vue,public/favicon.svg); the credit to scaffold-xrp stays inapps/web/README.md,UPSTREAM.mdand the MITLICENSE. - CSP.
connect-srcis now a list of origins built from the networks and their explorers (lib/csp.ts), no bare scheme and no wildcard (tests).vercel.jsonis generated from the same policy and tested for drift;_headersis written into the static output. - Hosted node. A page served over https only accepts
wss://andhttps://node URLs (mixed content), said in the UI next to the field;docs/DEPLOY.mdlists it, the passkey /fixPasskeyChallengecaveat for the public AlphaNet, and "use Chrome" for WebHID. - Still open: every transaction the UI builds must go through
packages/corewith the definitions loaded from the node'sserver_definitions.packages/corelanded in #21 and the screens are not wired to it yet (milestone M2, next); until then no transaction is encoded by the web app.
- Three adapters replace the template's Xaman, GemWallet and WalletConnect (and their API keys, environment variables and QUICKSTART text): Ledger Stax (Device Management Kit only; the
LedgerAdapterof xrpl-connect, built on ledgerjs, is never imported, and a test scans the sources for it), Passkey (this Mac) and Demo account. They implement theWalletAdapterinterface read atapps/web/node_modules/xrpl-connect/index.d.ts:1928and register in the realWalletManager, which the tests drive (connect,sign,signAndSubmit,signMessagerefused). - SIMULATED: the node behind them (still
lib/sim/node.ts), the Stax (lib/sim/device.ts, so the Ledger adapter is SIMULATED whatever the node), the demo account. The Passkey adapter uses the browser's WebAuthn only when a person presses Connect; in tests it uses the software authenticator of@carbs/sessions(real P-256, no prompt), which the modal labels SIMULATED. No real WebAuthn prompt, no device and no browser automation were used here. - Passkey path, tested end to end on the simulated node: the passkey is registered by a master-signed
PasskeyListSet, signs through anAuthenticatorof@carbs/sessions, and the simulated node verifies the assertion likeSTTx.cpp:455-517(typewebauthn.get, challenge equal to base64url of the signing data, P-256 overauthenticatorData ‖ SHA-256(clientDataJSON)); a tampered transaction is refusedtemBAD_SIGNATURE. The signing data is the canonical JSON of the simulated transaction, not the binary encoding (the simulation does not encode); the live backend will pass theencodeForSigningbytes throughsignWithPasskeyof core. The simulated wallet's passkey is now this real software authenticator instead of a fake. - Browsers: WebAuthn needs https or localhost; WebHID needs Chrome; the modal gives the reason when an adapter is unavailable.
SIMULATED: the ML-DSA-44 key of K3 is a software key of @noble/post-quantum 0.7.1 standing in for the device; the classical master keys are software keys too. Client-side signing only, fresh faucet accounts, pnpm --filter @carbs/sessions exec tsx spikes/k3-alphanet.ts. Explorer: https://alphanet.xrpl.org/transactions/<hash>.
- K3, the base question: yes. A
Paymentsigned by an ML-DSA-44RegularKey(1 312-byte key, 2 420-byte signature made by@noble/post-quantum) is accepted:2C8878527C31D1835C3D2DF240CE1A28BFD2CF7F6C32436B48AD14B3E8AD7B38tesSUCCESS(accountrSVRWjk6Ma5L843PitewhF5JQbcJX3nzx, key accountr9iGR3sKh5x2qva2Lhb9yzXhyeSem3HN3v). This settles item 4 of the UNVERIFIED list of FIANSO-SPEC.md section 6.14 on the AlphaNet (a signature made by@noble/post-quantum, not on the device, accepted by a node) and item 5 for that key (the client'sRIPEMD160(SHA256(pubkey))is the AccountID the node accepted asRegularKey). - The order is enforced by the node. On the same account: the ML-DSA key signing before it is the regular key gets
tefBAD_AUTH, "Transaction's public key is not authorized." (F4C84C9BBA141AFB804718DBAFB7E0B930FB1CE5C18B39CF618F06DC905A9B74, not included);SetRegularKeyby the masterDC3569DB24C41D2BA1890A598245533D160D8C6D217B6B8FD1131701ED6B8165tesSUCCESS;asfDisableMastersigned by the ML-DSA regular key getstecNEED_MASTER_KEY(8A7DF265ECEE7D2C811380C7E44807D4816C35737F118C2A290677B30CBA7394, included, fee charged). - K3 extended: the migration of section 8.7 holds as specified, run by
runPqMigrationon a second fresh accountrnCQwebvNKsbaiByERfaj9NTNiTtkpENoV(key accountrDkSPCGkmTVUGGfXV8v6yNoZ4LN2vqoNNm):SetRegularKey, master:EEA75218F13CAC191A1EAC2282EE4C4798A4C6B7A07F5DCCDA0C75078F48B0A2tesSUCCESS;- proof
AccountSet(no change), ML-DSA:4477DF76EEFE0B542FB76EE0307AFEA6DFB0B9136B1461C79C39D72C22D60DFBtesSUCCESS; - guard before step 3: the proof validated, the ML-DSA key derived again from its seed in a separate process (software, SIMULATED; on the device this is the reinstall check), the exposure report reviewed (master exposed by step 1, no other authority);
asfDisableMaster, master:85B5A3A5989E1873B944817C76B80B408C83E4D0D91EDE79E682DC37C83DA9A1tesSUCCESS. Afterwards the master getstefMASTER_DISABLED, "Master key is disabled." (0BABF5ADE0B2EE070DDD497B55BACA5563D9C40711B5A13CBA53FC62C223D8F4, not included) and the ML-DSA key still signs (49AE6A196E3164347D40E5A258655C792E2C89B54DD2E75E26F1674550936C17tesSUCCESS).checkExposurethen answersno-exposure-found, "the master key is exposed but disabled", with the ML-DSA regular key exposed but not quantum-vulnerable.
- Friction: the AlphaNet RPC proxy answers HTTP 403 (an nginx page) to any
account_txthat carriesforward; a first K3 run stopped there after its first four transactions (fresh accountsrLtXhBrahFDudPsNFkbVnQN3K79wFxJktpandrB5rTuCkZ6mRX9zSuSn4MkFTRKwdo7F8Tb;tefBAD_AUTH,SetRegularKeyD21EA13B…, ML-DSAPayment5277E0FA…,tecNEED_MASTER_KEY600AD8A7…, the same results).checkExposurenow falls back to newest first and keeps the oldest reveal; the second run used new accounts.
K7, mainnet, read-only (spikes/k7-mainnet.ts, https://xrplcluster.com, rippled 3.4.1, history from ledger 32570; the answers are recorded in packages/core/src/quantum/fixtures/ and replayed by mainnet.test.ts):
- Known exposed, picked as a sender whose master key signed in ledger 107879672:
rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah,exposed, first self-signed transaction5773874D4E9E6DC8C8756EAC2B30DCBC860113EA20FE5EB29CEFC84025E581FE, found on the first page of an oldest-first search. - Known safe, picked as an account created by a payment in that ledger that has sent nothing:
rEpekNdzt2izvF2MQb1DHksNprz1KRsJyC,no-exposure-found(its creation is in the searched history, one transaction, the search not cut short). The same answers with a smaller bound giveunknown, "not found within 1 pages of 10 transactions". - Pages for a busy account: the same exposed account (89 transactions in one ledger) needs more than 25 pages of 200 transactions to reach its end: 5 000 transactions in 537 s, the search cut short. Finding a first reveal is cheap oldest first; proving absence for a busy account is not, so the bound and the
unknownanswer of rule 6 are what makes the check usable. - Friction: the public server rate-limits (
tooBusy, "rate limit: units quota (2000 per 10s) exhausted, retry in ~7428ms");JsonRpcClientnow retriestooBusy/slowDowna bounded number of times, waiting what the server says. Mainnet has noPasskeyListorBeneficiaryentries and refuses thoseaccount_objectsfilters withinvalidParams, "Invalid field 'type'."; the check asks only for entry types the node's ownserver_definitionslists. - Adapted to D13 and D14:
createMlDsaSessionKey()gives a Ledger account its fresh ML-DSA-44 session key (page memory, extractable, FIANSO-SPEC.md section 12).
docs/device-contract.mdwrites down what the client sends to the XRP app and what it expects back, every number cited in the device track's code: the classical0x02/0x04instructions as app-xrp does today (fields withoutSTX\0, the app adds it), the ML-DSA-44 instructions0x10/0x11/0x12of the device track (STX\0included, at most 2 048 bytes), the status words, the review lines, the byte layout, vectors, and the items left to the device-track owner.packages/ledgergets the client side of that contract (getClassicPublicKey,signClassic,getMlDsaPublicKey,signMlDsa,deviceMlDsaSigner,deviceClassicSigner,dmkTransportover the DMK 1.11sendApdu), the review-line predictionreviewLines, the device's ML-DSA key derivation (SLIP-21 labelcarbs ML-DSA-44, checked against the four test vectors of SLIP-0021), and a SIMULATED device that follows the contract APDU by APDU and signs in software.- Cross-checks:
reviewLinesreproduces line for line, order included, ten frozen screen vectors of the device track (SigningSessionSetin the three modes, ten permissions, a short duration, bothSigningSessionDeleteshapes,SetRegularKey,AccountSet asfDisableMaster, aPayment); ML-DSA signatures returned through the full0x11/0x12exchange verify with@noble/post-quantum0.7.1; classical signatures verify withripple-keypairs. - Adapted to the device track: its branch
STOOOKEEE/n1-passkey-challenge-fix(not indevelopyet) has its own DMK client inpackages/ledger/src/device.tsandsign.ts(aLedgerXrpsession with a rawexchange). This package adds files beside them and an adapter to thatexchangeshape (rawTransport,rawExchangeOf) instead of a second connection layer; the two clients send the same APDUs (0x11chunks of 250 bytes there, 255 here, both accepted byledger-app/src/pq/pq_root.c:164-181). When that branch lands,src/index.tsandpackage.jsonneed their exports and dependencies merged. - Deviation:
docs/ARCHITECTURE.mdsaidpackages/ledgerimports only types ofpackages/core; the device commands use its encoder and theBytesSignershape, so the rule now says it importspackages/coreand nothing else of the workspace. - SIMULATED: every result of
packages/ledger's tests comes from the simulated device; no device was touched. The key-derivation vector for Speculos' default mnemonic is UNVERIFIED on a device.
- Live source, real reads. The Quantum screen has a Data switch, default Live:
checkExposureof@carbs/sessionsruns against the real node of the chosen network (AlphaNethttps://rpc.alphanet.xrpl.org, mainnethttps://xrplcluster.com), read-only (JsonRpcClientrefuses any method outside its public list; a test asserts that onlyaccount_info,account_tx,account_objectsandserver_definitionsare called). The core report is mapped to the screen's report (lib/live/exposure.ts). Simulated histories stay behind the other radio button, badged SIMULATED. The migration panel is still the simulated one in this change. - Checked on the real AlphaNet (read-only, from Node, no browser): account
rpxMtodcdjFGpssK5k7D4CixifFBCLS7eV(recorded by the device track), history of 4 transactions in ledgers 220005 to 236826 with the creation inside: master key exposed but disabled, regular key ML-DSA-44 (post-quantum), statusno-exposure-found. Mainnet: the wiring reachedaccount_txon the genesis account and was rate-limited (below); no mainnet verdict was obtained. - Findings (also in
docs/ledger-dx-feedback.md): (1) the AlphaNet RPC answers the CORS preflight with 400, so a browser cannot POST JSON to it; the client sends the body astext/plain, a "simple" request without preflight, which both nodes accept (curlevidence, 2026-10-24); a test pins the header. happy-dom preflights every cross-origin request, so the component tests use a recorded node and the real-node check ran in plain Node. (2) the public mainnet cluster rate-limitsaccount_tx(tooBusy, "retry in ~7042ms"): the clients wait as long as the node asks, up to four times. (3)loadDefinitionsrefuses a node without the SigningSessions names by default; the exposure check does not need them, so it loads the definitions withrequireSessions: false(the session screens will require them). - No browser was driven (I do not use the Chrome tools), so the page-level behaviour of the preflight-free request is argued from the specification of simple requests and the
curlanswers, not observed in a browser.
- Deviation, planned: the passkey wallet (an account that signs with its passkeys only) is not in FIANSO-SPEC.md section 8.5; this pull request adds it there.
buildPasskeyWallet/runPasskeyWalletinpackages/core:PasskeyListSet(master), a proofAccountSetsigned by the passkey through WebAuthn,SetRegularKeytorrrrrrrrrrrrrrrrrrrrBZbvji(master),asfDisableMaster(master). The proof step is added to the planned order, for the reason the post-quantum migration has one: on a node that refuses the passkey's signature (P5 withoutfixPasskeyChallenge), disabling the master key would lock the account for good. The client refuses step 4 unless step 2 was validated. - Spike K1, public AlphaNet, fresh accounts, client-side signing (
spikes/k1-alphanet.ts): a WebCrypto P-256 key generated non-extractible, set asRegularKey(2A6B9E4052D4DEAB9BEF1CF75D6D1F588A5447528A93DCA99C6635D78F06C971), signs a 1 XRPPaymentwith a raw ECDSA signature normalized to low-S strict DER:E60C191803F88DDBD9316CA23B90D7FF48EAEE4A304B8E148B325C16E2B7766DtesSUCCESS(accountrpC3w3GwyXiZsjosZjduqQBxA9v2e9ZYkp). The outcome of K1 holds: the P-256 session key works; no Ed25519 fallback is needed. - SIMULATED, the passkey wallet on the AlphaNet (software WebAuthn authenticator, account
rKVYVn9TTrn2L4K1zg6UUfsxEMEJSf39Go):PasskeyListSet5D4B6E62B6B855C8D9C5E2AC4D1A608787AFA1E7B9A670B5FF4D3A760E3AC3FCtesSUCCESS; the WebAuthn proof is refused before it reaches a ledger,submitanswersinvalidTransaction, "fails local checks: Invalid signature." (0A32A379…, never included), as P5 predicts on a tree withoutfixPasskeyChallenge(the AlphaNet's platform is unknown: this says nothing more, as spec section 6.14 notes); the wallet stopped there and the master key stayed enabled. A raw P-256 signature by the same passkey key (noPasskeySignature) is accepted:B4613788A564A3877629FCA869B52C78C5E60764F1993A08AAE8AAD9FD267962tesSUCCESS. A first run of the same spike (fresh accountsrGpEjaLgmr1LEf4TTA7bafTXckoqJ96Wvh,r97RZuVj7rXK7uWD5pzDbXyLhU2TzgXry8; K1 paymentF644F01F…tesSUCCESS) stopped on that refusal becausesubmitBlobthrew on the RPC error; it now returnsinvalidTransactionwith the node'serror_exception. - Still to run on the local node once N2/N9 are merged (step 6 of the goal): the full passkey wallet with
fixPasskeyChallenge, then aSigningSessionSetsigned by the passkey (W2) and a session-signedPayment. - Hybrid note (a passkey as a multisig signer): recorded, nothing built. Multi-signing never reads the passkey list:
Transactor::checkMultiSignaccepts a signer's key only when it is the signer account's master key or itsRegularKey(rippled/src/libxrpl/tx/Transactor.cpp:1187-1340; the only read ofkeylet::passkeyListis in the single-signature path,:1141-1158), and theSignerinner object has noPasskeySignaturefield (rippled/src/libxrpl/protocol/InnerObjectFormats.cpp:21-27), so a WebAuthn assertion cannot even be carried. A raw P-256 key can still be a signer when it is the master key orRegularKeyof the signer account, sincecheckMultiSignaccepts any key typepublicKeyTypeknows (rippled/src/libxrpl/tx/Transactor.cpp:1254-1258); UNVERIFIED by a test.
SIMULATED: the device. It is the contract-following software device of @carbs/ledger with a random seed made in the page, not a Stax; the page's simulated Stax screen shows each review. Real: the node. Everything below is the public AlphaNet's own answer, signed client-side, never through the node's sign RPC, on a fresh faucet account (createLiveMigration, apps/web/lib/live/migration.ts).
- What it is. With the Data switch on Live, "Start with a fresh AlphaNet account" creates a master key and an ML-DSA-44 key through the device contract (
deviceClassicSigner,deviceMlDsaSigner), funds the account from the faucet (https://faucet.alphanet.xrpl.org/accounts), builds the three steps withbuildPqMigration, signs each withsignTransaction(NetworkIDand the other common fields fromfetchCommonFields), submits withsubmitAndWait(validated result), and shows each hash as a link to the explorer of the AlphaNet. Step 3 goes throughdisableMasterRefusals: it needs the validated proof, a post-quantum backup and the reviewed authorities. A software device cannot be reinstalled, so the recoverability evidence is a backup: the page shows the simulated device's 64-byte seed and the person must confirm saving it; a test derives the regular key back from that seed. - Run once against the real AlphaNet (from Node with the same controller, no browser, 26 s): fresh account
rfsQK9XifCgJ1BRYyZnDXBJfcequPJEKaq, exposure before:no-exposure-found;SetRegularKeyto the ML-DSA key, master:3C0B9A0DC86E03940A17A4BF7BE812998D967F253AB62462F73D81036E0D3039tesSUCCESS;- proof
AccountSet, ML-DSA:D073C5166244802D0517A10306F3E241D37099A6FAC54BA15B1749E28EB61806tesSUCCESS; asfDisableMasterfirst refused by the app ("the ML-DSA key is not proven recoverable and no post-quantum backup is given; the authorities that survive asfDisableMaster were not acknowledged"), then, after the seed backup and the review, master:B6E91FDBF765456633FB85BC32CEEBE31593DD4433CA58D62F688D933F5DB856tesSUCCESS. Exposure after:no-exposure-found(master exposed but disabled, regular key ML-DSA-44). Explorer:https://alphanet.xrpl.org/transactions/<hash>; the account page path/accounts/<address>is UNVERIFIED (the explorer is a single-page app that answers 200 to any path).
- Tests (121 in the web app): the controller against a test double of the AlphaNet that decodes the real blobs (
lib/live/fake-alphanet.ts: order enforced with the node's own codes, no signature check, a TEST DOUBLE), and the Quantum screen on both sources. - Not done: no browser was driven, so the faucet's CORS answer (the preflight returns 200 and reflects the origin, checked with
curl) and the explorer links are not observed in a page. Mainnet is never written to.
Not verified on the amendment node: it does not exist on develop yet (the amendment's definitions are in an open pull request). What is built and what it was tested against:
- Built (
apps/web/lib/live/):createLiveBackendasks a node on its public port for itsserver_definitionsand refuses one without the SigningSessions names;LiveNode(NodePorton@carbs/sessions:ledgerfor the close time,account_info,account_objectswith thesigning_sessiontype,fetchCommonFields,encodeTx,submitAndWait);createLiveBuilders(the node's own shapes, flags and permission objects from the definitions, never typed by hand);LiveWallet(a secp256k1 master key, the passkey throughsignWithPasskeywith the browser's WebAuthn or the software authenticator, a non-extractible P-256 or an ML-DSA-44 session key, fresh per session);createLiveDevice(the contract-following software double of@carbs/ledger, SIMULATED).lib/boot.tspicks the backend at page load (NUXT_PUBLIC_BACKENDor the saved choice); if the node cannot be reached, answers too slowly (8 s) or lacks the amendment, the page stays on the simulation and the header says why. A page served over https never tries aws://node. - Tested (149 tests in the web app): the whole demo store on the live backend over a test double of the node that decodes the real blobs and serves core's SIMULATED session definitions: account setup for the three profiles, a mode C session opened and closed, session-signed payments counted against the cap, the node's refusals for the attacks, expiry on a live node without a simulated clock, a passkey signing the binary signing data, a rejected device. The double applies a handful of the session rules with the node's codes (
lib/live/fake-alphanet.ts); it is not the amendment. - Unverified until a node carries the amendment: the shape of
signing_sessionobjects inaccount_objects(Expiration,Permissions,SessionCap,SessionSpent,Flags), theledgeranswer used for the close time, whether the standalone node servesaccount_objectsfor the type namesigning_session, and every real result code. Nothing was submitted to any node for this change. - Refactors on the way: builders, common fields and counterparties come from the backend (
TxBuilders,NodePort.commonFields,Backend.counterparties); the context keepssimnext to the activebackend. The local node's dev faucet (NUXT_PUBLIC_LOCAL_FAUCET_URL, defaulthttp://localhost:3005/accounts) is part of the Content-Security-Policy; the script that starts it is milestone M3 proper.
- One macro batch, minimal transactors, one build.
SigningSessions(Supported::Yes,DefaultNo) at the top offeatures.macro, abovefixPasskeyChallenge; the fieldsSessionKeyACCOUNT 34,SessionCapAMOUNT 47,SessionSpentAMOUNT 48,SessionDurationUINT32 114;ltSIGNING_SESSION0x009A(signing_session);SigningSessionSet130 andSigningSessionDelete131;tfAllTransactionsandlsfAllTransactions0x00010000;tefSESSION_EXPIRED,tefSESSION_LIMIT,tefSESSION_TX_NOT_ALLOWEDat the end ofTEFcodes(-172, -171, -170) with theirTER.cppstrings;keylet::signingSessionin namespace'z';kMaxSessionDuration,kSessionFeeMultiple,kSessionMaxLedgerWindowinProtocol.h;jss::session_key; theledger_entryparser; theAccountDeletedeleter. Every number equals spec 6.1 andledger-app/src/xrp/signing_session.h(130, 131, 34, 47, 114, 52, 29, 15,0x00010000);SessionSpent(48) is not parsed by the app, which never signs it. - No field for D13 to D16 or ADR 0010. D13, D14 and D16 need no field (spec 6.14: an ML-DSA-44 session key goes through generic code). ADR 0010 is still
Proposedand nothing of it is adopted (D15), sosfSessionFirstTicketand the ticket budget fields are not added. - What the transactors do in N2.
SigningSessionSet:checkExtraFeaturesonPermissionDelegationV1_1, the flag mask,tecDUPLICATEfor an existing (account, key), the sponsor-awarecheckReserve(tecINSUFFICIENT_RESERVE), the entry withExpiration = parentCloseTime + SessionDurationandSessionSpent = 0in mode C, the owner directory and owner count. Its preflight validation is N3.SigningSessionDelete: the owner closes withoutOwner; withOwner, any account closes an expired session (tecNO_PERMISSIONwhile it is live);Owner == AccountistemMALFORMED; a missing session istecNO_ENTRY. Deviation from the order of spec 10.1: these closes are N7's; they are here so that the transactor ondevelop, which the other goals now use, never lets a stranger close a live session. The own close by the session key needs the session path (N4) and stays in N7. - Decisions. Reserve sponsorship: not supported (spec 6.10).
SigningSessionSetis not inisReserveSponsorAllowed(rippled/src/libxrpl/ledger/helpers/SponsorHelpers.cpp:28-61), sopreflight1refuses a reserve sponsor withtemINVALID_FLAG(rippled/src/libxrpl/tx/Transactor.cpp:216-224), tested;ltSIGNING_SESSIONis not inisLedgerEntrySupportedBySponsorship(:268-286). Firewall:Allow, the default, asDelegateSetandSignerListSet(rippled/include/xrpl/protocol/detail/transactions.macro:837-840,:173-174): opening or closing a session moves no value, and every session-signed transaction still meetscheckFirewall(spec 6.5). Both types keep the defaultNotDelegable, so theDelegatecounter stays at 71. - Deviation from
docs/PLAN-CHECK.md, N2 bullet: the twoTRANSACTIONentries name only.amendment. The macro header asks to name only the settings that differ from the defaults (rippled/include/xrpl/protocol/detail/transactions.macro:28-29); the firewall and privilege decisions are pinned by the testtransaction settingsinstead. The four fields are appended together at the end ofsfields.macro, not after the highest code of each type. - Tests first.
SigningSessions(cases 1, 7, 18 without the own close, 19, 24 for RPC and sponsorship, the reserve code, flags, settings, an MPTSessionCaprefused by the local checks) and the jtx helperstest/jtx/session.hwere written before the definitions; they did not compile without them. jtx and not gtest: every case goes throughsubmit,preflight,preclaimandTransactor::operator(), which need anApplication(docs/PLAN-CHECK.mdfinding 9). Two upstream suites get a case for the new type, nothing removed or weakened:LedgerEntry(testSigningSession, the malformed-field sweep ofrunLedgerEntryTest, astestDelegate) andMPToken(testMPTInvalidInTxcoversSigningSessionSet.SessionCap, finding 3). - Generated code.
code_genrewrote 21 unrelated files and created 14 (the drift of finding 8:AccountRoot,MPToken,MPTokenIssuance,PasskeyList,Repo,Vault,CouponScheduleCreate,PasskeyListSet,RepoAccept,RepoCancel,RepoClose,RepoCreate,RepoDefault,VaultCreate,VaultSetand their tests changed;Beneficiary,TokenIssuance,BeneficiarySet,TokenConvert,TokenIssuanceCreate,TokenIssuanceDestroy,TokenIssuanceSetand their tests new). All of them were restored or removed; only the sixSigningSession*files are committed. - Levelization (scratch mirror): no new line; the only difference is the known base drift
test.server > xrpl.rdb. - Results (
rippled/build, Debug; the run before review round 1; after it, the suite has 10 cases and 284 tests, below)../xrpld --unittest=SigningSessions: 9 cases, 268 tests, 0 failures../xrpld --unittest=SigningSessions,Delegate,PasskeyListSet,AccountDelete,PassKey,ServerDefinitions,PasskeyWebAuthn,Apply,MPToken,Feature,LedgerEntry,AccountObjects --unittest-jobs=6: two failures, both indocs/base-failures.txt—Delegatecounts 71 delegable types against 60 pinned (unchanged by N2: both new types are non-delegable) andMPTokenfails its completeness assertion, now at line 2105 since ten lines were inserted aboverippled/src/test/app/MPToken_test.cpp:2095. Printed once with a temporary log line, then removed: the set it asserts empty holdsFirewallSetMaxFeeandOfferCreateMinQuantity, the base pair;SigningSessionSetSessionCapleft it../xrpl_tests: 1135 tests, 1134 passed, the one failure is the baseVaultTests.BuilderFromSleRoundTrip; the 15 generatedSigningSession*tests pass. - Test that first failed for a reason of its own: the
tecINSUFFICIENT_RESERVEcase. ASigningSessionSetrefused for its reserve in the open ledger was applied again when the ledger closed, after a payment had covered it (the open ledger retries atec). The test now closes the ledger after the refusal. - Device-track watch. A draft pull request of the device-track owner (#29, branch
STOOOKEEE/n2-signing-sessions) implements mode C and adds two protections not in the spec:SessionMaxPerTx(AMOUNT 49),SessionWitness(ACCOUNT 35) withWitnessSignature(OBJECT 43), andtefSESSION_NO_WITNESS; its description says the owner must confirm them. The branchSTOOOKEEE/l1-session-clear-signingadds 49 and 35 to the device'ssigning_session.has optional rows. Every number #29 gives to an item of spec 6.1 equals N2's (130, 131, ACCOUNT 34, AMOUNT 47 and 48, UINT32 114,0x009A, the threetefSESSION_*codes in the same order), so the device's header ondevelopand N2 agree. N2 follows the spec and does not add the two protections; codes 35, 43, 49 and thetefvalue aftertefSESSION_TX_NOT_ALLOWEDstay free, so they can be appended without renumbering if the owner adopts them. - Review gate 2, round 1:
VERDICT: CHANGES REQUIRED(Plan agent, model opus, read-only). Blocking, all applied: (1) the stranger's close "at Expiration exactly" was never at it, since close times move in 10-second steps and a 25-second duration ends between two of them; the test now uses 30 seconds, is refused at every ledger beforeExpirationand assertsparentCloseTime == Expirationbefore the close that succeeds; (2) the reserve test did not prove that the reserve is checked on the balance before the fee; it now funds one drop short of the reserve of one more object (tecINSUFFICIENT_RESERVE), then exactly that reserve, which succeeds although the balance after the fee is below it; (3) four comments named theValidSigningSessioncheck, which does not exist before N6; they now say there is no transaction-specific invariant yet. Non-blocking: the MPTSessionCapcase promised bydocs/PLAN-CHECK.mdfinding 3 is added; blank lines between the macro entries; a citation off by one line fixed (transactions.macro:28-29). Left for later, as the reviewer allows: before N3,SigningSessionSetstores any shape, including a duration that makes the 32-bitExpirationwrap (no authority follows from a session until N4, and N3 lands first); a fee-only sponsoredSigningSessionSetis accepted and untested (N8). - Mutation check of the two strengthened tests (temporary edits, reverted): the stranger's close with
<=in place of<fails atrippled/src/test/app/SigningSessions_test.cpp:271(tecNO_PERMISSIONatExpiration); the reserve checked on the balance after the fee in place ofpreFeeBalance_fails at:216(tecINSUFFICIENT_RESERVE). After the revert:./xrpld --unittest=SigningSessions, 10 cases, 284 tests, 0 failures. - Review gate 2, round 2:
VERDICT: APPROVED. All three blocking findings resolved (the cited lines:271and:216checked against the assertions); one new non-blocking note, applied: the earlier result above is marked as the run before round 1. Comparison suites again on the final tree,--unittest-jobs=6: 13 suites, 294 cases, 87171 tests, 2 failures, the same two base failures (Delegate71 vs 60,MPToken_test.cpp:2105).
- The device-track owner's commit, cherry-picked alone.
infra/standalone.cfg,infra/closer.tsandinfra/compose.yamlcome unchanged fromebdd0ce(branchSTOOOKEEE/n1-passkey-challenge-fix, draft #22), with its author; the other commits of that branch stay out (its P5 fix repeats #13; the test wallet and the DMK signer are the owner's to bring). Our commit addsSigningSessionsto[amendments]and updatesinfra/README.md. - Deviation from spec 6.12 (
[features]): the configuration enables the amendments through[amendments], whose entries are voted up and enabled in the genesis ledger of--start; the owner's commit says[features]does not seed the genesis ledger. Checked here only in the direction that matters: with[amendments], thefeaturecommand reports all six enabled (below). That[features]alone would not is the owner's observation, UNVERIFIED here. compose.yamlbuilds awalletservice fromapps/passkey-wallet, which is not ondevelop(it comes with the owner's test-wallet commit);infra/README.mdsays so. The node and closer services do not depend on it.- Checked on the node built from N2 (#32), with a copy of
infra/standalone.cfgwhose paths and ports point at a scratch directory, the closernode infra/closer.tsagainst its admin WebSocket, and a script that signs on the node's admin port with secp256k1 keys of fresh accounts (local standalone node only; no P-256 key goes through the node's signing):server_definitionson the public port:SigningSessionSet130,SigningSessionDelete131,SigningSession154 (0x009A),SessionDurationUInt32 114,SessionCapAmount 47,SessionSpentAmount 48,SessionKeyAccountID 34,tefSESSION_EXPIRED-172,tefSESSION_LIMIT-171,tefSESSION_TX_NOT_ALLOWED-170.feature:Passkey,Quantum,SigningSessions,BatchV1_1,PermissionDelegationV1_1,fixPasskeyChallengeallenabled: true.fixBatchV1_2was not needed for anything run here; it stays UNVERIFIED as a need.- The closer logged
closer: connected to ws://127.0.0.1:16007, closing every 3000 msand ledgers advanced. - A session opened and closed in mode A (
Flags65536) and in mode C (SessionCap50000000,SessionSpent0):SigningSessionSettesSUCCESS, the entry listed byaccount_objectstypesigning_session,SigningSessionDeletetesSUCCESS, no session left. ledger_accepton the public port:forbidden, "Bad credentials.".
- Tests first. The new cases of
SigningSessionswere written and run against the N2 tree before any of this code: 21 cases, 804 tests, 92 failures (every validation case answeredtesSUCCESSortecDUPLICATE, every session-signed transactiontefBAD_AUTH). After the code: 28 cases, 1720 tests, 0 failures. - N3,
SigningSessionSetvalidation (spec 6.3; cases 2 to 5, case 6 without the session part). Preflight: exactly one mode, elsetemMALFORMED;SessionKey == AccounttemMALFORMED; a duration of 0 or above 600temBAD_EXPIRATION;PermissionsemptytemARRAY_EMPTY, above 10temARRAY_TOO_LARGE, a duplicate, a non-delegable value or an excluded typetemMALFORMED(asDelegateSet::preflight,rippled/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp:25-48); aSessionCapthat is not a positive XRP amounttemBAD_AMOUNT. Preclaim: aSessionKeythat is theRegularKey, the AccountID of a key of the passkey list (read ascheckSingleSignreads it,rippled/src/libxrpl/tx/Transactor.cpp:1220-1236) or the beneficiary (:1238-1256) istecNO_PERMISSION. WithDelegate, the type being non-delegable, preflight answerstemINVALID(tested). The wrap of a 32-bitExpirationthat review gate 2 noted for N2 is no longer reachable: the duration is at most 600. - N4, the predicate.
isSessionSignedandsigningSessionOflive in one header,rippled/include/xrpl/tx/SigningSession.h;signingSessionOfis the predicate returning the session's keylet, whichapplySteps.cpppasses to the hook andTransactor.cppreads for the accounting, andisSessionSignedis its boolean form (a test asserts both agree, N10). The invariant (N6) uses the same shape test without the existence check (sessionKeyletOf), because an own close erases the session it must still recognise: a deviation in form from spec 6.4 ("one function"), not in what the three parts decide. A transaction is session-signed whenSigningSessionsis enabled (no ledger read before), it carries noDelegate,Sponsor,SponsorSignatureorCounterpartySignatureand is not a Batch inner transaction, it is single-signed with a key of a known type and has noSigners, and a session exists for (Account, the signer). The checks ofSignersand of the Batch inner flag are defensive: a multi-signed transaction and a Batch inner transaction have an emptySigningPubKey, so the signer test already refuses them (so a mutation of either would go unnoticed; neither is in the mutation check). - N4, the hook. In
invokePreclaim,Transactor::invokeCheckSession<T>replacesT::checkSignwhen the predicate holds, betweeninvokeCheckPermission<T>andcheckFirewall(spec 6.5); it returnsNotTEC. Its rules, in order: (1) eligibility,tefSESSION_TX_NOT_ALLOWED: the own close (aSigningSessionDeletewithoutOwnerwhoseSessionKeyis the signer) is always eligible; mode A takes a delegable type not inkSessionExcluded; mode P runs the sequence ofTransactor::checkPermission(rippled/src/libxrpl/tx/Transactor.cpp:391-419) with the session entry, thenT::checkGranularSemantics, every refusal mapped to thetef; mode C signs nothing yet (N5); (2)LastLedgerSequencepresent and at most 256 ledgers ahead,tefSESSION_TX_NOT_ALLOWED; (3) expiry,tefSESSION_EXPIRED, except the own close; (4) the fee ceiling,tefSESSION_LIMIT, the own close included. What the replacedTransactor::checkSigndid and the hook must redo: the pseudo-account refusal (tefBAD_AUTH, asrippled/src/libxrpl/tx/Transactor.cpp:964-974), and the unknown key type, which is part of the predicate (such a transaction is not session-signed and meets the ordinarytefBAD_AUTH). - Deviation, the fee ceiling: the hook calls
T::calculateBaseFeedirectly, asinvokeCalculateBaseFeedoes inside the samewithTxnTypedispatch, instead of the freecalculateBaseFeeand itsExpected; an exception there leavesinvokePreclaimand is caught bypreclaim(rippled/src/libxrpl/tx/applySteps.cpp:438-441), which answerstefEXCEPTION, atefas well; that catch takesstd::exceptiononly, whereinvokeCalculateBaseFeealso catches....AMMCreateandLedgerStateFix, whose required fee is the owner reserve increment, pass the ceiling (tested;LedgerStateFixthen fails for its own reason,tecFAILED_PROCESSING). - Deviation, where
kSessionExcludedlives: inSigningSession.hbeside the predicate, not inrippled/include/xrpl/protocol/Protocol.hwith the numeric constants:Protocol.hdoes not seeTxType. - Types that check their own signature (
BatchandLoanSetoverridecheckSign) are never authorized by a session: the hook refuses a type whosecheckSigndiffers fromTransactor::checkSign, and case 9b asserts that no eligible type has one (both are non-delegable today, so the refusal is defence in depth).FirewallSet,FirewallDeleteandWithdrawPreauthcheck a counterparty signature inpreclaim, which still runs after the session path; they are out of reach because they are not delegable. - Case 9b pins the 68 types a session signs in mode A and lists in mode P (the 71 delegable types minus the three excluded). A new delegable type fails the test until it is added there or to
kSessionExcluded. The test also lists every type in mode P and expectstesSUCCESSfor exactly those 68. - Mode C answers
tefSESSION_TX_NOT_ALLOWEDto everything but the own close until N5 brings its whitelist and its accounting together: the cap check without the counter would let a session spend without bound. The testmode C before its accountingpins this and changes in N5. - Case 20 (D14). Session keys of the four key types sign a payment and close their session: P-256 (raw ECDSA, no
PasskeySignature), Ed25519, secp256k1 and ML-DSA-44. The Ledger account: master disabled,RegularKeyan ML-DSA-44 key (1 312 bytes); the disabled master cannot open a session (tefMASTER_DISABLED), a session for theRegularKeyitself istecNO_PERMISSION, theRegularKeyopens one; the entry'sSessionKeyiscalcAccountIDof the session key; the ML-DSA-44 session key pays, is refused for another account (tefBAD_AUTH), and closes its own session; a corrupted ML-DSA-44 signature is refused bysubmit(invalidTransaction). A 1 312-byteSigningPubKeythat is not a key is typed ML-DSA-44 by its size and refused bysubmit(invalidTransaction). This settles items 1 and 2 of the UNVERIFIED list of spec 6.14 on our node (jtx keys, not@noble/post-quantum: items 4 to 6 stay open). Low-S strict DER for P-256 is the client's duty (the node accepts high-S, spec 6.14): not claimed here. - Case 14 and 15. A session key used for another account, with
Delegate, withSigners, with a feeSponsor, with aCounterpartySignature(aFirewallSetupdate) or as a Batch inner signer falls to the ordinary signature check and getstefBAD_AUTH. Deviation from case 14 as written: the multi-signed shape getstefBAD_SIGNATURE, nottefBAD_AUTH: a signer that is not in the account's signer list is refused bycheckMultiSignwith that code, an existing rule this amendment does not touch. - Case 17 and 17b. A session key later set as
RegularKeystays bounded (SetRegularKeysigned by ittefSESSION_TX_NOT_ALLOWED) and has full authority once the owner deletes the session with its master key.LastLedgerSequencemissing or atseq + 257:tefSESSION_TX_NOT_ALLOWED; atseq + 256: applied. - Not here: the pseudo-account refusal of the hook has no test yet (a session entry for a pseudo-account needs injected state; N8, case 24). The fee-only sponsored
SigningSessionSetnoted by gate 2 stays for N8. - Mutation check (one temporary edit at a time, rebuilt, run, reverted; script output kept in the pull request): the predicate without the
SponsorandSponsorSignaturetest (1 failure), without theCounterpartySignaturetest (1), without theDelegatetest (1); expiry at>instead of>=(4); the fee ceiling one base fee higher (23); the window one ledger longer (1);SubscriptionSetout ofkSessionExcluded(34);checkGranularSemanticsskipped (3); theRegularKeycheck ofSigningSessionSetremoved (the suite aborts). After the revert: 0 failures. - Neighbouring suites (
--unittest-jobs=6): SigningSessions, Delegate, PasskeyListSet, AccountDelete, PassKey, ServerDefinitions, PasskeyWebAuthn, Apply, Batch, Firewall, Beneficiary, MultiSign, Sponsor, TxQ, Feature, MPToken, LedgerEntry, SetRegularKey, AccountSet and everyInvariants*suite: 34 suites, 618 cases, 133273 tests, 4 failures, all indocs/base-failures.txt(Delegate71 vs 60,MPToken_test.cpp:2105,InvariantsVault_test.cpp:3035twice). Levelization (scratch mirror): no new line. - Review gate 2, round 1:
VERDICT: CHANGES REQUIRED. Blocking, applied: a mode-P session could list a granular permission of a non-delegable type and sign with it.Permission::isDelegableaccepts a granular value when its type's amendment is enabled, even for a non-delegable type (rippled/src/libxrpl/protocol/Permissions.cpp:235-244), soAccountDomainSet,AccountTransferRateSetand the otherAccountSetgranular permissions passed, and a session could change an issuer'sTransferRate.SigningSessionSetnow refuses any permission, transaction-level or granular, whose type mode A could not sign (temMALFORMED), and the hook checks the same eligibility at use under the ledger's rules. Tests:AccountDomainSetandAccountTransferRateSetrefused; case 9b lists every granular permission and pins the seven a session may hold (TrustlineAuthorize,TrustlineFreeze,TrustlineUnfreeze,PaymentMint,PaymentBurn,MPTokenIssuanceLock,MPTokenIssuanceUnlock). Non-blocking, applied: the comment and this entry no longer count the firewall types among those that overridecheckSign; the exception path of the fee ceiling is described as it is; the predicate is evaluated once (invokePreclaimpasses the session's keylet to the hook); trace logs on thetecNO_PERMISSIONandtecDUPLICATErefusals; the whitespace change clang-format made to upstreamcheckSingleSignis reverted (only our block is formatted); and the tests the reviewer found missing:SponsorshipSetandSubscriptionSetsigned by a mode-A session (tefSESSION_TX_NOT_ALLOWED), the close of another key's live session, a session-signedBatchwith an innerSetRegularKey(tefSESSION_TX_NOT_ALLOWED), the session key as the counterparty signer of a firewall (tefBAD_AUTH), a pre-funded fee sponsor withoutSponsorSignature(tefBAD_AUTH), the documented limitation of 17b (with a fee sponsor or as a Batch inner signer, a key that is both theRegularKeyand a session key acts with theRegularKey: applied), and a P-256 session key signing through WebAuthn (PasskeySignature, software authenticator, SIMULATED): the session still bounds it. Result: 28 cases, 1844 tests, 0 failures. Mutations of the new checks: the granular refusal removed (16 failures), theSponsorexclusion alone removed (1 failure). Not covered by a test: the eligibility check of mode P at use, reachable only if a type stops being delegable under a live session. - Review gate 2, round 2:
VERDICT: CHANGES REQUIRED, for one documentation finding: the pull request body still said "SIMULATED: nothing" after the WebAuthn test was added (hard rule 7); every code finding of round 1 was found fixed, and the reviewer wrote "Once the PR body is fixed, I have nothing left to raise." The body now labels the test SIMULATED. Deviation fromPLAN.mdsection 3 (at most two rounds per gate): a third, confirmation-only round checked that body edit and nothing else:VERDICT: APPROVED. Neighbouring suites after the fix: 34 suites, 618 cases, 133397 tests, the same 4 base failures.
Real: the node. It is the standalone node of infra/standalone.cfg started by pnpm demo on this machine, with a build from the protocol track made at the N2 stage (the macros and the minimal transactors; its server_definitions serve SigningSessionSet 130, SigningSessionDelete 131 and the three tefSESSION_* codes). SIMULATED: the Stax (the software double of the device contract) and, for the passkey, the software authenticator of @carbs/sessions (real P-256, no prompt). No browser, no device and no real WebAuthn prompt were used; the run is the web app's own live backend driven from Node.
pnpm demo(scripts/, package@carbs/demo): rewritesinfra/standalone.cfgfor this machine (data in a temporary directory, loopback only), startsxrpld(XRPLD_BIN, defaultrippled/build/xrpld),infra/closer.ts, a dev faucet on127.0.0.1:3005that holds the genesis key (GENESIS_SEEDfrom the environment, never written; the browser never sees it; only the page's own origins may call it) and the web app withNUXT_PUBLIC_BACKEND=live. Tested: the configuration rewrite, the ports read from the file, the destination parser, and the faucet over HTTP (the payment it signs is decoded:Payment, 100 XRP, from the genesis account; origins, preflight, bad body, other paths).- What worked on the real node (one fresh account per profile, each funded by the faucet):
- the older build without the amendment: the live boot refuses it with the exact missing names and the page stays on the simulation;
- Demo account: funded, then
SigningSessionSetin mode C (20 XRP, 600 s)tesSUCCESS; thesigning_sessionobject the node returns (Account,SessionKey,Expiration,SessionCap,SessionSpent) maps correctly inLiveNode, which settles the UNVERIFIED shape noted in the entry above; - Mac account: master-signed
PasskeyListSettesSUCCESS(the node accepts thePasskeysobject shape built from the definitions), thepasskey_listobject read back, then a session opened with a WebAuthn assertion over the binary signing datatesSUCCESS(fixPasskeyChallengeis enabled by the configuration); - Ledger account:
SetRegularKey(master), proofAccountSet(ML-DSA),asfDisableMaster(master), alltesSUCCESS, then a mode A session opened by an ML-DSA-44 signaturetesSUCCESS.
- What failed, and why: every session-signed transaction (actions, attacks, the session key's own close) got
tefBAD_AUTH: the authorization hook of the amendment (N3/N4) is not in that build. So Actions and Attack cannot be checked on the real node yet. - Bug found by this run (fixed):
account_objectsreads the validated ledger, so right after funding it answersactNotFound, which aborted account setup.LiveNodenow waits for the account to be validated before returning from the faucet and treatsactNotFoundonaccount_objectsas an empty list; the test double raises it too. - Addresses of the accounts of this run are throw-away standalone accounts and are not recorded.
- The client reads the node now. N2 and N9 are merged (
develop0882800). Theserver_definitionsof our standalone node (hashF3F8D90EA7D8C770C528567852D1EE2AEC8F1F6F248BA6995C9CCB7E42CB18D9) are recorded inpackages/core/src/fixtures/standalone-server-definitions.json; a test shows that every SigningSessions identifier of the SIMULATED definitions equals the node's (types 130 / 131, fields 34 / 47 / 48 / 114, entry0x009A,tfAllTransactions/lsfAllTransactions0x00010000,tefSESSION_EXPIRED−172,tefSESSION_LIMIT−171,tefSESSION_TX_NOT_ALLOWED−170). The encoding and device tests use the node's definitions;encodeForDevicestill reproduces the device track's seven vectors byte for byte. The SIMULATED definitions stay exported for code written against them. - The node:
xrpld3.4.1+DEBUG, a copy of the binary built in GOAL-1's worktree (whose head was0882800, with N3 work not yet committed), started standalone (-a --start) oninfra/standalone.cfgwith local ports and paths;featurereportsSigningSessions,fixPasskeyChallenge,Passkey,Quantum,BatchV1_1andPermissionDelegationV1_1enabled;infra/closer.tscloses a ledger every 3 s. The genesis seed came from an environment variable. Run:spikes/k1-local.ts. - SIMULATED passkey wallet on our node (software WebAuthn authenticator), account
rJ1mTefJFYYJgv9fcCVZxN4PRHVvkhhY6Y:PasskeyListSet(master)F9194EED619B7F3E1CCB214134950B7DDDB1025FC8F624C9F20384860077A968tesSUCCESS; the WebAuthn proof5EE08F891163968E207FF26DE48E7AA4D0AE094B1F92D3102430828FE347FB0AtesSUCCESS(withfixPasskeyChallenge; the public AlphaNet refused the same shape, see "Passkey wallet and spike K1");SetRegularKeytorrrrrrrrrrrrrrrrrrrrBZbvji57163143E76EA0B9480E905E67C34149A193E6B1F2CB97918EC0C123B4CB4C77tesSUCCESS;asfDisableMaster3CAC1409CB97DAD4F2170D5E76461B0DFC85040B0CD1DB1963234D779C0EC7A9tesSUCCESS; the master key then getstefMASTER_DISABLED, "Master key is disabled.". - W2, a session opened by the passkey:
SigningSessionSetin mode C (20 XRP, 600 s), signed through WebAuthn by the passkey of that account:B78CB7C811CDB1866EB90366866EAF7C4CCB3A2165D0D429A5F954E2FE25BACCtesSUCCESS; the entry is found at the index the client computes (signingSessionIndex,SHA512Half(0x007A ‖ Account ‖ SessionKey)) withSessionCap20000000,SessionSpent0,Expiration846197110. - Ledger account (D13, D14; SIMULATED software ML-DSA keys):
SetRegularKeyto an ML-DSA-44 keyF770A9D1…, proof4F0535B0…,asfDisableMasterB33BF7C5…, alltesSUCCESS; then aSigningSessionSet(mode A) signed by the ML-DSA-44 regular key8B82D785DF23A51CB9AC97834919EECDCED1BE6B5B82982DA70CFDB3DABCEF0CtesSUCCESS, whose session key is a fresh ML-DSA-44 key. - Not yet: a session-signed transaction. The session path is N3 / N4 / N4b, not merged; until then the node authorizes a session key like any other key, so every transaction it signs gets
tefBAD_AUTH, "Transaction's public key is not authorized." (the P-256Payment5AD00254…, the ML-DSA-44PaymentB1F50D8D…; also theSetRegularKey,PasskeyListSet, over-capPaymentand own close attempts). The spike runs again once N4 is merged. - Device-track watch: the device track's draft #29 (based on its own branch, not
develop) addsSessionMaxPerTx(AMOUNT 49),SessionWitness(ACCOUNT 35) andtefSESSION_NO_WITNESS, a design that is not in the spec; GOAL-1 left those codes free. The client looks every identifier up by name, so nothing changes here unless the owner adopts it.
- Production Node server checked over HTTP (
nuxt build, thennode .output/server/index.mjs): the Content-Security-Policy and the other headers are sent, the page holds no executable inline script,/_carbs/inline/<hash>.jsis served astext/javascript, and the meta fallback is present. The dev server and the static build were checked earlier. No browser was driven. - Wording and paths: the import recipe in
UPSTREAM.mdno longer carries an absolute path, and an attack title that said "deadline" now says "last ledger" (it isLastLedgerSequence).
- FIANSO-SPEC.md section 8.3 asks the client to identify a transaction by
(Account, Sequence), not by hash, while P-256 malleability exists (the node does not enforce low-S, so a relay can re-encode a P-256 signature and change the id).submitAndWaitwaited by hash only; withWaitOptions.identity(set bysubmitSignedAndWait, which the migration and the passkey wallet now use) it also looks the transaction up inaccount_txonce the account's validated sequence has moved past it (or byTicketSequence), and returns the id the ledger holds. Tested with a recorded answer whose id differs from ours.
An independent read-only review (a separate agent, no build, no run) of apps/web found the defects below; each was checked against the code before it was fixed, and each has a test (lib/review-fixes.test.ts and the component tests).
- High: the live migration could show the seed of an earlier run as the backup of the current key (the seed was kept after an exposure check that could fail). The seed is now tied to the run that made it.
- Results and state: a failed refresh after a submit lost the node's answer (and, for a session opening, the session key); the answer is now kept. A slow poll could overwrite a newer refresh with an older state (the cap-overflow attack then built a one-drop payment): refreshes carry a sequence number and only the latest writes; the poll never throws and skips while an action runs. A preliminary
tesortecthat never validated was reported as final:NodeResult.finalsays whether it is the node's last word, and the view says "not validated". A setup that stopped half-way left the other screens believing an account existed: they wait forready. - Labels: the faucet and the passkey were called SIMULATED on a live backend, and a Ledger account signed by the software device had no badge on a live node; the labels now follow what is actually simulated (the device, the authenticator) and what is not.
- Wallet adapters on the live backend: they returned the simulation's hash and address and signed the passkey's challenge in the simulation's form; they now use the node's transaction hash, the signer's real address and the live wallet's passkey. Tested against the node double.
- Explorer link: a live result linked to the explorer of the network selected in the header; it now links to the explorer of the node it was sent to.
- Keyboard: the device screen takes the focus, closes on Escape, keeps Tab inside, and makes the page behind it inert; two reviews are queued instead of the second replacing the first and leaving its caller waiting; the wallet dialog takes the focus and gives it back.
- Smaller: a fresh Ledger account now gets a fresh simulated device (the second one reused the first's address); the victim account is funded only for the attack that needs it; the Actions and Attack logs keep 50 entries; the Open button says why it is disabled; a missing
lsfDisableMasterin the definitions is an error instead of "not disabled". - Simulated node, order of checks (spec 6.5):
temchecks come before the account lookup, and the minimum fee is checked after the signature, so a stranger's key getstefBAD_AUTHrather thantelINSUF_FEE_P. - Not fixed here, belongs to
packages/core: the WebSocket client has no request timeout, and while it connects each concurrent caller opens its own socket (the extra ones leak). The web app guards against a node that does not answer at boot (8 s) but not mid-session. - Documented: the Node server keeps the externalized config script in the memory of its process, so a multi-instance or serverless deployment of it needs the static output instead (DEPLOY.md).
2026-10-24: Actions and Attack on a real node with the authorization hook (web app, M3b, modes A and P)
Real: the node. The standalone node of pnpm demo on the protocol track's build of the branch of pull request #45 (Authorize session-signed transactions in modes A and P, merged into develop as 5690796 afterwards; the binary is that branch's build, not a build of develop). SIMULATED: only the software authenticator of the Mac account. Driven from Node through the web app's live backend (no browser); one fresh Mac account per mode, funded by the dev faucet, a session of 600 s.
- Mode A and mode P (P with the six action types): the session opened
tesSUCCESS, and every actiontesSUCCESSsigned by the P-256 session key with no prompt: payment, offer, trust line, ticket, NFT mint, check. The session's own closetesSUCCESS. - Every refusal on the Attack screen is the node's own code, none charged a fee:
SetRegularKey,PasskeyListSet,AccountSetand a secondSigningSessionSetalltefSESSION_TX_NOT_ALLOWED; a fee of 500 000 dropstefSESSION_LIMIT; a transaction withoutLastLedgerSequencetefSESSION_TX_NOT_ALLOWED; the session key signing for another accounttefBAD_AUTH. - Mode C is not in that build: every payment of a mode C session got
tefSESSION_TX_NOT_ALLOWED(and so did the ticket and the cap overflow). The six-payment cap scenario and the spent bar are therefore not yet verified on the real node; they wait for N5. - Not run on the real node: the attack after expiry (the screen waits for the real Expiration, 600 s).
- Finding fixed by this run: the first run showed
tecNO_DST_INSUF_XRP,tecNO_ISSUER,tecNO_DSTfor the payment, offer, trust line and check, because the payee did not exist on a real ledger and 3.5 XRP is below the reserve that creates an account. The store now funds the payee (not a session-signed transaction) before the first action. - Divergent design noticed (device-track watch): the owner's draft pull request #29 and the branch
STOOOKEEE/n2-signing-sessionscarry a different mode C (a per-payment capSessionMaxPerTx, aSessionWitnessandWitnessSignature,tefSESSION_NO_WITNESS) that is not in the specification or in the protocol track's build. The web app builds only the modes of the specification; if the design is adopted,SessionParamsand the Session screen need a per-payment cap and a witness field.
Step 6 completed on our node: a session-signed payment (K1), refusals, a passkey-opened session (W2)
Node: a copy of the binary built from PR #45's head c73de94, whose rippled/ is identical to develop 5690796 (git diff c73de94 5690796 -- rippled/ is empty), standalone on infra/standalone.cfg with local ports, infra/closer.ts every 3 s, definitions hash F3F8D90E… (identical codes to the SIMULATED ones). Fresh accounts funded by the genesis account, whose seed came from an environment variable. SIMULATED: the passkey is a software WebAuthn authenticator, the ML-DSA-44 keys are software keys. spikes/k1-local.ts.
- Passkey wallet, account
rEM66HTuk56Y94RWUqQhrayRRPzBoQ4hUG:PasskeyListSetFD8B7CF3…, WebAuthn proofC7E31133…,SetRegularKeyto the blackholeA00D4B47…,asfDisableMaster1A63E160…, alltesSUCCESS; the master key then getstefMASTER_DISABLED. - W2, mode A, opened by the passkey:
SigningSessionSetsigned through WebAuthn24F768E3A590A6792FF937FD8C31F8F9A0BE6F03632D339E37B8AF30D1AFBFECtesSUCCESS. - K1 on our node: a 1 XRP
Paymentsigned by the non-extractible WebCrypto P-256 session key (low-S strict DER,LastLedgerSequenceset)1CCDC51464024A739DB5EC0760BD60C9A09AC3236FA679102B5DC88FD2CE2BA1tesSUCCESS. - Refusals, the node's own codes, each "The signing session does not allow this transaction." and never included:
SetRegularKeytefSESSION_TX_NOT_ALLOWED(F8203D47…),PasskeyListSettefSESSION_TX_NOT_ALLOWED(5D5AF229…),AccountSettefSESSION_TX_NOT_ALLOWED(44331E82…), aSigningSessionSetsigned by the session keytefSESSION_TX_NOT_ALLOWED(BC82EA1E…). - Own close by the session key:
SigningSessionDeleteAE7B020AC6125E0614A65A9665F45731891FA8F71B2E2098BDC794BF0049D17AtesSUCCESS. - Mode C (20 XRP, opened by the passkey,
859983AA…tesSUCCESS, entrySessionSpent0): the session-signed 1 XRPPaymentgetstefSESSION_TX_NOT_ALLOWED(61867F38…), as does the 25 XRP one, andSessionSpentstays0: mode C is N5, not merged yet. Its own close succeeds (8386C6D7…). - Ledger account (D13, D14):
SetRegularKeyto an ML-DSA-44 keyF0702305…, proof84658D9E…,asfDisableMaster50F3FB85…; aSigningSessionSetsigned by that ML-DSA-44 regular key61FDFF1E…; a 1 XRPPaymentsigned by a fresh ML-DSA-44 session key238EA325B3B6052238B8926A80029EBFE30DFE6F8DCC8CBCF895E5A37234C254tesSUCCESS; the classical mastertefMASTER_DISABLED. This settles on our node item 1 of the UNVERIFIED list of FIANSO-SPEC.md section 6.14 for a client-made key (a Ledger account opens a session with its ML-DSA-44 key and an ML-DSA-44 session key signs).
- Tests first. The mode-C cases were written and run before the code: 30 cases, 1981 tests, 21 failures (every mode-C payment answered
tefSESSION_TX_NOT_ALLOWED). The new suiteInvariantsSigningSessionwas run before the invariant existed: 6 cases, 795 tests, 96 failures. After the code:SigningSessions31 cases, 2001 tests, 0 failures;InvariantsSigningSession6 cases, 839 tests, 0 failures. - N5, eligibility in mode C (spec 6.5 rule 1, case 12):
isSessionCapPaymentinrippled/include/xrpl/tx/SigningSession.h, a closed whitelist: aPaymentof XRP whose only flag istfFullyCanonicalSigand whose fields, besides the common transaction fields (TxFormats::getCommonFields), are onlyDestination,DestinationTagandAmount. An IOU amount,SendMax,Paths,DeliverMin,tfPartialPayment,tfSponsorCreatedAccount,InvoiceIDand every other type answertefSESSION_TX_NOT_ALLOWED.tfNoRippleDirectandtfLimitQualityon an XRP payment are refused earlier byPayment's own preflight (temBAD_SEND_XRP_NO_DIRECTandtemBAD_SEND_XRP_LIMIT,rippled/src/libxrpl/tx/transactors/payment/Payment.cpp:249,:242), so they are not in the test of the whitelist;SendMax,Paths,DeliverMinand the partial flag are tested on an XRP amount paid with an IOU, a shapePaymentaccepts. - N5, the cap (rule 5, case 11 and 13):
Fee + Amount ≤ SessionCap − SessionSpent, except for the own close,tefSESSION_LIMIT; a remaining cap of zero refuses even a fee. - N5,
chargeSession(spec 6.6): the predicate's keylet and the own-close shape are read at the top ofTransactor::operator(), beforeapply(). Exactly two call sites: before the firstcheckInvariantsand after thecanApplyblock, and right after the fee-claimresetthat follows atecINVARIANT_FAILED. The fee claimed is added toSessionSpent, and theAmounttoo ontesSUCCESS, for a session with a cap only; the own close is exempt by shape, and a session that no longer exists is skipped. Case 23 on a real transaction for each path:tecNO_DST_INSUF_XRPfrom preclaim andtecUNFUNDED_PAYMENTfrom doApply count the fee only; the reset path is reached by writing into the open ledger a destination balance above the XRP supply, so the first invariant pass fails onXRPBalanceChecksand the reset pass succeeds:tecINVARIANT_FAILED, the fee counted again. Case 13: a mode-C payment by ticket applies and lowers the owner count by one. - N6, the invariant (spec 6.7, case 21):
ValidSigningSession,rippled/include/xrpl/tx/invariants/SigningSessionInvariant.handrippled/src/libxrpl/tx/invariants/SigningSessionInvariant.cpp, in theInvariantCheckstuple, whose every member runs in both passes (rippled/src/libxrpl/tx/invariants/InvariantRunner.cpp:46-77).visitEntrykeeps the before and after states of sessions, account roots and the entries of the account's authority (signer list, passkey list, delegate entries, beneficiary, firewall). The transaction is session-signed when the session its shape names (sessionKeyletOf, the predicate without the existence test) existed before it, so an own close that erased its session is still recognised. One test per rule, on a mutated view, modelled onInvariantsBeneficiary_test; plus a valid mode-C payment that passes. Deviation: rule 1's "the owner count changes by exactly one" allows one less on the account when the transaction consumes a ticket (aSigningSessionSetby ticket adds the session and removes the ticket). Testopen and close by ticket; without the allowance it fails (4 failures, a mutation reverted). - N7 (case 18): the third-party and owner closes came with N2, the own close with N4. Here, in mode C: the own close is exempt from the cap (it succeeds with nothing left), not from the fee ceiling.
AccountDeleteremoves sessions (case 19, since N2). - Mutation check (one temporary edit at a time on
SigningSessionsandInvariantsSigningSession, reverted): first call site ofchargeSessionremoved (15 failures), second call site removed (3), cap rule removed (9), whitelist flag test removed (4), whitelist field test removed (6), XRP-only test removed (1), the amount counted on atectoo (4), invariant rule 4 outflow check removed (2), rule 5 removed (28), rule 3 "outside its session" removed (2), rule 1 owner count removed (8). After the revert: 0 failures. - Neighbouring suites (
--unittest-jobs=6): the 20 of N4 plusInvariantsSigningSession,TicketandPayment: 37 suites, 647 cases, 136545 tests, 4 failures, the base ones (Delegate71 vs 60,MPToken_test.cpp:2105,InvariantsVault_test.cpp:3035twice). Levelization: no new line. - Review gate 2:
VERDICT: APPROVEDin round 1 (Plan agent, model opus, read-only): no path lets a mode-C session move more XRP than its cap; the transaction queue may hold several session transactions against the sameSessionSpent, but each is checked again when applied (rippled/src/xrpld/app/misc/detail/TxQ.cpp:342,:632), so the extra ones end intefSESSION_LIMIT. Nine non-blocking findings; applied before merging: (1) rule 1 skipped the owner-count check when a ticket brought the expected change to zero, so aSigningSessionSetby ticket that forgotincreaseOwnerCountwould pass; the actual change is now compared in every case (testrule 1: a creation by ticket; the old behaviour fails it, 4 failures, mutation reverted); (3) the tuple position does not matter, reworded; (4) the code oftfLimitQualitycorrected; (5) the comment ofcheckSessionnames the cap; (6) a valid own close in mode C added to the invariant suite; (7) the anti-drain loop of spec 10.1 row N5 is now the testmode C anti-drain loop: a key that only sends fee-burningtecNO_DST_INSUF_XRPpayments is stopped bytefSESSION_LIMITafter ten, and the account loses exactly ten fees, under the cap; (9) mutated-view tests for a change ofPermissions, ofAccount, ofSponsoringAccountCount, and an own close that movesSessionSpent. Left as recorded: (2) the invariant reads the named session for every single-signed transaction once the amendment is on (written in the XLS, N10); (8) a fee cut short by the reset when the balance is short is counted at its real amount by construction (chargeSessiontakes the feeresetreturns) but has no test, since a jtx account always holds its reserve. After these:SigningSessionsandInvariantsSigningSession, 41 cases, 3077 tests, 0 failures.
Real: the node, the standalone node of pnpm demo on the protocol track's build of develop at #49 (mode C accounting and its invariant), including the earlier hook. SIMULATED: only the software authenticator of the Mac account. Driven from Node through the web app's live backend (no browser); fresh accounts funded by the dev faucet.
- Mode C, 20 XRP cap, 600 s (the six-payment scenario of the specification): the session opened
tesSUCCESSwithSessionCap20 000 000 andSessionSpent0 read back from the node. Payments of 3.5 XRP, fees included in the count: 1 to 5tesSUCCESS,SessionSpent3 500 010, 7 000 020, 10 500 030, 14 000 040, 17 500 050 (the fee is 10 drops); the sixthtefSESSION_LIMIT, no fee charged,SessionSpentunchanged (the acceptance of task W6). A ticket and an offer in mode CtefSESSION_TX_NOT_ALLOWED. - Attacks in mode C, all the node's own codes, none charged:
SetRegularKey,PasskeyListSet,AccountSet, a secondSigningSessionSetand a missingLastLedgerSequencetefSESSION_TX_NOT_ALLOWED; the cap overflow and the 500 000-drop feetefSESSION_LIMIT; another accounttefBAD_AUTH. The session's own closetesSUCCESS. - Expiry: a mode A session of 6 s: once the ledger's close time passed its
Expiration, a ticket and the "after expiry" attack gottefSESSION_EXPIRED, no fee, and the session key's own close was stilltesSUCCESS. (The first action already met the expiry, because funding the payee took longer than the session.) - Regression: mode A payment and mint
tesSUCCESS; mode P withPaymentandTicketCreatelisted: paymenttesSUCCESS, a minttefSESSION_TX_NOT_ALLOWED. - So the Session, Actions and Attack screens are now checked against a node with the amendment in modes A, P and C. Not run: the screens in a browser (only the stores behind them, through the live backend); the Ledger account's sessions on this build (device-contract double, run earlier on the N2-stage build); the build is the protocol track's, not one of
developmade by me.
- Tests only: no production code changes; every rule they exercise landed in N2 to N7. Run before this entry:
./xrpld --unittest=SigningSessions, 36 cases, 2249 tests, 0 failures;InvariantsSigningSessionunchanged. - Case 16, Firewall (enabled through
testableAmendments, spec 6.11): a session-signed payment to a destination the firewall does not authorize istefFIREWALL_BLOCKin mode A and in mode C, and counts nothing; a payment to the preauthorized backup applies and is counted. A firewall set beforehand does not stop the opening (FirewallAction::Allow, the N2 decision). - Case 20: done in N4 (P-256, Ed25519, secp256k1, ML-DSA-44, and a P-256 key signing through WebAuthn, SIMULATED).
- Case 22, the transaction queue: with the queue's minimum ledger size at 3, the open ledger is filled past the size the queue expects (read from
TxQ::getMetrics), so a session-signed payment at the base fee isterQUEUED. When its session expires at the next close, the queue drops it; when the owner deletes the session by ticket (with a fee above the escalated one) while it waits, the queue drops it too. Either way the payment never applies and claims no fee. A mutation that never expires a session fails the test (6 failures, reverted). - Case 24:
ledger_entryandaccount_objectssince N2. The pseudo-account refusal of the session path (tefBAD_AUTH): an account root marked as a pseudo-account (sfAMMID) is written into the open ledger next to an existing session, since a pseudo-account never opens one; without the refusal the payment would be authorized (mutation: 1 failure, reverted). The sponsorship decision: a fee sponsor may pay for an opening (spfSponsorFeewith itsSponsorSignature), the reserve stays the account's; a reserve sponsor is refused (N2). - Self-review against spec 6.15 (no review gate for N8,
PLAN.mdAmendment A): cases 16, 20, 22 and 24 each have a test asserting the exact result code; no case of 1 to 24 is left without one: 1, 7, 18, 19 and 24 (N2); 2 to 6, 8, 9, 9b, 10, 13, 14, 15, 17, 17b and 20 (N3, N4, N4b); 11, 12, 13, 18, 21 and 23 (N5 to N7); 16 and 22 (N8). Limits stated where they are: the short-balance reset of N5 has no test, the at-use eligibility check of mode P has no test (DEVLOG, N3/N4 and N5 entries). - Device-track watch:
STOOOKEEE/n2-signing-sessionsnow carries six more commits: modes A and P, its ownValidSigningSession, a test-wallet panel and an agent demo on a session. It is a parallel implementation in the same files, not merged here. Every number it gives to an item of spec 6.1 still equalsdevelop; its extras (SessionMaxPerTxAMOUNT 49,SessionWitnessACCOUNT 35,WitnessSignatureOBJECT 43,tefSESSION_NO_WITNESS) remain the owner's decision, and their codes remain free ondevelop.
-
docs/XLS-Sessions.md, written from the merged implementation (developatf889b6a): identifiers, the entry, both transactions, the predicate, the authorization rules in their order, the accounting, the invariant, RPC, security considerations and test coverage. Everypath:lineit cites was printed and checked against the tree. It also records the read cost the N5 review noted: once the amendment is enabled, each single-signed transaction reads one session keylet in preclaim, at the top ofTransactor::operator()and in the invariant. -
Full
./xrpld --unittest --unittest-jobs=6ondevelopatf889b6a: 706.8 s, 284 suites, 5436 cases, 1,292,631 tests, 203 failures, of which one isValidatorSiteV2, which did not complete ("The child process may have crashed"), as in N0. The 202 others, per suite in the full run:AMMBinned152,AMMCurves41,InvariantsVault2,ValidatorListExpiration2,ValidatorList_Dilithium2,Delegate1,MPToken1,SignatureVerification1 (its timing assertion, only under parallel jobs). The N0 baseline: 280 suites, 1,285,447 tests, 204 failures. Every suite with a failure was run again alone and compared withdocs/base-failures.txtper assertion (file and line).AMMBinned(152),AMMCurves(41),InvariantsVault(2),ValidatorListExpiration(2) andSignatureVerification(0 alone, the timing assertion that fails only under parallel jobs) match line for line.Delegate(1, "Expected: 60 Actual: 71") andValidatorList_Dilithium(2, "Could not find vl.json") are the same messages; the comparison script did not read their location.MPToken(1) is the base assertion, moved from line 2095 to 2105 by the ten lines N2 added above it. No new failure. The four suites more than N0 areSigningSessionsandInvariantsSigningSession(this goal) andPasskeyWebAuthnandPasskeyRelay(N1); all four pass.LedgerEntryandMPTokengained cases in N2 and add no failure. -
Full
./xrpl_tests: 1135 tests from 243 suites, 1134 passed; the one failure is the baseVaultTests.BuilderFromSleRoundTrip. -
The node, again, on the final binary: with a copy of
infra/standalone.cfg(its[amendments]identical, ports and paths moved to a scratch directory) andnode infra/closer.ts,featurelists the six amendments enabled. In mode A and in mode C:SigningSessionSettesSUCCESS;- a
Paymentsigned by the session keytesSUCCESS, and in mode CSessionSpent1000010 (1 XRP and the fee); - an
AccountSetsigned by the session keytefSESSION_TX_NOT_ALLOWED; - the own close
tesSUCCESS, no session left.
The session key signs through the node's
signin offline mode (a secp256k1 key, local standalone node only), because the node's sign-and-submit refuses a key that is neither the master nor the regular key. -
What went wrong on the way: the first rerun of this check answered
tefBAD_AUTHto every session-signed transaction. A node started for the N9 check, on the N2 binary without a session path, was still running and held the ports, and the cleanup pattern had never matched its command line. The node was stopped and the check repeated; nothing in the code changed. A temporary probe used to find this was reverted before the rebuild. -
Final review gate, round 1:
VERDICT: CHANGES REQUIRED(Plan agent, model opus, read-only), one blocking finding: the XLS gavetefBAD_AUTHfor a session key used as a multi-signer, where the code and the test givetefBAD_SIGNATURE; fixed in §6. Non-blocking, applied: the deviation of case 14 for the multi-signer recorded (above, N4 entry);isSessionSigned, required in the shared header but not called by production code, is now covered by a test (the session-signed predicate) that checks it agrees withsigningSessionOf, and the N4 entry says which function each part calls and why the invariant usessessionKeyletOf; the XLS now lists the pseudo-account refusal as rule 0 and thecheckSign-override refusal first inside eligibility, says thatAccountDelete's owner count is not checked, that the one-mode andSessionSpentchecks apply at creation, that the own close never movesSessionSpent, and thatSigningSessionDeletealso needsPermissionDelegationV1_1; this entry names every failure of the full run. Left:infra/Dockerfile(stretch). After the fixes:SigningSessionsandInvariantsSigningSession, 46 cases, 3296 tests, 0 failures. The body of #45 no longer says that a mode-PTicketCreatesession "cannot burn the balance": a refused transaction claims no fee, but eachTicketCreateit signs may pay up to the fee ceiling (XLS §11). -
Final review gate, round 2:
VERDICT: APPROVED. Every round 1 finding found fixed (the Dockerfile left as recorded). Three non-blocking notes, applied before merging: the pull request body now says this PR adds a test; the XLS says that authorization and accounting ask the predicate and the invariant its shape test; the predicate test adds a transaction that passes the shape test but has no session (isSessionSignedandsigningSessionOfboth false). After them:SigningSessionsandInvariantsSigningSession, 46 cases, 3298 tests, 0 failures. -
The full runs again on the final
develop(0bcb324), after #53 merged; its onlyrippled/change sincef889b6ais the 43-line predicate test../xrpld --unittest --unittest-jobs=6: 594.8 s, 287 suites, 5556 cases, 1,356,135 tests, 205 failures:AMMBinned152,AMMCurves41,InvariantsVault2,ValidatorListExpiration2,ValidatorList_Dilithium2,Delegate1,MPToken1,SignatureVerification1,ValidatorSite1 ("database is locked"),ServerInfo2. Every one was run again alone and compared per assertion withdocs/base-failures.txt: the first eight match the baseline (MPTokenat line 2105, moved by N2);SignatureVerification,ValidatorSiteandServerInfopass alone (0 failures), so they fail only under parallel jobs.ServerInfo(rippled/src/test/rpc/ServerInfo_test.cpp:139,:150, the port protocol checks) is new to that list and is unchanged sincemain;docs/base-failures.txtnow names it in its header../xrpl_tests: 1135 tests, 1134 passed, the baseVaultTests.BuilderFromSleRoundTrip. No new failure.
Final review gate (an independent reviewer, model Opus, read-only, on develop at 0bcb324): VERDICT: CHANGES REQUIRED, one blocking finding and several should-fix. Applied here:
- Blocking, fixed: the web app typed
tfFullyCanonicalSigby hand and sent it on the live path. The flag now comes from the backend (CommonFields.Flags): the simulated node adds it, a live node needs none and none is sent. The hand-typed flag values are confined to the simulation (lib/sim,lib/tx-builders.ts,lib/constants.ts), and a test fails if any other source mentions them. - SIMULATED labelling: the setup steps of a Ledger account signed on the software double of the device are badged on the Account screen even when the node is live (they were badged only on the Session screen).
- Documentation made true:
apps/web/README.mddescribes the two backends;docs/ARCHITECTURE.mdno longer sayspackages/ledgerdepends on the Device Management Kit (it reaches it through theDmkApduSenderinterface only, and no DMK or WebHID path is in the repository) nor that the real-device end-to-end test exists (it does not); the root README marks the Device Management Kit adapter and thecreate-xrptemplate as planned, not built (tasks E1 and E2), and lists what is not done.docs/ledger-dx-feedback.mdhad a duplicated table (my conflict resolutions) and three unfinished sections: deduplicated and written from its own log. - Deviation from the specification, recorded (case 13): the spec says a mode P session limited to
TicketCreate"cannot burn the balance". The fee ceiling bounds the fee of each session-signed transaction, not their number, so modes A and P can pay fees up to the ceiling on every transaction they sign during their 600 seconds; only mode C counts fees against a total.docs/XLS-Sessions.mdsection 11 now says so. - Deviation, recorded: the web app has a third profile, a throw-away "Demo account" (master key only), beside the two of D13 (Mac account, Ledger account). It exists for people without a passkey or a device; it is badged SIMULATED.
- Real-node runs are now reproducible:
pnpm --filter web live-run(apps/web/manual/live-run.manual.test.ts) runs, againstpnpm demo --no-web, the flows the DEVLOG records. Its printed output on the protocol track's latest build (developat #53) is below. The earlier entries record the same flows from throw-away drivers; the transaction hashes of those runs are not recorded for the web app's own runs (the hashes of the AlphaNet runs are). - CSP: the local node's HTTP port (the admin RPC) is no longer in
connect-src; the app uses its public WebSocket only. - Not fixed here, belongs to the protocol track: the node checks of the N9 and N10 entries used scripts that are not committed;
infra/Dockerfileand theapps/passkey-walletthatinfra/compose.yamlbuilds are not ondevelop. Commit hygiene: a squash subject says(#32)for pull request #33, threemerge:commits use a type outside the list, and several squash bodies have lines over 72 columns; history is not rewritten.
Summary of the printed output of pnpm --filter web live-run (three tests, 108 s; lines grouped), each account funded by the dev faucet, the Mac account's passkey the software authenticator (SIMULATED):
[A] open: tesSUCCESS
[A] action payment, offer, trust, ticket, mint, check: tesSUCCESS (six times)
[A] attack regular-key, passkey, disable-master, open-session: tefSESSION_TX_NOT_ALLOWED, fee charged: false
[A] attack fee-burn: tefSESSION_LIMIT fee charged: false
[A] attack no-last-ledger: tefSESSION_TX_NOT_ALLOWED fee charged: false
[A] attack other-account: tefBAD_AUTH fee charged: false
[A] own close: tesSUCCESS
[P] (the same, with the six types listed): identical results
[C] open: tesSUCCESS
[C] payment 1..5: tesSUCCESS, spent 3500010, 7000020, 10500030, 14000040, 17500050
[C] payment 6: tefSESSION_LIMIT, spent 17500050
[C] ticket: tefSESSION_TX_NOT_ALLOWED
[C] cap overflow: tefSESSION_LIMIT
[expiry] after expiry: tefSESSION_EXPIRED
[expiry] own close: tesSUCCESS
- A fresh review gate on N3, N4 and N4b as merged (round 1 of at most two, Plan agent, model opus, read-only), run because the gate of #45 needed a third round:
VERDICT: CHANGES REQUIRED, one blocking finding. Spec 6.14 item 3 (the fee ceiling applies to an ML-DSA-44 session transaction as to a P-256 one) had no test, and the N4 entry did not say so. Now settled: the key-type test of case 20 sends a payment at the ceiling and one drop above it for P-256, Ed25519, secp256k1 and ML-DSA-44 session keys (tesSUCCESS, thentefSESSION_LIMIT). Non-blocking, applied: a test of the rule order of spec 6.5 when two rules fail together (not eligible and expired: not allowed; noLastLedgerSequenceand expired: not allowed; expired and over the ceiling: expired; the own close, expired and over the ceiling: the ceiling), which fails when the expiry and ceiling rules are swapped (a mutation, reverted); the expiry test now assertsparentCloseTime == Expirationbefore its refusal. Recorded: spec 6.15 row 13 says "abovecalculateBaseFee × 1000" while rule 4 of spec 6.5 sayscalculateBaseFee + 1000 × base; the code, the tests and the XLS follow rule 4. TheTransactor.cppcitations of the N3/N4 entry are lines at5690796; on later commits the passkey and beneficiary checks moved (to:1250-1268and:1271-1288at0bcb324). After:SigningSessionsandInvariantsSigningSession, 47 cases, 3359 tests, 0 failures. - Round 2:
VERDICT: APPROVED. Its words: "PR #56 fixes the blocking finding, and the non-blocking findings are either fixed or recorded, so this round approves." It checked that only the test file and the DEVLOG change, that the ceiling test uses the formula of rule 4, that the rule-order test fails when expiry moves ahead of rule 1, 2 or 4, and that the expiry boundary holds with 10-second close times and a 30-second duration. This gate on N3, N4 and N4b was approved within the two roundsPLAN.mdsection 3 allows.
Final review gate, round 2 (an independent reviewer, model Opus, read-only, on develop at aea1dd0): VERDICT: APPROVED. No blocking finding. It confirmed the round-1 blocking item fixed in the code (the live path takes flags from server_definitions), and that no as any, secret, third-party script or removed upstream test is in the diff. Its should-fix and notes, applied in the next pull request (documentation, one committed symlink and the driver's assertions only; no source of the app or the protocol changed):
apps/web/dist, a symlink to a local scratch path, was tracked; removed, and the ignore rule now matches files as well as directories.- The root README and
docs/ARCHITECTURE.mdsaid more than was run: the live stores were run from Node against a real node, the screens never in a browser, the browser passkey path never against a real authenticator. Both now say so; WebHID and explorer wording inapps/web/README.mdanddocs/DEPLOY.mdcorrected. - The live-run driver now asserts the node's codes (it only printed them), and a stray separator row of the feedback table is gone.
- Listed, not changed:
FIANSO-SPEC.md(the owner's reference) still says in its UNVERIFIED list, item 4, that no node has accepted an@noble/post-quantumsignature; the step-6 entry above records one accepted (238EA325…), so the owner may update the line.rippled/src/test/app/MPToken_test.cppgained aSigningSessionSetcase required by that suite's exhaustiveness check (nothing removed or weakened); its commit does not carry the justification PLAN 1.8 asks for, and it is given here.infra/compose.yamlbuildsapps/passkey-wallet/Dockerfile, which is not ondevelop(already recorded).
What. The two protections of the first implementation (STOOOKEEE/n2-signing-sessions) on top of develop's sessions: SessionMaxPerTx (AMOUNT 49, mode C only, tefSESSION_LIMIT above it) and SessionWitness (ACCOUNT 35) with the co-signature WitnessSignature (OBJECT 43, prefix WTS\0, tefSESSION_NO_WITNESS; the own close is exempt). @carbs/sessions opens sessions with both and co-signs as the witness. A new suite SigningSessionsBatch and buildBatch.
Why. Develop is the reviewed base; the protections were the only protocol work that existed only on the other branch. Numbers were free on develop, so the shared wire format does not change. Sofiane still has to accept both protections (they are not in the spec).
Path. Merging the two branches was set aside: about twenty rippled files conflict and several clean merges would define things twice. SessionMaxPerTx keeps temMALFORMED without a cap and temBAD_AMOUNT for a bad amount, like develop's SessionCap.
Batch. The AlphaNet observation (a key migration in one Batch returns tesSUCCESS and applies nothing) is explained and pinned in a test: an inner transaction has no signature, so the inner asfDisableMaster fails with tecNEED_MASTER_KEY and, under tfAllOrNothing, every inner change is discarded while the outer Batch keeps its tesSUCCESS. A session key cannot sign an outer Batch (tefSESSION_TX_NOT_ALLOWED, Batch overrides checkSign) nor be a BatchSigner (tefBAD_AUTH).
Tests (Mac, Debug): SigningSessions with SigningSessionsBatch 47 cases / 2681 checks, InvariantsSigningSession 9 / 1079, STTx, Batch, PasskeyWebAuthn, PasskeyRelay, AccountDelete 0 failures; Delegate and MPToken keep their base failure (docs/base-failures.txt). @carbs/sessions 99 tests.
Bugs & fix. The first witness test disabled the witness's master key with its regular key: tecNEED_MASTER_KEY; only the master key may set asfDisableMaster.
- The session note no longer claims more than the node enforces (audit finding A5, confirmed). "Cannot change keys, signers or the account" became "Cannot change keys or signers":
AccountSetis not delegable, but its granular permissions are (AccountDomainSet,rippled/include/xrpl/protocol/detail/permissions.macro:29), so a mode P session can change the account. Changed in the app (ledger-app/src/xrp/signing_session.h), the frozen screen vectors (30-signing-session-set/01to05and12), the client's prediction (packages/ledger/src/screen.ts), the web app's SIMULATED device,docs/device-contract.mdand the screen table ofFIANSO-SPEC.md7.3. The DEVLOG entry of L1 above quotes the old text and is left as it was written. - Mode C says what it allows. A capped session's review gains a synthetic row
Allows; XRP payments only, fees included, before theNote, what the node enforces (rippled/src/libxrpl/tx/Transactor.cpp:1108-1112,:1131-1142). It is the parser's own row like theNote(field id0xFC, refused when it appears in the signed data: new vector13-spoofed-scope, which went to blind signing before and is now refused). RAM: the field array has a fixed size (MAX_FIELD_COUNT) and the text is a constant in flash, so no RAM is added. - What ran: the app's unit tests natively (recipe in
docs/BUILDING.mdon the branch of the review-lock pull request): with the vectors updated and the parser unchanged,test_txfailed ("Cannot change keys, signers or the account" != "Cannot change keys or signers", and13-spoofed-scopeanswered blind signing); after the changetest_printers4/4,test_swap2/2,test_tx4/4.packages/ledger: the five session vectors failed before thescreen.tschange and pass after; a new SIMULATED-device test inapps/web/lib/sim/device.test.tsfailed before and passes after. - What did not run: the app was not built for any device and the Ragger snapshots were not regenerated (no Speculos or builder image here). These snapshot folders now show the old note, and the mode C ones lack the new row; they need regeneration with
--golden_run:ledger-app/tests/standalone/snapshots/{stax,flex,apex_p,nanosp,nanox}/30-signing-session-set/{01-mode-all,02-mode-permissions,03-mode-cap,04-ten-permissions,05-short-duration,12-cap-per-tx-and-witness}/. Nothing was shown on the Stax.
signWithSessionKeyneeds the current ledger.currentLedgerwas optional, and without it theLastLedgerSequencewindow (kSessionMaxLedgerWindow) was not checked although the node refuses a transaction outside it. It is now required, and a missing or non-integer value is refused at run time too (for JavaScript callers). The one caller outside the tests (packages/core/spikes/k1-local.ts) already passed it.- Exposure report of a session key. A key that signed for the account and is none of its standing authorities was reported as "not one of its current authorities"; for the key of a live signing session that is wrong. The reason now says that such a key, while its session lives, holds an authority bounded by that session (mode, cap, expiry). Only the text changed; the finding is still reported and not counted.
- A vacuous assertion in
packages/ledger/src/ledger.test.tscompared the ML-DSA seed with itself. It now recomputes SLIP-0021 by hand with@noble/hashes(master node from"Symmetric key seed", child for0x00 ‖ "carbs ML-DSA-44", last 32 bytes) and compares; a mutant taking the first 32 bytes fails it. - CSP
form-action 'self'(it does not fall back todefault-src): in the header, the meta fallback,vercel.jsonanddocs/DEPLOY.md. - Wording. The Ledger profile of the Account screen said "Every own-authority transaction is signed on the device" also on the simulation; it now says "signed by the simulated Stax (software keys, no device involved)" when the device is simulated. The Mac profile never claimed device signing, and
apps/web/pages/index.vuemakes no "signed on the device" claim (its headline is decision D16, left as it is). - Tests: each change has a test that failed before it (the session refusal, also as a type error; the exposure reason; the CSP directive; the Account wording) and passes after;
pnpm format:check,pnpm typecheckandpnpm testpass. Nothing was run in a browser.
- The genesis seed reached every child process (audit finding A5, confirmed).
scripts/src/demo.tsspawnedxrpld, the closer and the Nuxt dev server with{ ...process.env, ...env }, so each of them inheritedGENESIS_SEED; none needs it, because the faucet that signs with it runs in the demo process. Children now getchildEnv(process.env, env)(scripts/src/child-env.ts), which dropsGENESIS_SEEDeven when a caller passes it again, and the demo removes it from its own environment once the signer is built. - The faucet's only gate was the Origin header. Any local process can send the page's origin. Chosen of the two options of the audit: a per-origin cap (10 fundings per origin per minute, then
429withRetry-After), because it bounds the drain whoever calls, needs no change in the web app, and a per-run token handed to the web app would sit in its public runtime config, readable by any local process that can load the page. Not done: the token. - Tests:
scripts/src/demo.test.tsgains achildEnvcase (it failed before: the module did not exist) and a cap case with an injected clock (it failed before: the third request got200); 7 of 7 pass.pnpm demoitself was not run (no node was started for this change).
- The simulated device predicted a clear review where the app goes blind (audit finding A4, confirmed).
reviewLinesinpackages/ledger/src/screen.tsprinted an unknown flag asFlags; 196608and an unknown permission by its value, both asclear, and listed eleven permissions asclear; the app asks for blind signing (ledger-app/src/xrp/flags.c,has_unknown_flags;session_permissions.creturns no name) or refuses. Nowblindfor anyFlagsbit other thantfFullyCanonicalSigand the session'stfAllTransactions(both read fromserver_definitions), for a permission value outside the app's name table, and abovekPermissionMaxSize;0x80010000readsAllows; All transactionsand0x80000000alone shows nothing, as on the app. The app's permission names are mirrored inkDevicePermissionNames, and a test readssession_permissions.cand checks that both lists are equal. - Tests (SIMULATED device logic only, no device): six new cases in
packages/ledger/src/ledger.test.ts(vectors07and11from their bytes,06from a decoded form since the codec throws on its bytes, the canonical-flag cases, other types, the name-table check) all failed before the change and pass after. Left as they were, and written indocs/device-contract.md:08-duplicate-flags(the decoded form keeps oneFlags, so the client still predictsclear),09and10(they do not decode; the simulated device refuses them).
- Review lock on the ML-DSA path (audit finding A1, confirmed). While the review of transaction A was on screen,
INS 0x11kept accepting chunks, so a host could replace the buffer with B and the approval of A signed B; a switch to another instruction during the review zeroedtmp_ctxand the approval signed an empty message. The classical path refuses both (ledger-app/src/apdu/messages/sign_transaction.c:273-285, andsign_transaction()outsidePENDING_REVIEW). Now: areviewingflag and the SHA-256 of the reviewed bytes in the PQ context; every0x11chunk during the review gets0x6A80; the approval signs only bytes with the reviewed hash and signs nothing after an instruction change.INS 0x10needs no check of its own: entry.c resets the context, and so cancels the review, before any other instruction runs. - Blind-signing setting (audit finding A2, confirmed). The ML-DSA path showed the blind review even with blind signing disabled; it now shows the classical error screen and answers
0x6985, asreview_transaction()does (ledger-app/src/transaction/transaction.c:203-205). - First C tests of the
0x10-0x12path (audit finding A3).ledger-app/unit-tests/src/test_pq_root.ccompiles the realentry.c,global.candpq_root.cwith the real parser; only the SDK is replaced by recording doubles (unit-tests/include_pq/). 16 cases: public key chunks, chunk reassembly, prefix, unparsable data, the 2 048-byte limit, bad P1/P2, rejection, a stale signature after a new first chunk, the interleaved cases of A1, the hash check, the blind-signing setting. Before the fixes, the four lock cases and the blind-disabled case failed; after, 16 of 16 pass. Two mutants of the existing code (no signature reset on a new first chunk; a limit one byte too large) are caught. - What ran: the four unit-test binaries of
ledger-app/unit-tests, natively on macOS (Apple clang 17, arm64, cmocka 1.1.7 built from its tarball into a scratch prefix, Homebrew OpenSSL 3), the first run of these unit tests on this repository:test_printers4/4,test_swap2/2,test_tx4/4,test_pq_root16/16. Recipe and the two local workarounds indocs/BUILDING.md. - What did not run: the app was not built for the Stax (no
ledger-app-builderimage and no SDK on this machine; Docker was not running), so the change is compiled only against the test doubles of the SDK headers, not against the reallcx_mldsa.h,io.horcx.h. No Speculos or Ragger run, no device. The real SDK calls used are the ones the app already uses elsewhere (cx_sha256_initandcx_hash_no_throwinledger-app/src/xrp/xrp_helpers.c:130-131,ui_error_blind_signinginledger-app/src/transaction/transaction.c:205). Left as before, as on the classical path: when an approval signs nothing (a review cancelled by an instruction change, or bytes that changed), the status page that follows still says "signed" (ledger-app/src/ui/transaction/review_menu_nbgl.c:63-72).
2026-10-25: audit 24.10, section B1 (decision D4: CredentialCreate and DIDSet excluded from sessions)
From the read-only audit of develop at ce2c7cb, section B item 1.
- Decision (owner, D4): exclude
CredentialCreateandDIDSet. Both are delegable (rippled/include/xrpl/protocol/detail/transactions.macro:645,:769) and grant lasting rights outside the account's own authority: a credential is an attestation about a third party that other accounts' deposit preauthorizations and permissioned domains accept; a DID is the account's lasting identity document. Neither is bounded by an amount fixed at signing, and both outlive the session. - Change:
kSessionExcluded(rippled/include/xrpl/tx/SigningSession.h) holds five types; spec 6.8 and D4,docs/XLS-Sessions.mdsections 3 and 11, and the SIMULATED type table of the web app (apps/web/lib/sim/types.ts) follow. - Tests first: mode A signing both (
tefSESSION_TX_NOT_ALLOWED, and neither object created); mode P listing either (temMALFORMED); a new casemode P exclusions at usethat writes a mode-P session listingPayment,CredentialCreateandDIDSetinto the open ledger (no transaction can create one) and signs all three:Paymentsucceeds, the two otherstefSESSION_TX_NOT_ALLOWED. Case 9b moves both names out of the eligible list. Before the fix,./xrpld --unittest=SigningSessions: 39 cases, 2384 tests, 15 failures (9b "new eligible type" twice, mode-P creation 2 + its cascade, mode A 4, at use 2, among others). After:./xrpld --unittest=SigningSessions,InvariantsSigningSession: 48 cases, 3399 tests, 0 failures../xrpld --unittest=Delegate,Credentials,DID,PermissionedDomains,DepositAuth: 126 cases, 9299 tests, 1 failure, theDelegatecounter that fails on the import as well (71 counted, 60 pinned; N0 above); the count does not move, since both types stay delegable.pnpm format:check,pnpm typecheck,pnpm testgreen (web app: 26 files, 189 tests). - This also covers, for these two types, the at-use eligibility check of mode P that the N3/N4 entry listed as untested.
From the read-only review of the session guardrails on develop at 98f358b (findings F1 to F6; F6, the durable effects of TrustSet, PermissionedDomainSet and OracleSet in modes A and P, is a design question left to the owner).
- F1 (medium), the session key could delay its own revocation. The queue replaced a queued transaction by any transaction on the same
SeqProxypaying 25 % more, whoever signed it, and the open ledger did the same when the new fee cleared escalation (TxQ::tryDirectApplyremoves the queued entry it replaces). The key could therefore outbid the owner'sSigningSessionDeletewaiting in the queue, or use up the tickets the owner would revoke with. Change:sessionDisplacesOther(rippled/src/xrpld/app/misc/detail/TxQ.cpp) refuses withtelCAN_NOT_QUEUE_FEEa session-signed transaction that would replace a queued one its session key did not sign, inTxQ::applyand, before any direct apply, intryDirectApply(which then falls through to the queue's refusal). And a session-signed transaction other than the own close may no longer use a ticket (tefSESSION_TX_NOT_ALLOWED, rule 1 ofcheckSession): that is the rule ADR 0010 gives for a session without a ticket range, applied alone, so the owner can always revoke by ticket and multi-signed transactions keep their tickets. Case 13 said "a mode-C payment usingTicketSequenceapplies";testModeCAccountingnow asserts the refusal, that no fee is counted and that the ticket stays. No product flow used a ticket under a session: the agent and the web app useSequence, and the web app's "ticket" action is aTicketCreate, which is unaffected. Spec 6.5, case 13, section 12 and open item 5, anddocs/XLS-Sessions.mdfollow; ADR 0010 stays Proposed for its options. - F2,
WitnessSignaturebefore the amendment.preflight1now answerstemDISABLEDto any transaction carrying it whileSigningSessionsis disabled, as for the sponsor fields. - F3, the own close kept the 1000× fee margin. It escapes expiry, cap and witness and is never counted, so past the cap it could cost up to 10 010 drops. Its ceiling is now the base fee of its type, no margin (
Transactor::checkSession, rule 4): past the cap, the account loses at most one base fee more. Interaction with #76 (open, the exception-safe base fee): both touch the same three lines; #76 replacesbaseFee()with*required, this change only addsmargin(0 for the own close), so the merged line isfee > *required + margin. - F4, the invariant saw only the XRP outflow. Rule 4 now also fails on a mode-C session transaction other than the own close that is not a session cap payment (an IOU payment, any other type), and on one that counts more than
SessionMaxPerTxbeyond its fee (the fee argument offinalizewas unused). Rule 5 also fails when a session-signed transaction creates a sponsorship, subscription, credential or DID entry. - F5, documented only. The
LastLedgerSequencewindow bounds the distance to the current ledger, not the session: a post-dated transaction refused now can apply later, and 256 ledgers can outlast 600 s. The comments ofkSessionMaxLedgerWindowand of rule 2 say so; the defence is a fresh key per session. - Tests:
SigningSessions: the witness co-signature with the amendment off; the own close above its base fee refused in mode A and in mode C with the cap used up (the account loses the cap and one base fee); a new casetickets and session-signed transactions(a payment and aTicketCreateby ticket refused, the own close by ticket applies); the queue: the owner's queued close is displaced neither by a session payment at twice the base fee nor at the ceiling, the session is deleted at the next close and the payment never applies.InvariantsSigningSession: an IOU payment and anAccountSetin mode C;SessionMaxPerTxone drop over the bound, and at the bound passing; one creation of each of the four types. Mutations (reverted): without the direct-apply guard, 4 failures; without the queue guard, 5. - Runs (Mac, Debug,
--unittest-jobs=6):SigningSessions,SigningSessionsBatch,InvariantsSigningSession,TxQ,Batch,STTx,PasskeyWebAuthn: 8 suites, 167 cases, 16 253 tests, 0 failures.TxQ,Batch,STTx,PasskeyWebAuthn,Delegate,MPToken: 7 suites, 283 cases, 59 637 tests, 2 failures, the base ones (Delegate"New delegable transaction detected",MPToken_test.cpp:2115txWithAmounts.empty(), listed at:2095indocs/base-failures.txton the imported tree). - Not changed: the simulated node of the web app (
apps/web/lib/sim/) still models the own close with the 1000× margin and does not simulate tickets or the queue.
- What:
SigningSessionSettakes an optionalSessionMaxTxCount(UINT32 115), from 1 tokMaxSessionTxCount= 100 000 (temMALFORMEDotherwise), in every mode. TheSigningSessionentry stores it withSessionTxCount(UINT32 116) at 0.Transactor::checkSession(rule 6) refuses a session-signed transaction withtefSESSION_LIMITonceSessionTxCount >= SessionMaxTxCount, except the own close;chargeSessionadds one for every session-signed transaction applied,tesortec, in modes A, P and C, the own close excluded, including after the fee-claim reset. - Why: the device profiles (agent, game, trading, streaming, minting) bound the number of actions as well as the amounts; modes A and P had no bound but the duration.
- Path: counting in
chargeSession, whereSessionSpentis already charged, keeps one place for the accounting and inherits its two calls (before the first invariant pass and after the reset), so a transaction counts exactly once whatever its path. A separate counter per mode was rejected: the field is mode-independent. - Invariant
ValidSigningSession:SessionMaxTxCountis immutable; at creationSessionTxCountis present exactly with the bound, at 0, and the bound is in range; afterwards the counter neither appears nor disappears, moves by exactly one in a transaction its session signs (the own close excepted) and by zero otherwise, never exceeds the bound, and a session-signed transaction that keeps a counting session must count. - Generated wrappers:
SigningSession.h,SigningSessionSet.hand their two gtest files regenerated withcmake/scripts/codegeninto a temporary directory and copied alone (the other generated files differ from the generator ondevelopand were left untouched). - Tests:
SigningSessions: a new caseSessionMaxTxCount(range, all three modes open with the bound, atecin mode A counts; mode C: atesand ateccount, the third paymenttefSESSION_LIMITwith no fee, the own close passes after the limit; mode POfferCreate: atecKILLEDand atescount, the third refused, the own close passes);mode C accountingnow also checks the count through thetecINVARIANT_FAILEDreset (3 after twotec, a refused ticket payment and the reset).InvariantsSigningSession: a new case with the bound changed, three bad creations, the counter removed, +2 in a session payment, +1 in anAccountSetand in the own close, a decrease, a count past the bound, a session payment that does not count, and the valid +1. - Runs (Mac, Debug):
SigningSessions,SigningSessionsBatch,InvariantsSigningSession,TxQPosNegFlows,TxQMetaInfo,Batch,STTx(--unittest-jobs=6): 7 suites, 160 cases, 16 428 tests, 0 failures.Delegate: 27 cases, 6093 tests, 1 failure,MPToken: 150 cases, 41 475 tests, 1 failure, the base ones.xrpl_tests --gtest_filter='*SigningSession*': 15 tests passed. - Not changed:
FIANSO-SPEC.md,docs/XLS-Sessions.mdand the simulated node of the web app do not describe the bound yet.
The README said "at most 10 minutes" without saying on which clock, and did not say that the own close escapes the cap. Checked in the code before writing: Expiration is the parent close time plus SessionDuration (rippled/src/libxrpl/tx/transactors/session/SigningSessionSet.cpp:218-219), expiry compares the parent close time of the current ledger with it (isSessionExpired, rippled/src/libxrpl/tx/SigningSession.cpp), and the own close skips expiry and the cap (Transactor::checkSession rules 3 and 5) and is not charged to SessionSpent (Transactor::chargeSession). The audit's "may pay up to base fee × 1000" no longer holds since #79: rule 4 now gives the own close no margin, so it pays at most the base fee its type requires; the README says that. One clause each added. Documentation only, nothing run.
From the read-only audit of develop at ce2c7cb, section D item 2. Verified first: nothing in rippled/src or rippled/include reads featurePasskey except the PasskeyListSet definition (rippled/include/xrpl/protocol/detail/transactions.macro:1357), and nothing reads featureQuantum at all (grep -rn "featurePasskey\|featureQuantum"). The feature names are Passkey and Quantum (rippled/include/xrpl/protocol/detail/features.macro:161, :156).
- Change:
Transactor::checkSessionrefuses a session-signed transaction withtefSESSION_TX_NOT_ALLOWED, the own close included, when itsSigningPubKeyis P-256 andPasskeyis not enabled, or ML-DSA-44 andQuantumis not enabled. It sits with the pseudo-account refusal, before rule 1 of spec 6.5, as part of the key-type check the hook redoes in place ofTransactor::checkSign. - Deviation from the audit's wording: the audit asked for the check in
SigningSessionSetpreflight or preclaim as well. That is not possible: the opening carriesSessionKey, an AccountID (spec 6.3), not a public key, so its key type is unknown until the key signs. Such a session can still be removed by the owner, or by anyone after expiry. - Not changed (upstream, out of scope): the imported tree accepts P-256 and ML-DSA-44 signatures from a master key, a
RegularKeyor a signer whatever these amendments say; this change only covers the session path. - Test first, new case
session key types need their amendment: for P-256 withoutPasskeyand ML-DSA-44 withoutQuantum, a payment and the own close are refused and the master-signed close works; with the amendment, both succeed. Before the change,./xrpld --unittest=SigningSessions: 41 cases, 2601 tests, 8 failures (tesSUCCESSwheretefSESSION_TX_NOT_ALLOWEDwas expected, and the cascade). After:./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch: 57 cases, 3880 tests, 0 failures. - After merging
developat620774f(#64 and #79), rebuilt: the three suites, 62 cases, 4304 tests, 0 failures; with the gate removed again,SigningSessionsfails the same 8 assertions. - After merging
developate27a154(#82, #83), rebuilt: the three suites, 64 cases, 4756 tests, 0 failures.
From the read-only audit of develop at ce2c7cb, section B item 3: SigningSessionSet and SigningSessionDelete used ConsequencesFactoryType::Normal, while SetRegularKey and SetPasskeyList are Blocker (rippled/include/xrpl/tx/transactors/account/SetRegularKey.h:17, SetPasskeyList.h:16).
SigningSessionDeletebecomes a blocker. The transaction queue holds a blocker alone in an account's queue (rippled/src/xrpld/app/misc/detail/TxQ.cpp:661-675, checks at:865-882and:913-928). Once a close applies, what its session key signed loses its authority and would leave the queue without claiming a fee, which is exactly the case blockers exist for. Direct application is unaffected (TxQ::tryDirectApply,TxQ.cpp:1687, runs before the blocker checks), so the owner can still revoke at once by paying the open ledger's fee at the account's sequence or with a ticket; the existing case 22 (a close by ticket while a session payment waits) still passes unchanged. Trade-off, the same as forSetRegularKey: a session key that keeps transactions queued for the account makes the owner pay the open-ledger fee to close it, and a queued own close blocks the account's queue until it applies or is replaced.SigningSessionSetstaysNormal, on purpose. An opening grants a new key authority and removes none, and spec 6.3 refuses a session for a key that already has authority (RegularKey, passkey, beneficiary; the master key is refused bySessionKey == Account), so no transaction already queued changes validity; a transaction signed by the new key fails preclaim, and is not queued, until the opening applies. The test asserts it: a queued opening and a payment queued behind it both queue.- Test first, new case
transaction queue: the close is a blocker. First version, before the change:./xrpld --unittest=SigningSessions: 41 cases, 2605 tests, 7 failures (a session-signed payment behind a queued close gotterQUEUED, a close behind a queued payment gotterQUEUED, with their cascades). While writing it, one assertion showed that the node keeps a refused local transaction and applies it on a later open ledger (a refused master-signed close came back two ledgers later astecNO_ENTRYand took 10 drops); the refused transactions now end with the current ledger (LastLedgerSequence). Final version against the old header (stashed and rebuilt): 4 failures (telCAN_NOT_QUEUEinstead oftelCAN_NOT_QUEUE_BLOCKEDtwice andtelCAN_NOT_QUEUE_BLOCKStwice). With the change:./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch,TxQ: 96 cases, 11147 tests, 0 failures. - After merging
developat620774f(with #79, which changes the queue for session-signed transactions and gives the own close no fee margin), rebuilt: the four suites, 101 cases, 11571 tests, 0 failures; with the header set back toNormal,SigningSessionsfails the same 4 assertions. #79 does not make the blocker unnecessary: it stops a session transaction from displacing another key's queued transaction, not a session transaction from queuing behind a close. - After merging
developate27a154(#82, #83), rebuilt: the four suites, 103 cases, 12023 tests, 0 failures.
HashRouterFlags::PRIVATE9, added by the fixPasskeyChallenge pull request (#13) for forced signature verdicts (kSfSigforced, rippled/src/libxrpl/tx/apply.cpp:70), sat at 0x0020, in the public range (BAD to TRUSTED are 0x02 to 0x10), because the 16-bit private range 0x0100-0x8000 was full. A public flag later given 0x20 upstream would collide with it. This is our own commit's defect, not an upstream one.
- Change:
HashRouterFlagsis nowstd::uint32_tandPRIVATE9is0x10000. Every use was checked: the type is only combined with its own operators (HashRouter.h), stored inHashRouter::Entry, and passed by value (HashRouter.cpp,PeerImp); nothing serializes it, casts it to 16 bits or masks a byte of it (grep -rn HashRouterFlags rippled/src rippled/include). The entry grows by two bytes. - Build cost, measured before starting: 27 translation units depend on
HashRouter.h(ninja -t deps), so the change is cheap to rebuild. - Test first (gtest
HashRouterFlags, new file): every private flag is one bit at or above0x0100, the public ones below it, and no two share a bit. Before:Expected: (bits) >= (kFirstPrivateBit), actual: 32 vs 256. After: 2 tests passed../xrpld --unittest=HashRouter,PasskeyWebAuthn,PasskeyRelay,Batch,TxQ,NetworkOPs,SigningSessions: 149 cases, 14612 tests, 0 failures. - After merging
developat620774f, rebuilt: the same suites, 150 cases, 14752 tests, 0 failures;PRIVATE9set back to0x0020fails the gtest again (actual: 32 vs 256). Rebuilding after a change ofHashRouter.halone took 9 s forxrpl_testsand 27 s for both binaries. - After merging
developate27a154(#82, #83), rebuilt: the same suites, 151 cases, 14842 tests, 0 failures; gtest 2 passed.
The N3/N4 and N5 entries above, and the N8 self-review, say that two paths had no test: the eligibility check of mode P at use, and a reset that charges less than the fee because the balance is short. Both now have one. The code was already right; each test was proven to bite by a mutation (one at a time, rebuilt, run, reverted).
- Mode P eligibility at use (
mode P eligibility at use): two mode-P sessions are rewritten in the open ledger (no transaction can create them,SigningSessionSetrefuses both lists) to list, besidesPayment, the non-delegableAccountSetat the transaction level and its granular permissionAccountDomainSet. Payments still sign; anAccountSet(asfRequireDest) and anAccountSetthat only setsDomainare refused (tefSESSION_TX_NOT_ALLOWED) and change nothing. Mutation: theisSessionEligibleTypecheck of the mode-P branch ofTransactor::checkSessionremoved: 2 failures, both transactionstesSUCCESS. - Fee cut short by the reset (
mode C accounting, fee cut short): the reset only cuts the fee in a closed ledger (in an open ledgercheckFeereturnsterINSUF_FEE_B,rippled/src/libxrpl/tx/Transactor.cpp, the end ofcheckFee, andresetassertsbalance >= fee), so the test builds the next ledger and applies by hand, as upstreamRegression_test.cpp:100-130does. Alice's balance is set to 4 drops, a mode-C session-signed payment with a 10-drop fee ends intecINSUFF_FEE, andSessionSpentgrows by 4 drops, the amount actually taken; the balance is 0. Mutation: the firstchargeSessioncall given the declaredFeeinstead of the feeresetreturned: 1 failure (SessionSpent10). The invariant alone does not catch that mutation (counting more than the outflow satisfies rule 4); this test does. - Runs:
./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch: 58 cases, 3826 tests, 0 failures. The at-use test for the two D4 types (CredentialCreate,DIDSet) is in the D4 pull request. - After merging
developat620774f(#64, #79), rebuilt: the three suites, 63 cases, 4250 tests, 0 failures. Mutations again:chargeSessiongiven the declared fee, 1 failure; the at-use check removed, 4 failures (this branch's twoAccountSetrefusals, and #64'sCredentialCreateandDIDSetat use, which #79's invariant now stops astecINVARIANT_FAILED).
From the audit task list (section B, minor): Transactor::invokeCheckSession passed T::calculateBaseFee straight to checkSession, while the fee check of invokePreclaim uses xrpl::calculateBaseFee (rippled/src/libxrpl/tx/applySteps.cpp:247-275, :446-449), which catches an exception and returns tefEXCEPTION.
- Change: rule 4 of
checkSessiontakes the required fee fromxrpl::calculateBaseFeeand returns its error as aNotTEC(temUNKNOWNortefEXCEPTION; anything else would betefINTERNAL), as spec 6.5 rule 4 asks; thebaseFeecallback parameter is gone.isSessionExpirednow callshasExpired(view, Expiration, ExpiryComparison::Inclusive)(rippled/src/libxrpl/ledger/View.cpp:49-63):InclusiveisparentCloseTime >= Expiration, the comparison the function made by hand, so the semantics are identical. - No failing test is possible, said plainly. No session-eligible transactor's
calculateBaseFeethrows (the overrides areEscrowFinish, the owner-reserve types and the confidential MPT multipliers), and the two types with their owncheckSign(Batch,LoanSet) stop at rule 1. Before the change a throw would also have ended intefEXCEPTION, through the catch ofpreclaim(applySteps.cpp:432-441), with a fatal log; the result code does not change, only the path. The expiry change is covered by the existing boundary tests: the mutationExpiryComparison::Exclusivefails 7 assertions ofSigningSessions(expiry, rule order, delete after expiry, reverted). - Runs:
./xrpld --unittest=SigningSessions,InvariantsSigningSession,SigningSessionsBatch: 56 cases, 3760 tests, 0 failures. - After merging
developat620774f: #79 rewrote rule 4 (the own close gets no margin) on the same lines; the merge keeps both: the required fee fromxrpl::calculateBaseFee, and no margin for the own close. Rebuilt: the three suites, 61 cases, 4184 tests, 0 failures; theExclusivemutation again fails 7 assertions. - After merging
developate27a154(#82, #83), rebuilt: the three suites, 63 cases, 4636 tests, 0 failures.
Upstream defects of the imported tree, fixed in their own commits (one defect each, no amendment code mixed in), on one branch. Verified in our tree before any change: parseDERSignature (rippled/src/libxrpl/protocol/PublicKey.cpp:335-376 at 4a3a774) skips the sequence length byte, never checks for bytes after S, accepts non-minimal and negative integers, has no low-S rule, and can read one byte past the buffer; verifySigObject (rippled/src/libxrpl/protocol/STTx.cpp) only reads PasskeySignature next to a P-256 key and, on that path, ignores TxnSignature. The audit's wording "over-read when S ends the buffer" is slightly off: the read past the end happens when the buffer ends at the tag of S (the length byte of S is then read from beyond it).
- Over-read (
fix: Stop the P-256 DER parser reading past the signature, no amendment). The outcome was already a rejection for every input (the bounds check after the read fails whatever byte is read), so no signature changes validity. Test first: a gtest places such a signature against aPROT_NONEpage; before the fix the gtest binary dies (exit 138, SIGBUS), after it passes. - Signing side (
fix: Produce low-S P-256 signatures, no amendment):sign()returned high-S P-256 signatures about half the time, which a strict verifier must refuse; it now writes n - s. Every earlier signature still verifies. Test first: 64 signatures, 28 high before, 0 after. - Strict DER and low S (
fix: Require strict DER and a low S for P-256 signatures, new amendmentfixP256StrictDer,Supported::Yes,DefaultNo, at the top offeatures.macro, enabled ininfra/standalone.cfg). Strict parse, asecdsaCanonicalitydoes for secp256k1: the sequence length equal to the signature's, minimal integers, nothing after S, 0 < R < n, 0 < S <= n/2. The rule is passed fromSTTx::checkSign(Rules const&)through the single, multi, WebAuthn and BatchSigner paths, asfixPasskeyChallengeis. Consensus safety, judged: the change refuses signatures that verified before, so it needs an amendment; activation is made safe as forfixPasskeyChallenge: until it is enabled, a transaction with a P-256 key anywhere bypasses the signature cache, and NetworkOPs and PeerImp do not mark it bad (isSignatureVerdictCacheable). Tests first (the plumbing built with the strict flag ignored): gtest 4 of 8 failing (high S, trailing byte, wrong total length, non-minimal integers accepted under the strict flag);P256StrictDer20 failures, all in the "with the fix" cases (each altered form verified under a new transaction ID); after the parser, 0. PasskeySignaturethat no P-256 key uses (fix: Refuse a PasskeySignature that no P-256 key uses, same amendment): next to a secp256k1 key or next toSignersit was ignored, so anyone could add one and re-hash the transaction. Test first: with the fix, both variants still verified (Valid); after,SigBad; without the fix they verify and keep no verdict.TxnSignaturenext to aPasskeySignature(fix: Refuse a TxnSignature next to a PasskeySignature, same amendment): found while writing the previous test; same class of defect. Test first: verified under a new ID with the fix; after, refused.- One amendment for the three validity changes, said plainly: all three are P-256 signature-shape malleability fixes and share the activation work; splitting them into three amendments is possible if a reviewer prefers.
- Client: the passkey wallet (
apps/passkey-wallet) sent the authenticator's DER as it came; it now goes throughcanonicalizeDerfrom@carbs/sessions(the web app's passkey path already did). Without it, about half of its passkey payments would be refused on our node. - Runs:
./xrpl_tests: 1142 tests, 1 failure, the baseVaultTests.BuilderFromSleRoundTrip(N0)../xrpld --unittest=P256StrictDer,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,InvariantsSigningSession,Delegate,Sponsor,Firewall,AccountDelete,PasskeyListSet,ServerDefinitions,Feature: 267 cases, 34304 tests, 1 failure, the baseDelegatecounter. Full./xrpld --unittest --unittest-jobs=6on this branch: 640.9 s, 1,293,550 tests, 203 failures, the same suites and counts as the recorded baseline (AMMBinned152,AMMCurves41,InvariantsVault2,ValidatorListExpiration2,ValidatorList_Dilithium2,Delegate1,MPToken1,SignatureVerification1, andValidatorSiteV2not completing);SignatureVerificationpasses alone, and the two validator-list suites fail alone exactly asdocs/base-failures.txtlists. WebAuthn results use the software authenticator: SIMULATED. - After merging
developat67af145(#64, #79, #82), rebuilt:P256Signature8 passed;P256StrictDer,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,InvariantsSigningSession: 167 cases, 10780 tests, 0 failures. Mutation (the strict parser never selected): gtest 4 failures,P256StrictDer19 failures, reverted.
Owner request: the web app follows the XRPL Commons look (an XRPL showcase app with no stance of its own), with one exception: whenever a Ledger is involved the whole app turns into Ledger mode. Branch fianso/frontend-da (the audited final pull request keeps its state).
- Sources read. xrpl-commons.org: the homepage and its Webflow stylesheet (colours,
--navy #000637,--white #f5f4e7,--blue-violet #791aff,--dodger-blue #1aa4ff,--yellow #faff1a; Clash Display and Plus Jakarta Sans; pill buttons at100rem, cards at 1 to 1.25 rem with 2 px navy borders). Figma file "Présentations" (the link the owner gave points at the page "HISTORY 25/26", a canvas of every deck): the XRPL Commons material is in page "26/27", the deckWave of Innovation <> DeVinci Blockchain(9499:49) and itsSource · WOI assetsframe (9481:1146). That deck is dark navy (#05192f), Montserrat, with violet (#6b6bf2) and cyan (#27c4e8) offset layers behind photos; it is the XRP Australia and London delegation proposal, not a design system, so the site is the reference and only the offset-layer motif is taken from it. - Assumption, unverified. Ledger's exact purple: the Ledger homepage gave no usable token and a web search found none (a third-party spec lists black, white and an orange-red
#ff5300). Ledger mode uses#bbb0ffas the accent over a violet silk (#6a4cf0); both are tokens (--ledger-violetand thecolorprop inlayouts/default.vue). - Ledger mode (
lib/ledger-mode.ts): on for a Ledger account, the Ledger Stax wallet connecting or connected, a real Stax connected or connecting, or a device review; only colours change. The dark theme that followed the system is gone: the app is light unless a Ledger is involved. - Components from React Bits, ported to Vue (no React in the app):
RubberSegment(top bar, with an empty state for the landing page),ThoughtLine(replaces the busy line; shows the setup steps of an account as they happen, then "Done in 39.6s"),Silk. They run on themotionpackage (12.38.0). Icons are inline SVG, no icon package. - Fonts are self-hosted in
public/fonts(Clash Display copied from the site's stylesheet under the Fontshare licence, Plus Jakarta Sans from@fontsource, OFL);font-src 'self'is unchanged. - Checked in Chrome against the dev server: Commons look on the Account screen, Ledger mode switching on during a simulated Ledger account creation (silk, device review, steps, settled line), navigation through the segmented control.
pnpm --filter web generateandcheck-staticpass. Not checked: a real Stax in Ledger mode, other browsers, the Safari rendering ofbackdrop-filterand WebGL2.
- What:
demo.html,game.html,dex.html,stream.htmlandmint.html(apps/passkey-wallet) leave their own dark style for the look of the web app: cream, navy ink, violet, Clash Display and Plus Jakarta Sans, 2 px navy borders, pill buttons. The column where the Ledger Stax is involved (the Stax column of the demo, the owner column of each use case) and the footer's Device layer are in Ledger mode (dark violet). The 1440x900 single screen and the large type stay. - One token file:
src/theme/stage.cssimportsapps/web/assets/tokens.cssand holds no value of its own; the wallet'svite.config.tsusesapps/web/publicas itspublicDirfor the fonts, and the wallet Dockerfile copies both. The Ledger-mode block of the tokens now also matches[data-theme="ledger"]on any element (the web app keeps setting it on<html>). A copy of the token file was rejected: two files would drift. - Semantic colours map to the tokens: paid
--color-ok, ledger refusal--color-danger, device refusal--color-accent(violet), waiting--color-warn(amber), expiry--color-highlight; a prompt injection is a yellow--color-tagmarker. Blue text uses--color-highlight-text, the brand blue mixed with the ink, because the brand blue on cream is too light at projector distance. In Ledger mode the violet is close to the muted ink, so a lit Device layer also gets a violet wash. - Checked: headless Chrome at 1440x900 against the Vite dev server, before and after, empty state, plus the demo with one timeline event of every tone injected in the page. Pages fit without scrolling as before (the mint page scrolled before and still does; the game's centre column already clipped its tx grid).
- Bug: a double click on migration step 3 sent the same
AccountSettwice:tesSUCCESS, thentefPAST_SEQfor the second copy (sameSequence). Every signing button ran its action once per click. - Change:
guarded(apps/passkey-wallet/src/dom.ts), already the click wrapper of the test page, now runs one action at a time: the pressed button (or the form's submitter) is disabled from the click until the action and the page refresh end, and clicks in between are ignored. It also takes thesubmitevent itself (preventDefault), so the three forms pass it instead of calling a fresh wrapper per submit (which had no memory of the running one). The mint and stream pages' own wrappers now build on it; the game's auto-clicker start goes through it without its click so the same button can still stop it. Agent approvals ignore a second decision on a request already being decided. - Path: the button is re-enabled before the refresh, which then sets its real state; restoring it afterwards would undo the refresh (step 3 must stay off once done). Refreshes that re-enable it mid-flight are harmless: the running flag drops the click.
- Ledger Stax section: « signer en ML-DSA-44 » is never chosen by hand (checked and disabled once migrated). Once migrated, « Essayer avec l'ancienne clé classique » signs a 1 XRP payment with the classical key on the Stax;
tefMASTER_DISABLEDis logged green (logOutcome), anything else red.
- What:
SigningSessionDeletedeclaresgetFlagsMask, returningtfUniversalMask(the pattern ofFirewallDelete): every bit but the universal flags istemINVALID_FLAGin preflight, as FIANSO-SPEC 6.3 asks. Theflagstest now tries each of the 30 non-universal bits on a close, then closes withtfFullyCanonicalSig.FIANSO-SPEC.md(6.1 to 6.7, 6.9, 6.10, 6.13, 6.15, appendix A) anddocs/XLS-Sessions.mdnow describe develop:SessionMaxPerTx,SessionWitnesswithWitnessSignature(WTS\0) andtefSESSION_NO_WITNESS,SessionMaxTxCount/SessionTxCount, the hardening of #79 (no queue displacement, no ticket but the own close, the own close fee ceiling, the new invariant checks) and the Batch interplay; the XLS line references are refreshed, and the test plan gains cases 25 to 28. - Why: the behaviour was already the default of
Transactor::getFlagsMask; declaring it keeps the rule visible next toSigningSessionSet's. The two documents still described the three-code design of N2 and called the witness and the per-payment cap "awaiting decision". - Path: a mask refusing every bit, universal ones included, was rejected:
TxFlags.hasserts that transaction masks allowtfInnerBatchTxn, and the owner revokes sessions inside a Batch (SigningSessionsBatch, "revoke three sessions in one Batch"). - Full run (
./xrpld --unittest --unittest-jobs=6, Mac, Debug, branchSTOOOKEEE/d-rippled-finishone27a154): 889.7 s, 288 suites, 5581 cases, 1,357,960 tests, 222 failures:AMMBinned152,AMMCurves41,InvariantsVault2,ValidatorListExpiration2,ValidatorList_Dilithium2,Delegate1 (60 vs 71, now atDelegate_test.cpp:2854),MPToken1 (:2115), all indocs/base-failures.txt;SignatureVerification1 andValidatorSiteV21, which pass alone (the second hittest_fetch1, a file another suite left in the working directory under parallel jobs; once it was removed,ValidatorSite,ValidatorSiteV2gave 0 failures); andSHAMapStore19, new to the list. SHAMapStore, not caused by sessions: it fails alone too, 19 failures each time (SHAMapStore_test.cpp:1443,:1620-1642, "online_delete rotation has been unable to make progress for 8 ledgers"), with this branch's binary, with the binary ofcarbs-dev(196ad5c) and with the main checkout's binary built on 2026-10-24 at 19:24, before0bcb324(20:39) and therefore before #56, #59, #64, #79 and #82. The0bcb324full run passed it, and neither the test norSHAMapStoreImphas changed since the import (516f6a5): code older than a tree that passed it now fails it, so the cause is the host or the date, not the session code. Load is not it either: the alone runs failed at a load average near 4. The online-delete health check readsLedgerMaster::getValidatedLedgerAge, which compares the validated close time with the time keeper; the exact factor is not identified.docs/base-failures.txtnames it in its header, likeServerInfo.- Rebased on
developafter #87 (the session key-type gate,SigningSessionDeleteas a queue blocker, the exception-safe fee ceiling, strict DER for P-256), which the two documents now also describe. Full run again on the rebased tree: 675.1 s, 286 suites, 5469 cases, 1,294,829 tests, 222 failures: the same base failures (Delegate60 vs 71,MPToken_test.cpp:2115),SignatureVerification1,SHAMapStore19 as above, andValidatorSiteV2, which did not complete ("The child process may have crashed"), as in N0.ValidatorSiteV2,ValidatorSite,SignatureVerificationalone: 3 suites, 173 cases, 8815 tests, 0 failures.SigningSessions,SigningSessionsBatch,InvariantsSigningSession,P256StrictDer: 4 suites, 78 cases, 5412 tests, 0 failures. No new failure from the session code.
- What: an independent review of the merged witness extension (#59) found that
WitnessSignature, a common field of every transaction and not a signing field, was verified bySTTx::checkSignagainst any key, but checked against the ledger only for a session-signed transaction whose session has aSessionWitness, the own close excepted. OnceSigningSessionsis enabled, anyone relaying a valid transaction (master-signed,RegularKey-signed, signed under a session without a witness, or an own close) could add aWitnessSignaturemade with a throwaway key, or strip one from an own close, and change the transaction's ID while it stays valid: the malleability classfixP256StrictDercloses for P-256, opened by our own extension. Now a transaction that needs no witness takes noWitnessSignature, withtefBAD_SIGNATURE("A signature is provided for a non-signer."):invokePreclaimrefuses one on a transaction that is not session-signed, and rule 7 ofTransactor::checkSessionon a session without a witness and on the own close, even co-signed by the witness. No new amendment:SigningSessionsis unreleased, and aWitnessSignatureistemDISABLEDbefore it. - Witness key types: a witness key of a type whose amendment is off (P-256 without
Passkey, ML-DSA-44 withoutQuantum) no longer co-signs (isSessionWitnessed,tefSESSION_NO_WITNESS), as for a session key since #87. The XLS now also says that outside sessions the imported tree gates no key type (master keys andRegularKeys), an upstream behaviour left as it is. - Not changed, documented: a witness account left with a SignerList only (master key disabled, no
RegularKey) can co-sign nothing, so its session signs only its own close (fail-closed; the test shows it). The temDISABLED test the review asked for already existed (SigningSessions"amendment", #79). - Run (Mac, Debug, this branch on
469843a): the two new cases fail without the code change, 16 failures (stray signatures accepted withtesSUCCESS; P-256 and ML-DSA-44 witness keys co-signing with their amendment off), with the tests kept and the three code files reverted. With the change:SigningSessions,SigningSessionsBatch,InvariantsSigningSession,P256StrictDer,PasskeyWebAuthn,Batch,TxQPosNegFlows,TxQMetaInfo,STTx: 9 suites, 186 cases, 17,763 tests, 0 failures.xrpl_tests --gtest_filter='*SigningSession*:*Witness*:P256Signature*:HashRouterFlags*': 25 passed.clang-format22.1.8 andpnpm format:checkclean. - After the review gate of #92 (approve with nits): the refusal of a stray
WitnessSignatureon a transaction that is not session-signed now comes after its signature check, so a key without authority keepstefBAD_AUTH(the wallet's "attack after close" button, which co-signs, still shows that code); a Batch inner transaction carrying one fails the whole Batch inBatch::preflight(temBAD_SIGNATURE, before any fee; gated onSigningSessions, before which the inner preflight already answerstemDISABLED);signAsWitness(packages/core) refuses to co-sign a session close; the wallet explainstefBAD_SIGNATURE. New cases: multi-signed, by a delegate, a Batch outer and inner, and a co-signed payment by the key after its session is closed (tefBAD_AUTH). The XLS line references are refreshed, "not signed by a session key" became "not session-signed", and the older sentence "no node verifies one before the amendment" now says that no transaction carrying one is applied (its signature is still verified at relay and submit). The XLS notes that a co-signature can be stripped only if the same key later signs without a witness, which a fresh key per session rules out, and that withoutfixP256StrictDera P-256 session or witness signature stays re-encodable. Mutation run: the check moved back before authorization and the Batch refusal removed give 5 failures. ThenSigningSessions,SigningSessionsBatch,InvariantsSigningSession,P256StrictDer,PasskeyWebAuthn,Batch,TxQPosNegFlows,TxQMetaInfo,STTx,Delegate,MultiSign: 11 suites, 235 cases, 24,750 tests, 1 failure, the baseDelegateone (60 vs 71).pnpm test: core 102, scripts 7, ledger 57, web 229, all passing.
- What: an independent review of #78 (merged in #87) found two P-256 signatures that
fixP256StrictDerdid not reach, because they are checked in preflight withverify(), outsideSTTx::checkSign, andverify()defaulted its strictness tofalse: a payment channel claim (PaymentChannelClaim.cpp:98) and a cross-chain attestation (XChainAttestations.cpp:79, called byattestationPreflight). A channel key or an attestation key may be P-256, andSignaturethere is not a signing field, so a relay could still re-hash such a transaction once the amendment is on. Both now passrules.enabled(fixP256StrictDer), andchannel_verifyanswers as the claim would be checked under the open ledger's rules. - No default: the four-argument
verify()has no default any more. A three-argument form remains for keys that are not P-256 (the upstream tests use it with secp256k1, Ed25519 and ML-DSA keys, unchanged) and refuses a P-256 key, so a caller that does not say which rules apply cannot accept a P-256 signature in either form. Manifests (Sign.cpp) and publisher lists (ValidatorList.cpp), which have no ledger rules (they accept a P-256 key like any other), passfalseexplicitly: behaviour unchanged. - Also:
sign()stops withlogicError(which terminates the process, as the other failure branches ofsign()do) when the low-S normalization cannot run (BN_neworBN_rshift1failing), instead of silently emitting a possibly high S. The comments that named onlyfixPasskeyChallenge(NetworkOPs.cpp,PeerImp.cpp,apply.cpp,HashRouter.h) name both amendments. - Not fixed, documented:
PasskeySignature.PasskeyIDis neither signed nor checked against the ledger (onlySetPasskeyListreads it), so a relay can change it and a passkey-signed transaction's ID, on the master,RegularKey, passkey list and session paths alike. Only the passkey list path has a ledger record to bind it to; a full fix is a protocol decision for thePasskeyamendment (tie it to the passkey list entry, or sign it). Stated in the spec, the API changelog and the limits table ofdocs/ARCHITECTURE.md, to report upstream. - Run (Mac, Debug, this branch on
469843a): failing first, with the four sites made lax again (claim,channel_verify, attestation, the three-argumentverify()), 7 failures inP256StrictDerand 1 gtest failure. With the change:P256StrictDer,PayChan,PayChanToken,XChain,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,Manifest,ValidatorList,SecretKey,PublicKey,Seed: 21 suites, 519 cases, 124,612 tests, 4 failures, the base ones ofdocs/base-failures.txt(ValidatorListExpiration2,ValidatorList_Dilithium2).xrpl_tests --gtest_filter='P256Signature*:HashRouterFlags*:*SigningSession*': 27 passed. New: claim andchannel_verifywith and without the fix, the attestation's two forms, a P-256 session witness's high-S form (regression: the witness role already had the flag), R = n refused, and the three-argumentverify()refusing P-256.clang-format22.1.8 clean. - After the review gate of #93 (approve with nits): a transaction-level test of the attestation (
XChainAddClaimAttestationwith a high-S P-256 signature:temXCHAIN_BAD_PROOFwith the fix,tecNO_ENTRYat the bridge lookup without it), since the directAttestationBase::verifytest did not cover the preflight wiring (mutation:falseatXChainBridge.cppgives 1 failure);channel_verifyreads the current ledger's rules assubmitdoes; the comments ofapply.cppname both amendments; thePasskeyIDitem moved to a "Known issues" section of the API changelog, and the limits row says what bounds it today (submitSignedAndWaitfinds a transaction by account and sequence; the apps still poll by hash) and what would fix it (bind it to the passkey list entry's ID, else empty, underfixP256StrictDer). On the HashRouter: atemfrom preflight can be marked BAD (NetworkOPs.cpp), and that stays safe across the activation because any signature the strict form accepts, the lax form accepts too. Run:P256StrictDer,PayChan,PayChanToken,XChain,PasskeyWebAuthn,PasskeyRelay,PassKey,MultiSign,Batch,STTx,SigningSessions,SigningSessionsBatch,Manifest,SecretKey,PublicKey,Seed: 18 suites, 495 cases, 116,335 tests, 0 failures; gtests 27 passed.
2026-10-25: only a close by the account itself blocks the queue; the fee escalation sentence (review of #70 and #81)
- What: the review of #70 (merged in #87) noted that
SigningSessionDeletewas a queue blocker for every close, including a third party's cleanup of an expired session (Ownerpresent), which removes no authority from the cleaner's own account and only kept the cleaner's next transactions out of its queue.SigningSessionDeletenow uses a custom consequences factory: a blocker withoutOwner(the owner's close, or the own close by the session key), an ordinary transaction withOwner. TxQ behaviour only, local to each node: no consensus change. - Docs: the XLS, the spec and the README said that "a close that pays the open ledger's fee still applies at once". That holds for the owner's close; the own close signed by the session key pays at most the base fee of its type (rule 4, since #79), so while fees are escalated it can only wait in the queue. All three now say so, and the API changelog describes the blocker. The XLS lists the unpriced verification of a
WitnessSignature(a multi-signer adds one base fee each) as an open question. - Run (Mac, Debug, this branch on
469843a): failing first, with the test kept and the two code files reverted,SigningSessions2 failures (carol's payment behind her cleanuptelCAN_NOT_QUEUE_BLOCKEDinstead ofterQUEUED). With the change:SigningSessions,SigningSessionsBatch,TxQPosNegFlows,TxQMetaInfo: 4 suites, 93 cases, 10,654 tests, 0 failures.clang-format22.1.8 andpnpm format:checkclean. - After the review gate of #94 (approve with nits, no must-fix): the README, the XLS and the spec no longer say the own close "waits in the queue": it can at best be queued, alone in its account's queue, and the owner's close applies at once at the account's sequence or with a ticket. Two more cases: a cleanup queues behind the cleaner's own payment (the other direction of the blocker rule), and an own close under fee escalation is queued and blocks what follows (the case behind the doc sentence). The API changelog notes that
auth_changeandauth_change_queuedread false for a cleanup;SigningSessionDelete.cppincludesapplySteps.h; the XLS open question on the witness fee cites the precedents (CounterpartySignaturepriced byLoanSetandFirewallSet,SponsorSignaturefree).SigningSessions: 49 cases, 3,586 tests, 0 failures.
- What: the review gate of #92 suggested it and it was left out at first: a P-256 key signs under a session, as the session key or as the witness, only when both
PasskeyandfixP256StrictDerare enabled (isSessionKeyTypeEnabled, shared byTransactor::checkSessionandisSessionWitnessed). Before, on a ledger withSigningSessionsandPasskeybut without the strict DER fix, a P-256 session or witness signature could be re-encoded by anyone (high S, loose DER) and the transaction's ID changed. Both are unreleased amendments of this fork, proposed together, so no behaviour on any network changes. - Run (Mac, Debug, on
6b189db): failing first, with the old gates kept,SigningSessions5 failures (a P-256 session key and witness accepted withfixP256StrictDeroff). With the change, theP256StrictDercase "session witness, without the fix" now expectstefSESSION_NO_WITNESS(the witness is refused before the form of its signature matters);SigningSessions,SigningSessionsBatch,P256StrictDer,PasskeyWebAuthn,InvariantsSigningSession,Batch,STTx: 7 suites, 154 cases, 11,016 tests, 0 failures.clang-format22.1.8 andpnpm format:checkclean. - After the review gate of #95 (approve with nits): three line references the new code moved are fixed (
SigningSession.cpp:159,SigningSession.h:26and:23-27);isSessionKeyTypeEnabledis aswitchover the four key types with no default, so a new type must be decided there;SigningSession.cppincludesKeyType.h; the spec and the changelog say "unless bothPasskeyandfixP256StrictDerare enabled"; the XLS says the session key gives up its session only when its type may sign, the owner or anyone after expiry deleting it otherwise. New case: an Ed25519 witness co-signs without the P-256 fix. Run: 7 suites, 154 cases, 11,049 tests, 0 failures.
- What: the Ledger app's fuzzer (
ledger-app/fuzzing/, libFuzzer under ClusterFuzzLite) needs Docker, the Ledger SDK and a clang with libFuzzer, none of which this Mac has (Docker was not running; Apple clang ships no libFuzzer).ledger-app/fuzzing/native/run.shbuilds the same entry point (fuzzing/src/fuzz_tx.c:parse_tx, then format the title and value of every field) with the parser and the session policy sources ofunit-tests/against their SDK test doubles, under AddressSanitizer and UndefinedBehaviorSanitizer with no recovery, and feeds it random mutations (bit flips, interesting bytes, insertions, deletions, truncation, splicing, swaps, duplicated chunks) of the 102 ClusterFuzzLite seeds and the 159 test vectors oftests/standalone/testcases, the signing session, Batch, policy and Agent ID ones included. It is not coverage-guided, and says so; it does not replace a ClusterFuzzLite run. - Run (Mac, Apple clang 17, arm64): 900 s, 448,495,487 executions, no crash, no sanitizer report. A 30 s measuring run found that 6.3% of mutated inputs parse completely, with 8.2 fields each formatted, so about 28 million mutated transactions went through the whole parse and display path; the rest exercise the refusal paths.
- Not done: a coverage-guided campaign (ClusterFuzzLite in Docker), and fuzzing of the APDU layer and the ML-DSA chunking (
pq_root.c), which this entry point does not reach.
2026-10-25: the full rippled suite on the final tree, and the wallet's end-to-end script on the final node
- Full suite (Mac, Debug,
./xrpld --unittest --unittest-jobs=8on6b189db, develop and main after #94): 522 s, 286 suites, 5,411 cases, 1,315,625 tests, 204 failures:AMMBinned152,AMMCurves41,InvariantsVault2,ValidatorListExpiration2,ValidatorList_Dilithium2,Delegate1 andMPToken1, all indocs/base-failures.txt;SignatureVerification1 andValidatorSiteV21, andValidatorSitedid not complete ("The child process may have crashed").ValidatorSiteV2,ValidatorSite,SignatureVerificationalone: 3 suites, 173 cases, 8,815 tests, 0 failures.SHAMapStorepassed in this run. #95 (P-256 session keys needfixP256StrictDer) came later and ran its neighbouring suites (7 suites, 0 failures). - The wallet's end-to-end script (
apps/passkey-wallet/scripts/session-demo.ts) against a local standalone node built from the final tree with #95 (infra/standalone.cfg, the closer, the well-known standalone genesis seed): 38 checks, all as expected. Among them: mode C with a 50 XRP cap and 20 XRP per payment (tefSESSION_LIMITfor 25 XRP and for the remainder), the session key closing its own session (tesSUCCESS) then refused (tefBAD_AUTH), a witness session (tefSESSION_NO_WITNESSwithout or with a stranger's co-signature,tesSUCCESSwith the witness's), modes A and P refusingSetRegularKeyandAccountSet(tefSESSION_TX_NOT_ALLOWED), a session opened and revoked inside Batches, and the all-or-nothing key migration Batch that applies nothing. It drives the wallet's own session code, not the browser pages.
- What: a sixth screen, Apps (
/apps, after Attack in the top bar, and on the landing page), with one card per page of the passkey wallet's server:demo.html(the AI agent),game.html,dex.html,stream.html,mint.htmlandindex.html(the wallet itself). A card says what the app shows, the session it opens (mode, Stax profile, and the limits as the Stax review lists them: Valid for, Max transactions, Max spend, Max per payment, Allows), what the ledger refuses and with which code, what the Stax refuses before it signs (0x6986), and links to${NUXT_PUBLIC_APPS_URL}/<page>in a new tab (rel="noopener noreferrer"). The default host is the team's wallet server,https://debian-docker.tail8b5861.ts.net:9449; a value that is not a plain http(s) URL shows no link. The data is one typed module,apps/web/lib/live-apps.ts. - Why: the live apps exist only on the team's node and server; the front did not say they exist, nor what each one bounds and how.
- Numbers, as the apps' sources set them (in
apps/passkey-wallet): agent: mode C, 20 XRP, 5 XRP per payment, 180 s (scripts/agent/agent.tsdefaults; the run sheet sets 120 s); game: C, 5 XRP, 0.1 XRP, 300 transactions, 600 s, profile game (src/apps/game/rules.ts); trading: P (OfferCreate, OfferCancel), 200 transactions, 600 s, profile trading (src/apps/dex/rules.ts); streaming: C, 1 XRP, 0.01 XRP, 600 transactions, 600 s, profile streaming (src/apps/stream/config.ts); minting: P (NFTokenMint), 50 transactions, 600 s, profile minting (src/apps/mint/mint-session.ts); wallet: the form's defaults, C, 50 XRP, 20 XRP, 300 s (index.html). - Found while reading: the device has an
agentprofile (50 XRP, 5 XRP, 3 min, 20 transactions, #88), but the agent's opening ondevelop(approvalTx,src/agent-approvals.ts) names no profile and sets noSessionMaxTxCount, so the Stax checks it against its default session policy (the run sheet's 50 XRP, 5 XRP per payment, 3 min) and its review has no Profile or Max transactions row. The card says so; the wallet was not changed. The wallet page's sessions name no profile either. - Checked:
lib/live-apps.test.tsreads the wallet's sources (aspackages/ledgerreadssession_permissions.c) and fails when a number on a card drifts from them (mutation: two limits changed, 4 failures); each use case's opening passes the client's mirror of the device's profile check (sessionPolicyRefusal, policy on) and each device probe on its card is refused withProfile <name>:, and passes with the policy off; the agent's budget passes the run sheet's policy and its first, 500 XRP budget is refused; the Stax rows equal the client's prediction (reviewLines); every ledger code is a name ofrippled/src/libxrpl/protocol/TER.cpp;connect-srcdoes not list the team's host.AppsScreen.test.ts: the links, the rows, the codes, the note, no link for an unusable URL, no request sent. In Chrome against the dev server at 1440 px, and with mobile emulation at 390, 375 and 320 px: no horizontal overflow. With six screens the top bar's labels touched at 390 px, so below Tailwind'ssmbreakpoint they use the small type. The six pages answered HTTP 200 with their titles at the default host from this machine (on the team's tailnet), and their bundles carry the same session rules (read from the minified JavaScript). - Not checked: no app was used from this screen: nothing was signed and no session was opened. The Stax rows come from the device's code, its frozen vectors (game, trading) and the client's prediction, not from a real Stax; the device's refusals need its session policy on. The profile checks above are the client's SIMULATED mirror of the device, not the device.
- What: an account address or a transaction hash shown in the web app opens the explorer of the network it lives on, in a new tab (
rel="noopener noreferrer"): the account page (its transactions) or the transaction page. One helper,explorerLink(apps/web/lib/explorer.ts, next toexplorerTxUrlandexplorerAccountUrloflib/networks.ts), and one component,ExplorerLink(withLinkedTextfor the addresses a sentence names), used by the result view, the Account screen (address, regular key), the Session screen (session key), the wallet entry, and the Quantum screen (the checked account, which the report now shows, the transaction that first revealed a key, the addresses in the reasons, the coverage and the surviving authorities, the migration's account and steps). The text keeps its font and its shortened form; the link is underlined in the accent with an arrow (.explorer-link, CSS only, no icon package). - Rules: only a real classic address (checksum) or a 64-hex hash on a network with a public explorer is linked: AlphaNet and mainnet. A SIMULATED value, a value on the local node (no public explorer) and a transaction no validated ledger holds (a refusal, or an answer that never validated) stay plain text, as before; the SIMULATED badges are unchanged. A transaction's link uses the explorer of the node it was sent to:
NodePort.explorerUrl(hash)becameexplorerNetwork()(the live node's network, none for the simulation). - AlphaNet explorer:
https://alphanet.xrpl.organswers 403 "Forbidden" to a browser (checked with curl and a browser's headers; the team found it in Chrome), so the AlphaNet explorer is now the XRPL Explorer in custom-network mode,https://custom.xrpl.org/alphanet.xrpl.org, whose account page showed "Custom: alphanet.xrpl.org" and the account's balance in Chrome.wss://alphanet.xrpl.org, the RPC and the faucet do not change.kAlphaNetExplorerTxUrlof@carbs/sessions, printed by the spikes, follows. Mainnet stayshttps://livenet.xrpl.org. The public AlphaNet keeps only recent history (server_infogavecomplete_ledgers240005-253753 on 2026-10-25), so the link of an older transaction opens the explorer's "not found" page; account pages always work. The DEVLOG entries above keep the old URL as it was written. - Content-Security-Policy:
networkOriginsput each network's explorer inconnect-src, although the page never fetches one; with the new base that would have addedhttps://custom.xrpl.org. Links are navigation, whichconnect-srcdoes not govern, so the explorers left it:https://alphanet.xrpl.organdhttps://livenet.xrpl.orgare gone from the policy and nothing was added.vercel.jsonwas rewritten withUPDATE_HOST_FILES=1, anddocs/DEPLOY.mdcarries the same policy (checked againstbuildCsp). - Checked:
lib/explorer.test.ts(both explorers, no link for the local node or without a network, no link for a malformed or wrong-kind value, sentences cut around real addresses and hashes only),ExplorerLink.test.ts(a real AlphaNet hash renders a link with the custom explorer's URL in a new tab; a SIMULATED one, the local node's and a fake address render plain text; a sentence's address and hash link on mainnet, nothing in a SIMULATED sentence),ResultView.test.ts(the node's own explorer, not the header's; plain text for a SIMULATED hash, a refusal and the local node),QuantumScreen.test.ts(the live report links the account and the revealing transaction on AlphaNet and on mainnet; the simulated report links nothing; the live migration's links use the new base),networks.test.ts(the explorers are not inconnect-src).pnpm --filter web generateandcheck-staticpass. - Not checked: the Account, Session, Actions and Attack screens run on the simulation or the local node, so their links never appear today; they are wired and tested through the result view only. A transaction page was not opened on the custom explorer for a transaction older than the AlphaNet's history. The device modal still shows the Stax's rows as text, as the device does.
- What: the violet placeholder mark next to the
carbswordmark is gone from the header (components/ui/BrandMark.vueremoved): the wordmark is the logo of the app. The square logo ofmedia/brand/(the "c" of the wordmark) serves only as the favicon and for the submission, as the owner asked.pnpm --filter web typecheckandtest(268) pass.
- What: an account whose master key is disabled pays only through an all-or-nothing Batch signed by its current RegularKey K_n, carrying [Payment, SetRegularKey -> K_{n+1}]. Inner transactions carry no signature, so a payment reveals only K_n, the key it retires; the ledger stores K_{n+1} as an AccountID (a hash), so the public key in force is never on the ledger.
SetRegularKeyhas no master-key check (unlikeasfDisableMaster), so it works as an inner transaction. - rippled:
KeyRotationBatch(rippled/src/test/app/KeyRotationBatch_test.cpp): three rotations, each paying bob and installing the next key (the outer'sSigningPubKeyis K_n and its blob does not hold K_{n+1}'s public key), the retired keytefBAD_AUTHand the mastertefMASTER_DISABLEDafter each; a Batch whose payment fails (tecNO_DST_INSUF_XRP) leaves the RegularKey unchanged (only the outer fee and sequence consumed); a rotation to a key never revealed stores its AccountID only (the account root holds no copy of its public key). 3 cases, 154 tests, 0 failures;BatchandSigningSessionsBatchstill pass. - Live:
pnpm --filter @carbs/passkey-wallet rotation-demo(GENESIS_SEED,XRPLD_WS, defaultws://127.0.0.1:16006). Keys come from one 32-byte secret (ROTATION_SECRET, random by default): key n is the secp256k1 family seed whose entropy is the first 16 bytes of SHA-512("carbs-rotation" | secret | n), index 0 being the master; chosen over BIP32 (not a dependency) and overderiveKeypair'saccountIndexbecause each key stays a plain family seed the existingsignWithSeedsigns with. On the Mac standalone node: 5 payments, every innertesSUCCESS, K1–K5 then refused (tefBAD_AUTH), the master too (tefMASTER_DISABLED);account_txshows each of K1–K5 exactly once, in the Batch installing the next key, and K6 (in force) never. - Limit: the master public key is revealed by the two setup transactions (SetRegularKey, then
asfDisableMaster, which needs the master's own signature and cannot be an inner transaction); it is disabled afterwards.
- How it was found:
pnpm dlx knip@5with the passkey wallet's HTML entry points declared (a first run without them flagged every stage page's sources as unused, a false positive),--include-entry-exportsfor@carbs/sessions;tsc --noUnusedLocalson each package andvue-tsc --noUnusedLocals --noUnusedParameterson the web app (clean, except one unused type import in the passkey wallet'spq-migration.ts, left alone because that code is being changed elsewhere); a grep for every candidate over the repository; the files of the create-xrp import (0508c48) that #24 did not replace. - Removed:
apps/web/.scaffold-xrp.json(the generator's state, read by nothing);apps/web/.gitignore(Next.js, Turbo and Rust/Bedrock entries; what still applies,.vercel/,*.pem, the debug logs and theapps/web/distsymbolic link thatnuxt generatewrites, is now in the root.gitignore, whosedist/becamedistso it matches a link); the root's!.env.local.example(the file went in #24); the web app'stype-checkscript (a copy oftypecheck); thestream->stream-browserifyalias (the package is not installed);packages/core/.prettierignore(prettier reads the root file, which has the same rule);isAddress(lib/sim/keys.ts) andknownResultCodes(lib/result-codes.ts), with no caller; the--backdroptoken, never read (the Ledger theme's silk is the.ledger-backdropelement). - One source for the protocol constants: the web app's copies of the session limits, the
LastLedgerSequenceoffset, the default duration and the blackhole address are gone; the simulated node, the Session screen and the demo store import them from@carbs/sessions. The passkey wallet's two browser-only copies of the Ripple epoch offset usekRippleEpochOffset. Values are unchanged. - Left in place, on purpose: the passkey wallet's own
batch.ts,hex.ts,rpc.tsand the agent script's epoch constant duplicate@carbs/sessions, but they also run under Node's type stripping (server/,scripts/), which cannot load@carbs/sessions(ERR_MODULE_NOT_FOUNDon its./x.jsimports, checked with Node 22.20); the public exports of@carbs/sessionsthat no package calls yet (accountIdHex,isSessionResultCode,signerAddress,assembleSigned) are the library's API;FIANSO-SPEC-v1.md, which no file links to, says it is kept for its evidence; the template'sBuffer,eventsandprocessstand-ins, which would need each wallet of xrpl-connect checked in a browser before going. - Checked:
pnpm format:check,pnpm typecheck,pnpm test(core 102, ledger 57, web 268 and 5, scripts 7, passkey wallet 5),pnpm --filter web generateandcheck-static(clean), and the passkey wallet'svite build. Nothing was run on a device or a node: no behaviour changed.
- What: the wallet server answers CORS for its faucet (
/api/faucet) to the origins inWEB_ORIGINS(defaulthttps://carbs-kappa.vercel.app): a preflight from a listed origin gets 204 withaccess-control-allow-origin, any other 403; the wallet's own pages stay same-origin. The hosted web app is built withNUXT_PUBLIC_LOCAL_NODE_URL=wss://debian-docker.tail8b5861.ts.net:9448andNUXT_PUBLIC_LOCAL_FAUCET_URL=https://debian-docker.tail8b5861.ts.net:9449/api/faucet, so "Live (local node)" reaches the team's node from a browser on the team's tailnet; elsewhere it falls back to the simulation with the reason shown. Both endpoints are on the team's server only (no public exposure). - Checked: the server locally: preflight from the listed origin 204 with the CORS headers, from another origin 403, a POST from the listed origin carries the header. The live run against the team's node needs the server redeployed with this change.
- What: Ledger's official
@ledgerhq/device-signer-kit-xrp(0.3.0,LedgerHQ/device-sdk-tsdevelopat8ab7cdc) is imported unmodified intopackages/signer-kit-xrp(its tree object equals upstream's;UPSTREAM.md), made to build and test here with tooling changes only (@carbs/device-signer-kit-xrp; upstream's Vitest 3.2.6 and Prettier 3.5.3; three shared configs of Ledger's monorepo copied byte for byte; an esbuild and tsc build run bypnpm install), then extended in its own style:getPqPublicKey(0x10),signTransactionPq(0x11then0x12),getSessionPolicy(0x08, a typed model), the session refusals0x6815and0x6986in its error mapping, and JSDoc and README on theSTX\0prefix the app adds itself on the classical path.packages/ledgernow runs every command through the kit: on a real Ledger throughSignerXrpanddmk.executeDeviceAction(skipOpenApp: true), on the SIMULATED device and test doubles through the kit's exported device action factories overexchange. Its public API is unchanged, withgetAppConfig(real device only) andLedgerDevice.signeradded.packages/signer-kit-xrp/UPSTREAM-PR.mddrafts an upstream pull request; nothing was sent. - Why: stop building next to Ledger's kit and extend it instead, so that the kit carries what this app needs and the extension can be offered upstream.
- Run (Mac, Node 22.20): the kit's 106 upstream tests pass unedited, 205 with ours (30 files, Vitest 3.2.6); on the root Vitest 4, 4 upstream tests fail ("is not a constructor", a DX log row), hence the pin.
@carbs/ledger68 (the 57 existing ones unchanged, on the kit path, and 11 that drive a realSignerXrpon aDeviceManagementKitwhoseexecuteDeviceActionruns each action against the simulated device), the web app 269,@carbs/sessions102,scripts7.pnpm format:check,pnpm typecheck,pnpm --filter web generateandcheck-staticpass (CSP unchanged, no third-party script). Six DX log rows. - Not done: nothing ran on a real device: the kit path (OpenApp skipped,
executeDeviceAction, the PQ commands in 255-byte chunks) was not tried on the Stax, and the classical signature through the kit neither. The passkey wallet's Dockerfile was updated (it copies the kit) but not built. ESLint was not run on the kit (its config is private to Ledger's monorepo). The kit exports no factory forgetAppConfig, so app configuration is not available on the simulated device. - After the review gate of #112 (approve with nits, blocked only by a conflict with develop): develop merged again (both DEVLOG entries kept).
0x6815and0x6986leave the kit's shared upstream map: a separateXRP_SESSION_APP_ERRORS(spreading the upstream one) is used by the ML-DSA sign command only, so the upstream classical command reports them as an unknown device exchange error carrying the status word, whichpackages/ledgerstill turns into the sameDeviceError. The JSDoc of the newSignerXrpmethods names no fork.GetSessionPolicyCommandrefuses a profile with more permissions than the app stores (SESSION_PROFILE_MAX_PERMISSIONS, 2). The length checks ofgetMlDsaPublicKeyandsignMlDsathat the kit made unreachable are gone: a malformed answer (a short chunk, a wrong announced length) now surfaces as the kit'sInvalidResponseFormatError, a DMK error object thatdescribeLedgerErrorprints, no longer as aDeviceContractError. The web app's device ports take anXrpTransport. The kit README says thatlib/is missing afterpnpm install --ignore-scripts;UPSTREAM-PR.mdlists ESLint not run and the interface methods as breaking for implementers.
- Why: the owner's Ledger developer contacts pointed to the FIDO2 "Security Key" app. With ML-DSA-44 credentials a Ledger is a post-quantum WebAuthn authenticator whose passkeys can sign XRPL transactions through the passkey path (a parallel task makes rippled verify a
PasskeySignaturewhoseSigningPubKeyis the raw 1,312-byte ML-DSA-44 key and whoseSignatureis the raw 2,420-byte signature overauthenticatorData || SHA-256(clientDataJSON)). - Import:
LedgerHQ/app-security-keymainat1fce46957b5d155cc2d5d47d2cea6840e50b3b23(app 1.7.8) intosecurity-key-app/, unmodified, in its own commit; tree9e26a7d5…equal to upstream's (UPSTREAM.md). - Change (
security-key-app/CARBS.md): getInfo lists -48 (thealgorithmsmember, over USB CTAPHID); makeCredential accepts -48 and returns a COSE AKP key{1: 7, 3: -48, -1: bstr(1312)}generated in place; getAssertion signsauthData || clientDataHashwith pure ML-DSA-44, empty context. Key pair derived, never stored:xi = SHA256((3 << 128 | nonce) || privateKeySeed), the app's ECDSA construction with its own role, thenMLDSA_internal_keygen(xi), the SDK functionledger-app/src/pquses. Low-RAM ML-DSA (HAVE_MLDSA_OPTIMIZATION), Stax, Flex and Apex P only. Attestation unchanged (packed, ES256). No screen added. - RAM on the Stax:
.bss11,094 → 14,830 B (the CTAP2 response buffer 1,032 → 3,445 and the authData buffer 500 → 1,822), stack 25,760 → 22,024 B. No buffer for keys or signatures: the secret key is on the stack for one call, the signature is computed at the end of the response buffer and moved into place by the CBOR encoder. Static estimate of the deepest ML-DSA path (-fstack-usageplus the call graph ofapp.asm): 12,680 B fromget_assertion_confirm, callers and indirect calls not counted.CUSTOM_IO_APDU_BUFFER_SIZEstays 1,031: a CTAPHID reply is sent from the app's buffer, so ML-DSA-44 is offered only over USB CTAPHID and refused over U2F_MSG and NFC, whose replies are copied into the IO buffer. - Built in
ledger-app-builder-lite(image3561f39cb806) for Stax, Flex, Apex P (with ML-DSA) and Nano X, Nano S Plus (without). Staxbin/app.hexsha256edefd8a5871db827…, application hash8d68de4438860be1…(after the review gate below). - Tests: seven new LedgerUT suites (COSE AKP bytes, derivation chain and wiping, the role-3 seed, the decoded registration and assertion responses with the signature intact behind the credential ID, transports, getInfo); three mutations caught. 17 of 18 suites pass; upstream
test_client_pincrashes under ASan at exit on the unmodified import too. The SDK's ML-DSA is faked in them. - Host side:
security-key-app/carbs/mldsa_passkey_test.py(python-fido2 1.2.0, dilithium-py 1.5.1) runs getInfo, makeCredential with -48, getAssertion, verifies the ML-DSA-44 signature on the host and prints thePasskeySignatureshape;verify_noble.mjscross-checks with@noble/post-quantum0.7.1;load_stax.shsideloads with ledgerblue (its--offlineoutput is byte-identical tomake'sapp.apdu). SIMULATED: the script'sselftestuses a software ML-DSA-44 authenticator; dilithium-py and noble agree on its signature and onKeyGen_internal(seed). - After the review gate (APPROVE-WITH-NITS on #109): over U2F_MSG and NFC an ML-DSA-44 credential of an allowList is skipped as unusable, so an ES256 credential later in the list signs instead of the request failing; the signature buffer is wiped when ML-DSA signing fails (the SDK leaves rejected candidates in it);
cx_mldsa_internal.his included instead of a hand-written prototype; the makeCredential response and the user-entity tail of the assertion return an error on an encoder fault instead of a truncated reply. Four tests added, three more mutations caught. Documented, not changed: a side-loaded build attests with the app's public TEST key under Ledger's AAGUID (the Stax test certificate failsopenssl verifyagainst the production CA); no keepalive during an ML-DSA computation; U2F authenticate of an ML-DSA credential ID signs with a role-0 ECDSA key no RP holds (upstream behaviour). The transport check itself is mocked in every suite: only the logic after it is tested. The host script now fails on an attestation other than packed ES256 and says whether the certificate is the test or the production one..bssand stack unchanged. - Not run on a device: no Stax was connected; nothing of this was loaded or shown on a real device. Signing time, stack headroom on the device, browsers with -48 and a node accepting the result are open.
- What ran, on the owner's Stax (USB, Security Key app of #109 side-loaded with
security-key-app/carbs/load_stax.sh, application hash8d68de44…, the same as the build):security-key-app/carbs/mldsa_passkey_test.py make(python-fido2 1.2.0, dilithium-py 1.5.1). getInfo lists algorithms -7, -8, -47 and -48. makeCredential with alg -48, approved on the device: a 64-byte credential ID and a 1,312-byte ML-DSA-44 public key in the AKP COSE key; the packed ES256 attestation verifies (the app's TEST attestation key, public in its repository: not proof of a genuine Ledger). getAssertion, approved on the device: the 2,420-byte ML-DSA-44 signature over authenticatorData || SHA-256(clientDataJSON) verifies on the host (pure ML-DSA-44, empty context). Both device operations, approvals included, took 24 s in total. The challenge was the script's test string, not yet an XRPL transaction. - Fixed:
load_stax.shexpanded an empty array underset -u, which macOS's bash 3.2 refuses ("unbound variable"); it now uses${extra[@]+…}. - Next: the same assertion over an XRPL transaction's signing data, submitted to a node with
PasskeyMLDSA(#113).
- What: a new amendment,
PasskeyMLDSA(Supported::Yes,DefaultNo, effective only withPasskeyandQuantum:isPasskeyMLDSAEnabled), enabled ininfra/standalone.cfg.PasskeyListSetaccepts ML-DSA-44 public keys (1 312 bytes, raw) next to P-256 ones; aPasskeySignaturenext to an ML-DSA-44SigningPubKeyis checked as a P-256 one is (webauthn.get, the challenge equal to the signing data as bytes, whateverfixPasskeyChallengesays) with a pure ML-DSA-44 signature, empty context, overauthenticatorData ‖ SHA-256(clientDataJSON)(COSE -48). ATxnSignaturenext to it is always refused. Such a signature authorizes only through a passkey list entry that holds the key under the samePasskeyID(Transactor::checkMLDSAPasskey;tefBAD_AUTHotherwise, also for a master key,RegularKeyor session key signing through WebAuthn), so a relay cannot change itsPasskeyIDand the transaction's ID, which it still can on the P-256 path. Until activation, no signature verdict on such a transaction is cached (isSignatureVerdictCacheable). The jtx WebAuthn authenticator signs with ML-DSA-44 keys too, and can alterAuthenticatorData. Spec:docs/XLS-PQ-Passkeys.md. - Why: a passkey has full authority, and in the alphanet it is always P-256 with its public key on the ledger from registration: on any account that has one, it is a quantum backdoor that moving the other keys to ML-DSA-44 does not close.
- Prior art read first (read only, nothing posted): XLS-83d and XLS-84d (Ripple, 2024, closed as stale), XLS-0103 and the alphanet
Quantumamendment (Transia-RnD/rippled#27), the alphanetPasskeyamendment (Transia-RnD/rippled#29) and its open review comments. The key and signature format is the one ofQuantumand RFC 9964; thePasskeyIDbinding follows XLS-84d's lookup of the credential in the list;Quantumis required explicitly because the imported tree does not gate ordinary ML-DSA-44 signatures on it. - SIMULATED: every WebAuthn result comes from the software authenticator (
rippled/src/test/jtx/webauthn.h): no device, no browser. Whether any browser or authenticator creates analg: -48credential is UNVERIFIED. - Run (Mac, Debug, arm64, on
00755b0):- Failing first, with the amendment defined and the tests written but no behaviour:
PasskeyMLDSA16 cases, 801 tests, 61 failures (lists refused withtemMALFORMED, WebAuthn payments refused with "Invalid signature.", and the second submission of a refused blob answered "Transaction has bad signature.", a cached verdict). - Mutations: without the cache rule of
isSignatureVerdictCacheable, 12 failures; with thePasskeyIDcomparison removed, 3; without the list-only rule incheckSign, 5; without the session-path refusal, 2. - With the change:
PasskeyMLDSA17 cases, 865 tests, 0 failures.PasskeyMLDSA,PasskeyWebAuthn,PasskeyListSet,PassKey,PasskeyRelay,P256StrictDer,SigningSessions,SigningSessionsBatch,STTx,Batch,InvariantsSigningSession,KeyRotationBatch,MultiSign,Delegate,Feature,ServerDefinitions,TxQPosNegFlows,TxQMetaInfo: 18 suites, 289 cases, 32,206 tests, 1 failure, the baseDelegatecounter. Full suite (--unittest-jobs=8): 288 suites, 5,468 cases, 1,318,716 tests, 203 failures: the 201 that repeat indocs/base-failures.txt(AMMBinned152,AMMCurves41,InvariantsVault2,ValidatorListExpiration2,ValidatorList_Dilithium2,Delegate1,MPToken1),SignatureVerification1, andValidatorSite, which did not complete (counted as one);ValidatorSite,SignatureVerificationalone: 87 cases, 0 failures.xrpl_tests: 1146 passed, 1 failed, the baseVaultTests.BuilderFromSleRoundTrip.clang-format22.1.8 andpnpm format:checkclean.
- Failing first, with the amendment defined and the tests written but no behaviour:
- Not done: the TypeScript client (another agent implements it against
docs/XLS-PQ-Passkeys.md§6); a relay test over the overlay for this case (the condition is the onePeerImpalready reads, covered byPasskeyRelayfor P-256); P6 (the list can be neither replaced nor removed),PasskeyListSetbeingDelegableand its missing reserve check, allPasskeydefects that ML-DSA-44 passkeys inherit; binding the P-256PasskeyID. - After the review gate of #113 (approve with nits, no hole found):
-
isPasskeyMLDSAEnabledalso requiresfixP256StrictDer: before it no verdict on a transaction with aPasskeySignatureis ever cached, so every ML-DSA-44 passkey transaction would have been verified again on everycheckValiditycall. TheTxnSignaturerefusal next to an ML-DSA-44 passkey is now thefixP256StrictDerrule. The runs withoutfixP256StrictDerare gone; the cache test now crossesfixP256StrictDerwithPasskeyMLDSAalready voted. -
A fixed vector from an independent implementation: key,
AuthenticatorData,ClientDataJSONand signature made by @noble/post-quantum 0.7.1 over the signing data that ripple-binary-codec 2.11.0 encodes (generatorpackages/core/spikes/passkey-mldsa-vector.ts, hex committed in the test). The node's signing data equals the codec's, the node verifies the assertion as it is, refuses it without the amendment, and refuses it with one bit changed in the signature (first and last byte) or inAuthenticatorData. -
The binding on the other top-level paths: a
Delegatesigning with its own ML-DSA-44 passkey, an outerBatchsigned by the account's passkey, a sponsored transaction with the account's passkey at the top level; anotherPasskeyIDgivestefBAD_AUTHin each, and the delegate cannot use the delegator's passkey. A key that is already a session key when added to the list signs through WebAuthn only once the session is deleted. -
Not changed, documented: no cap on the number of ML-DSA-44 entries (
docs/XLS-PQ-Passkeys.md§8). ThegetSignaturecomment says it is not gated and off the consensus path. -
Run:
PasskeyMLDSA18 cases, 957 tests, 0 failures. Mutations, each restored afterwards:Gate removed Failures Cases that catch it the verdict-cache rule ( isSignatureVerdictCacheable)12 testSignatureBeforeAmendment,testActivation,testCacheableCondition,testCacheAcrossActivation,testNetworkOPsDoesNotMarkBadthe PasskeyIDcomparison (checkMLDSAPasskey)8 testPasskeyIdBound,testOtherSigningPathsthe list-only rule ( Transactor::checkSign)10 testPasskeyIdBound,testOtherSigningPathsthe session-path refusal ( checkSession)4 testSession,testSessionKeyAddedToListthe byte comparison of the challenge on the ML-DSA-44 path 2 testChallengeAsBytesthe amendment gate of PasskeyListSet9 testListSetthe dependencies in isPasskeyMLDSAEnabled(PasskeyMLDSAalone)45 testListSet,testSignatureBeforeAmendment,testCacheableCondition,testCacheAcrossActivationNeighbouring suites (the list above plus
Sponsor*): 21 suites, 347 cases, 41,421 tests, 1 failure, the baseDelegatecounter.xrpl_tests: 1146 passed, the baseVaultTestsfailure.clang-format22.1.8,pnpm format:checkand the@carbs/sessionstypecheck clean.
-
- Why: the owner found the header of the hosted site confusing: a Backend select (Simulated / Live (local node)), a separate Network select (AlphaNet / Local node), a URL field with a Use button, warnings, and a SIMULATED badge that stayed when "Local node" was picked in the Network select, because that select never changed the backend. On the https site the live backend also refused
ws://localhost:6006as mixed content, although browsers allow the loopback. - What: one select, Node: Simulated (default), Local node (on your machine), Team node (the team's server) (
components/ui/NodePicker.vue,lib/node-choice.ts, saved ascarbs-node; the formercarbs-backendandcarbs-networkkeys are ignored, so every visitor starts on the simulation again). Picking a live node opens a dialog (NodeDialog.vue): for the local node, the command (XRPLD_BIN=/path/to/xrpld GENESIS_SEED=… pnpm demo --no-web, plus--web-origin <page origin>when the page is not the local dev server), a link todocs/BUILDING.md, the URLs (ws://localhost:6006, faucethttp://localhost:3005/accounts), that the defaults need no typing, a collapsed custom URL; for the team node, its preset (NUXT_PUBLIC_TEAM_NODE_URL,NUXT_PUBLIC_TEAM_FAUCET_URL) and a note on the tailnet and on browsers whose own secure DNS cannot resolve*.ts.net. Connect asks the node for its definitions (SigningSessions names required) and its last validated ledger (lib/node-probe.ts) and shows "Connected · ledger N" or the exact reason (timeout after 8 s,cannot connect to …,does not serve SigningSessions), with Retry; on success the choice is saved and the page reloads on that node. A saved node that does not answer at a later load leaves the page on the simulation, the header says why once (12 s) and the saved choice is forgotten. The Quantum screen keeps its own AlphaNet / Mainnet choice.NUXT_PUBLIC_BACKENDnow takeslocalorteam(livestill meanslocal);pnpm demosetslocal. - URL rule:
checkNodeUrlacceptsws://andhttp://onlocalhost,127.0.0.1and[::1]from an https page and still refuses them on any other host (127.0.0.2,localhost.example.org, a LAN or tailnet name). - CSP: the team node's WebSocket and faucet origins (
wss://debian-docker.tail8b5861.ts.net:9448,https://debian-docker.tail8b5861.ts.net:9449) are inconnect-src, built fromlib/networks.ts; no wildcard, no bare scheme.vercel.jsonrewritten withUPDATE_HOST_FILES=1,docs/DEPLOY.mdcarries the same policy. The hosted build no longer needsNUXT_PUBLIC_LOCAL_*pointed at the team's server (previous entry): Local node and Team node are separate now. The Apps links are still navigation only; their origin is in the policy because of the team faucet. - Dev faucet:
pnpm demo --web-origin <origin>(repeatable, a plain http(s) origin only) adds a page the faucet answers, and the faucet answers Chrome's Private Network Access preflight (access-control-allow-private-network: true) for listed origins only.parseArgsmoved toscripts/src/args.tsso it is tested. - SIMULATED badge: audited on every screen. The header badge shows only while the simulation is in effect (
HeaderStatus.vue). The screens already keyed their badges on the backend in effect and the device; one wrong claim was fixed: the wallet entry called the Demo account's account SIMULATED on a live node, where it is a real funded account (only its key is thrown away); it is now SIMULATED on the simulation only. The wallet's reported network reads "Simulated node (SIMULATED)" on the simulation instead of "AlphaNet (SIMULATED)". - Found in the browser: the header's
backdrop-filtermade the wallet dialog (and would have made the node dialog) a fixed box as small as the header bar, so "Connect a wallet" showed only its title row. The blur now sits on a::beforelayer behind the bar, and the bar is above the page (z-index), so the dialogs cover the whole page again. - Checked:
pnpm format:check,pnpm typecheck,pnpm test(web 37 files and 288 tests, scripts 11),pnpm --filter web generateandcheck-static(clean). Unit tests:networks.test.ts(loopback accepted from https, other plain hosts refused, team presets and theirconnect-srcorigins),network-store.test.ts,node-choice.test.ts,node-probe.test.ts(ledger index, missing amendment, unreachable, timeout, the socket closed after),boot.test.ts(local, team, refusals,ws://localhostfrom https), the faucet's--web-originand preflight. Component tests (NodePicker.test.ts): one select with three choices and no URL field, the local dialog's command and URLs, connect then save then reload, the failure reason then Retry, the team dialog's preset and note, custom URL checked before any request, Escape gives the focus back, going back to the simulation, the live URL reopening its dialog, the refusal shown once then gone, the header badge on the simulation, after a refusal and not on a live node. In Chrome against the static build served onhttp://127.0.0.1:4173(an http page, on the team's tailnet): Team node > Connect answered, the page reloaded on the team node (ledger 2048, no SIMULATED badge in the header or on the Account screen, the Stax device picker still badged); the wallet entry showed SIMULATED for the Stax only and none for the passkey and the demo account; Local node > Connect with no node running said "Could not connect: cannot connect to ws://localhost:6006"; a saved Local node with no node gave the header notice and the badge, and clearedcarbs-node. - Not checked: the hosted https page against
ws://localhost:6006in a real browser (the rule is unit-tested; Chrome's local-network permission prompt was not seen), the dev faucet called from the hosted origin with--web-originagainst a running node, the team faucet from the hosted origin, and any account created or transaction sent on the team node from this screen. Clicks on the top bar's segmented control sent by the browser automation did not navigate (the cards did); not investigated, and not changed here. - After the review gate: the timeouts left sockets connecting.
WebSocketClient(packages/core/src/node.ts) kept the socket only once it opened, soclose()did nothing while it connected, and the page load never closed a refused client. The client now keeps the socket from its creation, shares one connection among the requests that wait for it, fails it onclose(), and closes a socket that opens afterclose();chooseBackendcloses the client when it refuses the node. Proved with the realWebSocketClientagainst a server that accepts TCP and never answers the handshake (packages/core/src/node.test.ts,apps/web/lib/node-sockets.test.ts: the TCP connection ends after the probe's and the page load's timeout) and a socket double that opens late; with the closing removed, those tests fail. Also: the simulated wallet reports a networksimulatedwith no URL instead of the local node's (xrpl-connect checks the id,api.test.tscaught it); the wallet server answers the Private Network Access preflight for its listed origins only (server/faucet-cors.ts, tested), since a browser on the public site reaches it on a tailnet address (100.64.0.0/10), not checked from the hosted site;defaultNetwork(unused) is gone; the loopback rule is stated for Chrome and Firefox (Safari not checked);docs/DEPLOY.mdwarns thatNUXT_PUBLIC_BACKEND=teammakes each load wait up to 8 s while the team node is down. In Chrome, the Demo account still connects on the simulation.
-
What: the client side of PR #113, which verifies ML-DSA-44 WebAuthn passkeys (COSE -48) in rippled under
PasskeyMLDSA.@carbs/sessions:keys/pq-passkey.tscovers registration (browserCreateMlDsaPasskey,mlDsaPasskeyFromAttestationover a new CBOR subset,cbor.ts) and signing (signWithMlDsaPasskey, a raw 2,420-bytePasskeySignature.Signature, with the node's stateless checks run first inmlDsaAssertionProblem). It also hasSoftwareMlDsaAuthenticator(SIMULATED).signWithPasskeyroutes a 1,312-byte credential to the new path, and P-256 is unchanged.buildPasskeyListSetnow refuses a key that is neither P-256 nor ML-DSA-44, and an empty credential id.browserAuthenticatortakes optional transports and a timeout.- Passkey wallet: a section, "Passkey post-quantique (Ledger Stax)", creates a -48 credential on a cross-platform authenticator, registers it, pays, opens a session and locks the account. It lists each node answer.
scripts/pq-passkey-demo.tsruns the same flow, from the same module (src/pq-webauthn.ts), with the software authenticator.
-
Simulated: every WebAuthn assertion in the tests and in the live run below comes from the software authenticator, with no device and no browser ceremony. The node's answers are real.
-
Unit tests (
cbor.test.ts,keys/pq-passkey.test.ts, 17 new cases), covering:- the RFC 8949 vectors;
- the AKP header bytes
A3 01 07 03 38 2F 20 59 05 20; packed(a synthetic ES256 statement, the shape PR #109's Stax returns) andnoneattestations;- a challenge equal to
STX\0 ‖ fields(1.4 KB, base64url without padding); - the signature verified with
@noble/post-quantum, failing with a context, over the bare signing data, or with another key; - a
PasskeyIDequal to the listed id; - nine assertions the client refuses before the node would.
Four mutations (the alg check, the
rawIdcheck, the challenge comparison, thePasskeyID) each failed one test. -
Live run (Mac, standalone): the
xrpldbuilt in thefianso/pq-passkeyworktree (head48ca687,xrpld version 3.4.1+DEBUG), with a temporary copy of that branch'sinfra/standalone.cfg(paths moved to a scratch directory),infra/closer.ts, and the standalone genesis seed.featuregavePasskeyMLDSAenabled: true.pnpm --filter @carbs/passkey-wallet pq-passkey-demoreturned 14 node answers, all as expected:Step Node answer PasskeyListSet(master key)tesSUCCESS;account_objectsholds the 1,312-byte keyPayment signed by the passkey tesSUCCESS(see below)PasskeyIDof another credentialtefBAD_AUTH("Transaction's public key is not authorized.")One signature bit flipped invalidTransaction("fails local checks: Invalid signature.")Valid assertion of an unregistered ML-DSA-44 passkey tefBAD_AUTHSigningSessionSetmode C (passkey)tesSUCCESSPayment by the P-256 session key tesSUCCESSSigningSessionDelete(passkey)tesSUCCESSSetRegularKeyto the black hole, thenasfDisableMastertesSUCCESSeachPayment signed by the master key tefMASTER_DISABLEDPayment signed by the passkey again tesSUCCESSThe validated payment holds a
SigningPubKeyof 1,312 bytes, noTxnSignature, a 2,420-byteSignature, and a 1,395-byte challenge of typewebauthn.get. This is the first time a node of ours accepted a WebAuthn ML-DSA-44 assertion built by@noble/post-quantum(an open point ofdocs/XLS-PQ-Passkeys.md§6). -
Not done when this entry was written: nothing ran with the Stax or in a browser. The next entry records the Stax run; what it proves and what it leaves open is listed there.
- What ran:
apps/passkey-wallet/scripts/stax-pq-passkey.tsagainst a local standalone node built from #113 (2fe8bd8's tree;PasskeyMLDSA,Passkey,Quantum,fixP256StrictDerenabled), with the owner's Stax running the Security Key app of #109 (application hash8d68de44…). The credential is the one made on the device earlier (mldsa_passkey_test.py make: 1,312-byte ML-DSA-44 key, COSE AKP alg -48). Each assertion is a real CTAP2 getAssertion approved on the device, with the XRPL signing data as challenge; python-fido2 stands in for the browser (the Chrome ceremony is still untested). python-fido2, not the Device Management Kit: the Security Key app speaks FIDO2 (CTAP2 over CTAPHID), which is not Ledger app access; the DMK drives Ledger apps over their APDUs, not FIDO. - Node answers:
PasskeyListSetwith the Stax key (master key)tesSUCCESS(DE46B0D8…F3EC); a 5 XRPPaymentsigned by the Stax passkeytesSUCCESS(D7AE42A4…6319), on the ledger with a 1,312-byteSigningPubKey, noTxnSignatureand a 2,420-bytePasskeySignature.Signature, challenge 1,395 bytes; a mode CSigningSessionSetsigned by the Stax passkeytesSUCCESS(E4BBB04F…2B90), challenge 1,403 bytes. - Timings on the device: 6.2 s and 4.2 s per assertion, approval included.
- What this proves: real CTAP2 ML-DSA-44 assertions from the Stax are accepted by a node with
PasskeyMLDSA(registration, payment, session opening), and the client's own check (mlDsaAssertionProblem, whichsignWithMlDsaPasskeyruns before returning) passed on those real signatures. - What it does not prove:
- the browser ceremony: Chrome forwarding
alg: -48to the Stax over USB, accepting the AKP credential, and passing a 1.4 KB challenge (the wallet section is still untested in a browser); - the TypeScript registration parser (
mlDsaPasskeyFromAttestation, the CBOR and COSE code) has never read real device bytes: the Stax credential came frommldsa_passkey_test.py, which python-fido2 parsed.mldsa_passkey_test.py make --dump-attestation packages/core/src/keys/fixtures/stax-attestation.jsonnow records the device'sattestationObjectandrawId, andkeys/pq-passkey.test.tsparses that file when it exists (skipped, with a message, until it is recorded).
- the browser ceremony: Chrome forwarding
- Error labels in the wallet section: "Cette étape n'a rien envoyé au nœud" only for errors thrown before the step's submit (the page's own checks, a
DOMExceptionfrom WebAuthn or WebCrypto, the library'sKeyError,EncodeErrorand session builder errors). Any other error (the faucet, the connection,submitAndWaitwhile it polls) reads "État inconnu : … vérifiez le hash ou le compte sur le nœud".NotSupportedErrorandNotAllowedErrorfrom WebAuthn are shown as "-48 non pris en charge par ce navigateur ou cet authentificateur, ou cérémonie annulée ou expirée", with the raw name and message. - Master seed in clear: the section says "Nœud de test uniquement, graine en clair" and refuses to create an account unless the node is
localhost,127.0.0.1,[::1]or a host listed inVITE_TEST_NODE_HOSTS. Locking asks for confirmation; forgetting an unlocked account warns that the seed is lost, and says thePasskeyIDand key can be read back fromaccount_objects. - Library:
verify: falseandpasskeyIdmoved tosignWithMlDsaPasskeyForTests(@carbs/sessions/testing);signWithMlDsaPasskeyalways checks and always writes the credential's own id.- CBOR tags are refused; non-canonical lengths are read as written, documented.
- Credential ids above 1,023 bytes are refused (WebAuthn's limit); a CBOR error in the extensions is a
KeyError. browserCreateMlDsaPasskeytakesexcludeCredentialIds.- The
mlDsaAssertionProblemdocstring says it is a mirror of the node's checks, not the node.
- Tests added: the depth limit (16 accepted, 17 refused), huge declared lengths (2^53 − 1 and 2^32 − 1 arrays, maps and strings refused in under 100 ms), non-canonical lengths read, tags refused; the UP-flag refusal, empty and 1,024-byte credential ids (1,023 accepted), unreadable extensions and COSE key as
KeyError; the publicsignWithMlDsaPasskeywrites the credential's own id; the fixture test above. - SIMULATED interop check of the dump:
dump_attestationfed with a python-fido2 encoding of a -48 registration (dilithium-py key, synthetic packed statement, no device) wrote a file that the fixture test parsed, with the same key as python-fido2's reading; the temporary fixture was then deleted. stax-pq-passkey.ts: each expected result name is checked withdefs.resultCode, and its temporary directory is deleted at the end.
- Recorded on the owner's Stax:
mldsa_passkey_test.py make --dump-attestation packages/core/src/keys/fixtures/stax-attestation.json(a fresh ML-DSA-44 credential, approved on the device; the device'sauthDatakept byte for byte,fmtandattStmtre-encoded by python-fido2 as a browser re-encodes them). The fixture holds only public data: the 64-byte credential id, the 2,060-byte attestationObject and python-fido2's reading of the 1,312-byte key. - Result:
keys/pq-passkey.test.tsnow runs the fixture test instead of skipping it:parseAttestationObjectreadsfmt: packed, andmlDsaPasskeyFromAttestationreturns the same key and credential id as python-fido2 (core: 126 tests, none skipped). This closes the "parser never read device bytes" gap of the entry above; the Chrome ceremony is still untested. - Fixed:
--dump-attestationnow creates the file's directory; the first recording on the device failed after the approval becausefixtures/did not exist yet (one approval lost).
- Defect (P7, open review comment on Transia-RnD/rippled#29, not reported upstream by us):
PasskeyListSetisDelegable(rippled/include/xrpl/protocol/detail/transactions.macro:1354-1358). An account that grants a delegate thePasskeyListSetpermission lets it add its own key to the account's passkey list, and a passkey has full authority over the account. - Failing first (Mac, Debug, the amendment declared but with no effect yet): new cases
PasskeyListSet"a delegate cannot register its own passkey for the account" (P-256) andPasskeyMLDSA"a delegate cannot register its own ML-DSA-44 passkey for the account". alice grants bobPasskeyListSet; bob submitsPasskeyListSetfor alice withDelegateand his own key; then aPaymentfrom alice signed by that key, raw and through WebAuthn (software authenticator, SIMULATED). On the old rules every step istesSUCCESSand carol receives 200 XRP from alice. The half that expects the fix failed:PasskeyListSet15 failures (DelegateSetand the delegatedPasskeyListSettesSUCCESSwheretemMALFORMEDandtemINVALIDwere expected, the paymentstesSUCCESSwheretefBAD_AUTHwas expected, and alice's own list thentefEXCEPTIONbecause of P6),PasskeyMLDSA6 failures. - Fix:
fixPasskeyListNotDelegable(Supported::Yes,DefaultNo, ID2A769EFF52FBAC63FA14CD4AC3AEB250B8085B82EA5B49E4804758F8FAD3C988, added toinfra/standalone.cfg, 122 amendments).Permission::isDelegableanswers false forPasskeyListSetonce it is enabled (rippled/src/libxrpl/protocol/Permissions.cpp:250-254). Upstream refuses non-delegable types in two places and both follow:DelegateSetgranting the permissiontemMALFORMED(rippled/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp:42-43) and a transaction withDelegatetemINVALIDinpreflight1(rippled/src/libxrpl/tx/Transactor.cpp:258-260). There is no granular permission for the type (rippled/include/xrpl/protocol/detail/permissions.macro). The macro keepsDelegableso the rules before activation do not change; a test enables the amendment over aDelegateentry granted before it: the delegatedPasskeyListSetis refused, the entry'sPaymentpermission still works. - Signing sessions:
kSessionExcludedalready listsPasskeyListSet(rippled/include/xrpl/tx/SigningSession.h:26-32), so no session could sign it before the fix either. Case 9b asserted that every excluded type is delegable; it now expectsPasskeyListSetdelegable only without the fix, and checks that it is still excluded. That is the one change to our own amendment's tests, in the same commit because the fix changes what it asserts. - Results:
PasskeyListSet12 cases, 384 tests;PassKey3 cases, 65 tests;PasskeyWebAuthn9 cases, 277 tests;PasskeyMLDSA19 cases, 1,038 tests;SigningSessionswithSigningSessionsBatch56 cases, 3,955 tests; all 0 failures.Delegate27 cases, 6,093 tests, 1 failure, the base counter (71 counted, 60 pinned), unchanged because the counter reads the macro at compile time.Feature,AmendmentTable,ServerDefinitions: 0 failures. Mutation: the check inisDelegabledisabled, 22 failures (PasskeyListSet15,PasskeyMLDSA6,SigningSessions1).clang-format22.1.8 on the changed lines andpnpm format:checkclean. - Not done: the reserve check of
SetPasskeyListand P6 stay open; no upstream pull request or comment (D7).
- Defect.
derive_keypair(ledger-app/src/pq/pq_root.c) passed a zeroed 64-byte buffer toos_derive_bip32_with_seed_no_throw(HDW_SLIP21, …)and used bytes 32 to 64 as the ML-DSA seed. InHDW_SLIP21mode the OS writes only the 32-byte SLIP-21 key, at the start of the buffer (Speculossrc/bolos/os_bip32.c,hdw_slip21:memcpy(private_key, node + 32, 32)), so bytes 32 to 64 stayed zero and every device, whatever its 24 words, derivedML-DSA-44.KeyGen_internal(32 zero bytes). The unit-test double filled all 64 bytes, which hid it. - What it means for the AlphaNet proofs. The real Stax's ML-DSA account
rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2is exactly the address of@noble/post-quantum0.7.1ml_dsa44.keygen(new Uint8Array(32))(SHA-256 of the public keyEB4E7302…08E1). The accountsrGcT27HiGhoJWYRxqg9jcnPboGD4RGuLLmandrpxMtodcdjFGpssK5k7D4CixifFBCLS7eV(and the session reviewed forrDX7h…above) therefore use a key whose secret anyone can compute: they are test accounts with no value, and must not be read as protected by a hardware-held secret. The signatures were really made on the Stax and checked with@noble/post-quantum, so they still prove the review and signing path. The earlier observation "the SLIP-21 key is stable across reinstalls" was the symptom: a constant key is stable too. The spec's recoverability check (§7.7, step 3: derived twice, including after a reinstall) cannot catch a constant key; two different seeds must give two different keys, which is now tested on Speculos. The spike accountrs3drH…came from another app and derivation and is not covered here. The entries above are kept, with a correction marker. - Failing first (cmocka,
ledger-app-builder-lite+libssl-dev): the double now writes exactly 32 bytes at the start of the buffer, as the OS does, and the keygen double makes the public key a function of the seed. On the old codetest_public_key_chunks,test_key_is_derived_from_the_slip21_key("1312 bytes … the same" as the zero-seed key) andtest_zero_slip21_output_is_refused(0x9000instead of0x6F00) fail: 19 of 22 pass. - Fix. The seed is the first 32 bytes the OS writes (the buffer handed to the OS stays 64 bytes, as
raw_privkey[static 64]inos_seed.hasks); an all-zero output is refused with0x6F00forINS 0x10and for signing; the buffer is wiped, and the key buffers too on failure. The SLIP-21 label isaligned(4)(the OS takes it asconst unsigned int *), and the SDK'scx_mldsa_internal.hreplaces the hand-written prototype ofMLDSA_internal_keygen(a test double of the header joinsunit-tests/include_pq/). Result: test_pq_root 22 of 22, and test_printers, test_swap, test_tx, test_policy unchanged. - Speculos (simulated device, Stax model): with the old build, the default mnemonic and
abandon … artboth return the zero-seed keyEB4E7302…; with the fixed build they returnDDDC0737…(rsr2Kobu5srdxbFTFhWnmTR5PPDZoZtTUi, the vector ofdocs/device-contract.md§4, now verified on Speculos) and2CCB8F5A…(rL9z2woZeC6vBBQeRRVLd11soKg5CBJRDr), asslip21.tsand@noble/post-quantumcompute them.ledger-app/carbs/pq_key_check.py --speculosgave the same answer. - Built for the Stax in
ledger-app-builder-lite(image3561f39cb806), no new warning:bin/app.hexsha256d6ba36dddc01861c…, application hash6988de51e15297c8…(this commit alone; the PR's final hashes are in its description). - Not done: nothing was loaded on the Stax. The owner loads it (
ledger-app/carbs/load_stax.sh, which runs theledgerblue.loadAppcommandmake loadprints) and runsledger-app/carbs/pq_key_check.py, which must not answer "ZERO-SEED KEY". The new key is a new account: nothing on the AlphaNet moves to it automatically.
- What was made: a demo video (3:01, 16:9, no sound) and a pitch video (2:52, vertical, narrated, word-synced captions), written as code with Remotion outside this repository and published on YouTube (demo, pitch); the README links them. Both use the web app's tokens and fonts, and the Ledger look on every Ledger scene.
- Real: every code line on screen, with its line number (85 lines in 9 excerpts, each compared with
b4c5b76, no mismatch); the live-run output, copied from the 24 October entry; the AlphaNet ML-DSA-44 payments; the numbers, from the README and this log. - Corrected after #120: a first render of the demo, made before #120, read "SLIP-21 from the 24 words: the same key after a reinstall". The entry above shows that the old build derived the zero-seed key on every device, so that stability was the symptom. The demo was rendered again: the line now reads "seed from the 24 words (fixed in #120)", the AlphaNet panel says the payments were signed with the old build's zero-seed test key (they prove the review and signing path, not a protected secret), and the
pq_root.cexcerpt shows its new lines (150 to 168, 135 to 153 before #120). - Emulated or synthetic: every Stax screen is a Ragger snapshot on the Speculos emulator, captioned "Speculos emulator, same app code"; no footage of the real Stax is used. The narration is text-to-speech (fish.audio, model Drama 3) from a voice cloned from Sofiane's; each take was transcribed with whisper and checked against the script before it was kept.
- An approval after an instruction change signed bytes the user never saw (defence in depth). Whether an ML-DSA review was pending lived in
tmp_ctx(PQ->reviewing). After another instruction,tmp_ctxbelongs to that instruction, and the classicalINS 0x04buffers up toMAX_RAW_TXraw bytes over all of it without parsing them. Bytes laid out as a pending review of other signing data (flag, message, length, its SHA-256) were signed with ML-DSA when the user approved the transaction still on screen. Failing first:test_stale_approval_ignores_a_forged_review(signatures1 instead of 0). What a host could get this way is limited: the first bytes of the signed message are the classical context's curve and path, neverSTX\0, so the signature is not a valid XRPL transaction signature; it is still a signature over bytes nobody reviewed. Fix: the pending flag lives outsidetmp_ctx(pq_review_pending), andreset_transaction_context()clears it (pq_cancel_review), which is how an instruction change cancels the review. - The stale approval wrote into the other instruction's memory and said "Transaction signed". It now reads and writes nothing, answers nothing, and calls
review_menu_request_cancelled(), so the status page showsRequest cancelled(review_menu_nbgl.c). Failing first:test_stale_approval_writes_nothing(2 bytes oftmp_ctxchanged). The status page itself is not unit-tested (the review screens are doubles intest_pq_root) and was not shown on Speculos or on the Stax. INS 0x12could return an earlier signature. A new request that started with P1 = 1, or had a refused P1/P2, left the previous signature readable. EveryINS 0x11that is not refused for a review on screen now wipes it, as do the reject, mismatch and error paths; a next chunk with nothing before it is refused with0x6A80, as on the classical path. Failing first:test_new_request_drops_the_old_signature(0x9000instead of0x6982) andtest_next_chunk_needs_a_first_chunk(no answer instead of0x6A80).- "Profile: " was shown with the policy off. The row claimed limits that nobody checked (
session_policy_checkreturns true at once when the policy is off).session_policy_check_transactionnow removes it unless the policy is on and the session fits its profile, on both paths. This reverses the earlier choice recorded intest_profile_vectors("the review still names the profile"), whose assertion is changed accordingly; failing first:test_profile_vectors(row at index 4 instead of absent). The Ragger test with the policy off gets its own snapshots (04-profile-game-policy-off,07-profile-trading-policy-off), recorded on Speculos (Stax model) only: the Profile row is gone from the third page. Flex, Apex P, Nano X and Nano S Plus snapshots of that test were not recorded and must be golden-run before the suite passes there. - Nits: the SDK's
status_words.h(SWO_*) instead of hand-copied status words (a test double of the header joinsinclude_pq/, andtest_pq_rootasserts the wire values as numbers); the../includes ofpq_root.candentry.creplaced by plain names (two include directories added for the test). - Results: cmocka in
ledger-app-builder-lite+libssl-dev: test_pq_root 26/26, test_policy 23/23, test_printers, test_swap, test_tx unchanged. Ragger on Speculos, Stax only: the whole standalone suite, 144 passed, 41 skipped (the skips are the suite's own device conditions). Built for the Stax and the Nano S Plus with no new warning; Staxbin/app.hexsha2560bad479b266bc13a…, application hash03035f570409ec61…. - Not done: nothing loaded on the Stax; the classical path keeps its upstream status page after a cancelled review (it still says "signed" there,
sign_transaction.c); no Ragger run on the other four devices.
- What ran (recorded after the fact from the owner's notes and device photos, around 14:40 ET): the hosted site (
carbs-kappa.vercel.app, build of1df4df7), Node set to Team node, Account > Ledger account > Ledger Stax (USB, WebHID in Chrome), the owner's Stax running the XRP app before the seed fix (#120). The device's accountr4MG66fUzV75ZHbSiEAXGHAxANrwQTnGh8was funded by the team faucet, thenSetRegularKey(C27FAAC491…), the ML-DSA-44 proof (4EC5199E19…) andasfDisableMaster(56B4038B7C…), each signed on the Stax andtesSUCCESS. - The key is the zero-seed one: with the pre-#120 build, the regular key is
rDX7hJiDtUVUnN2tjRqswtuZcbE88yi9i2, the zero-seed key of the entry "the ML-DSA root key was the zero-seed key". So this run proves the web app's flow on the device, not a protected secret. - The owner's policy refused a session on the device: opening a session with no maximum per payment, the Stax showed its policy refusal, "No max per payment (limit XRP 5)" (the profile requires one of at most 5 XRP). The status word was not recorded.
- A second run failed at
SetRegularKeywithtefMASTER_DISABLED, and the store left the other screens on "No account yet". This is the reuse defect fixed after the hackathon (next entry). - Reset, made possible by the defect itself: to run the flow again before the jury, the account was reset on the team's node by signing in software with the zero-seed ML-DSA-44 key,
AccountSetClearFlag 4 (D3793583…3997), thenSetRegularKeywith no key (F8BEAF4E…4742). Anyone could have done the same, which is why the fixed app derives the key from the device's SLIP-21 key. After the fixed app is loaded, a reset needs the device.
- Result: carbs won the Ledger Protocol Innovation track ($4,000). The judged version is
STOOOKEEE/carbsat the tagsubmission(ce42905); this copy,gamween/carbs, carries everything after it. - Demo remarks fixed (
polish/app-fixes, 8 commits):- the device status words the UI showed raw get a sentence (
0x650F: open the XRP app); infra/standalone.cfgsets mainnet's reserves (1 XRP, 0.2 XRP per object), and the Account screen shows what the node reports;- a Ledger account the device already set up is recognised and reused (a second run used to fail at
SetRegularKeywithtefMASTER_DISABLED), with the non-secret profile kept per node across reloads; - the wallet dialog connects a real Stax over WebHID, and the SIMULATED badge stays on the simulated Stax only;
- the front names the five amendments, from a list a test checks against
features.macroand the cfg.
- the device status words the UI showed raw get a sentence (
- Full post-quantum profiles: the P-256 Mac account is gone from the web app. The Mac account now seals an ML-DSA-44 key in the browser, opened by Touch ID through the WebAuthn PRF extension (the vault of #106, moved to
@carbs/sessions). That key becomes theRegularKey, and the master key is disabled after an ML-DSA-44 proof, only once the sealed key reads back from storage. Session keys are ML-DSA-44. Apple's platform authenticator cannot create analg: -48passkey, hence PRF. The node and@carbs/sessionskeep their P-256 code. - Upstream (Ledger only, after the owner's go): LedgerHQ/device-sdk-ts#2052 and LedgerHQ/app-xrp#101, documentation only. The P-256
PasskeyIDbinding in rippled is not pursued: the base defect stays documented. - Checked:
pnpm format:check,pnpm typecheck,pnpm test: core 132, signer kit 205, ledger 74, web 329 (plus 5 node tests), passkey wallet 7, scripts 12. The web build passes. - Not checked:
- the wallet dialog's USB entry, the
0x650Fmessage and the reuse of a set-up account ran against software doubles only, not on the Stax; - PRF with a real Touch ID was never run;
- the team node keeps 10 XRP and 2 XRP until it restarts from a fresh ledger with the new cfg;
pnpm --filter web lintreports 3 errors in files these commits did not touch.
- the wallet dialog's USB entry, the
-
Found while preparing the upstream Batch pull request. That version's review checks were run against this app's parser on 226 vectors. Eight malformed Batches still clear-signed here:
- an inner Payment without an
Account; - fields put straight into
RawTransactions, outside anyRawTransactionobject. AnAmountof 10 XRP between the two inner transactions was shown asAmount [1], as if it belonged to the first one; NFTokenOfferson the outer Batch;- a stray
RawTransactionsarray placed before the top-levelTransactionType. Its objects were not counted, but their fields were shown as rows of the real inner transactions: a secondAmount [2]of 10 XRP.
rippled rejects all eight, but the screen must never mislabel a row.
- an inner Payment without an
-
Fix (
check_batch,check_batch_inner_field,handle_array_field):- every inner transaction names its
Account; - every row is an outer field, an outer memo or a field of one inner transaction;
- a
RawTransactionsarray read before the type counts as the Batch's one array, so a second one stops the parse.
The first two cases go to blind signing. The third is refused, like any Batch whose parsing stops early (it might hold a session).
- every inner transaction names its
-
Tests:
32-batch/07-inner-without-account,08-field-outside-raw-transactionand09-outer-nftoken-offersare inbatch_blind_testcases;10-raw-transactions-before-typeis insession_never_blind_testcases;- 08 and 10 are spliced by hand and have no
.json.
Without the change,
test_batch_blind_requiredandtest_session_never_blindfail. With it, 5/5 suites pass. A native parse of the 226 vectors, before and after the change, differs on 17 malformed Batches only. The review of every valid transaction is unchanged. -
Ragger on Speculos (Stax): 144 passed, 45 skipped, no snapshot changed. The four new vectors are skipped there, like the other Batch vectors that must not clear-sign.
-
Not done: the other four devices; not loaded on the Stax (the reload of the fixed app is still pending).