Skip to content

feat(coldcard): verified firmware download and export for Coldcard Q and Mk4 - #2447

Open
BullishNode wants to merge 5 commits into
developfrom
feat/coldcard-firmware-ui
Open

feat(coldcard): verified firmware download and export for Coldcard Q and Mk4#2447
BullishNode wants to merge 5 commits into
developfrom
feat/coldcard-firmware-ui

Conversation

@BullishNode

Copy link
Copy Markdown
Contributor

Part of #1260.

Why

Keeping Coldcard firmware up to date currently requires the command line. Coinkite's own instructions are: download the .dfu from coldcard.com, fetch signatures.txt, verify its PGP signature with gpg, then check the file's SHA-256 by hand. Realistically almost nobody does this, people download the file from a browser and copy it to the microSD unverified, trusting that the device's own signature check will catch anything wrong. That on-device check is real, but it is the last line of defense, not a reason to skip supply-chain verification entirely.

This PR gives users the whole verified pipeline in the app: pick your Coldcard model, see the latest firmware, download it, have the signature and hash checked automatically, and export the verified file to any folder or microSD card. No terminal, no gpg, no manual hash comparison.

The flow

Settings → Bitcoin settings → Coldcard update.

  1. The user picks the model (Q or Mk4).
  2. The app looks up the latest firmware offered for that model and shows the version, release date and exact filename.
  3. "Download and verify the firmware" streams the download with live progress, then verifies it.
  4. On success: green checkmark, version, filename and manifest signer, with an Export button (system file picker, so any folder or a mounted microSD/USB reader works, keeping the original filename) and an Instructions sheet covering the on-device upgrade steps, the red CAUTION LED behavior and the don't-power-off warning.

Any failure at any step lands on an error state with a retry. There is no path to the export button except through successful verification.

How verification works

Coinkite publishes signatures.txt in the firmware repo: a PGP clear-signed manifest listing the SHA-256 of every release ever shipped, signed by Peter Gray's release key. The app bundles that public key at compile time (fingerprint 4589 779A DFC1 4F33 2753 4EA8 A3A3 1BAD 5A2A 5B10) and never fetches it at runtime, since fetching the key over the same channel as the manifest would make the verification circular. Verification requires a valid signature by exactly that pinned key, then an SHA-256 match between the downloaded bytes and the manifest entry for that exact filename.

The coldcard.com downloads page is scraped only to learn which release is currently offered; nothing is shown to the user unless that filename also appears in the verified manifest. If the page layout ever changes, discovery falls back to the newest manifest entry for the model.

For comparison, rust-coldcard's upgrade tool checks the SHA-256 against signatures.txt but never verifies the manifest's signature, so a compromised endpoint could feed it a matching bad hash and bad file together. Checking the signature closes that hole. The Coldcard's bootloader check against factory keys remains the final authority; this is the gate in front of it.

Some implementation details that matter for review:

  • DownloadedFirmware and VerifiedFirmware have private constructors. The only way to obtain a verified result is verify(), which recomputes the hash over a private copy of the bytes and returns that copy as an unmodifiable list. A lying hash field or later mutation cannot produce or corrupt a verified result, and the app's export path only accepts the verified type.
  • No security knob is exported: trust anchor, verifier and endpoints are fixed in the public constructor. The overrides used by the package's own tests are @visibleForTesting.
  • Edge (X) and -factory builds are filtered out at the parser level and can never be offered.
  • Metadata fetches are size-capped and the HTTP client has connect/receive timeouts, so an oversized or stalled unauthenticated response fails instead of hanging or exhausting memory. The firmware download itself is streamed with a 20 MB cap.
  • A fresh client (and therefore a freshly verified manifest) is used per flow, so a long-lived app session cannot pin week-old signed data and mysteriously fail the day a new release ships.
  • Leaving the screen aborts an in-flight download.

Structure

Two layers, per the monorepo direction in ARCHITECTURE.md:

  • packages/coldcard_firmware — a pure Dart workspace package (no Flutter) that owns discovery, download and verification. Second workspace member after bull_ui. Usable from a CLI or another app as-is.
  • lib/core/coldcard_firmware + lib/features/coldcard_firmware — the app integration, following the ledger module shape: domain entities and sealed ColdcardFirmwareFailure behind a repository interface, one usecase per intent, a freezed cubit provided at the route, failure l10n extension, and screens reusing the existing widget kit (BorderedTappableTile, BBButton, BlurredBottomSheet, the success-tick animation). Package types never cross the repository boundary.

Dependencies

New dependency, package level:

  • dart_pg (pinned exact at 2.0.0) — the only maintained pure-Dart OpenPGP implementation, used solely to verify the clear-signed manifest. Two things to know: it is pinned exact rather than caret because it is the trust anchor of the feature and unaudited upstream, so bumps should be deliberate and reviewed; and it is 2.0.0 rather than 2.1.0 because 2.1.0 requires pointycastle 4.x, which conflicts with ledger_bitcoin (pointycastle 3.x) — the 2.1.0 diff is only that bump plus removal of cipher engines we never touch. The alternatives were the openpgp package (gopenpgp compiled to native binaries — a large opaque blob per platform, and it breaks the pure-Dart goal) or hand-rolling the RSA signature check with pointycastle (smaller surface, more custom crypto code to review; still available as a fallback if we ever want to drop dart_pg). As defense in depth against a bug in dart_pg, the SHA-256 comparison is computed independently with crypto, so the PGP layer alone can never bless tampered bytes.
  • crypto, dio, meta — already direct dependencies of the app, reused at the same versions: crypto for streaming SHA-256, dio as the HTTP client (and so the app can later hand the package a Tor-configured instance), meta for @useResult/@visibleForTesting.

