Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ee20a64
Smartcard menu: drop Common menu, split per-applet; add master finger…
3rdIteration Sep 7, 2026
4d4112c
Satodime: fix claim/seal/address bugs, match the official app, back u…
3rdIteration Sep 8, 2026
f02675b
Fix WIF bug and handle altcoins
3rdIteration Sep 8, 2026
d45add6
docs: Satodime page, including how to sign with a raw key from Electrum
3rdIteration Sep 8, 2026
3281e1f
Satodime: slot-centric menus; cache slot state on the Controller
3rdIteration Sep 9, 2026
1408bcc
Merge branch 'feat/smartcard-menu-split-dev' of https://github.com/3r…
3rdIteration Sep 9, 2026
5de830b
Satodime: hide NDEF, refine slot messaging, and harden the Card PIN skip
3rdIteration Sep 9, 2026
65e53b4
fix: skip backup navigation loop + regression tests
3rdIteration Sep 9, 2026
82da6ff
feat: add Claim Ownership button to Satodime main menu
3rdIteration Sep 9, 2026
1c377d4
refactor: rename 'unlock code' to 'ownership key' for Satodime
3rdIteration Sep 9, 2026
cddbfd3
fix: ReshowUnlockView distinguishes contact reader from lost key
3rdIteration Sep 9, 2026
6c0399c
fix: block ownership key operations on contact readers
3rdIteration Sep 9, 2026
25cbe59
ui: rename unseal button to 'Unseal Slot (Access Private Key)'
3rdIteration Sep 9, 2026
dbae759
Satodime: assume contact, prompt for ownership key when the card asks
3rdIteration Sep 10, 2026
56e913f
test: let jcardsim simulate contactless protocol media
3rdIteration Sep 10, 2026
ea2a5bb
fix: sync Satodime unlock counter from card before gated APDUs
3rdIteration Sep 10, 2026
670cdfa
feat: transfer then claim owned Satodimes; MicroSD-aware backup/resto…
3rdIteration Sep 10, 2026
c733e06
ui: warn up front about losing the Satodime ownership key
3rdIteration Sep 10, 2026
d93fba6
ui: add a how-to-save chooser at the start of the Satodime backup flow
3rdIteration Sep 10, 2026
e12d7d2
fix: make the Satodime backup scan-back actually decode the QR
3rdIteration Sep 10, 2026
4a3e808
test: use real pyzbar when a native zbar library is available
3rdIteration Sep 10, 2026
20ccac1
docs: describe installing pyzbar on Windows for desktop mode
3rdIteration Sep 10, 2026
e1d80dc
test: cover the Satodime QR backup ceremony over simulated NFC end to…
3rdIteration Sep 11, 2026
48428df
fix: make Satodime card identification self-healing and fail honestly
3rdIteration Sep 11, 2026
8a38919
fix: re-query card identification in init_satochip and harden all UID…
3rdIteration Sep 11, 2026
c02eff4
feat: name Satodime ownership keys and match them on restore via the …
3rdIteration Sep 12, 2026
5c008d8
feat: name Satodime keys up front and export them by name when the ca…
3rdIteration Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ When reviewing test results, focus on **new** failures compared to the baseline.

**Note:** The `_msys2_path()` helper in `test_gpg_message.py` auto-detects whether the installed GPG binary is from Git-for-Windows (needs MSYS2-style `/c/...` paths) or native Windows Gpg4win (needs native `C:\...` paths). If GPG tests fail on Windows with a "no writable keyring found" error, check that `_msys2_path()` correctly identifies the installed GPG variant.

### jcardsim RAM guard

Every simulated card is its own JVM (`tests/jcardsim/simulator.py`, ~250-400MB RSS each). CI runners are fresh VMs running one pytest at a time, but on a developer machine the suite shares RAM with everything else — and several pytests run in parallel will page the whole machine to a freeze. `SimulatedCard.start()` therefore refuses to spawn a JVM when free physical RAM is below **3GB** (measured via `GlobalMemoryStatusEx` / `/proc/meminfo`, no new dependency) and raises `JCardSimUnavailable`, which every jcardsim test already turns into a clean skip. The threshold is overridable with `SEEDSIGNER_JCARDSIM_MIN_FREE_RAM_MB`.

