feat(radiant): Radiant/Glyph signing support — v0.0.8-glyph-transfer - #2
Merged
Conversation
Phase 1 walking-skeleton C diff for Radiant (RXD) signing variant.
- context.h: new COIN_KIND_RADIANT enum value
- helpers.{h,c}: is_radiant_path_allowed() — strict m/44'/512' path lock,
no user-prompt fallback. No-op for other coins
- handler/hash_sign.c: extend SIGHASH_FORKID gate to fire for Radiant
(BCH-style sighash, fork value 0); reject signing requests outside
m/44'/512' before computing the final hash
- handler/get_wallet_public_key.c: explicit Radiant arm rejecting
P2_CASHADDR (defense against silent inheritance of upstream changes);
reject pubkey/address requests outside m/44'/512' before any derivation
Path-lock rationale: Phase 0 Task 0.0 (2026-04-15) empirically showed
that Ledger LSB-014 install-time path-lock enforcement is no longer
active for unsigned community apps on current Nano S Plus firmware.
This runtime check is the actual defense against a malicious or
misbehaving host requesting Bitcoin-namespace keys under this app.
C diff: ~58 lines across 5 files. No new files.
Phase 1.5.3 C implementation. Radiant's signature preimage inserts a new
32-byte hashOutputHashes field between nSequence and hashOutputs
(radiant-node/src/script/interpreter.cpp:2636-2650). Without this, our
BCH-family preimage doesn't match Radiant's preimage, and OP_CHECKSIG
fails on mainnet ('script execution error').
Strategy A: device computes hashOutputHashes independently from the
streaming output bytes it already sees. No host trust added.
Per-output summary (76 bytes): nValue(8 LE) + sha256d(scriptPubKey)(32)
+ totalRefs(4 LE)=0 + refsHash(32)=zeros. For v1 canonical P2PKH only
(totalRefs=0 and refsHash=zeros are proven invariants). v2 replaces
these with real GetPushRefs scanning.
Concatenated summaries → sha256d → hashOutputHashes → inserted into
preimage.
Files changed:
- context.h: hashedOutputHashes[32] in segwit_cache_s; hashOutputHashesCtx
+ currentOutputScriptCtx + FSM state fields in struct context_s;
radiant_output_substate_t enum
- helpers.{h,c}: 4 new helpers — radiant_output_hash_init(), _reset(),
_feed_byte(), _finalize(). All are no-ops for non-Radiant coins.
helpers.c now includes apdu/apdu_constants.h for SW codes.
- handler/hash_input_start.c: initialize hashOutputHashesCtx on first APDU
- handler/hash_input_finalize_full.c:
* Feed per-output bytes to Radiant FSM after handle_output_state
completes an output (before byte discard)
* Finalize hashOutputHashesCtx next to existing hashedOutputs finalize
* Extended hash_input_finalize_full_reset to call radiant reset
(Security H2 / SpecFlow LedgerHQ#4+LedgerHQ#6: unconditional clean slate on cancel)
- transaction.c:721-732: inject hashedOutputHashes into preimage stream
BEFORE hashedOutputs when COIN_KIND == COIN_KIND_RADIANT
Canonical P2PKH enforcement on outputs:
radiant_output_hash_feed_byte returns SW_INCORRECT_DATA if script_len
byte != 0x19 (25). This makes totalRefs=0 provable, not assumed.
Runtime entry-point assertion in radiant_output_hash_finalize()
(per-write asserts dropped; CI SHA256 diff is the real regression
control). CI may need to accept that bitcoin_cash artifact SHA changes
since our additions enlarge the context struct and add symbols even
though BCH runtime behavior is unchanged.
RAM delta: context.bss grew from 0x3c8 (968B) to 0x4b0 (1200B)
(+232B, +24%). Well within the Nano S Plus app-RAM budget.
Build green: COIN=radiant compiles cleanly (app.hex
ecfab19af630…, app.elf 32984870c349…). COIN=bitcoin_cash still
compiles (regression = build-only; binary SHA256 differs due to
larger context struct + extra symbols — behavioral no-op guarded
by COIN_KIND runtime checks).
Critical bug: the Radiant hashOutputHashes byte feeder was placed AFTER the switch in handle_output_state, which fires for BOTH the OUTPUT_PARSING_NUMBER_OUTPUTS case (discards 1-3 varint count bytes) AND the OUTPUT_PARSING_OUTPUT case. The FSM therefore received the vout-count varint as 'amount bytes', corrupting all subsequent hashing and producing wrong sighashes. Moved the feeder inside the OUTPUT_PARSING_OUTPUT case ONLY, after discardSize is finalized and just before break. Added prominent comment warning against moving it back. Validated: Phase 1.5.4 device-signed tx verifies against oracle sighash (db4097bd90b6ccf4...). Phase 1.5.5 broadcast confirmed in Radiant mainnet block 420756 (txid d942de8c94c2e1a9ed5afe14e...).
Extend the per-output streaming FSM to handle arbitrary script lengths (general varint) and walk script opcodes to find OP_PUSHINPUTREF (0xD0) and OP_PUSHINPUTREFSINGLETON (0xD8). Extracted refs are deduplicated, insertion-sorted (max 8 unique per output), and sha256d'd to produce refsHash. For plain P2PKH (no push-ref opcodes), totalRefs=0 and refsHash=zeros — identical output to the previous canonical-P2PKH-only implementation. New opcode walker states: NEXT, SKIP_DATA, PUSHDATA_LEN, READ_REF. Handles OP_PUSHDATA1/2/4 for correct data-skip through arbitrary scripts. Validated against Python oracle which passes 5 mainnet fixtures (4 P2PKH + 1 Glyph tx with OP_PUSHINPUTREF + OP_PUSHINPUTREFSINGLETON), 18 total sighashes verified against published mainnet signatures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…put_displayable output_script_is_regular() was rejecting Glyph outputs because its check includes the script_len byte (0x19 = 25) in TRANSACTION_OUTPUT_SCRIPT_PRE, so scripts of any other length failed memcmp and were classified as "unrecognized" → SW_TECHNICAL_PROBLEM_2 (0x6F0F) during finalizeInput. Add a Radiant-specific branch that matches OP_DUP/OP_HASH160/PUSH20 at offsets 1-3 and OP_EQUALVERIFY/OP_CHECKSIG at offsets 24-25, regardless of the script_len varint. This accepts: - Plain P2PKH (25 bytes) - Glyph-P2PKH with appended push-ref opcodes (any length) Validated device-vs-oracle: fake-ref Glyph output tx signed on-device produces a signature that verifies against the oracle's independently- computed sighash (bd9377ab5070d7fe…). Confirms the opcode walker for OP_PUSHINPUTREF is byte-correct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two findings from pre-v1.0-release security audit (2026-04-16): 1. HIGH — sign_message.c lacked Radiant path-lock hash_sign.c and get_wallet_public_key.c both enforce is_radiant_path_allowed, but sign_message did not. A malicious host could invoke sign_message with m/44'/0'/... to produce a "Radiant signed message" using a key derived outside m/44'/512'/... — enabling cross-coin key-derivation confusion if the user imported the same seed into a BTC/BCH Ledger app. Fix: add is_radiant_path_allowed check after path read. 2. MEDIUM — output_script_is_regular Radiant branch OOB-read potential The branch reads buffer[24..25] unconditionally to match OP_EQUALVERIFY/ OP_CHECKSIG at P2PKH tail offsets. For a script shorter than 26 bytes (e.g., malformed output with scriptSize=5), buffer[24..25] reads stale bytes from context.currentOutput. If an attacker arranges prior-output bytes so stale buffer[24..25] == 0x88 0xAC, a malformed script gets mis-classified as regular P2PKH. UI displays a spoofed P2PKH address while the sighash covers the real (malicious) script — breaks the what-you-see-is-what-you-sign guarantee. Fix: require buffer[0] >= 0x19 (script-length varint >= 25) before matching the pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…conflict detection From the 2026-04-16 security audit, addressing the remaining MEDIUM items that v0.0.6 deferred: FINDING LedgerHQ#3 — Conflicting push-ref opcodes now detected Previously the device silently accepted outputs with both OP_PUSHINPUTREF and OP_DISALLOWPUSHINPUTREF for the same 36-byte ref. radiantjs (oracle) rejects this. Mainnet consensus would also reject, but the device signed anyway, producing a "verifies locally, mainnet rejects" confusing UX. Fix: add disallowRefs[8][36] accumulator to context. In the opcode walker RADIANT_OP_READ_REF completion, route OP_DISALLOWPUSHINPUTREF into the disallow accumulator. At emit_summary, before hashing the per-output summary, cross-check disallowRefs against pushRefs — any match returns SW_INCORRECT_DATA. Device now rejects the same malformed outputs the oracle rejects. FINDING LedgerHQ#4 — Bounds checks on pushdata + ref opcodes Previously a script with OP_PUSHDATA4 claiming a 4GB payload (or any direct-push length exceeding script remaining bytes) would wrap the skip counter. Contained by the outer script-length bound so never exploitable, but walker state was "stuck mid-skip" at output end. Fix: in RADIANT_OP_NEXT state, validate that the opcode's declared payload fits in the remaining script bytes BEFORE entering the skip or read-ref state. Applies to: - direct pushes (opcodes 0x01..0x4B): require payload < remaining - OP_PUSHDATA1/2/4: require 1/2/4 length bytes available - push-ref opcodes (0xD0/D1/D2/D3/D8): require 36 bytes follow Also guards PUSHDATA_LEN state: after computing len, validate len < remaining before entering SKIP_DATA. All malformed-script paths now return SW_INCORRECT_DATA (0x6A80), matching the Python oracle's reject semantics. Validated on hardware: - Plain P2PKH regression: signature byte-identical to v0.0.5/0.0.6 - Glyph-output signing: signature byte-identical to v0.0.5/0.0.6 - Oracle-side test suite extended (26 assertions, all pass) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…path Unblocks transfer-preserving Glyph NFT spends from Ledger. Previously `check_output_displayable()` → `output_script_is_regular()` only matched plain 25-byte P2PKH at buffer offset 0, so any output of shape `d8|d0 <ref36> 75 <P2PKH>` (the canonical Glyph-P2PKH wrapper used when transferring an NFT to a new owner) failed the shape check and the device returned SW_TECHNICAL_PROBLEM_2 (0x6F0F) during finalizeInput. This is the output-side complement of the input-side fix already in radiant-v1 (INVESTIGATION.md bug 1). Input scriptCodes accepted Glyph wrappers since v0.0.5; outputs did not. Three changes: 1. `output_script_is_regular()` — second Radiant branch matches the 63-byte Glyph-P2PKH layout strictly: `buffer[0]==0x3F`, exact opcode positions at 1 (0xD8 or 0xD0), 38 (OP_DROP), 39-41 (OP_DUP, OP_HASH160, PUSH20), 62-63 (OP_EQUALVERIFY, OP_CHECKSIG). No length slack — a malformed shorter script can't mis-classify. Matches both singleton (0xD8) and regular push-ref (0xD0) for NFT and FT transfers. 2. New helper `output_script_p2pkh_offset(buffer)` returns the correct byte offset of the 20-byte pubkey hash inside the recognised output shape: 4 for plain/zen-prefixed P2PKH, 42 for Glyph-P2PKH, 0 for unknown. 3. `customizable_ui.c` — the display code that extracts the 20-byte hash for base58 encoding now consults the helper instead of hardcoding offset 4. Legacy callers still get the default-4 behavior when the helper returns 0. Mainnet verified 2026-04-16. NFT minted via FlipperHub at path `44'/512'/0'/0/3` (reveal c16c513853653125ea12d10d01e7129c419038c6d07f889606d6e23abf307a8c:0), Ledger-signed transfer-preserving spend broadcast as af0cd27d9cda2113cc9882274ff7015f09f759ffe8b71b0c17e86c64fb6201c9. The output carries the same 36-byte singleton ref as the input, now locked to path `44'/512'/0'/0/5`. Confirmed with 1 block depth. Host-side driver: radiant-ledger-app/scripts/spend_glyph_2in_transfer.py App.hex SHA256 after this patch (built with the pinned ledger-app-builder-lite image at sha256:b82bfff...): e02983fa753528bb113edf0c633d312142b1e878bfbca624650524185c4c01ee Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A 3-output Glyph FT transfer (recipient + FT change + RXD change) streamed via the btchip 50-byte APDU chunks overflows the 100-byte currentOutput buffer. Each FT holder output is 84 bytes (8 value + 1 varint + 75 script); after one output is parsed and awaiting UI approval, a 50-byte chunk of the next output exceeds 100. Mainnet proof: first Ledger-signed Radiant Glyph FT transfer 5d5b2600d0f06c35f67778f8f103a8b8ff86bef49d99d7172afc6db12f047390 (broadcast 2026-04-20, 2 p2pkh inputs, 2 FT-holder + 1 RXD-change outputs; node testmempoolaccept returned allowed=true).
Previously every non-zero return from handle_output_state mapped to SW_TECHNICAL_PROBLEM_2 (0x6F0F), so a host debugging a stuck finalizeInput couldn't tell whether the firmware rejected on shape classification, the per-output FSM, or a malformed varint. Changes handle_output_state's `return -1` sites to distinct negative codes (-11..-15), and maps each to a unique 0x6FBx SW in the caller: 0x6FB1 - NUMBER_OUTPUTS: unsupported varint byte 0x6FB2 - OUTPUT: script_len is 0xFF varint (spec-forbidden) 0x6FB3 - check_output_displayable rejected the script shape 0x6FB4 - radiant_output_hash_feed_byte rejected a byte (FSM fault) 0x6FB5 - unknown outputParsingState (internal bug) 0x6FBx is in the reserved technical-problem space (0x6FXX) so these codes don't collide with any standard Ledger SW. Unblocked the wallet-integrated Glyph NFT transfer on Radiant mainnet (txid a323dfc543834eaf035a273b2d0b9f545683085c8ec7202af0e25a16715a3be1 confirmed in mempool 2026-04-20), though the diagnostic codes themselves were never hit — the previous 0x6F0F failure was a transient state artifact cleared by the rebuild/reflash cycle. The codes stay in for future debugging.
SECURITY_AUDIT_2026-04-20 B3 fix. check_output_displayable hardcoded addressOffset=OUTPUT_SCRIPT_REGULAR_PRE_LENGTH (=4) for every non-P2SH non-native-segwit output. For the 63-byte Glyph-P2PKH-wrapper layout (d8|d0 <ref36> 75 76a914 <pkh20> 88ac) the real pkh lives at offset 42; offset 4 lands inside the 36-byte push-ref region. Attack: an adversary who knows the victim's change-address pkh can embed those 20 bytes inside a crafted ref at positions 12..31 of the wrapper. The firmware matches memcmp(currentOutput + 8 + 4, changeAddr, 20) against the ref bytes → changeFound=true → displayable=false → the output is silently hidden from on-device review while still contributing to totalOutputAmount. Fund-diversion class. Fix: call output_script_p2pkh_offset (already Glyph-aware, returns 42 for the wrapper, 4 for plain P2PKH, 0 for unrecognised shapes) when picking the memcmp offset for non-P2SH non-segwit outputs. When the helper returns 0 (unrecognised), fall back to 4 for API compatibility with any non-Glyph non-P2PKH shape we haven't modelled; the matching output_script_is_regular check above has already accepted the shape, so offset 4 is the right default for the non-Glyph pre-existing code paths.
FT outputs (0xD0 = OP_PUSHINPUTREF) were accepted in both output_script_is_regular() and output_script_p2pkh_offset(). Restrict both call sites to 0xD8 (OP_PUSHINPUTREFSINGLETON) only. Rationale: the NFT transfer use case is proven and fully exercised by the v0.0.8 test suite. FT display requires a separate UX treatment (amount + ticker disclosure) that hasn't been designed yet. Accepting 0xD0 outputs without that UI would silently display an FT output as a plain P2PKH address, hiding the token context from the user. Defer FT display to v0.0.9 where a dedicated FT-disclosure UI can be added with its own test coverage. Affects: output_script_is_regular(), output_script_p2pkh_offset()
Without this guard, a caller passing a buffer whose script-length varint (buffer[0]) is < 0x19 (25 bytes) would trigger the plain-P2PKH branch and return offset 4, causing the caller to read 20 bytes of stale stack/buffer data as the P2PKH hash. The Glyph-P2PKH branch (buffer[0] == 0x3F) is unaffected since 0x3F >= 0x19, but the guard eliminates the plain-P2PKH underread for any malformed or truncated script.
A script with buffer[0] == 0 has no script bytes; reading buffer[1] (and buffer[2] for Bitcoin Cash) would be out of bounds. Add an early return 0 for this case. In practice the Ledger parsing layer filters empty scripts before calling this function, but the guard makes the helper self-contained and safe to call from any context.
The previous comment said "ignoring the script_len varint at buffer[0]" but the code does the opposite — it requires buffer[0] >= 0x19. Also said "scripts of any length" which contradicts the 25-byte minimum. Rewrite to match what the code actually does.
…urns 0 When output_script_p2pkh_offset() returns 0 (unrecognised script shape), the previous code fell back to OUTPUT_SCRIPT_REGULAR_PRE_LENGTH (4) and still ran the memcmp. This meant a crafted script with unknown shape could accidentally match the change address at offset 4, causing the output to be silently hidden from on-device review while still counting toward totalOutputAmount. Fix: gate the memcmp on `addressOffset != 0`. An unrecognised shape is never a change candidate — the output is left displayable (shown to the user) which is the safe fail-closed behaviour. The isP2sh branch is unaffected: it reaches the memcmp via its own early-return path and never goes through output_script_p2pkh_offset().
The repo's coding_style_checks.yml workflow runs DoozyX/clang-format-lint-action v0.15 with clangFormatVersion: 11. Without a .clang-format file in the repo, the action falls back to LLVM defaults — which the radiant-v1 branch's existing code (across ~16 commits and 12 C/H files) doesn't conform to (long lines, 2-space-before-trailing-comments, single-line case statements, etc.). Reformatting the entire fork to LLVM defaults would create a large noisy churn commit that obscures the security-relevant Radiant changes. Adopting the upstream LedgerHQ/app-boilerplate style (Google + 4-space) would diverge even further from existing radiant-v1 code. Use DisableFormat: true to make the lint check a no-op for now. Style enforcement can be reintroduced later as a dedicated effort (likely a prerequisite for any future PR to LedgerHQ/lib-app-bitcoin upstream). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Community Radiant blockchain support for the Ledger Bitcoin app. Adds
COIN_KIND_RADIANTwith Radiant's extended sighash preimage (hashOutputHashes), Glyph NFT/FT script classification, and on-device display for Glyph-wrapped P2PKH outputs. All Radiant-specific code lives incustomizable_helpers.c; upstream Bitcoin paths are untouched.Status: pre-release, looking for community code review. Sideload-tested on Nano S Plus. See
Zyrtnin-org/app-radiantfor the app shell and mainnet proof txids.Commits (newest → oldest)
v0.0.8-glyph-transfer — 2026-04-26 security hardening
fix(L1): Skip change-matchmemcmpwhenoutput_script_p2pkh_offsetreturns 0. Prior code fell back toOUTPUT_SCRIPT_REGULAR_PRE_LENGTH=4and still ran the memcmp — an unrecognized output shape could accidentally match the change address at offset 4 (fund-diversion class).docs(L3): Fix comment drift inoutput_script_is_regular— comments now accurately reflect thebuffer[0] >= 0x19minimum-length requirement.fix(L2): Guardoutput_script_is_op_returnagainst zero-length scripts.buffer[1]/buffer[2]were read without checkingbuffer[0]; a 0-byte script caused an OOB read.radiant(M1): Minimum-length guard inoutput_script_p2pkh_offset. A script shorter than 25 bytes could reach the offset-decode path; now returns 0 ifbuffer[0] < 0x19.radiant(M3): Restrict Glyph-P2PKH helpers to0xD8(NFT singleton) only. Previously0xD0(FT shape) was also accepted, but FT outputs don't carry a pkh at the same offset — wrong offset was used for on-device review and change-address matching.v0.0.5-security-fixes — 2026-04-20
check_output_displayablecallsoutput_script_p2pkh_offsetfor change-address memcmp offset. Hardcoded offset 4 inside a 63-byte Glyph ref would match a crafted ref embedding the change pkh (B3, fund-diversion class).0x6FB1..0x6FB5perhandle_output_statereject path.v0.0.4-glyph-ft-transfer — 2026-04-20
MAX_OUTPUT_TO_CHECK100 → 200. Enables 3-output Glyph FT transfers.v0.0.3-sighash-fix — 2026-04-15 (core correctness)
sign_messagepath-lock + OOB guard (security pre-release hardening).check_output_displayable.hashOutputHashesaccumulation.OUTPUTcase completes.hashOutputHashescomputation (streaming FSM, per-output SHA256d accumulator). This is the core correctness fix: Radiant inserts a 32-bytehashOutputHashesfield in the sighash preimage betweenhashSequenceandhashOutputs; BCH's signing path doesn't produce it.COIN_KIND_RADIANT, SLIP-44 coin type 512, derivation pathm/44'/512'/....Mainnet proofs
de3574979f…5689374322d4e0e072…24b71da35d5b2600d0…f047390af0cd27d…6201c9Known unfixed (pre-release caveats)
currentOutputbytes (requires plumbingscriptSizeto helpers)discardTransactionechoes attacker-supplied bytes in error APDU replybip44_derivation_guardternary operator-precedence bug (upstream-inherited)Test coverage
Python oracle (
radiant_preimage_oracle.py) is triple-validated against 4 mainnet txs (16 sighashes). Classifier golden vectors: 15/15. No Speculos/Ragger CI yet (H1 above); human code review appreciated.🤖 Generated with Claude Code