A self-custodial Bitcoin wallet powered by FROST threshold signatures and an RP2350 TrustZone hardware signer. No single device ever holds the full private key.
Three independent identities — your phone, a hardware signer, and a coordination server — jointly control your funds through a 2-of-3 threshold scheme. Transactions require cooperation between any two parties, eliminating single points of failure while keeping the user in control.
+-----------------------+
| Coordination Server |
| (Rust, gRPC) |
| Identity 3/3 |
+-----------+-----------+
|
gRPC (50051)
|
+---------------------+---------------------+
| |
+-------------+--------------+ +--------------+-------------+
| Android Phone | USB OTG | HW Signer (RP2350) |
| Flutter App +-------------+ TrustZone firmware |
| Identity 1/3 | HID 64B | Identity 2/3 |
| Signing + wallet logic | reports | Keys in Secure world |
+----------------------------+ +----------------------------+
| Identity | Held by | Purpose |
|---|---|---|
| Signing | Phone (local) | Day-to-day transaction signing |
| Recovery | HW Signer (USB HID) | Policy changes, recovery operations |
| Server | Coordination server | Co-signs transactions, never learns the full key |
Any 2-of-3 can produce a valid Taproot (BIP-340) signature. The server alone cannot move funds.
MPCWallet/
+-- ap/ Flutter mobile app (Merlin Wallet)
+-- client/ Dart client library (DKG, signing, UTXO management, FFI wrapper)
+-- threshold/ FROST & DKG cryptography (Rust, no_std, secp256k1)
+-- threshold-ffi/ C-ABI shared library wrapping threshold for Dart FFI
+-- server/ Rust gRPC coordination server (Wasmtime + cosigner WASM)
+-- cosigner/ WASI cosigner component (server-side threshold crypto)
+-- hwsigner/ Non-Secure world firmware (Embassy USB HID, RP2350)
+-- hwsigner-secure/ Secure world firmware (crypto, key storage, TRNG)
+-- embassy-rp-fork/ Forked embassy-rp with TrustZone NS support (init_ns)
+-- protocol/ gRPC stubs and proto definitions
+-- e2e/ End-to-end integration tests (includes signer-server)
+-- keys/ Secure Boot signing keys (gitignored)
+-- scripts/ Utilities (bitcoin.sh, test_hwsigner.py, udev rules)
+-- docker-compose.yml Bitcoin regtest environment (bitcoind + electrs)
+-- Makefile Build, flash, sign, and run targets
The hardware signer uses ARM TrustZone on the RP2350 (Cortex-M33) to provide hardware-enforced isolation between the crypto engine and the USB attack surface.
┌──────────────────────────────────────────────────────────┐
│ SECURE WORLD (hwsigner-secure/) │
│ Flash: 0x10000000 — 512K │
│ RAM: 0x20060000 — 128K │
│ │
│ • Boots from ROM (rp235x-hal) │
│ • Initializes clocks, PLLs │
│ • Configures SAU, ACCESSCTRL, DMA SECCFG, NVIC_ITNS │
│ • FROST threshold signing, DKG, nonce generation │
│ • TRNG hardware random number generator │
│ • Key storage in Secure flash (0x103FF000) │
│ • BLXNS → hands off to Non-Secure world │
│ │
│ NSC entry points (SG veneers): │
│ nsc_init() — init crypto library │
│ nsc_process() — handle JSON crypto request/response │
└────────────────────────┬─────────────────────────────────┘
│ SG veneers (Secure Gateway)
┌────────────────────────┴─────────────────────────────────┐
│ NON-SECURE WORLD (hwsigner/) │
│ Flash: 0x10080000 — 3584K │
│ RAM: 0x20000000 — 384K │
│ │
│ • Embassy async runtime + USB HID │
│ • JSON protocol over 64-byte HID reports │
│ • Forwards crypto requests to Secure via nsc_process() │
│ • CANNOT access: Secure flash, Secure RAM, TRNG, keys │
└──────────────────────────────────────────────────────────┘
| Region | Address Range | Attribute | Purpose |
|---|---|---|---|
| 0 | 0x10080000 - 0x103FEFFF |
NS | Non-Secure firmware flash |
| 1 | 0x20000000 - 0x2005FFFF |
NS | Non-Secure RAM |
| 2 | 0x1002AB00 - 0x1002ABFF |
NSC | SG veneers (crypto entry points) |
| 3 | 0x40000000 - 0x50FFFFFF |
NS | Peripherals + USB DPRAM |
| 4 | 0xD0000000 - 0xD0020FFF |
NS | SIO |
| 5 | 0x00000000 - 0x00007DFF |
NS | Boot ROM |
| 7 | (boot ROM) | NSC | Boot ROM SG gateway |
| — | Everything else | Secure | Crypto library, key flash, crypto RAM |
The firmware is signed with ECDSA secp256k1 + SHA-256. The RP2350's boot ROM verifies the signature against a public key hash burned into OTP before executing. Unsigned or tampered firmware will not boot.
# Build → Sign → Flash workflow
make hw-build # Build Secure + NS worlds
make hw-sign # Sign Secure world with picotool seal
make hw-flash # Flash signed Secure + unsigned NS via debug probe
make hw-test # Smoke test over USB HID- Boot ROM verifies Secure world signature against OTP key hash
- Secure world initializes clocks (XOSC 12MHz, PLL_SYS 150MHz, PLL_USB 48MHz)
- Deasserts NS peripherals from reset (IO_BANK0, PADS_BANK0, DMA, USB)
- Configures DMA internal security (SECCFG channels + IRQs)
- Configures SAU (6 regions) + ACCESSCTRL (with write password
0xACCE00FF) - Locks ACCESSCTRL (Core0 lock — NS code cannot reconfigure)
- Retargets interrupts to NS (TIMER0, DMA, USB, IO_BANK0 via NVIC_ITNS)
- Configures FPU for TrustZone (NSACR, FPCCR)
- Initializes crypto library (TRNG, loads keys from Secure flash)
- Sets NS VTOR + MSP from NS vector table
- BLXNS → transitions CPU to Non-Secure state
- NS world runs cortex-m-rt Reset handler →
embassy_rp::init_ns()→ Embassy executor → USB HID
A minimal fork of embassy-rp 0.9.0 that adds TrustZone Non-Secure support:
init_ns(NsClockConfig)— initializes Embassy without touching clocks/PLLs/resets (Secure world already configured them). Populates the internalCLOCKSstatic with known frequencies and enables timer/DMA/GPIO interrupts.trustzone-nsfeature — disablespre_initwhich writes to Secure-only registers (SIO spinlock, PSM, ACTLR) that would fault from NS state.
The Non-Secure world communicates with the Secure crypto library through two NSC (Non-Secure Callable) functions exposed via SG (Secure Gateway) veneers:
nsc_init() -> i32
Initialize crypto library (TRNG, load keys, signer state).
Returns 0 on success.
nsc_process(ns_in_ptr, in_len, ns_out_ptr, out_cap) -> i32
Process a JSON crypto request.
Secure world copies data from NS buffers, processes, copies response back.
Returns response length (>0) or error code (<0).
The NS world keeps its own buffers in NS RAM and passes pointers to the Secure world. The Secure world copies data internally, processes the request, zeros the input buffer (may contain DKG secrets), and copies the response to the NS output buffer.
Android wallet UI built with Provider state management and GoRouter navigation. Onboarding flow guides the user through server connection, hardware signer pairing, and DKG key generation. Supports sending/receiving Bitcoin, spending policies, and QR codes.
High-level Dart API that orchestrates the full MPC protocol. Manages two local identities (signing + recovery), communicates with the coordination server over gRPC, drives the hardware signer over USB HID, and handles Taproot address derivation, UTXO tracking, coin selection, and PSBT construction.
#![no_std] Rust implementation of FROST over secp256k1 using the k256 crate. Includes the full 3-round DKG protocol, Pedersen VSS, nonce commitment generation, signature share computation, Lagrange interpolation, Taproot key tweaking, and key refresh. Compiles for four targets: native, wasm32-wasip1 (cosigner), thumbv8m.main-none-eabihf (hwsigner), and Dart FFI.
Rust gRPC server that participates as the third identity in DKG and signing. Each user gets an isolated WASI sandbox — the server uses Wasmtime to instantiate a per-user cosigner WASM component. Routes packages between participants, aggregates signature shares, enforces spending policies, and interfaces with Bitcoin Core (RPC) and Electrs (UTXO indexing).
WASI P2 Component Model guest that encapsulates all threshold cryptography on the server side. Compiled to wasm32-wasip1 and loaded by the server into per-user Wasmtime instances.
- Dart >= 3.3
- Flutter >= 3.4 (Android SDK configured)
- Rust (stable + nightly toolchains)
- Docker & Docker Compose
- ARM GNU toolchain (
arm-none-eabi-ld) for CMSE veneer generation - picotool (SDK version with
sealsupport for Secure Boot) - probe-rs for SWD flashing via debug probe
- Android device with USB OTG support (for hardware signer testing)
# Install Rust targets
rustup target add wasm32-wasip1 # Cosigner WASM component
rustup target add thumbv8m.main-none-eabihf # HW Signer firmware
# Install nightly (required for TrustZone CMSE features)
rustup toolchain install nightly
# Install tools
cargo install cargo-component # WASI component building
cargo install probe-rs-tools # Debug probe flash/reset
# Symlink picotool with signing support
ln -sf ~/.pico-sdk/picotool/2.2.0-a4/picotool/picotool ~/.local/bin/picotoolmake regtest-up # Docker: bitcoind + electrs
make bitcoin-init # Mine 150 blocksmake server-run # Builds cosigner WASM + server, runs on :50051mkdir -p keys
openssl ecparam -name secp256k1 -genkey -noout -out keys/ec_private_key.pem
openssl ec -in keys/ec_private_key.pem -pubout -out keys/ec_public_key.pemBack up keys/ec_private_key.pem securely. If Secure Boot is enabled and you lose this key, the device is permanently bricked.
make hw-flash # Builds both worlds, signs Secure, flashes via debug probeThis runs:
hw-build-secure— builds Secure world withcargo +nightly(generatestarget/veneers.o)hw-build-ns— clean-builds NS world (linksveneers.ofor NSC symbols)hw-sign— signs Secure ELF withpicotool seal --sign --hash- Flashes both images via
probe-rs download - Resets the device
make hw-test # Quick: just get_info
make hw-test ARGS="--full-dkg" # Full: DKG + signing with signer-serverRequires Python hidapi and the udev rule:
sudo cp scripts/99-hwsigner.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm triggerAll OTP writes are permanent. There is no undo. If you lose keys/ec_private_key.pem after enabling Secure Boot, the device is bricked forever.
Put the device in BOOTSEL mode (hold BOOTSEL + plug in USB) for all OTP commands.
make hw-sign generates keys/otp.json which includes "crit1": {"secure_boot_enable": 1}. Remove that section so we enroll the key without enabling enforcement:
jq 'del(.crit1)' keys/otp.json > keys/otp_enroll_only.json# Burns SHA-256 hash of your public key into BOOTKEY0 (OTP rows 0x0080-0x008F).
# These 16 rows can NEVER be changed.
picotool otp load keys/otp_enroll_only.jsonpicotool otp get BOOT_FLAGS1 # KEY_VALID should be 1
picotool otp get BOOTKEY0_0 # Should show non-zero hash value
picotool otp get CRIT1 # SECURE_BOOT_ENABLE should still be 0# The RP2350 has 4 key slots (BOOTKEY0-3). We only use slot 0.
# Invalidating slots 1-3 prevents an attacker from enrolling their own key
# in an empty slot and signing malicious firmware.
# KEY_INVALID bits 8-11 = 0x0E, combined with existing KEY_VALID bit 0 = 0x01.
picotool otp set BOOT_FLAGS1 0x0e01# WARNING: This is IRREVERSIBLE.
# After this:
# - Only firmware signed with your private key will boot
# - RISC-V cores are permanently disabled (ARM-only)
# - Unsigned firmware is rejected by the boot ROM
# - Losing ec_private_key.pem = bricked device
picotool otp set CRIT1 0x01# Reboot device (unplug + replug, or probe-rs reset)
# Flash SIGNED firmware — should boot:
make hw-flash && make hw-test
# Flash UNSIGNED firmware — should be REJECTED by boot ROM:
probe-rs download --chip RP2350 hwsigner-secure/hwsigner-secure.elf
probe-rs reset --chip RP2350
# Device will not enumerate on USB — boot ROM rejected unsigned image# Disable all debug access (SWD probe will no longer work)
picotool otp set CRIT1 0x05 # SECURE_BOOT_ENABLE + DEBUG_DISABLE
# Enable glitch detection (hardware defense against fault injection)
picotool otp set CRIT1 0x11 # SECURE_BOOT_ENABLE + GLITCH_DETECTOR_ENABLEadb pair <ip>:<pairing-port> # Pair once (wireless debugging)
adb connect <ip>:<connect-port>
make adb-reverse # Forward ports to PC
cd ap && flutter run # Select "Hardware Signer (USB)" in onboardingConnect the signer to the phone via USB OTG adapter. The app will auto-discover it.
Messages between the phone and hardware signer are split into 64-byte HID reports:
First report: [channel:2][cmd:1][seq:2][total_len:2][payload:57B]
Continuation: [channel:2][cmd:1][seq:2][payload:59B]
Channel 0x0101, command 0x05 (MSG). Sequence numbers are big-endian u16. Last packet zero-padded. Strictly request-response.
| Command | Description |
|---|---|
dkg_init |
Initialize DKG round 1 |
dkg_round2 |
Process round 1 packages, generate round 2 output |
dkg_round3 |
Finalize with round 2 packages, derive key material |
generate_nonce |
Create ephemeral nonce pair for signing |
sign |
Generate signature share |
get_info |
Query key material status |
make threshold-test # Threshold library unit tests (Rust)
make threshold-ffi-test # Threshold FFI tests
make e2e # Full E2E test (builds all deps, starts Docker)
make e2e-ark # Ark E2E test
make hw-test ARGS="--full-dkg" # HW Signer firmware over USB HID
make crypto-bench # Cryptography benchmarks (Criterion)
make stress-test # Multi-user E2E stress test| Target | Description |
|---|---|
| Primary | |
e2e |
Run E2E test (no Ark) |
e2e-ark |
Run Ark E2E test |
hardware |
Start regtest for hardware device (no Ark) |
hardware-ark |
Start regtest for hardware device with Ark |
down |
Stop everything |
| HW Signer | |
hw-build |
Build both TrustZone worlds (Secure + NS) |
hw-sign |
Sign Secure world firmware (ECDSA secp256k1 + SHA-256) |
hw-flash |
Build, sign, flash via debug probe |
hw-test |
Smoke test over USB HID (ARGS="--full-dkg" for full test) |
| Build | |
server-build |
Build the Rust gRPC server |
cosigner-build |
Build WASM cosigner component |
ffi-build |
Build threshold + ark FFI shared libraries |
ffi-android |
Build FFI for Android arm64 |
| Infrastructure | |
regtest-up |
Start bitcoind + electrs via Docker Compose |
server-run |
Build and run server on :50051 |
signer-run |
Build and run test signer-server on :9090 |
adb-reverse |
Forward ports from phone to PC |
proto |
Regenerate Dart gRPC stubs |
- The full private key never exists on any single device.
- The hardware signer's secret share is stored in TrustZone Secure flash — inaccessible from the NS world (USB attack surface) via SAU hardware enforcement.
- The TRNG peripheral is Secure-only — the NS world cannot access the hardware random number generator.
- ACCESSCTRL is locked after configuration — NS code cannot reconfigure peripheral security.
- Secure Boot verifies firmware signatures against an OTP-burned public key hash before execution.
- SG veneers are the only entry points from NS to Secure — the NS world can only call
nsc_init()andnsc_process(). - The server cannot unilaterally sign — it always needs cooperation from the phone or hardware signer.
- Each user's server-side key share runs in an isolated WASM sandbox (Wasmtime).
- Signing requests are authenticated with Schnorr signatures over timestamped messages.
- Policy changes require a recovery signature from the hardware signer.
- FROST: Flexible Round-Optimized Schnorr Threshold Signatures
- BIP-340: Schnorr Signatures for secp256k1
- BIP-341: Taproot
- ARMv8-M TrustZone
- RP2350 Datasheet
- Embassy: Async embedded framework for Rust
- WASI Component Model
This project is part of the Bitspend Payment ecosystem.