Skip to content

release: 7.14.3 — bitcoin-only variant with taproot - #461

Closed
BitHighlander wants to merge 8 commits into
keepkey:developfrom
BitHighlander:release/7.14.3-bitcoin-only
Closed

release: 7.14.3 — bitcoin-only variant with taproot#461
BitHighlander wants to merge 8 commits into
keepkey:developfrom
BitHighlander:release/7.14.3-bitcoin-only

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Cuts 7.14.3: a bitcoin-only firmware variant, with taproot, built from the
same tree as the full image.

develop is 7.14.1; this is 121 commits / +6,721 −893.

What this delivers

A second build variant. release.yml now builds full and bitcoin-only
from one matrix. Porting that surfaced three collisions that would each have
shipped wrong artifacts from a green job:

  • ${SUFFIX} was referenced but never assigned, so both variants wrote the
    same filename and one clobbered the other
  • both legs uploaded the same artifact name — a hard failure on upload-artifact@v4+
  • both legs wrote HASHES.txt, so one manifest silently overwrote the other
    under merge-multiple, leaving a manifest describing one variant sitting
    beside two binaries

Verified by enumerating what each leg produces: 6 distinct names, no collisions.

Taproot. BIP-340/341, 18/18 unit tests, and BIP-86 path validation in
coins.c. A P2TR spend has been executed on a real device, on mainnet — the
strongest evidence available, and stronger than screenshots: the network
accepted the sighash, signature and witness serialisation.

A storage band for bitcoin-only seeds. A seed CREATED under bitcoin-only
firmware is stamped 10000 + STORAGE_VERSION, so multi-chain firmware refuses
it. Without this, a bitcoin-only seed was indistinguishable from a multi-chain
one and any multi-chain build would load it — the variant's whole point is that
a seed born under it is never exposed to multi-chain code.

Only creation paths stamp, so a pre-existing multi-chain wallet migrated under
bitcoin-only firmware keeps its portable version. Multi-chain firmware
refuses rather than wipes (flash untouched; reflashing bitcoin-only
recovers it), bitcoin-only firmware recovers the underlying version and loads
through the normal migration chain, storage_commit() is gated on the lock,
storage_wipe() is the only way out, and a static assert keeps
STORAGE_VERSION below the band forever.

A storage floor. STORAGE_VERSION_LAST_SHIPPED (17) with a companion
static assert, so lowering STORAGE_VERSION below a shipped version now fails
the build instead of silently wiping every device that upgrades.