App level: no new dependencies at all. Export uses the existing file_picker saveFile, dates use intl, state uses freezed/flutter_bloc, all already in the tree.

Testing

  • Package: 48 tests, all offline against committed fixtures (a real signatures.txt snapshot, real downloads-page HTML, and a throwaway signing key generated for the test suite). Coverage includes: one flipped hash character in the manifest, an injected manifest line, a manifest signed by the wrong key, tampered firmware bytes, oversized responses, hostile filenames, and the forged/mutated-verdict bypasses (which now fail to compile or fail verification).
  • App: 11 cubit tests covering the staged download/verify flow with progress, every failure path, export success/cancel/failure, and retry behavior (a discovery failure re-discovers instead of looping a stale release).
  • Live: the full flow was exercised against production coldcard.com for both models, and end to end on an Android emulator — the exported Q file's SHA-256 (1139f219…af95e7) matches Coinkite's signed manifest byte for byte.

Review focus

packages/coldcard_firmware/lib/src/firmware/trusted_key.dart is the file that deserves the most scrutiny. The armored key was obtained independently from keybase.io/dochex and keyserver.ubuntu.com (identical), and gpg --verify of the live signatures.txt reports a good signature from it. Please re-derive the fingerprint from those sources yourself before approving, and treat any future diff to that file the same way.

Out of scope

The rest of #1260 (USB device listing, bag number, automated USB upgrade, PSBT flow) needs the CKCC USB protocol and is Android-only territory; it will come as separate packages/PRs. Mk3 support (frozen at v4.1.9) was deliberately left out of the model list for now.

New workspace package packages/coldcard_firmware: discovers the latest firmware on coldcard.com per model, downloads it streamed with a size cap, and verifies it against Coinkite's PGP clear-signed signatures.txt using a compile-time-pinned signing key (DocHex, 4589779ADFC14F3327534EA8A3A31BAD5A2A5B10). Improves on rust-coldcard, which checks SHA-256 but never the manifest signature.

DownloadedFirmware and VerifiedFirmware are constructible only by the client: verify() recomputes the SHA-256 over a private copy of the bytes and returns them unmodifiable, so a verified result can be neither forged nor mutated. No security knob is exported — trust anchor, verifier and endpoints are fixed in the public constructor, with @VisibleForTesting overrides reserved for this package's own tests. Metadata fetches are size-capped and the default client has connect/receive timeouts.

No Flutter dependency. dart_pg pinned exact (2.0.0, because 2.1.0 requires pointycastle 4 which conflicts with ledger_bitcoin). 48 offline tests against snapshotted real manifest and download pages plus a throwaway test key, including forge, tamper, oversized-input and wrong-key coverage; verified live end to end against coldcard.com for Q and Mk4.

Plan: plans/coldcard-firmware-update.md (PR 1 of 3 for issue #1260).
Settings > Bitcoin settings > Coldcard update: pick a model (Q or Mk4), fetch and display the latest firmware from the signed manifest, download with live progress, verify (PGP manifest signature by the pinned Coinkite key + SHA-256), then export the verified .dfu to any folder or microSD via the system picker, with install instructions in a bottom sheet.

Core module lib/core/coldcard_firmware wraps the coldcard_firmware package behind a domain boundary (entities, sealed ColdcardFirmwareFailure, repository + three usecases); only a verified entity ever reaches the export path and every failure fails closed to an error state. A fresh package client is created per flow so the cached signed manifest can never go stale in a long-lived session, and leaving the screen aborts an in-flight download via CancelToken.

Feature module lib/features/coldcard_firmware follows the ledger shape: freezed state + cubit (unit-tested, 11 tests), router-provided cubit, failure l10n extension, screens reusing BorderedTappableTile/BBButton/BlurredBottomSheet and the success-tick animation. Verified end to end on the Android emulator: Mk4 v5.5.1 and Q v1.4.1Q discovered, downloaded, verified, and the exported file's SHA-256 matches Coinkite's signed manifest.
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown

Claude finished @BullishNode's task in 9m 59s —— View job


Reviewing this PR

Todo

  • Gather context (diff vs origin/develop, package + app code)
  • Independently re-derive the pinned trust-anchor fingerprint from the embedded key bytes
  • Review app layer (cubit/repository/UI/l10n) — in progress
  • Review tests, fixtures, and dependency wiring — in progress
  • Verify findings and post review

So far: the embedded PGP key in trusted_key.dart was re-derived byte-for-byte (armor CRC + v4 fingerprint computation) and matches the pinned fingerprint 4589779adfc14f3327534ea8a3a31bad5a2a5b10.

@BullishNode
BullishNode marked this pull request as draft July 14, 2026 01:46
@BullishNode

Copy link
Copy Markdown
Contributor Author

I’m reworking the branch in follow-up commits based on review: package test-surface hardening, feature-owned flow-scoped firmware session, and safe route handling. The PR is staying draft and existing history will not be rewritten; I’ll request re-review after the focused tests and full checks are green.

@BullishNode
BullishNode marked this pull request as ready for review July 14, 2026 02:57
@BullishNode

Copy link
Copy Markdown
Contributor Author

@thibistaken ready for review (reviewed by Fable and GPT 5.6 SOL ULTRA already)

Coinkite ships one firmware image for the Mk5 and Mk4 (the -mk- suffix line, listed as Mk5/Mk4 on coldcard.com since v5.5.0), so the model card labeled Coldcard Mk4 silently excluded Mk5 owners. Rename the two models to Q and MK, matching Coinkite's line naming, and say on the MK card that it covers Mk5 and Mk4. Presentation only: enum values, page paths, filename parsing and version spaces are unchanged.
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.

2 participants