Consequences:
- On a low-memory machine, jcardsim tests may **skip** with "insufficient free RAM for a jcardsim JVM" — that is expected, not a regression.
- Run only **one pytest process at a time** on a dev machine; the guard makes parallel runs safe (they skip instead of OOMing) but they also make each other slower and less useful.

### Hardware-in-the-loop smartcard tests

`tests/test_smartcard_hardware.py` and `tests/test_flows_smartcard_hardware.py` are **local-only** — no CI job runs them. They self-skip when `pygp` is missing or no reader/card is present, so they are safe to leave in the default suite.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ Support and discussion relating to this fork can happen via this [Telegram Group
- Factory Reset Card
- Smartcard info screen with card UID
- Genuineness check
* Satodime Card features ([docs](./docs/satodime.md))
- View deposit addresses per key slot, in the same format the official Satodime apps derive
- Seal and unseal key slots, tagged with the slot's coin (BTC, LTC, BCH, ETH, POL, XCP)
- Sign a Bitcoin transaction with an unsealed key (signing is Bitcoin-only)
- Transfer card ownership, with unlock-code backup for contactless readers
* Satochip Card features
- Load any Seed from the SeedSigner on to the Satochip Card
- Enable 2FA on the Satochip Card
Expand Down
37 changes: 37 additions & 0 deletions docs/desktop_simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ seeds or private keys.
On start-up a warning splash screen reminds you that desktop mode is for
testing only.

### QR scanning (pyzbar) on Windows

QR scanning needs [pyzbar](https://pypi.org/project/pyzbar/), which wraps the
native ZBar library. `requirements.txt` pins our
[pyzbar fork](https://github.com/seedsigner/pyzbar) (commit `c3c2378`), whose
`decode()` supports the `binary=` keyword argument used for binary QR formats
such as SeedQR.

On Linux you also install the native library (`sudo apt install libzbar0`).
On Windows there is no equivalent package-manager step: the fork ships only
source, so after installing the requirements you must supply ZBar's DLLs
yourself. The official PyPI `pyzbar` wheel bundles them; copy the two DLLs
out of it into the installed fork's package directory:

```powershell
# 1. Download and unpack the official Windows wheel (64-bit Python)
python -m pip download pyzbar==0.1.9 --only-binary=:all: -d %TEMP%\pyzbar_dl
python -m zipfile -e %TEMP%\pyzbar_dl\pyzbar-0.1.9-py2.py3-none-win_amd64.whl %TEMP%\pyzbar_dl

# 2. Copy the ZBar DLLs into the installed fork package
$pkg = python -c "import pyzbar, os; print(os.path.dirname(pyzbar.__file__))"
Copy-Item "%TEMP%\pyzbar_dl\pyzbar\libiconv.dll", "%TEMP%\pyzbar_dl\pyzbar\libzbar-64.dll" $pkg

# 3. Verify (both checks must succeed)
python -c "from pyzbar import pyzbar; print('pyzbar_ok')"
python -c "import inspect; from pyzbar import pyzbar; assert 'binary' in inspect.signature(pyzbar.decode).parameters; print('fork_ok')"
```

The fork's `zbar_library.py` loads `libzbar-64.dll` (and its dependency
`libiconv.dll`) from the package directory on 64-bit Windows. Without them,
importing pyzbar fails with `FileNotFoundError: Could not find module
'libiconv.dll'`, and QR scanning is disabled at run time
(`DecodeQR.is_qr_scanner_available()` returns False).

The same two DLLs work for any Python version; re-copy them if you ever
reinstall pyzbar into a fresh environment.

## Controls

The Waveshare HAT's physical buttons are mapped to your keyboard:
Expand Down
134 changes: 134 additions & 0 deletions docs/satodime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Satodime Support

A [Satodime](https://satochip.io/product/satodime/) is a bearer card. Each of its key
slots holds a private key the card generated and has never revealed: you can read the
slot's deposit address and send funds to it, and the key only becomes visible when you
*unseal* the slot, which is a one-way operation the card records. Whoever holds the card
holds the coins.

SeedSigner reads slots, seals and unseals them, and can sign a Bitcoin transaction with
an unsealed key. Everything here is designed to agree byte-for-byte with the official
[Satodime apps](https://github.com/Toporin/Satodime-Android), so a card set up on one
reads correctly on the other.

Tools -> Smartcard -> Satodime.

## Claiming a card

A factory-fresh Satodime has no owner. The first `SETUP` command claims it, generating
the card's *unlock code* and handing it back exactly once — it can never be read again.
SeedSigner claims a card the first time you ask it to do something that changes card
state, after an explicit confirmation.

Satodime has **no PIN**. If you are ever asked to set one for a Satodime, that is a bug.

### The unlock code, and when it matters

The applet only checks the unlock code over a **contactless (NFC)** reader. Over a
**contact** reader it skips the check entirely, so:

- **Contact reader** — nothing to back up. SeedSigner claims the card and moves on.
- **NFC reader** — SeedSigner walks you through backing the code up, because without it
an NFC-only setup can no longer seal, unseal, reset, *or even transfer* the card.

The backup flow shows the code as a QR and asks you to scan it back, so a code you never
actually captured cannot be mistaken for a backup. You can additionally save it to the
MicroSD card. Codes are held in RAM for the session only — they are never written to the
device. Restore one later with Card Settings -> Restore Unlock Code.

> **The unlock code is not theft protection.** Anyone holding the card and a contact
> reader can unseal it without the code. It protects against a contactless attacker
> in proximity to the card, and nothing else. The tamper-evident seal is what tells you
> whether a card has been interfered with.

## Supported coins

A slot records which coin it holds as a SLIP-44 code. SeedSigner derives the deposit
address and the private-key format from that code, matching
[Javacryptotools](https://github.com/Toporin/Javacryptotools), the library the official
apps use:

| Coin | Address format | Unsealed key format |
|------|----------------|---------------------|
| BTC | bech32 P2WPKH (`bc1q…`) | WIF |
| LTC | bech32 P2WPKH (`ltc1q…`) | WIF |
| BCH | CashAddr (`bitcoincash:q…`) | WIF |
| XCP | legacy base58 (`1…`) | WIF |
| ETH | keccak, EIP-55 (`0x…`) | raw hex |
| POL | keccak, EIP-55 (`0x…`) | raw hex |

Mainnet vs testnet follows SeedSigner's own Network setting, exactly as it follows the
testnet toggle in the official apps — the card records the coin, never the network.

Slots sealed for any other chain are shown as an unsupported coin rather than given a
guessed address. The official apps treat them the same way.

**Signing is Bitcoin-only.** Sign Transaction offers Bitcoin slots only. Other chains can
be viewed and unsealed; export the key and import it into a wallet for that chain.

## Signing a Bitcoin transaction from a Satodime

Unsealing hands SeedSigner a single private key with no BIP32 tree behind it, so the
usual "which seed signs this?" routing does not apply. The transaction has to be built by
a watch-only wallet that knows the slot's address.

Electrum is the reference:

1. **Read the slot's deposit address** in SeedSigner: Satodime -> View Deposit Addresses.
Use this address verbatim.
2. **Create a watch-only wallet in Electrum** for that address:
`File -> New/Restore`, choose *Import Bitcoin addresses or private keys*, and paste the
address. This gives a wallet that can build transactions but cannot sign them.
3. **Build the transaction** in Electrum as normal. Because the wallet has no keys, it
produces an unsigned PSBT.
4. **Export it as a QR code** and scan it with SeedSigner.
5. In SeedSigner: Satodime -> **Sign Transaction**, pick the sealed Bitcoin slot, and
confirm. The slot is unsealed (one-way) and the transaction is signed.
6. SeedSigner displays the **finished raw transaction** as a QR code — not a partially
signed PSBT, because a single-key spend is complete once signed. Scan it with a
broadcasting tool, or paste it into Electrum's `Tools -> Load transaction -> From text`.

### If this did not work for you before

Two things used to break this, both fixed:

- **The address format was wrong.** SeedSigner derived a legacy `1…` address where the
official apps derive `bc1q…`. Both are controlled by the same key, but a watch-only
wallet built around the legacy address is watching a different address than the one the
Satodime app shows, and any deposit made to it is invisible in that app. If you set up
an Electrum wallet from an address SeedSigner showed before this fix, re-check it
against View Deposit Addresses.
- **The key was silently dropped.** The screen that decides which key signs a PSBT
matched on BIP32 fingerprints. An Electrum watch-only wallet exports a PSBT with no
derivation data at all, so the check found nothing and fell through to the seed picker —
where a raw key is not on offer. Raw keys are now matched on the key itself, the same
test embit's signer uses.

### PSBT requirements

The PSBT needs nothing beyond what Electrum already puts in it:

- a `witness_utxo` for segwit inputs, or the full previous transaction (`non_witness_utxo`)
for legacy ones — this is how the input amount is proven;
- a `redeem_script` for P2SH-wrapped segwit.

Derivation fields (`bip32_derivation`) are *not* required and are not expected.

SeedSigner accepts PSBTs as base64, base43, Specter, UR2 and BBQr QR codes.

## Transferring a card

Transfer Ownership releases the card: the applet clears its setup flag so the next holder
can claim it and mint their own unlock code. Over NFC this needs the current unlock code;
over a contact reader it does not.

## Testing

- `tests/test_satodime_coins.py` — address and key formats, pinned to the EIP-55 and
CashAddr specification vectors and to Javacryptotools' parameters.
- `tests/test_wif.py` — raw-key signing against PSBTs shaped the way an Electrum
watch-only wallet exports them, across P2PKH, P2WPKH, P2SH-P2WPKH and P2TR.
- `tests/test_real_screen_flows_satodime_simulated.py` — the views driven against a real
Satodime applet in jcardsim, including a golden vector taken off a card the official
Android app sealed.
- `tests/test_smartcard_hardware.py` — the same flows against a real card and reader.
31 changes: 30 additions & 1 deletion src/seedsigner/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,22 @@ def _load_block_anchor(cls):
Satochip_Connector = None
Satochip_PIN = None
Satochip_Last_UID_SHA1 = None
# Satodime unlock secrets for this session, keyed by card UID. The card emits its
# 20-byte unlock secret exactly once, from INS_SETUP, and it can never be re-read;
# without it, a contactless reader cannot seal, unseal, reset or even transfer the
# card. Held in RAM only -- the user is walked through backing it up at claim time.
Satodime_unlock_secrets: dict | None = None
# Reverse lookup of the names given to those keys at backup time:
# nickname -> (card_id, secret). Lets a key be found by name when its card's
# UID reads blank -- the id channel is dead and the name written on the card
# becomes the only handle. Wiped alongside Satodime_unlock_secrets above.
Satodime_unlock_nicknames: dict | None = None
# Cached slot data for the Satodime slot-centric menus: avoids re-reading the card
# when navigating the slot list, per-slot action menus, and view-address QR. Cleared
# on Home alongside the session secrets above. Keys: card_id (str), max_keys (int),
# slots (list of (state, coin, address) tuples), built once by ToolsSatodimeSlotsView.
satodime_slot_cache: dict | None = None
GPG_Admin_PIN = None
tools_common_card_filter: list[str] = None
javacard_keys: dict | None = None

# Destination placeholder for when we need to jump out to a side flow but intend to
Expand Down Expand Up @@ -461,6 +475,7 @@ def start(self, initial_destination: Destination = None, skip_startup_interstiti
used. Only used by the test suite.
"""
from seedsigner.views import MainMenuView, BackStackView, RemoveMicroSDWarningView
from seedsigner.views.smartcard_views import ToolsSmartcardMenuView
from seedsigner.views.screensaver import OpeningSplashView
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.desktop_warning import DesktopWarningView
Expand Down Expand Up @@ -567,9 +582,20 @@ def run(self):
self.Satochip_PIN = None
self.Satochip_Last_UID_SHA1 = None
self.Satochip_Connector = None
self.Satodime_unlock_secrets = None
self.Satodime_unlock_nicknames = None

# Always drop any cached OpenPGP admin PIN when returning home
self.GPG_Admin_PIN = None

# Always drop the cached Satodime slot data (it's read-only display
# state that could go stale across sessions).
self.satodime_slot_cache = None

elif next_destination.View_cls == ToolsSmartcardMenuView:
# Returning to the smartcard menu ends the applet session; drop any
# cached Satodime slot data so re-entering Key Slots reads fresh state.
self.satodime_slot_cache = None

logger.info(f"\nback_stack: {self.back_stack}")

Expand Down Expand Up @@ -760,6 +786,9 @@ def handle_wipe_timeout(self):
self.Satochip_PIN = None
self.Satochip_Last_UID_SHA1 = None
self.Satochip_Connector = None
self.Satodime_unlock_secrets = None
self.Satodime_unlock_nicknames = None
self.satodime_slot_cache = None
self.GPG_Admin_PIN = None
self.image_entropy_preview_frames = None
self.image_entropy_final_image = None
Expand Down
Loading
Loading