Seven dead ERC-20s removed — BNB, CMT, GTO, IOST, MCO, ODEM, QTUM. Each
migrated off Ethereum or died; the vetted source now gives three competing
addresses for six and none for ODEM, so the token generator refuses them and
the device was advertising tokens it cannot identify. (Also filed standalone as
#459 against master, since it stands on its own.)

Submodule pins

Both are heads of open PRs into master, and become plain master pins when
those merge:

pin PR
deps/device-protocol 1958cdda keepkey/device-protocol#120 — adds Features.supports_taproot (3 lines)
deps/python-keepkey d58dc63f keepkey/python-keepkey#197

device-protocol is pinned to the minimal taproot PR, not the full 7.15
proto PR (#112). #112 additionally carries structured EIP-712 and Solana LUT
fields that have no .options entries in this tree, so nanopb emits
pb_callback_t and both the ARM and emulator builds fail. No source file
here references any of them.

Review notes, including what is NOT proven

The storage band is now verified — on hardware and on the emulator.

On a real K1-14AM running this exact bitcoin-only build
(sha256 e9cfc7cc46a628ccc5fbac65e7b52a2d35ea8f793e7053e2887aec6d6e226c21):

band survives a power cycle seed stamped 10017 → replug → still initialized, all three addresses identical
BIP-86 matches the spec vector abandon seed → bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr
taproot signs byte-exact BIP-340 signature and full serialized tx match independently computed vectors
variant + capability firmware_variant = KeepKeyBTC, supports_taproot = True

The power-cycle result is the one that mattered: it exercises the
#if BITCOIN_ONLY arm recovering underlying = 10017 - 10000 = 17, the
arithmetic adapted by hand from alpha's V20 ladder. It had no coverage, and
every bitcoin-only user hits it on every boot.

On the emulator, both transition directions were verified against one
persistent flash image:

  • bitcoin-only → multi-chain: REFUSED with "Bitcoin-only wallet present.
    Use Wipe first."
    , and the flash image is byte-identical afterwards
    (109e2cdc… before and after). It refuses without wiping. This is exactly
    the path whose absence produced the Success("Device reset") defect.
  • multi-chain → bitcoin-only: ALLOWED, and the refusal on ResetDevice is
    the ordinary "Device is initialized already" rather than the band lock —
    which is what proves the classification is right. A pre-existing multi-chain
    wallet is never stamped, so it stays portable.

Still NOT proven: that storage survives a real firmware swap on hardware.
That is the bootloader's preserve path, not the firmware's, and it cannot be
shown with unsigned images — the bootloader wipes on any unsigned transition by
design (docs/StorageVersionGate.md). It needs a signed build on a production
device.

The band deliberately wipes on band-unaware firmware. Already-shipped
7.14.1/7.14.2 classify an in-band version as unknown → SUS_Invalid
storage_reset(); storage_commit(). That is alpha's documented design ("older
multi-chain firmware treats it as an unknown version and resets"), carried here
faithfully — but it is a data-loss path on downgrade and deserves an explicit
decision rather than inheritance. The alternative (a flags bit older firmware
ignores) trades the wipe for weaker isolation.

An adversarial review of the band found a critical defect, now fixed
(dfd24432b).
The lock had no caller: the accessor sat in a private header
fsm.c cannot include, so a locked device looked uninitialized, accepted a full
ResetDevice ceremony, wrote nothing, and reported Success("Device reset")
with the seed in RAM only and, under no_backup, never displayed. It now
refuses at CHECK_NOT_INITIALIZED, before the user does any work. The same
review found locked devices reporting an empty device_id; also fixed.

This PR will be updated in place as the pins and remaining items land.

Dice-roll entropy (added in this PR)

On-device dice entropy for seed creation, backported from the 7.15 line, where
its collection UI and press classification have been verified on hardware.
ResetDevice.dice_entropy collects 50/75/99 physical rolls on the device
(short press advances 1–6/undo, 800 ms hold commits) and folds them into the
internal entropy before EntropyRequest, so the host contribution arrives
strictly after the device has committed to its own. The seed recipe
SHA256(internal ‖ external) is unchanged; a completion screen shows
SHA256(rolls)[0..8] so the user can recompute it from their own written
rolls. docs/DiceEntropy.md documents what the digest proves and, more
importantly, what it deliberately does not.

How it was brought over. Eight files are byte-identical to the
hardware-verified 7.15 versions (dice_input.{c,h}, rng_health.{c,h},
rng.{c,h}, u2f.c, keepkey_flash.c, plus both unit-test files); the
reset.c / fsm_msg_common.h / fsm_msg_debug.h integration hunks were
ported surgically and diff clean against 7.15 except for the one deliberate
divergence below. The device-protocol pin advances to 8545cd5b (the same
pin release/7.15 builds against); every new bytes/string field on the compiled
protos was verified to carry nanopb sizing, and the pre-staged options files in
this tree already covered all but three Solana LUT lines.

One deliberate divergence from 7.15: display_random is RETAINED here,
not removed. Already-shipped 7.14-line hosts legitimately request the
Internal Entropy screen, and the pinned python suite asserts it below 7.15.0.
The two features are mutually exclusive by refusal: ResetDevice with both
display_random and dice_entropy fails with a SyntaxError, because the
screen shows the POST-mix value — honoring both would hand a host that reads
it the seed pre-image and make the dice fold-in worthless.

The seed-time RNG gate comes with it. reset_init() now draws through
rng_health_check() + random_buffer_checked() (SP 800-90B RCT/APT,
fail-closed, boot-latched), and the same gate covers every other call site on
rng_health.h's documented list: the storage encryption key and wipe-code key
(storage_drawKeyMaterial()), the V1-upgrade PIN-KDF salt, U2F key-handle
derivation, and the one-shot OTP randomness block. The list in the header is
the complete list in this tree, verified by grep — an earlier draft of this
backport gated only reset_init() and shipped a header that overclaimed;
an adversarial audit caught it and the remaining sites were ported.

Test coverage runs, rather than skipping. The python-keepkey pin advances
one commit: the dice end-to-end test, the ceremony re-entry regression
(host-chosen-seed guard), and report Section K were gated >= 7.15.0 and
silently skipped against this branch — CI went green with zero device-level
dice coverage. The gates now read >= 7.14.3, which is exact for the whole
fleet: no firmware between 7.14.3 and 7.15.0 exists without dice. CI on this
PR therefore exercises the full choreography over DebugLink (99 injected
rolls, digest parity, seed parity against a host-side recomputation) plus
Dice.* and RngHealth.* gtests (20), on every push. ci.yml additionally
gains a bitcoin-only ARM compile leg so the variant this release exists for
is built on every PR, not first at release time.

Verified locally before pushing (CI-parity docker, same base image
digest): both ARM variants link (bitcoin-only .text 372.5 KB, full 523.5 KB);
firmware-unit 103/103; the full python-integration compose suite passes with
zero failures; clang-format-20 clean.

Known limits, stated plainly: the physical short-press/long-hold
classification cannot be exercised on the emulator (the button ISR body is
compiled out) — it is proven on hardware on the 7.15 tree, whose
dice_input.c is byte-identical, but not yet on a device flashed with this
exact build. 99 rolls carry 255.91 bits against the 256-bit label
(d6 = 2.585 bits/roll, Coldcard convention); the RNG mix makes the seed at
least as strong as either source alone. A cancelled ceremony leaves the
derived mnemonic in bip39's static buffer until the next derivation — a
pre-existing property shared byte-for-byte with the baseline and 7.15, noted
here rather than fixed to keep this PR's diff reviewable.

Everything 7.14.3 sits on top of: the 7.14.2 disclosure and EVM work, its
release documentation, and the accompanying unit tests. Separated from the
7.14.3 commits below so the new work can be read on its own.
Schnorr signing, the taproot sighash and witness serialisation, and BIP-86
path validation in coins.c, with 18 BIP-340/341 vectors.

Proven on hardware: a P2TR spend has been executed on a real device on
MAINNET, so the sighash, signature and witness serialisation were all
accepted by the network.
KK_BITCOIN_ONLY gates the non-Bitcoin coin families out of the image, and
release.yml builds `full` and `bitcoin-only` from one matrix.

Three collisions had to be fixed for two variants to coexist, each of which
would have shipped wrong artifacts from a GREEN job: ${SUFFIX} was referenced
but never assigned, so both variants wrote the same filename; both legs
uploaded the same artifact name, a hard failure on upload-artifact@v4+; and
both wrote HASHES.txt, so one manifest silently overwrote the other under
merge-multiple.
STORAGE_VERSION_LAST_SHIPPED records the highest version actually shipped,
with a static assert so lowering STORAGE_VERSION below it fails the build
instead of silently wiping every device that upgrades.

A seed CREATED under bitcoin-only firmware is stamped 10000 + STORAGE_VERSION
so multi-chain firmware refuses it -- without this a bitcoin-only seed was
indistinguishable from a multi-chain one. Only creation paths stamp, so a
pre-existing multi-chain wallet migrated under bitcoin-only firmware keeps its
portable version. Multi-chain firmware REFUSES rather than wipes; bitcoin-only
firmware recovers the underlying version and loads through the normal
migration chain.

The lock is enforced at CHECK_NOT_INITIALIZED, not only at the write. A locked
device looks uninitialized (the RAM shadow was reset), so without this guard it
would accept a full ResetDevice ceremony, write nothing, and report Success --
leaving a funded seed in RAM only.
BNB, CMT, GTO, IOST, MCO, ODEM and QTUM each migrated off Ethereum or died.
The vetted source now gives three competing addresses for six of them and none
at all for ODEM, so the generator refuses them -- it takes a symbol only when
the source gives exactly one address, so a scam token cannot inherit a real
one's label. The device was advertising seven tokens it cannot identify.

Coins resolve by name, never by table index, and no index is persisted, so
removing entries cannot shift the meaning of stored data.
device-protocol is pinned to the MINIMAL taproot change
(Features.supports_taproot), not the full 7.15 proto branch: that one
additionally carries structured EIP-712 and Solana LUT fields with no .options
entries in this tree, so nanopb emits pb_callback_t and both the ARM and
emulator builds fail. No source file here references any of them.

Both pins are heads of open PRs into master and become plain master pins when
those merge.
@BitHighlander
BitHighlander force-pushed the release/7.14.3-bitcoin-only branch from 3c5f3ed to 66f78ec Compare August 22, 2026 23:57
@BitHighlander BitHighlander mentioned this pull request Aug 23, 2026

@BitHighlander BitHighlander left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Release-path review for the 7.14.3 head.

Comment thread .github/workflows/release.yml
Backported from release/7.15, where the collection UI and the physical
short-press/long-hold classification are hardware-verified. Eight files
arrive byte-identical to that tree (dice_input.{c,h}, rng_health.{c,h},
rng.{c,h}, u2f.c, keepkey_flash.c) along with both unit-test suites; the
reset.c / fsm_msg_common.h / fsm_msg_debug.h integration is ported
surgically and diffs clean against 7.15 except for the one deliberate
divergence below.

ResetDevice.dice_entropy collects 50/75/99 rolls on the device (short press
advances 1-6/undo, 800 ms hold commits) and folds
SHA256(int_entropy || rolls) into the internal entropy BEFORE
EntropyRequest, so the host contribution arrives strictly after the device
has committed to its own. The completion screen shows the first 8 bytes of
SHA256(rolls) so the user can recompute it from their written rolls; the
same digest is served over DebugLink for test parity. docs/DiceEntropy.md
covers what the digest proves and what it deliberately does not.

Divergence from 7.15, on purpose: display_random is RETAINED, because
already-shipped 7.14-line hosts request the Internal Entropy screen and the
pinned python suite asserts it below 7.15.0. The two are mutually exclusive
by refusal -- ResetDevice with both flags fails with a SyntaxError, since
the screen shows the POST-mix value, which is the seed pre-image once
ext_entropy is known.

The seed-time RNG gate covers every call site on rng_health.h's documented
list, so the header's COMPLETE LIST claim is true in this tree: reset_init
(fail with FirmwareError), the storage encryption and wipe-code keys and
the V1-upgrade PIN-KDF salt (storage_drawKeyMaterial, halt), U2F key-handle
derivation (refuse registration), and the one-shot OTP randomness block
(skip the write, leave the block claimable by a healthy boot).

Pins: deps/device-protocol advances to 8545cd5b -- the exact pin
release/7.15 builds against; every bytes/string field new to the compiled
protos verified to carry nanopb sizing (the options files here were already
pre-staged for it, plus three Solana LUT lines). deps/python-keepkey
advances to 9aaaa84, which moves the dice end-to-end test, the ceremony
re-entry regression, and report Section K from a 7.15.0 gate to 7.14.3 --
exact for the fleet, since no firmware between 7.14.3 and 7.15.0 exists
without dice. Those tests previously SKIPPED against this branch and CI
went green with zero device-level dice coverage.

ci: build-arm-firmware compiles both release variants on every PR, so a
change that only breaks the bitcoin-only image can no longer go green.

Verified in CI-parity docker (same base image digest): bitcoin-only ARM
links at .text 372,928 and full at 523,600; firmware-unit 103/103 including
Dice.* (5) and RngHealth.* (15); the full python-integration compose suite
passes 449/449 selected with test_reset_device_dice and
test_reset_reentry_disarms_entropy_ack RUNNING and green; clang-format-20
clean on every touched file.
@BitHighlander

Copy link
Copy Markdown
Collaborator Author

Parking this work in the fork for later integration rather than merging it upstream now. Preserved as draft BitHighlander#604 at the same head SHA (2c4b70210). This can be reopened upstream when the release/version and host capability strategy are ready.

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