Skip to content

feat(radiant): Radiant/Glyph signing support — v0.0.8-glyph-transfer - #2

Merged
Zyrtnin merged 17 commits into
mainfrom
radiant-v1
Apr 27, 2026
Merged

feat(radiant): Radiant/Glyph signing support — v0.0.8-glyph-transfer#2
Zyrtnin merged 17 commits into
mainfrom
radiant-v1

Conversation

@Zyrtnin

@Zyrtnin Zyrtnin commented Apr 27, 2026

Copy link
Copy Markdown

Summary

Community Radiant blockchain support for the Ledger Bitcoin app. Adds COIN_KIND_RADIANT with 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 in customizable_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-radiant for the app shell and mainnet proof txids.

Commits (newest → oldest)

v0.0.8-glyph-transfer — 2026-04-26 security hardening

  • d169caa fix(L1): Skip change-match memcmp when output_script_p2pkh_offset returns 0. Prior code fell back to OUTPUT_SCRIPT_REGULAR_PRE_LENGTH=4 and still ran the memcmp — an unrecognized output shape could accidentally match the change address at offset 4 (fund-diversion class).
  • 7e40367 docs(L3): Fix comment drift in output_script_is_regular — comments now accurately reflect the buffer[0] >= 0x19 minimum-length requirement.
  • 32528c8 fix(L2): Guard output_script_is_op_return against zero-length scripts. buffer[1]/buffer[2] were read without checking buffer[0]; a 0-byte script caused an OOB read.
  • ea60dc2 radiant(M1): Minimum-length guard in output_script_p2pkh_offset. A script shorter than 25 bytes could reach the offset-decode path; now returns 0 if buffer[0] < 0x19.
  • a7f6195 radiant(M3): Restrict Glyph-P2PKH helpers to 0xD8 (NFT singleton) only. Previously 0xD0 (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

  • 0967950: check_output_displayable calls output_script_p2pkh_offset for 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).
  • 346b263: Diagnostic SW codes 0x6FB1..0x6FB5 per handle_output_state reject path.

v0.0.4-glyph-ft-transfer — 2026-04-20

  • f8c8a4a: Raise MAX_OUTPUT_TO_CHECK 100 → 200. Enables 3-output Glyph FT transfers.

v0.0.3-sighash-fix — 2026-04-15 (core correctness)

  • 5e45c75: Accept Glyph-wrapped P2PKH outputs in display/approval path.
  • a5a949a: Close 2 FSM audit findings — bounds checks + disallow-conflict detection.
  • fb674c6: sign_message path-lock + OOB guard (security pre-release hardening).
  • 93ec095: Allow P2PKH-prefixed scripts of any length in check_output_displayable.
  • 4d7054e: Glyph push-ref opcode walker for hashOutputHashes accumulation.
  • 82a5492: Fix byte-feeder placement — feed bytes only after OUTPUT case completes.
  • bd7085c: On-device hashOutputHashes computation (streaming FSM, per-output SHA256d accumulator). This is the core correctness fix: Radiant inserts a 32-byte hashOutputHashes field in the sighash preimage between hashSequence and hashOutputs; BCH's signing path doesn't produce it.
  • 6e4228a: Add COIN_KIND_RADIANT, SLIP-44 coin type 512, derivation path m/44'/512'/....

Mainnet proofs

Txid What it proves
de3574979f…56893743 First Ledger-signed Radiant tx (v0.0.3)
22d4e0e072…24b71da3 First Ledger-signed Glyph NFT spend
5d5b2600d0…f047390 First Ledger-signed Glyph FT transfer (3-output)
af0cd27d…6201c9 First Ledger-signed Glyph NFT transfer-preserving spend

Known unfixed (pre-release caveats)

  • H1 — short-script classifier spoofing via stale currentOutput bytes (requires plumbing scriptSize to helpers)
  • M5discardTransaction echoes attacker-supplied bytes in error APDU reply
  • M9bip44_derivation_guard ternary 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

Zyrtnin and others added 17 commits April 15, 2026 01:46
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>
@Zyrtnin
Zyrtnin merged commit dddcc43 into main Apr 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant