Initial public release — multisig HSM reference + recipe book

Open-source 2-of-3 policy-enforced threshold HSM: auto-signs cold→hot treasury
refills under on-device Coldcard policy, no human in the loop. Includes the full
operator manual + quick-start, the reference coordinator/signing code, and a
signer-host bootstrap. No keys, seeds, or secrets — placeholders only.

Live signet demo: https://multisighsm.mineracks.com

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-06-26 13:56:51 +10:00
commit 7a17ffd12e
28 changed files with 2004 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Key material, addresses, and runtime artifacts — NEVER commit these.
*.secret
seeds/
*.seed
*_addr.txt
signet_addr.txt
sink_addr.txt
*.psbt
*.log
*.manifest
spend_ledger.json
.env
# Python
__pycache__/
*.pyc
.venv/
venv/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mineracks Pty Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

87
README.md Normal file
View File

@ -0,0 +1,87 @@
# Multisig HSM — a distributed, policy-enforced 2-of-3 threshold signer
**An automated treasury tier that signs its own cold→hot refills under on-device policy — with no human
in the loop, and no single machine able to move a satoshi.**
Three hardware signers (Coldcards in HSM mode), each on a separate host — ideally a separate site. A
**keyless** coordinator builds the refill transaction and fans it to any two of the three. Each device checks
the transaction against **its own on-device spending policy** (per-transaction cap, velocity limit, address
whitelist) and auto-signs or refuses. The signed refill broadcasts to your hot wallet. Lose any one signer
and refills carry on; lose two and funds simply freeze — safe by design.
The novel combination is **threshold + per-device programmable policy + unattended auto-signing** — an
automated treasury with cryptographic, physically-distributed guardrails, where the coordinator holds no keys
and the hardware is the final authority.
> 🔭 **Live reference deployment:** a working 2-of-3 runs on real Bitcoin **signet** at
> **[multisighsm.mineracks.com](https://multisighsm.mineracks.com)** — every spend is a genuine on-chain
> co-signature, and every policy decision is inspectable on a public explorer. *Simulators + signet only;
> never a real seed, no real funds.*
---
## Two ways to use this
### 1 · Run it yourself — open source
Everything you need to build and operate this is in here: the **full recipe book**
([`docs/OPERATOR-MANUAL.md`](docs/OPERATOR-MANUAL.md) + [`docs/QUICK-START.md`](docs/QUICK-START.md)), the
reference coordinator + signing code ([`reference/`](reference/)), and a signer-host bootstrap
([`ansible/`](ansible/)). MIT-licensed — fork it, run it, harden it.
If it's useful to you, **support the project with sats** ⚡ — Lightning address
**`multisighsm@mineracks.com`** (self-hosted, no middleman). Thank you.
### 2 · Get a helping hand — mineracks consultancy
Standing this up for a **business treasury, exchange, or fund** and want it done right? **mineracks** offers
guided design + execution: failure-domain placement, policy/velocity sizing, the signer-agent + coordinator
deployment, backup/DR rehearsal, and an operations runbook tailored to you.
> **What we do *not* do — by design:**
> - **We do not supply the Coldcards.** You buy your own hardware, direct from the manufacturer.
> - **We never see, hold, or generate your keys or seeds.** They are created on *your* devices, by *you*,
> and never leave their secure elements. Our work is the architecture, policy, and operations *around*
> your hardware — never the key material itself.
📧 **[hello@mineracks.com](mailto:hello@mineracks.com?subject=Multisig%20HSM%20consultancy)** ·
📅 **book a call: [calendar.mineracks.com](https://calendar.mineracks.com)**
---
## What's in here
```
docs/ OPERATOR-MANUAL.md — the full security model + setup + operations guide
QUICK-START.md — the one-page provisioning + on-call checklist
reference/ The coordinator (orchestrator.py) + wallet/HSM/signing scripts and the
simulator demo rig — the working reference behind the live signet demo
ansible/ Signer-host bootstrap skeleton (Tailscale + ckcc-protocol + signer agent)
config.example.env Copy to .env and fill in your node RPC details
```
## Quick start (read the manual first)
1. Read [`docs/OPERATOR-MANUAL.md`](docs/OPERATOR-MANUAL.md) §1§3 — the **safety model is not optional
context**; it is the reason the design is shaped the way it is.
2. **Do the whole provisioning run on signet/testnet first.** Only move to mainnet after you have rehearsed
a refill, a failover, a policy change, and a restore-from-backup.
3. `cp config.example.env .env` and set your watch-only node's RPC host/credentials.
4. Build the `wsh(sortedmulti(2,k1,k2,k3))` descriptor from **your own** three signer xpubs, register it on
each Coldcard, load each device's HSM policy, and start the coordinator.
## You bring
- **3× Coldcard Mk5** (or Q) — bought by you, direct from Coinkite. HSM mode + multisig co-signing are
supported in firmware.
- **3 distinct seeds**, generated on the devices, steel-backed, geographically separated. Never in this repo,
never on the coordinator.
- A watch-only-capable **Bitcoin full node** (descriptor wallets).
## Security & disclaimers
- This repository contains **no keys, no seeds, and no secrets.** The reference code reads any secret material
from local files / environment at runtime (see [`.gitignore`](.gitignore)); the placeholders
(`tpubREPLACE_WITH_YOUR_SIGNER_n_XPUB`, the policy whitelist address) **must** be replaced with your own.
- The reference deployment uses Coldcard **firmware simulators** on **signet** — for functional validation,
not custody. **Never hold mainnet value on a single host running more than one signer.**
- *Coldcard® is a trademark of Coinkite. This is an independent project, not affiliated with or endorsed by
Coinkite.* Built by **[mineracks](https://mineracks.com)**.
## License
[MIT](LICENSE).

28
ansible/README.md Normal file
View File

@ -0,0 +1,28 @@
# Signer-host bootstrap (Ansible)
A **starting-point** playbook for provisioning the three **signer hosts** — the small machines (NUC / Pi /
mini-PC) that each have a USB-attached Coldcard and run a thin **signer agent** over `ckcc-protocol`.
This bootstraps the *host*, not the device: it installs the prerequisites, joins the private network, and
lays down a signer-agent service unit. **It never touches keys or seeds** — those are created on the Coldcard
itself, by you. Treat this as a skeleton to adapt to your environment (it is intentionally minimal and not
opinionated about your network).
## What it does
- Installs Python + `ckcc-protocol` (the Coldcard CLI/library) and USB access for the device.
- Joins the host to your private mesh (Tailscale shown as an example — swap for your VPN/WG).
- Installs a `signer-agent` systemd unit (placeholder ExecStart — point it at your agent).
## What it deliberately does NOT do
- It does not generate, copy, or read any seed or key.
- It does not configure the on-device HSM policy (that is loaded onto the Coldcard directly — see
[`../docs/OPERATOR-MANUAL.md`](../docs/OPERATOR-MANUAL.md) §5, and mind the policy-vs-wallet ordering trap).
## Usage
```bash
# inventory.ini: one host per signer, in independent failure domains (≥1 offsite)
ansible-playbook -i inventory.ini signer-host.yml
```
> **Failure-domain placement is the make-or-break decision** — read §3 of the Operator Manual before you
> place signers. Never co-locate two signers behind a shared PSU / switch / host / hypervisor.

68
ansible/signer-host.yml Normal file
View File

@ -0,0 +1,68 @@
---
# Signer-host bootstrap — see ansible/README.md. Minimal, adapt to your environment.
# Never handles key material: seeds live on the Coldcard, created by you.
- name: Bootstrap a multisig-HSM signer host
hosts: signers
become: true
vars:
signer_user: signer
# Point this at your built signer agent (a thin authenticated service wrapping ckcc-protocol).
signer_agent_exec: "/usr/bin/python3 /opt/signer-agent/agent.py"
tasks:
- name: Install prerequisites
ansible.builtin.package:
name:
- python3
- python3-pip
- python3-venv
- libusb-1.0-0 # Coldcard USB access
state: present
- name: Install ckcc-protocol (Coldcard CLI / library)
ansible.builtin.pip:
name: ckcc-protocol
virtualenv: /opt/ckcc-venv
virtualenv_command: python3 -m venv
# udev rule so the signer user can talk to the Coldcard over USB (vendor 0d2b).
- name: Coldcard udev rule
ansible.builtin.copy:
dest: /etc/udev/rules.d/51-coinkite.rules
mode: "0644"
content: |
SUBSYSTEMS=="usb", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0660"
notify: reload udev
# Example mesh join — swap Tailscale for your own VPN/WireGuard. Provide your own auth key out-of-band.
- name: Join private mesh (Tailscale example)
ansible.builtin.debug:
msg: >
Install your mesh VPN here (e.g. Tailscale/WireGuard) so the coordinator can reach this signer
agent privately. Signer agents are RPC *clients* — they do not bind a public port.
- name: Install signer-agent service
ansible.builtin.copy:
dest: /etc/systemd/system/signer-agent.service
mode: "0644"
content: |
[Unit]
Description=Multisig-HSM signer agent (wraps ckcc-protocol; holds no keys)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart={{ signer_agent_exec }}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
notify: reload systemd
handlers:
- name: reload udev
ansible.builtin.command: udevadm control --reload-rules
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true

14
config.example.env Normal file
View File

@ -0,0 +1,14 @@
# Copy to .env and fill in. NEVER commit your real .env (see .gitignore).
#
# Watch-only Bitcoin node the coordinator uses to build PSBTs and broadcast.
# Use a watch-only-capable full node with descriptor wallets. Mainnet for
# production; signet/testnet for the lab. The coordinator holds NO keys — it
# only talks to this node and fans PSBTs to the signer agents.
RPC_HOST=127.0.0.1
RPC_PORT=38332
RPC_USER=rpcuser
RPC_PASS=
# Explorer base for the "watch it confirm" link (optional).
EXPLORER_TX_URL=https://mempool.space/signet/tx/

385
docs/OPERATOR-MANUAL.md Normal file
View File

@ -0,0 +1,385 @@
# Operator's Manual — mineracks distributed policy-enforced multisig HSM
**A 2-of-3 threshold HSM that auto-signs cold→hot refills under on-device policy, with no human in the loop.**
This manual is everything you need to set the system up and **operate it safely**. Read §1§3 before you
touch anything — the safety model is not optional context, it is the reason the design is shaped the way it
is, and operating it without understanding it will get funds lost.
| | |
|---|---|
| **Status** | A **live reference deployment** runs at `multisighsm.mineracks.com` on real Bitcoin signet — every spend is a genuine on-chain 2-of-3 co-signature enforced by on-device policy. This manual documents both the **reference configuration** (**[REF]**) and the **production hardware procedure** (**[PROD]**). |
| **Audience** | The operator running the treasury / refill tier (you), and a reviewer evaluating it. |
| **Companion docs** | Design & market rationale: [`README.md`](./README.md) · live demo: `multisighsm.mineracks.com` |
| **Scope** | The **cold/warm tier and the cold→hot refill pipe** — low-throughput, high-stakes. **NOT** your high-TPS hot-wallet signer (that's MPC's job — see §1.2). |
---
## 1. Read this first — the security model
### 1.1 What the system is
Three independent **Coldcard** hardware signers, each in **HSM mode** under its own spending policy, each on a
**physically separate host** (ideally one genuinely offsite). A **keyless coordinator** watches your hot
wallet, builds a refill transaction when it runs low, fans the unsigned PSBT to **any two** of the three
signers, they **auto-sign under their on-device policy with no human**, and the coordinator combines,
finalizes and broadcasts. Any 2 of 3 can move funds; lose any one signer and you keep operating; lose two and
funds are **frozen safe**.
### 1.2 What it is NOT
It is **not a hot-wallet signing engine.** Coldcard signing is seconds-per-PSBT and is not built for the
hundredsthousands of signatures/hour a busy exchange hot wallet does, and it carries no FIPS/PKCS#11
certification an auditor or insurer will expect for the primary hot engine. **Keep using an MPC platform for
the hot wallet.** This system secures the **95% of reserves behind it** and the **refill pipe** between cold
and hot — where automation with hardware keys you physically hold beats both a manual 3am ceremony and a
custodian.
### 1.3 The three enforcement layers (and which one you actually trust)
| Layer | Where it runs | Enforces | Trust property |
|---|---|---|---|
| **On-device policy** | Each Coldcard secure element | per-txn cap · velocity · **address whitelist** · message-signing paths | **Tamper-proof. This is the safety floor.** A compromised host cannot lift it. |
| **Coordinator global velocity cap** | The keyless coordinator (software) | the *authoritative* total-per-period across all signers | **Operational, not safety.** Precise day-to-day limit; bypassable if the coordinator is compromised — but bounded by the layer above. |
| **Quorum (2-of-3)** | The protocol | no single signer can move funds; no single signer outage freezes funds | Structural. |
> **The golden rule: the coordinator may *limit*, but only the hardware may *bound*.** Size the on-device
> limits as your real safety envelope; treat the coordinator cap as a tighter operational convenience.
### 1.4 What a compromise can and cannot do
**Coordinator fully compromised (worst realistic software breach):**
- It **holds no keys** → cannot sign or forge. Every spend still needs **two Coldcards** to each pass their
own on-device whitelist + cap + velocity.
- It **cannot redirect funds off the whitelist** → at worst it prematurely refills *your own* hot-wallet
addresses, never an attacker's address.
- It **cannot exceed the devices' velocity ceilings.** That ceiling is the true catastrophic bound.
- **Blast radius = 1.5 × the per-device velocity ceiling `V`** (with any-2-of-3): every spend burns two
signatures for one unit of value, so 3 devices × `V` ÷ 2 sigs = `1.5V` extractable before the hardware
freezes it. **Size accordingly — see §6.3.**
- *Residual:* a compromised coordinator **plus** a compromised hot wallet could drain up to `1.5V` into the
(whitelisted) hot wallet and out. That's why `V` lives on the secure element, not in software.
**One or more signer hosts compromised:**
- Owning **one** signer host moves nothing (need two; the device still enforces its own policy and holds the
key in its secure element — the host can't extract it).
- Owning **two** signer hosts: an attacker can produce two signatures, but each device **still enforces its
policy** — so spends are still bounded by per-txn cap + velocity and confined to the whitelist. To steal,
an attacker would need to defeat **two independent secure elements' policies**, not two Linux hosts.
**Coordinator offline:** **fail-safe for safety.** No coordinator → no PSBT is built → no spend → the cap
trivially holds. You lose the ability to *refill* (a liveness gap, §1.5), never control of funds.
### 1.5 No single point of failure — and the one you must engineer around
- **Keys:** 2-of-3 across independent failure domains. Survives losing any one signer.
- **Global velocity counter:** **derived from the blockchain**, not a single-host file (see §6.2). Any
coordinator replica on any host recomputes the same number from chain history → no single ledger to lose or
tamper.
- **Coordinator liveness:** a single coordinator is a *liveness* SPOF (if it's down you can't refill). Run it
**replicated across the same independent hosts as the signers** so any replica can drive a refill. Because
the counter is chain-derived and the cap is bounded by hardware, replicas need no shared state and a rogue
replica can't exceed the hardware bound.
---
## 2. Architecture & topology
```
hot wallet (MPC / your existing engine) ── monitored ──┐
▲ │
│ signed refill broadcast │ "balance below floor"
│ ▼
┌──────────────────┴───────────────────────────────────────────────┐
│ KEYLESS COORDINATOR (replicated; holds NO keys) │
│ • watches hot-wallet floor • builds PSBT from watch-only wallet│
│ • global velocity cap (chain-derived) • fans to any 2 of 3 │
│ • combines → finalizes → broadcasts │
└───────┬───────────────────┬───────────────────┬──────────────────-┘
│ tailnet │ tailnet │ tailnet
┌───────▼──────┐ ┌───────▼──────┐ ┌────────▼─────┐
│ signer host A│ │ signer host B│ │ signer host C│ ← independent failure domains
│ Coldcard + │ │ Coldcard + │ │ Coldcard + │ (power / switch / site)
│ signer-agent │ │ signer-agent │ │ signer-agent │ one ideally OFFSITE
│ HSM policy A │ │ HSM policy B │ │ HSM policy C │
└──────────────┘ └──────────────┘ └──────────────┘
keys live ONLY on the secure elements; agents hold none
watch-only 2-of-3 descriptor wallet
on a Bitcoin full node
```
**Components:**
- **Signer host (×3)** — a small machine (NUC/Pi/VM) with a USB-attached Coldcard, running a **signer agent**:
a thin authenticated tailnet service wrapping `ckcc-protocol`. Receives `{psbt, wallet_id}`, ensures its
device is HSM-started with the 2-of-3 registered, signs, returns `partial_psbt` or `denied(reason)`.
**Holds no keys.** **[REF]** the reference deployment runs all three as segregated `--mk5 --hsm` Coldcard
signers, each on its own unix socket.
- **Coordinator** — builds PSBTs, enforces the global cap, fans out 2-of-3, combines/broadcasts, exposes the
control surface. **[REF]** `orchestrator.py` (`multisig-orchestrator`, `:8099`).
- **Watch-only wallet** — a `bitcoind` descriptor wallet tracking the 2-of-3 descriptor. **Reuse, don't
build.** **[REF]** a watch-only wallet on a signet node.
- **Bitcoin node** — provides the watch-only wallet, builds PSBTs, broadcasts, and is the source of truth for
the velocity counter. **[REF]** a signet node (RPC over the tailnet).
---
## 3. Failure-domain placement (the make-or-break)
This is the single most important deployment decision, and the reasoning is concrete: nominally independent
hosts can still fail *together* — a shared power feed or UPS, a common chassis or drive batch, the same
hypervisor or network switch, or correlated hardware faults. **If two of three keys sit in the same failure
domain, a single event can take both down and freeze the treasury.** Therefore:
- The three signers **MUST** sit in **independent failure domains** — different physical hosts, ideally
different power circuits / UPS / network switches.
- **At least one signer should be genuinely offsite** (a small physical box over Tailscale). A cloud VPS
cannot host a USB Coldcard, but a Pi/NUC at a second location can be signer #3.
- Spread the coordinator replicas across those same domains.
- Do **not** co-locate two signers on hosts that share a single point of failure (same PSU, same switch, same
rack PDU, same hypervisor). Quorum HA is worthless if one event takes out two keys.
> **[REF] note:** the reference deployment runs all three signers on one host for convenience — that has **no**
> failure-domain independence and is for functional validation. Production uses three independent hosts (above);
> never hold mainnet value on a single host that runs more than one signer.
---
## 4. Prerequisites & bill of materials
**[PROD] hardware:**
- **3× Coldcard Mk5** (or Q) — the current device; dual secure elements (ATECC608 + DS28C36B). HSM mode +
multisig (P2SH/P2WSH) co-signing are supported in firmware.
- **3× signer hosts** in independent failure domains (one offsite), each with a free USB port.
- **Steel backups** for 3 seeds + a durable record of the wallet descriptor.
**Software / services:**
- A **Bitcoin full node** (watch-only-capable; descriptor wallets). Mainnet for production; signet for the lab.
- **Tailscale** on every signer host + coordinator (signer agents are RPC *clients* — they don't bind the
tailnet IP, so the bind-race gotcha doesn't apply).
- `ckcc-protocol` (the `ckcc` CLI / Python lib) on each signer host.
- The coordinator + signer-agent software (`orchestrator.py` is the reference coordinator).
- (Optional) **CKBunker** as a human break-glass UI, kept **off** the automated critical path.
**Skills:** comfortable with bitcoind RPC, descriptors/PSBT, Coldcard HSM mode, and Linux service ops.
---
## 5. Initial setup
> ⚠️ **Do a complete dry-run on signet or testnet first** (the lab does exactly this). Only move to mainnet
> once you have rehearsed a refill, a failover, a policy change, and a full restore from backup.
### Step 1 — Generate three independent seeds
Generate a **distinct** seed on **each** Coldcard (never clone one seed to three devices — that defeats the
whole model). Record each 24-word seed to steel and store the three **geographically separated**. Note each
device's master fingerprint + the BIP-48 account xpub (`m/48'/0'/0'/2'` mainnet; `m/48'/1'/0'/2'` signet).
### Step 2 — Build the 2-of-3 descriptor + watch-only wallet
Construct `wsh(sortedmulti(2, key1, key2, key3))` from the three `[fingerprint/48h/0h/0h/2h]xpub` keys
(receive `/0/*` and change `/1/*` branches). Create a **watch-only** descriptor wallet on the node and import
both branches (`importdescriptors`, private keys disabled, internal=true for the change branch). Record the
descriptor durably — see §9, losing it makes recovery painful even with all three seeds.
### Step 3 — Register the 2-of-3 wallet on EACH Coldcard
Each device must have the multisig wallet **registered** so it recognises change back to the wallet as
internal (and doesn't mistake it for an external send that the whitelist/velocity would block). Export the
Coldcard multisig config and `ckcc upload -m <wallet.txt>` to each device (confirm on-device).
### Step 4 — Author the HSM policy (per device)
Policy is **JSON, versioned in this repo**, deployed to each device. Minimum viable policy (see §6 for the
full reference and the diverse-policy option):
```json
{
"must_log": true,
"period": 60,
"msg_paths": ["any"],
"rules": [
{ "max_amount": 8000,
"per_period": 33333,
"wallet": "ckms23",
"whitelist": ["<hot-wallet deposit address 1>", "<...>"] }
]
}
```
(`per_period` here is the **per-device** ceiling `V`; see §6.3 for why `V = ⅔ × global cap`.)
### Step 4b — Enrol the TOTP "owner" (only if you use the surge tier)
If your policy has a TOTP-gated **surge tier** (§6.2b — a rule with `users:["owner"], min_users:1`), enrol the
`owner` TOTP user on **each** Coldcard **before** loading the policy. All three signers must hold the **same**
secret (one authenticator code has to work for whichever two devices sign), so the device-picks-its-own-QR
path does **not** apply here — a shared secret has to be generated once and loaded onto all three.
> 🔑 **Who generates it matters (production vs demo).** **Production:** *the owner* generates the secret
> (in their authenticator app, or offline) and loads it onto each Coldcard **directly over USB during setup**
> **never through the coordinator**. Then a fully-compromised coordinator can't mint surge codes; at spend time
> it only **relays** the owner's live 6-digit code (it never needs the secret). **Demo only:** the coordinator
> generates the secret + shows the enrolment QR for convenience — acceptable on signet with no real funds, but
> it raises a coordinator-compromise blast radius from tier-1 up to the surge ceiling, so don't do it in prod.
Mechanically: `create_user("owner", USER_AUTH_TOTP, <shared-secret>)` on each device (standard RFC-6238, so any
authenticator app works). The secret must persist so re-arming a rebooted signer re-enrols the same value.
### Step 5 — Load policy + start HSM mode (MIND THE ORDERING)
> 🪤 **Sharp edge (ckbunker issue #12):** loading an HSM policy can *delete a registered multisig wallet* on
> the Coldcard. **Order: register the multisig wallet (Step 3) → enrol the TOTP user (Step 4b, if any) → load
> the policy → verify the wallet still exists on the device.** Re-check `hsm_status` and the registered-wallet
> count after every policy load.
Load the policy and enter HSM mode on each device (the on-device approval is two-step: confirm, then a random
digit to save). HSM mode is a **one-way trip** until reboot — design for it.
### Step 6 — Configure the coordinator
- **Global velocity cap `G`** — the authoritative total-per-period (chain-derived; §6.2).
- **Per-device ceiling `V = ⅔ × G`** in each device policy (§6.3), and enable **round-robin** of the signer
pair so rotation stays even.
- **Whitelist** = your hot-wallet deposit addresses (anonymous/untrusted callers can never add to it).
- **Refill trigger** — the hot-wallet floor that starts a refill, and the refill amount.
- The coordinator's HMAC session secret lives **only on the host** (`chmod 600`, **never in git**).
### Step 7 — Verify before funding
Quorum check: **≥2 of 3** signers online **and** HSM-active **and** wallet-registered. Then, on signet/testnet:
run a within-policy refill (expect sign+broadcast), an over-cap spend (expect on-device refusal), an off-list
spend (expect refusal), and a velocity-exceeding burst (expect the coordinator to block at `G`). Confirm the
chain-derived counter increments on broadcast and the on-device refusal reasons read correctly.
---
## 6. Policy reference
### 6.1 The four on-device gates (tamper-proof, per device)
| Gate | Policy field | What it does | Notes |
|---|---|---|---|
| Per-txn cap | `max_amount` | refuses any single spend over the cap | sats |
| Velocity | `per_period` + top-level `period` | refuses once this device's signed total in the window exceeds it | **per-device**, counted locally — see §6.3 |
| Whitelist | `whitelist: [addr…]` | external outputs must be on this list; change back to `wallet` is exempt | the strongest control — confines *where* funds can go |
| Message paths | `msg_paths` | which derivation paths may sign messages (`["any"]` or specific) | proof-of-control without moving funds |
| (binding) | `wallet` | names the registered multisig so change is recognised as internal | required, or change trips the whitelist |
| (audit) | `must_log: true` | device writes a log entry per decision | feed into monitoring (§8) |
### 6.2 The coordinator global velocity cap (the authoritative limit)
On-device velocity counters **drift** under a rotating "any 2 of 3" (each device only counts what *it* signed)
and a device decrements its counter the moment it signs **even if the PSBT is later dropped and never
broadcast**. So no single device's counter reflects true global outflow. The coordinator therefore enforces
the real cap, and:
- it counts **only real broadcasts** — so dropped/refused PSBTs never burn budget;
- it **includes mempool (0-conf) sends** — they're counted the instant they broadcast, so a burst of spends
inside one ~10-min block interval can't slip past (abandoned/conflicted txns are excluded so a dropped tx
doesn't permanently burn budget);
- it is **chain-derived** — computed from the watch-only wallet's on-chain `send` total over the window
(`listtransactions`), not a single-host file, so any replica recomputes it and there is no ledger SPOF.
### 6.2b Surge tier — a TOTP-gated higher tier (human in the loop)
For occasional large moves, add a **second, ordered rule** that permits a higher `max_amount` + `per_period`
(and/or extra whitelist) but requires the owner's **TOTP** (`users:["owner"], min_users:1`). Coldcard
evaluates rules **first-match**, so routine spends match the automated tier-1 and a larger one falls through to
tier-2 and is refused unless a valid code is presented. **Secure model:** the owner reads the 6-digit code from
a normal authenticator app (standard RFC-6238); the **keyless coordinator only relays it** (`user_auth`) — the
secret lives on the devices + the owner's app, never on the coordinator, so a compromised coordinator can't
mint codes. The coordinator's global cap gets a matching surge ceiling. **Sizing:** the surge ceilings raise
the §1.4 blast-radius bound *when a code is present* — set them to the most you'd ever authorise in one
human-approved move, and treat entering a code as approving a spend up to the surge cap. *(Live for signed-in
users on the reference deployment; exercised end-to-end on real signet.)*
### 6.3 Sizing — the 1.5× rule (do not skip this)
A compromised coordinator that ignores `G` is bounded by the hardware ceilings. With any-2-of-3, worst-case
extractable = **`1.5 × V`** (each spend burns two signatures for one unit of value; `3V ÷ 2`). Therefore:
- **Set each device's `per_period` `V = ⅔ × G`.** Then worst-case `1.5 × ⅔G = G` — the hardware bound equals
your intended global velocity.
- **Round-robin the signer pair** so rotation is even, otherwise busy devices hit `⅔G` early and honest
refills stall (raising `V` for liveness margin pushes the worst case back above `G`).
- **Topology dial:**
- *any-2-of-3 auto-signers* → worst case **1.5×**, but "lose any one, keep running" unattended.
- *2 fixed auto-signers + 1 human break-glass (offline)* → every routine spend needs both online devices, so
`V ≥ G`, but the offline key can't be used unattended → worst case **1.0×**, at the cost of unattended
failover. Choose deliberately.
---
## 7. Day-to-day operations
**Automated refill (the normal path, no human):** hot wallet drops below the floor → coordinator builds a PSBT
to a whitelisted address from the watch-only wallet → checks the global cap → fans to 2 of 3 → devices
auto-sign under policy → combine → broadcast. Watch it confirm on your explorer.
**Change a policy value (cap / velocity / period):** edit the versioned policy, push to each device, **re-arm**
all signers. Re-arming restarts the HSM session and **resets the per-device velocity counters** — expected.
Re-verify the registered wallet survived (§5 Step 5 trap).
**Add a whitelisted destination:** add the address to the policy `whitelist`, push, re-arm. Only the operator
can do this; untrusted callers can never extend the whitelist.
**Take a signer down for maintenance:** quorum tolerates **one** down — the other two keep signing. Bring it
back, re-arm it (§ boot-to-signing-ready), confirm quorum is 3 again. **Never** take a second one down while
one is already offline (that freezes spending until one returns).
**Boot-to-signing-ready:** a Coldcard needs PIN + HSM-mode entry after any power loss. Unattended operation
means the signer agent must restore the device to signing-ready automatically after a reboot, and monitoring
must confirm it did — a device that silently fails to return erodes quorum. Rehearse: reboot a signer host and
confirm the agent re-arms it and quorum self-heals.
---
## 8. Monitoring & alerting (non-negotiable for unattended operation)
Wire these into your observability stack (your observability stack (e.g. Loki + Grafana + alerting)):
- **Quorum health** — are **≥2 of 3** signers online **and** HSM-active **and** wallet-registered? **Alert the
moment it drops to exactly 2** (one more failure = frozen).
- **Velocity near limit** — global cap approaching for the period; per-device counters near `V`.
- **Policy denials** — every on-device refusal (the `must_log` trail) → alert; a spike may signal an attack or
a misconfiguration.
- **USB / device health** — VMs surviving a host crash can carry latent USB/udev damage; don't repeat-restart,
restore from backup.
- **Refill anomalies** — refills outside expected cadence/size (a compromised coordinator's tell).
---
## 9. Backup, recovery & DR
- **3 seeds**, each to steel, **geographically separated**. 2-of-3 survives losing **one** seed.
- **The descriptor is load-bearing.** Losing it makes recovery painful **even with all three seeds** — store
the wallet descriptor offsite, independently of the seeds (in your vault).
- **Rehearse recovery** before funding mainnet: reconstruct the watch-only wallet from the descriptor on a
fresh node, recover a signer from seed, re-register the multisig, reload policy, sign a test spend.
- **Coordinator state is disposable** — it's chain-derived; a replacement coordinator recomputes the velocity
counter from chain history with zero handed-over state.
---
## 10. Incident response & break-glass
| Situation | Response |
|---|---|
| One signer down | Operate on the remaining two; restore + re-arm the third; do not drop a second. |
| Two signers down | Spending is **frozen (safe)**. Restore one to resume. This is the design working. |
| Coordinator compromised suspected | Funds are bounded by §1.4 (whitelist + `1.5V`). Rotate the coordinator host/secret; the hardware caps already contain the blast radius. Review the policy-denial + refill logs. |
| Large / exception spend needed | Use the **human break-glass** path (3rd human-held key / CKBunker), outside the automated policy. |
| Suspected key compromise (one device) | One key alone moves nothing. Rotate to a fresh 2-of-3 (new seeds + descriptor), sweep funds under the old policy to the new wallet. |
---
## 11. Known sharp edges (read before production)
- **USB passthrough pins a VM to its host** — a VM with a physical USB Coldcard **cannot live-migrate**. These
signer VMs deliberately break the "freely migratable" model; the multisig *is* the HA.
- **HSM + multisig together is advanced / lightly-charted** — soak on signet for a long time before mainnet.
- **CKBunker is niche** (v0.9.1, "at your own risk") — keep it as the human break-glass surface only; the
automated path is the signer-agent over `ckcc-protocol`, not CKBunker.
- **The #12 ordering trap** (§5 Step 5) — register wallet → load policy → verify wallet survived.
- **Velocity counters don't compose across devices** — that's the whole reason the authoritative cap is the
coordinator's chain-derived one; per-device velocity is the hardware bound, not the operational truth.
- **Post-host-crash latent damage** — a signer VM that survived a host hard-crash can carry subclinical FS/USB
damage; restore from backup rather than repeat-restarting.
---
## 12. Regulatory note
Running this for **your own** funds (treasury / your own refill tier) is **not** custody-of-others. Offering it
as **custody-as-a-service for third parties** is **regulated activity in AU (AUSTRAC / financial services)**
get legal advice before productising. Same flag as a regulated swap service.
---

43
docs/QUICK-START.md Normal file
View File

@ -0,0 +1,43 @@
# Quick-start & operator checklist — mineracks multisig HSM
*The one-page version. Full detail + the security model: [Operator Manual](./OPERATOR-MANUAL.md). Do the
entire provisioning run on **signet/testnet first**; only move to mainnet after rehearsing a refill, a
failover, a policy change, and a restore-from-backup.*
## Provision (once)
- [ ] **1. Seeds** — generate **3 distinct** seeds, one per Coldcard (never clone one to three). Steel-backup each; store **geographically separated**.
- [ ] **2. Wallet** — build the `wsh(sortedmulti(2,k1,k2,k3))` descriptor; create the **watch-only** wallet on the node (receive `/0/*` + change `/1/*`); **record the descriptor offsite** (it's load-bearing for recovery).
- [ ] **3. Register** the 2-of-3 wallet on **each** Coldcard (so change is recognised as internal).
- [ ] **4. Policy** — per-device HSM JSON: `max_amount`, `per_period` (= ⅔ × global cap), `whitelist` (your hot-wallet deposit addresses), `period`, `wallet`, `must_log:true`.
- [ ] **4b. TOTP (only if using the surge tier)** — enrol the **same** `owner` secret on **all 3** Coldcards. **Production: the owner loads it directly over USB — never via the coordinator** (which only relays the live code at spend time).
- [ ] **5. ORDER (the #12 trap):** register wallet → enrol TOTP (4b) → **load policy → VERIFY the wallet survived on the device.**
- [ ] **6. HSM start** on each device (two-step on-device approval).
- [ ] **7. Coordinator** — set global cap `G`; whitelist; refill floor + amount; **round-robin the signer pair**; session secret `chmod 600` (**never in git**).
- [ ] **8. Placement** — 3 signers in **independent failure domains**, **≥1 offsite**. Never two keys behind one PSU/switch/host.
- [ ] **9. Verify** — within-policy refill signs · over-cap refused · off-list refused · velocity burst blocked at `G` · restore-from-backup rehearsed.
## Daily / on-call check
- [ ] **Quorum = 3/3** online, HSM-active, wallet-registered. *(Alert fires at exactly 2 — one more failure = frozen.)*
- [ ] Global velocity has headroom for the period; **no unexpected policy denials**.
- [ ] All 3 signer hosts + coordinator replica(s) up; USB/device health green.
## Sizing (memorise)
- Worst case if the **coordinator is compromised = 1.5 × per-device velocity `V`** (2 sigs burned per 1× value).
- **Set `V = ⅔ × G`** ⇒ worst case = your intended global cap `G`.
- **any-2-of-3** = 1.5× worst case **but** unattended failover · **2-auto + 1 human break-glass** = 1.0× worst case **but** no unattended failover. Pick deliberately.
## Incident cards
| Situation | Do this |
|---|---|
| **One** signer down | Run on the other two; restore + re-arm it; **do not drop a second.** |
| **Two** signers down | Spending is **frozen — safe, by design.** Restore one to resume. |
| Coordinator compromise suspected | Funds are bounded (no keys · whitelist confines destination · ≤ `1.5V`). Rotate host + secret; review denial/refill logs. |
| Large / exception spend | Use the **human break-glass** (3rd key / CKBunker), off the automated path. |
| Suspected single-key compromise | One key moves nothing. Rotate to a fresh 2-of-3; sweep funds under the old policy to the new wallet. |
## Never
- Never clone one seed to multiple devices · never co-locate two keys on a shared PSU/switch/host/hypervisor.
- Never take a **second** signer offline while one is already down.
- Never **load policy before registering** the multisig wallet (it can delete the wallet).
- Never put the coordinator **secret / seeds / descriptor in git**.
- Never hold **mainnet value on a single host running more than one signer** (no failure-domain independence).

40
reference/build_wallet.py Normal file
View File

@ -0,0 +1,40 @@
import subprocess, json, os, time
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
CLI=os.path.expanduser(f'~/bitcoin-{V}/bin/bitcoin-cli')
def bc(*a, wallet=None):
base=[CLI,f'-datadir={DATA}','-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
if wallet is not None: base.append(f'-rpcwallet={wallet}')
r=subprocess.run(base+list(a),capture_output=True,text=True)
if r.returncode!=0: raise SystemExit(f'CLI {a} FAIL: {r.stderr.strip()}')
return r.stdout.strip()
TPUB={'00000001':'tpubREPLACE_WITH_YOUR_SIGNER_1_XPUB',
'00000002':'tpubREPLACE_WITH_YOUR_SIGNER_2_XPUB',
'00000003':'tpubREPLACE_WITH_YOUR_SIGNER_3_XPUB'}
def desc(branch):
keys=','.join(f'[{x}/48h/1h/0h/2h]{p}/{branch}/*' for x,p in TPUB.items())
return f'wsh(sortedmulti(2,{keys}))'
for i in range(40):
try: bc('getblockchaininfo'); break
except SystemExit: time.sleep(2)
def withck(d): return json.loads(bc('getdescriptorinfo', d))['descriptor']
rcv, chg = withck(desc(0)), withck(desc(1))
# miner wallet (has keys) to fund
wl=json.loads(bc('listwallets'))
if 'miner' not in wl: bc('createwallet','miner')
if 'ckms23-watch' not in wl: bc('createwallet','ckms23-watch','true','true','','false','true') # disable_privkeys, blank, ..., descriptors
bc('importdescriptors', json.dumps([{'desc':rcv,'active':True,'internal':False,'timestamp':'now','range':[0,20]},
{'desc':chg,'active':True,'internal':True,'timestamp':'now','range':[0,20]}]), wallet='ckms23-watch')
maddr=bc('getnewaddress','','bech32', wallet='ckms23-watch')
print('MULTISIG_ADDR', maddr)
mineaddr=bc('getnewaddress', wallet='miner')
bc('generatetoaddress','101',mineaddr, wallet='miner')
bc('sendtoaddress',maddr,'2.5', wallet='miner')
bc('generatetoaddress','1',mineaddr, wallet='miner')
bal=bc('getbalance', wallet='ckms23-watch')
print('WATCH_BALANCE', bal)
dest=bc('getnewaddress', wallet='miner')
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:1.0}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}), wallet='ckms23-watch'))
open(os.path.expanduser('~/cksim/unsigned.psbt'),'w').write(funded['psbt'])
print('UNSIGNED_PSBT_BYTES', len(funded['psbt']))
print('PSBT_HEAD', funded['psbt'][:40])
print('OK')

45
reference/demo_rig.sh Executable file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Demo rig: GUI Coldcard Mk5 sims (segregated => distinct per-PID sockets), clipped + streamed via x11vnc +
# websockify for noVNC. Each signer N uses display :10N, VNC 591N, WS 691N, socket symlink /tmp/cksim-N.sock.
# demo_rig.sh -> (re)start ALL 3 signers (used by /policy + every re-arm)
# demo_rig.sh 2 -> (re)start ONLY signer 2 (boot-to-signing-ready / reboot demo; others keep running)
set -uo pipefail
export PATH="$HOME/gccshim:$PATH"
CLIP="${CLIP:-192x328+96+90}"
RUN=~/cksim
TARGETS="${*:-1 2 3}"
cd ~/coldcard-firmware/unix
# --- kill ONLY the targeted signers' processes (so a single-N restart leaves the others alone) ---
for n in $TARGETS; do
DISP=$((100+n)); VNC=$((5910+n)); WS=$((6910+n))
oldsock=$(readlink "/tmp/cksim-$n.sock" 2>/dev/null || true)
pkill -f -- "--seed $(cat "$RUN/seeds/$n.txt")" 2>/dev/null || true
pkill -f "rfbport $VNC" 2>/dev/null || true
pkill -f "websockify $WS" 2>/dev/null || true
kill "$(cat "/tmp/.X$DISP-lock" 2>/dev/null)" 2>/dev/null || true
rm -f "/tmp/.X$DISP-lock" "/tmp/cksim-$n.sock" ${oldsock:+"$oldsock"}
done
sleep 2
# --- (re)launch the targeted signers ---
for n in $TARGETS; do
DISP=$((100+n)); VNC=$((5910+n)); WS=$((6910+n))
Xvfb :$DISP -screen 0 900x720x24 >/tmp/xvfb$n.log 2>&1 &
sleep 2
SEED=$(cat "$RUN/seeds/$n.txt")
before=$(ls /tmp/ckcc-simulator-*.sock 2>/dev/null | sort)
DISPLAY=:$DISP setsid ~/ccsim-venv/bin/python simulator.py --mk5 --segregate --seed "$SEED" >/tmp/guisim$n.log 2>&1 &
sock=""
for i in $(seq 1 25); do
now=$(ls /tmp/ckcc-simulator-*.sock 2>/dev/null | sort)
sock=$(comm -13 <(echo "$before") <(echo "$now") | grep -v '^$' | head -1)
[ -n "$sock" ] && break
sleep 1
done
ln -sf "$sock" "/tmp/cksim-$n.sock"
x11vnc -display :$DISP -clip "$CLIP" -rfbport $VNC -forever -shared -nopw -quiet -bg >/tmp/x11vnc$n.log 2>&1
setsid websockify $WS localhost:$VNC </dev/null >/tmp/ws$n.log 2>&1 &
echo "$n $sock /tmp/cksim-$n.sock :$DISP $WS"
done
echo DEMO_RIG_UP

18
reference/diag_hsm.py Normal file
View File

@ -0,0 +1,18 @@
import os, io, time, json
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
from ckcc.cli import real_file_upload
d=ColdcardDevice(sn='/tmp/cksim-3.sock')
def E(c):
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
def K(k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
def st():
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r; return json.loads(r)
S=lambda: E("RV.write(repr(sim_display.story))")
pf=os.path.expanduser('~/cksim/policy.json')
flen,sha=real_file_upload(open(pf,'rb'),d)
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.5)
print('SCREEN1:', S()[:600])
K('y'); time.sleep(1.5)
print('SCREEN2:', S()[:600])
print('ACTIVE_after_y:', st().get('active'))

6
reference/enable_hsm.py Normal file
View File

@ -0,0 +1,6 @@
from ckcc.client import ColdcardDevice
for n in (1,2,3):
d=ColdcardDevice(sn='/tmp/cksim-%d.sock'%n)
d.send_recv(b'EXEC'+b"settings.put('hsmcmd', True); settings.save(); RV.write('ok')", encrypt=False)
r=d.send_recv(b'EVAL'+b"settings.get('hsmcmd', False)")
print('sim%d hsmcmd=%s'%(n, (r.decode() if isinstance(r,(bytes,bytearray)) else r).strip()))

30
reference/enroll.py Normal file
View File

@ -0,0 +1,30 @@
import sys, time, subprocess, os
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
sock, wallet = sys.argv[1], sys.argv[2]
ckcc = os.path.expanduser('~/ccsim-venv/bin/ckcc')
up = subprocess.run([ckcc, '-c', sock, 'upload', '-m', wallet], capture_output=True, text=True)
print('UPLOAD:', (up.stdout + up.stderr).strip()[:200])
time.sleep(1.5)
dev = ColdcardDevice(sn=sock)
def E(cmd):
r = dev.send_recv(b'EXEC' + cmd.encode(), encrypt=False)
return r.decode() if isinstance(r, (bytes, bytearray)) else r
def V(cmd):
r = dev.send_recv(b'EVAL' + cmd.encode())
return r.decode() if isinstance(r, (bytes, bytearray)) else r
def K(k):
dev.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
print('STORY0:', E("RV.write(repr(sim_display.story))")[:400])
for i in range(8):
c = V("len(settings.get('multisig', []))").strip()
print('iter %d: ms_count=%r' % (i, c))
if c not in ('0', ''):
break
K('y'); time.sleep(1.0)
print('FINAL ms_count:', V("len(settings.get('multisig', []))").strip())
print('STORYZ:', E("RV.write(repr(sim_display.story))")[:200])

21
reference/finish.py Normal file
View File

@ -0,0 +1,21 @@
import subprocess, json, os
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
CLI=os.path.expanduser(f'~/bitcoin-{V}/bin/bitcoin-cli')
def bc(*a, wallet=None):
base=[CLI,f'-datadir={DATA}','-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
if wallet: base.append(f'-rpcwallet={wallet}')
r=subprocess.run(base+list(a),capture_output=True,text=True)
if r.returncode: raise SystemExit(f'{a} FAIL {r.stderr.strip()}')
return r.stdout.strip()
s1=open(os.path.expanduser('~/cksim/signed1.psbt')).read().strip()
s2=open(os.path.expanduser('~/cksim/signed2.psbt')).read().strip()
comb=bc('combinepsbt', json.dumps([s1,s2]))
fin=json.loads(bc('finalizepsbt', comb))
print('PSBT_COMPLETE:', fin['complete'])
txid=bc('sendrawtransaction', fin['hex'])
print('BROADCAST_TXID:', txid)
mineaddr=bc('getnewaddress', wallet='miner')
bc('generatetoaddress','1',mineaddr, wallet='miner')
tx=json.loads(bc('gettransaction', txid, wallet='ckms23-watch'))
print('CONFIRMATIONS:', tx.get('confirmations'))
print('WATCH_BALANCE_AFTER:', bc('getbalance', wallet='ckms23-watch'))

18
reference/gui_sim.sh Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
exec > /tmp/guisim.log 2>&1
set -x
export PATH="$HOME/gccshim:$PATH"
N="${1:-1}"; DISP=$((100+N)); VNC=$((5910+N)); WS=$((6910+N))
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq scrot >/dev/null 2>&1
pkill -f "Xvfb :$DISP" 2>/dev/null; pkill -f "x11vnc.*$VNC" 2>/dev/null; sleep 1
Xvfb :$DISP -screen 0 900x720x24 >/dev/null 2>&1 &
sleep 2
SEED=$(cat ~/cksim/seeds/$N.txt)
cd ~/coldcard-firmware/unix
DISPLAY=:$DISP ~/ccsim-venv/bin/python simulator.py --mk5 --seed "$SEED" >/tmp/guisim$N.log 2>&1 &
sleep 12
DISPLAY=:$DISP scrot /tmp/cc$N.png 2>&1
ls -l /tmp/cc$N.png
x11vnc -display :$DISP -rfbport $VNC -forever -shared -nopw -quiet -bg >/tmp/x11vnc$N.log 2>&1
setsid websockify $WS localhost:$VNC </dev/null >/tmp/ws$N.log 2>&1 &
echo "GUI_SIM_DONE disp=$DISP vnc=$VNC ws=$WS"

61
reference/hsm_2of3.py Normal file
View File

@ -0,0 +1,61 @@
import subprocess, json, os, io, time, re
from base64 import b64decode, b64encode
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
from ckcc.cli import real_file_upload
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
def bc(*a, wallet=None):
base=[CLI,'-datadir=%s'%DATA,'-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
if wallet: base.append('-rpcwallet=%s'%wallet)
r=subprocess.run(base+list(a),capture_output=True,text=True)
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()))
return r.stdout.strip()
def dev(n): return ColdcardDevice(sn='/tmp/cksim-%d.sock'%n)
def E(d,c):
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
def K(d,k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
def hstat(d):
try:
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r
return json.loads(r)
except Exception as e: return {'err':str(e)[:60]}
def story(d): return E(d,"RV.write(repr(sim_display.story))")
def activate(d):
pf=os.path.expanduser('~/cksim/policy.json')
if hstat(d).get('active'): return True
flen,sha=real_file_upload(open(pf,'rb'),d)
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.4)
for _ in range(20):
if hstat(d).get('active'): return True
s=story(d)
m=re.search(r'Press \((\d)\) to save', s)
if m: K(d,m.group(1)); time.sleep(1.6); continue
if 'Press OK to enable' in s or s.startswith("('Start HSM?'"): K(d,'y'); time.sleep(1.6); continue
time.sleep(0.5)
return hstat(d).get('active')
d1,d2=dev(1),dev(2)
print('sim1 active(before)=', hstat(d1).get('active'))
print('sim2 activate ->', activate(d2))
print('sim1 active(now)=', hstat(d1).get('active'), ' sim2 active(now)=', hstat(d2).get('active'))
print('== build PSBT + AUTO-SIGN (no keypress) on sims 1 & 2 ==')
dest=bc('getnewaddress',wallet='miner')
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:0.4}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}),wallet='ckms23-watch'))
psbt=b64decode(funded['psbt']); sigs=[]
for n,d in ((1,d1),(2,d2)):
flen,sha=real_file_upload(io.BytesIO(psbt),d)
d.send_recv(CCProtocolPacker.sign_transaction(flen,sha,flags=0x0),timeout=None)
done=None
for i in range(40):
time.sleep(0.5)
r=d.send_recv(CCProtocolPacker.get_signed_txn(),timeout=None)
if r is not None: done=r; break
if not done: print('sim%d TIMEOUT hsm=%s'%(n,hstat(d).get('active'))); continue
rl,rsha=done; res=d.download_file(rl,rsha,file_number=1)
sigs.append(b64encode(res).decode()); print('sim%d AUTO-SIGNED no-keypress bytes=%d'%(n,rl))
fin=json.loads(bc('finalizepsbt', bc('combinepsbt',json.dumps(sigs))))
print('PSBT_COMPLETE:', fin['complete'])
if fin['complete']:
txid=bc('sendrawtransaction',fin['hex']); print('TXID:',txid)
bc('generatetoaddress','1',bc('getnewaddress',wallet='miner'),wallet='miner')
print('BALANCE_AFTER:',bc('getbalance',wallet='ckms23-watch'))

67
reference/hsm_autosign.py Normal file
View File

@ -0,0 +1,67 @@
import subprocess, json, os, io, time, re
from base64 import b64decode, b64encode
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
from ckcc.cli import real_file_upload
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
def bc(*a, wallet=None):
base=[CLI,'-datadir=%s'%DATA,'-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
if wallet: base.append('-rpcwallet=%s'%wallet)
r=subprocess.run(base+list(a),capture_output=True,text=True)
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()))
return r.stdout.strip()
SK={1:'/tmp/cksim-1.sock',2:'/tmp/cksim-2.sock',3:'/tmp/cksim-3.sock'}
def dev(n): return ColdcardDevice(sn=SK[n])
def E(d,c):
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
def K(d,k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
def hstat(d):
try:
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r
return json.loads(r)
except Exception as e: return {'err':str(e)[:80]}
def story(d): return E(d,"RV.write(repr(sim_display.story))")
policy={"must_log":False,"period":60,"rules":[{"max_amount":300000000,"per_period":300000000,"wallet":"ckms23"}]}
pf=os.path.expanduser('~/cksim/policy.json'); open(pf,'w').write(json.dumps(policy))
print('== hsm-start on sims 1,2 (2-step approval: OK then random digit) ==')
for n in (1,2):
d=dev(n)
flen,sha=real_file_upload(open(pf,'rb'),d)
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.2)
s1=story(d)
K(d,'y'); time.sleep(1.3) # screen 1: Press OK
s2=story(d)
m=re.search(r'Press \((\d)\) to save policy', s2)
cc=m.group(1) if m else None
if cc: K(d,cc); time.sleep(1.5) # screen 2: the random digit
st=hstat(d)
print(' sim%d confirm_digit=%s HSM_active=%s' % (n, cc, st.get('active')))
print('== fund + build fresh PSBT ==')
dest=bc('getnewaddress',wallet='miner')
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:0.5}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}),wallet='ckms23-watch'))
psbt=b64decode(funded['psbt']); sigs=[]
print('== sign with NO keypress (HSM auto-cosign) ==')
for n in (1,2):
d=dev(n)
flen,sha=real_file_upload(io.BytesIO(psbt),d)
d.send_recv(CCProtocolPacker.sign_transaction(flen,sha,flags=0x0),timeout=None)
done=None
for i in range(40):
time.sleep(0.5)
r=d.send_recv(CCProtocolPacker.get_signed_txn(),timeout=None)
if r is not None: done=r; break
if not done: print(' sim%d AUTO-SIGN TIMEOUT (hsm=%s)'%(n,hstat(d).get('active'))); continue
rl,rsha=done; res=d.download_file(rl,rsha,file_number=1)
sigs.append(b64encode(res).decode())
print(' sim%d AUTO-SIGNED, NO keypress, bytes=%d' % (n, rl))
print('== combine + broadcast ==')
comb=bc('combinepsbt',json.dumps(sigs))
fin=json.loads(bc('finalizepsbt',comb)); print(' PSBT_COMPLETE:',fin['complete'])
txid=bc('sendrawtransaction',fin['hex']); print(' TXID:',txid)
bc('generatetoaddress','1',bc('getnewaddress',wallet='miner'),wallet='miner')
print(' BALANCE_AFTER:',bc('getbalance',wallet='ckms23-watch'))

64
reference/hsm_finish.py Normal file
View File

@ -0,0 +1,64 @@
import subprocess, json, os, io, time, re
from base64 import b64decode, b64encode
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
from ckcc.cli import real_file_upload
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
def bc(*a, wallet=None):
base=[CLI,'-datadir=%s'%DATA,'-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
if wallet: base.append('-rpcwallet=%s'%wallet)
r=subprocess.run(base+list(a),capture_output=True,text=True)
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()))
return r.stdout.strip()
SK={1:'/tmp/cksim-1.sock',3:'/tmp/cksim-3.sock'}
def dev(n): return ColdcardDevice(sn=SK[n])
def E(d,c):
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
def K(d,k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
def hstat(d):
try:
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r
return json.loads(r)
except Exception as e: return {'err':str(e)[:60]}
def story(d): return E(d,"RV.write(repr(sim_display.story))")
def approve(d):
for _ in range(14):
if hstat(d).get('active'): return True
s=story(d)
if 'save policy' in s:
m=re.search(r'Press \((\d)\) to save', s)
if m: K(d,m.group(1)); time.sleep(1.6); continue
if 'Press OK to enable' in s or s.startswith("('Start HSM?'"):
K(d,'y'); time.sleep(1.6); continue
time.sleep(0.5)
return hstat(d).get('active')
pf=os.path.expanduser('~/cksim/policy.json')
DEVS={}
for n in (1,3):
d=dev(n); DEVS[n]=d
flen,sha=real_file_upload(open(pf,'rb'),d)
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.2)
act=approve(d)
print('sim%d HSM_active=%s' % (n, act))
print('== build PSBT, AUTO-SIGN with NO keypress ==')
dest=bc('getnewaddress',wallet='miner')
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:0.4}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}),wallet='ckms23-watch'))
psbt=b64decode(funded['psbt']); sigs=[]
for n in (1,3):
d=DEVS[n]
flen,sha=real_file_upload(io.BytesIO(psbt),d)
d.send_recv(CCProtocolPacker.sign_transaction(flen,sha,flags=0x0),timeout=None)
done=None
for i in range(40):
time.sleep(0.5)
r=d.send_recv(CCProtocolPacker.get_signed_txn(),timeout=None)
if r is not None: done=r; break
if not done: print('sim%d AUTO-SIGN TIMEOUT hsm=%s'%(n,hstat(d).get('active'))); continue
rl,rsha=done; res=d.download_file(rl,rsha,file_number=1)
sigs.append(b64encode(res).decode()); print('sim%d AUTO-SIGNED (no keypress) bytes=%d'%(n,rl))
comb=bc('combinepsbt',json.dumps(sigs))
fin=json.loads(bc('finalizepsbt',comb)); print('PSBT_COMPLETE:',fin['complete'])
txid=bc('sendrawtransaction',fin['hex']); print('TXID:',txid)
bc('generatetoaddress','1',bc('getnewaddress',wallet='miner'),wallet='miner')
print('BALANCE_AFTER:',bc('getbalance',wallet='ckms23-watch'))

775
reference/orchestrator.py Normal file
View File

@ -0,0 +1,775 @@
#!/usr/bin/env python3
"""Reference demo orchestrator for the mineracks multisig HSM — interactive REAL-SIGNET policy HSM.
All 3 sims are HSM policy signers (any 2 sign real 2-of-3 failover). Chain access is the fleet's
synced signet node (RPC over your private network). Keyless coordinator; on-device policy gates.
Endpoints (nginx maps /api/ -> :8099/):
GET /status -> JSON {balance_sat, devices, quorum, policy, sink_addr}
GET /spend?amount=SATS&dest=sink|other -> SSE; builds+signs a real-signet spend. dest=other pays an
off-whitelist address (HSM refuses); over max_amount refuses.
GET /signmsg?text=... -> SSE; 2-of-3 sign a message (proof of control, no funds move)
GET /device?n=N&on=0|1 -> toggle a signer on/off (failover / quorum demo)
GET /policy?max_sat=&per_period_sat=&period_min= -> set policy (max-amount + velocity) + RE-ARM all sims
"""
import json, os, io, time, re, subprocess, threading, urllib.parse, hashlib, hmac, secrets
from contextlib import contextmanager
from base64 import b64decode, b64encode, b32decode, b32encode
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
from ckcc.constants import USER_AUTH_TOTP
from ckcc.cli import real_file_upload
HOME = os.path.expanduser('~'); V = '27.0'
CLI = HOME + '/bitcoin-%s/bin/bitcoin-cli' % V
CKCC = HOME + '/ccsim-venv/bin/ckcc'
WALLET_FILE = HOME + '/cksim/ckms23.txt'; POLICY_FILE = HOME + '/cksim/policy.json'
DEMO_RIG = HOME + '/cksim/demo_rig.sh'
WALLET = 'ckms23-signet'
RPC_HOST = os.environ.get('RPC_HOST', '127.0.0.1'); RPC_PORT = os.environ.get('RPC_PORT', '38332')
RPC_USER = os.environ.get('RPC_USER', 'rpcuser'); RPC_PASS = os.environ.get('RPC_PASS', '')
SOCK = {1: '/tmp/cksim-1.sock', 2: '/tmp/cksim-2.sock', 3: '/tmp/cksim-3.sock'}
LET = {1: 'A', 2: 'B', 3: 'C'}; ALL = (1, 2, 3)
DEFAULT_SAT = 2000; MSG_PATH = "m/48h/1h/0h/2h/0/0"
TPUB = {'00000001': 'tpubREPLACE_WITH_YOUR_SIGNER_1_XPUB',
'00000002': 'tpubREPLACE_WITH_YOUR_SIGNER_2_XPUB',
'00000003': 'tpubREPLACE_WITH_YOUR_SIGNER_3_XPUB'}
LOCK = threading.Lock()
ENABLED = {1: True, 2: True, 3: True}
POLICY = {'max_sat': 8000, 'per_period_sat': 500000, 'period_min': 60}
_WALLET_READY = [False]
# Coordinator-side GLOBAL velocity cap — the AUTHORITATIVE spend limit across ALL signers, and the epic's
# hardest open problem. On-device per-signer velocity (POLICY['per_period_sat']) is only a backstop: under a
# rotating "any 2 of 3" the three devices' local counters DRIFT (each tracks only the txns IT signed), and a
# Coldcard decrements its counter the moment it signs — even if that PSBT is later dropped and never
# broadcast. So no device's counter reflects true global outflow. The real cap therefore lives HERE in the
# keyless coordinator and counts ONLY real broadcasts (the ledger) — so dropped PSBTs never burn budget.
GLOBAL = {'per_period_sat': 50000, 'period_min': 60}
# TOTP-gated SURGE tier: a signed-in owner supplies a TOTP code to unlock a higher on-device tier (bigger
# per-txn cap + velocity) WITH a human in the loop — routine flows stay automated. The coordinator only
# RELAYS the code (via user_auth) to the signers; the secret lives on the devices + the owner's authenticator.
SURGE = {'max_sat': 200000, 'per_period_sat': 2000000} # tier-2 device limits (TOTP required)
SURGE_GLOBAL = {'per_period_sat': 1000000} # coordinator global cap when a TOTP is present
TOTP_SECRET_FILE = HOME + '/cksim/multisig-totp.secret' # owner's TOTP secret (chmod 600, NOT in git)
LEDGER_FILE = HOME + '/cksim/spend_ledger.json'
RESET_TS = [0] # demo-only: "Reset counter" moves the window start to now (production never resets)
_TX_CACHE = {'t': 0.0, 'txs': None}
def ledger_load():
try: return json.load(open(LEDGER_FILE))
except Exception: return []
def ledger_append(amount_sat, txid):
led = (ledger_load() + [{'ts': int(time.time()), 'amount': int(amount_sat), 'txid': txid}])[-5000:]
try: open(LEDGER_FILE, 'w').write(json.dumps(led))
except Exception: pass
_TX_CACHE['txs'] = None # force the next global_spent to re-read the chain (so the gate sees this spend)
def _recent_sends():
# cache the node's recent wallet txns ~4s so /status polling doesn't hammer RPC
if time.time() - _TX_CACHE['t'] < 4 and _TX_CACHE['txs'] is not None:
return _TX_CACHE['txs']
try: txs = json.loads(bc('listtransactions', '*', '300', '0', 'true', wallet=WALLET))
except Exception: return None
_TX_CACHE['t'] = time.time(); _TX_CACHE['txs'] = txs; return txs
def global_spent():
# CHAIN-DERIVED authoritative period outflow. The watch-only wallet's on-chain 'send' total over the
# window IS ground truth, so ANY coordinator replica on ANY host recomputes the same number straight from
# the blockchain — no single-host ledger to lose (no SPOF) and nothing to tamper. The local ledger is only
# a cache, used if the node RPC is briefly unreachable. (Change back to the 2-of-3 is recognised as
# internal by the descriptor wallet, so only real external sends — the refills — are counted.)
#
# MEMPOOL counts too: an unconfirmed (0-conf) send appears in listtransactions the moment it broadcasts,
# so a burst of spends inside one block interval can't slip past the cap. We exclude only abandoned /
# conflicted (confirmations < 0) sends, so a genuinely dropped tx doesn't permanently burn budget.
cut = max(int(time.time()) - GLOBAL['period_min'] * 60, RESET_TS[0])
txs = _recent_sends()
if txs is not None:
return sum(abs(int(round(t.get('amount', 0) * 1e8))) for t in txs
if t.get('category') == 'send' and t.get('time', 0) >= cut
and not t.get('abandoned') and t.get('confirmations', 0) >= 0)
return sum(int(e.get('amount', 0)) for e in ledger_load() if e.get('ts', 0) >= cut)
# ---- Nostr (NIP-07) sign-in -------------------------------------------------
# Optional opt-in identity (same pattern as walletplayground): anonymous use is unaffected. A signed-in user
# may authorise their OWN signet address onto every signer's on-device whitelist (a re-arm), instead of being
# confined to the sink. Self-contained pure-Python BIP-340 verify (no crypto dep). The rig is shared, so the
# authorised set is global; it's capped and the page says so. The HMAC secret lives only on the VM (chmod
# 600, NOT in git): a leak only lets someone forge a session, never move funds (the keys are on the devices).
SECRET_FILE = HOME + '/cksim/multisig-auth.secret'
COOKIE = 'ms_session'; CHALLENGE_TTL = 300; SESSION_TTL = 60 * 60 * 24 * 30
EXTRA_WL = [] # authorised custom destination addresses (in addition to the sink); capped, global
PUBKEYS = {} # uid -> nostr pubkey (in-memory, display only)
def _load_secret():
try:
s = open(SECRET_FILE, 'rb').read().strip()
if len(s) >= 32: return s
except FileNotFoundError: pass
s = secrets.token_hex(32).encode()
fd = os.open(SECRET_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'wb') as f: f.write(s)
return s
SECRET = _load_secret()
# secp256k1 BIP-340 schnorr verify (verify-only reference impl)
_p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
_n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
def _modinv(a, m): return pow(a, m - 2, m)
def _padd(P1, P2):
if P1 is None: return P2
if P2 is None: return P1
if P1[0] == P2[0] and P1[1] != P2[1]: return None
if P1 == P2: lam = (3 * P1[0] * P1[0] * _modinv(2 * P1[1], _p)) % _p
else: lam = ((P2[1] - P1[1]) * _modinv(P2[0] - P1[0], _p)) % _p
x3 = (lam * lam - P1[0] - P2[0]) % _p
return (x3, (lam * (P1[0] - x3) - P1[1]) % _p)
def _pmul(P, k):
R = None
while k:
if k & 1: R = _padd(R, P)
P = _padd(P, P); k >>= 1
return R
def _lift_x(x):
if x >= _p: return None
y_sq = (pow(x, 3, _p) + 7) % _p
y = pow(y_sq, (_p + 1) // 4, _p)
if pow(y, 2, _p) != y_sq: return None
return (x, y if y % 2 == 0 else _p - y)
def _tagged(tag, msg):
th = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(th + th + msg).digest()
def schnorr_verify(msg32, pub32, sig64):
if len(msg32) != 32 or len(pub32) != 32 or len(sig64) != 64: return False
P = _lift_x(int.from_bytes(pub32, 'big'))
if P is None: return False
r = int.from_bytes(sig64[:32], 'big'); s = int.from_bytes(sig64[32:], 'big')
if r >= _p or s >= _n: return False
e = int.from_bytes(_tagged('BIP0340/challenge', sig64[:32] + pub32 + msg32), 'big') % _n
R = _padd(_pmul(_G, s), _pmul(P, _n - e))
return R is not None and R[1] % 2 == 0 and R[0] == r
def nostr_event_id(ev):
s = json.dumps([0, ev['pubkey'], ev['created_at'], ev['kind'], ev['tags'], ev['content']],
separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(s.encode('utf-8')).hexdigest()
def _mac(msg): return hmac.new(SECRET, msg.encode(), hashlib.sha256).hexdigest()[:32]
def make_challenge():
nonce = secrets.token_hex(16); ts = str(int(time.time()))
return '%s.%s.%s' % (nonce, ts, _mac('%s.%s' % (nonce, ts)))
def check_challenge(ch):
try: nonce, ts, mac = ch.split('.')
except (ValueError, AttributeError): return False
if not hmac.compare_digest(mac, _mac('%s.%s' % (nonce, ts))): return False
return abs(int(time.time()) - int(ts)) <= CHALLENGE_TTL
def make_session(uid):
exp = str(int(time.time()) + SESSION_TTL)
return '%s.%s.%s' % (uid, exp, _mac('%s.%s' % (uid, exp)))
def read_session(cookie_header):
if not cookie_header: return None
c = SimpleCookie()
try: c.load(cookie_header)
except Exception: return None
if COOKIE not in c: return None
try: uid, exp, mac = c[COOKIE].value.split('.')
except ValueError: return None
if not hmac.compare_digest(mac, _mac('%s.%s' % (uid, exp))): return None
if int(time.time()) > int(exp): return None
return uid
def uid_for(provider, subject):
return provider + ':' + hashlib.sha256(('%s:%s' % (provider, subject)).encode()).hexdigest()[:24]
def add_wl(addr):
if addr not in EXTRA_WL:
EXTRA_WL.append(addr)
while len(EXTRA_WL) > 25: EXTRA_WL.pop(0)
def totp_secret():
# owner TOTP shared secret — generated once, persisted on the VM only (chmod 600, never in git), enrolled
# on each signer + shown to the signed-in owner for their authenticator. (Demo simplification: production
# enrols via the device's own QR so the coordinator never sees the secret.)
try:
s = open(TOTP_SECRET_FILE).read().strip()
if s: return s
except Exception: pass
s = b32encode(secrets.token_bytes(20)).decode()
fd = os.open(TOTP_SECRET_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as f: f.write(s)
return s
def bc(*a, wallet=None):
base = [CLI, '-signet', '-rpcconnect=%s' % RPC_HOST, '-rpcport=%s' % RPC_PORT,
'-rpcuser=%s' % RPC_USER, '-rpcpassword=%s' % RPC_PASS]
if wallet: base.append('-rpcwallet=%s' % wallet)
r = subprocess.run(base + list(a), capture_output=True, text=True, timeout=120)
if r.returncode: raise RuntimeError('bitcoin-cli %s: %s' % (a[:1], r.stderr.strip()[:160]))
return r.stdout.strip()
def descstr(b):
keys = ','.join('[%s/48h/1h/0h/2h]%s/%d/*' % (x, p, b) for x, p in TPUB.items())
return 'wsh(sortedmulti(2,%s))' % keys
def _ck(d): return json.loads(bc('getdescriptorinfo', d))['descriptor']
def ensure_wallet():
if _WALLET_READY[0]: return
if WALLET not in json.loads(bc('listwallets')):
try: bc('loadwallet', WALLET)
except RuntimeError:
bc('createwallet', WALLET, 'true', 'true', '', 'false', 'true')
ts = int(time.time()) - 14400
bc('importdescriptors', json.dumps([
{'desc': _ck(descstr(0)), 'active': True, 'internal': False, 'timestamp': ts, 'range': [0, 30]},
{'desc': _ck(descstr(1)), 'active': True, 'internal': True, 'timestamp': ts, 'range': [0, 30]}]), wallet=WALLET)
_WALLET_READY[0] = True
def signet_addr():
try: return open(HOME + '/cksim/signet_addr.txt').read().strip()
except Exception: return '(addr missing)'
def balance_sat():
ensure_wallet()
us = json.loads(bc('listunspent', '0', wallet=WALLET))
return us, int(round(sum(u['amount'] for u in us) * 1e8))
# A single-sig "sink" wallet on the fleet node receives the demo spends (EXTERNAL to the 2-of-3, so the
# Coldcard counts real outflow and max_amount actually bites). Each run first sweeps the sink back to the
# 2-of-3 funding address, so funds circulate and the demo is self-sustaining (depletes only by fees).
SINK = 'ckms-sink'; SINK_ADDR_FILE = HOME + '/cksim/sink_addr.txt'
def ensure_sink():
if SINK not in json.loads(bc('listwallets')):
try: bc('loadwallet', SINK)
except RuntimeError: bc('createwallet', SINK)
def sink_addr():
# STABLE whitelisted destination — reused (persisted) so it can live in each Coldcard's on-device
# address whitelist; an anonymous demo spend may only pay this address.
try:
a = open(SINK_ADDR_FILE).read().strip()
if a: return a
except Exception: pass
ensure_sink(); a = bc('getnewaddress', wallet=SINK); open(SINK_ADDR_FILE, 'w').write(a); return a
def other_addr():
# A fresh sink-wallet address deliberately NOT on the whitelist (the whitelist-block demo). The spend to
# it is refused on-device, so no coins ever land here.
ensure_sink(); return bc('getnewaddress', wallet=SINK)
def reclaim_sink():
# Sweep CONFIRMED sink coins back (minconf=1 -> trusted, so no untrusted-0-conf "-6" errors and no
# ever-growing unconfirmed chain). Sink funds become reclaimable one signet block after each demo spend.
try:
ensure_sink()
if json.loads(bc('listunspent', '1', wallet=SINK)):
bc('-named', 'sendall', 'recipients=' + json.dumps([signet_addr()]),
'fee_rate=1', 'options=' + json.dumps({'send_max': True}), wallet=SINK)
except Exception:
pass
# ---- device helpers ----------------------------------------------------------
# The ckcc simulator client BINDS a per-(simpid,ourpid) unix socket with only 5 instance slots; a leaked
# (un-closed) ColdcardDevice holds its slot until process exit (atexit), so opening many without closing
# eventually exhausts the slots -> "[Errno 98] Address already in use". So EVERY device is opened via this
# context manager and closed immediately after use — never leak a ColdcardDevice in a long-running process.
@contextmanager
def dev(n):
d = ColdcardDevice(sn=SOCK[n])
try:
yield d
finally:
try: d.close()
except Exception: pass
def is_active(n):
with dev(n) as d:
return bool(hstat_raw(d).get('active'))
def E(d, c):
r = d.send_recv(b'EXEC' + c.encode(), encrypt=False)
return r.decode() if isinstance(r, (bytes, bytearray)) else r
def Vv(d, c):
r = d.send_recv(b'EVAL' + c.encode())
return (r.decode() if isinstance(r, (bytes, bytearray)) else r).strip()
def K(d, k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
def hstat_raw(d):
try:
r = d.send_recv(CCProtocolPacker.hsm_status()); r = r.decode() if isinstance(r, (bytes, bytearray)) else r
return json.loads(r)
except Exception: return {}
def hstat(n):
with dev(n) as d:
s = hstat_raw(d)
return {'active': bool(s.get('active')),
'approved': s.get('approvals', s.get('approved', 0)),
'refused': s.get('refusals', s.get('refused', 0))}
# The sim sockets are single-client, so concurrent device reads (status poll vs a running spend) collide
# and make a live signer look offline. A single background poller reads the devices under LOCK and caches
# the result; the /status endpoint serves the cache (never touches a device). During a spend (which holds
# LOCK) the poller simply waits — no contention.
STATUS_CACHE = {n: {'active': False, 'approved': 0, 'refused': 0} for n in ALL}
HEAL = {n: 0.0 for n in ALL} # last auto-heal attempt per signer (cooldown)
_LAST_HEALTH = ['']
def _quorum():
return sum(1 for n in ALL if ENABLED[n] and STATUS_CACHE[n]['active'])
def status_poller():
tick = 0
while True:
time.sleep(2); tick += 1
with LOCK:
for n in ALL:
try: STATUS_CACHE[n] = hstat(n)
except Exception: # unreachable/dead signer -> mark inactive so quorum + heal see the truth
STATUS_CACHE[n] = {'active': False, 'approved': STATUS_CACHE[n]['approved'], 'refused': STATUS_CACHE[n]['refused']}
# quorum-health line for Loki/Grafana/Sentinel — on change, plus a ~30s heartbeat
q = _quorum(); ready = sum(1 for n in ALL if STATUS_CACHE[n]['active'])
lvl = 'OK' if q >= 3 else ('WARN' if q == 2 else 'CRIT')
msg = 'multisig-health quorum=%d needed=2 signers_ready=%d/3 status=%s' % (q, ready, lvl)
if msg != _LAST_HEALTH[0] or tick % 15 == 0:
_LAST_HEALTH[0] = msg; print(msg, flush=True)
def heal_loop():
# Boot-to-signing-ready auto-heal: re-arm any ENABLED signer that is reachable but not HSM-active (e.g. it
# rebooted and came back online). The quorum self-heals unattended. Cooldown avoids hammering; a signer
# whose process is gone can't be healed here (it raises) — the rig must relaunch it first.
time.sleep(24)
while True:
time.sleep(10)
for n in ALL:
if not ENABLED[n] or STATUS_CACHE[n]['active'] or time.time() - HEAL[n] < 25: continue
HEAL[n] = time.time()
try:
with LOCK:
if STATUS_CACHE[n]['active']: continue
print('auto-heal: signer %d not signing-ready — re-arming…' % n, flush=True)
arm_one(n, lambda *a, **k: None)
print('auto-heal: signer %d re-armed to signing-ready' % n, flush=True)
except Exception as e:
print('auto-heal: signer %d still unreachable (%s)' % (n, str(e)[:60]), flush=True)
def story(d): return E(d, "RV.write(repr(sim_display.story))")
def ms_count(d):
try: return int(Vv(d, "len(settings.get('multisig',[]))") or 0)
except Exception: return 0
def write_policy():
# Rule dimensions, all enforced ON-DEVICE: max_amount (per-txn), per_period+period (velocity),
# whitelist (allowed destinations — change back to ckms23 is auto-recognised and not gated by it).
# Two-tier, FIRST-MATCH ordered: rule#1 = automated baseline (no users); rule#2 = TOTP-gated surge
# (higher cap + velocity, requires the "owner" to present a code). A small spend matches rule#1 and
# auto-signs; one that exceeds it falls through to rule#2 and needs the TOTP.
wl = [sink_addr()] + list(EXTRA_WL)
pol = {"must_log": True, "period": POLICY['period_min'],
"msg_paths": ["any"], # allow on-device message signing under HSM (proof-of-control demo)
"rules": [
{"max_amount": POLICY['max_sat'], "per_period": POLICY['per_period_sat'],
"wallet": "ckms23", "whitelist": wl},
{"max_amount": SURGE['max_sat'], "per_period": SURGE['per_period_sat'],
"wallet": "ckms23", "whitelist": wl, "users": ["owner"], "min_users": 1}]}
open(POLICY_FILE, 'w').write(json.dumps(pol))
def arm_one(n, log):
with dev(n) as d:
if hstat_raw(d).get('active'): return
need_reg = ms_count(d) < 1
if need_reg:
log('coldcard %s · registering the 2-of-3 wallet…' % LET[n], 'c')
subprocess.run([CKCC, '-c', SOCK[n], 'upload', '-m', WALLET_FILE], capture_output=True)
time.sleep(1.2)
with dev(n) as d2:
for _ in range(10):
if ms_count(d2) >= 1: break
K(d2, 'y'); time.sleep(0.8)
with dev(n) as d:
E(d, "settings.put('hsmcmd', True); settings.save(); RV.write('ok')")
try: # enrol the "owner" TOTP user (referenced by the surge rule) — before HSM start, fresh device
d.send_recv(CCProtocolPacker.create_user(b'owner', USER_AUTH_TOTP, b32decode(totp_secret(), casefold=True)))
except Exception: pass
log('coldcard %s · entering HSM mode (auto ≤%d · surge ≤%d w/ TOTP)…' % (LET[n], POLICY['max_sat'], SURGE['max_sat']), 'c')
flen, sha = real_file_upload(open(POLICY_FILE, 'rb'), d)
d.send_recv(CCProtocolPacker.hsm_start(flen, sha)); time.sleep(1.3)
for _ in range(18):
if hstat_raw(d).get('active'): break
s = story(d); m = re.search(r'Press \((\d)\) to save', s)
if m: K(d, m.group(1)); time.sleep(1.4); continue
if 'Press OK to enable' in s or s.startswith("('Start HSM?'"): K(d, 'y'); time.sleep(1.4); continue
time.sleep(0.4)
log('coldcard %s · HSM ACTIVE ✓' % LET[n], 'g')
def arm_all(log):
write_policy()
for n in ALL: arm_one(n, log)
def rearm_all(log):
write_policy()
log('restarting all signers to load the new policy…', 'c')
subprocess.run(['bash', DEMO_RIG], capture_output=True, timeout=150)
time.sleep(3)
for n in ALL: arm_one(n, log)
log('policy applied · ≤%d sats/txn · ≤%d sats per %d min · whitelist enforced on all signers'
% (POLICY['max_sat'], POLICY['per_period_sat'], POLICY['period_min']), 'g')
def _reason(d, fallback):
# The device records WHY it refused in hsm_status.last_refusal (e.g. "over per-txn limit",
# "exceeds velocity", "address not allowed", "has 1 warning(s)"). Surface it — far clearer than the
# generic ckcc "You refused permission" string, and it makes the policy dimension obvious in the demo.
try:
time.sleep(0.2)
r = re.sub(r'^\s*Rejected:\s*', '', hstat_raw(d).get('last_refusal') or '').strip()
if r: return r[:90]
except Exception: pass
return fallback
def sign_hsm(n, psbt, code=None):
with dev(n) as d:
if code: # relay the owner's TOTP so the device's surge rule (min_users) is satisfied
try: d.send_recv(CCProtocolPacker.user_auth(b'owner', code.encode('ascii'), int(time.time()) // 30))
except Exception: pass
before = hstat_raw(d).get('refusals', hstat_raw(d).get('refused', 0))
flen, sha = real_file_upload(io.BytesIO(psbt), d)
try:
d.send_recv(CCProtocolPacker.sign_transaction(flen, sha, flags=0x0), timeout=None)
except Exception as e:
return ('refused', _reason(d, str(e)[:80]))
for _ in range(40):
time.sleep(0.4)
try:
r = d.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
except Exception as e:
return ('refused', _reason(d, str(e)[:80]))
if r is not None:
rl, rsha = r
return ('signed', b64encode(d.download_file(rl, rsha, file_number=1)).decode())
cur = hstat_raw(d)
if cur.get('refusals', cur.get('refused', 0)) > before:
return ('refused', _reason(d, 'on-device policy denied the spend'))
return ('refused', 'no signature (policy)')
# ---- streamed spend ----------------------------------------------------------
def sse_spend(send, amount_sat, dest_kind='sink', custom_addr=None, code=None):
def log(t, cls=''): send({'log': t, 'cls': cls})
def step(n): send({'step': n});
def done(n): send({'done_step': n})
amount_sat = max(546, int(amount_sat))
amount_btc = amount_sat / 1e8
surge = bool(code)
over = amount_sat > POLICY['max_sat']
off_list = dest_kind == 'other'
avail = [n for n in ALL if ENABLED[n] and is_active(n)]
if len(avail) < 2:
log('quorum lost · only %d policy signer(s) online — a 2-of-3 spend needs 2.' % len(avail), 'y')
log('(this is the failure-domain property: lose any one signer and you keep operating; lose two and funds are safe but frozen.)', 'c')
send({'end': True}); return
signers = avail[:2]
# COORDINATOR global velocity gate — the authoritative cap, enforced BEFORE any signer is asked, and
# counting only real broadcasts. Off-list spends are skipped (the devices refuse them on the whitelist).
if not off_list:
cap = SURGE_GLOBAL['per_period_sat'] if surge else GLOBAL['per_period_sat']
spent = global_spent()
if spent + amount_sat > cap:
log('coordinator %s velocity: this %d-sat spend would push the %d-min total to %d > the %d-sat cap.'
% ('surge' if surge else 'global', amount_sat, GLOBAL['period_min'], spent + amount_sat, cap), 'y')
log('blocked at the keyless coordinator, before any signer is asked — per-device velocity is only a backstop; the real cap is global and counts only broadcast txns, so dropped PSBTs never burn budget.', 'c')
send({'refused': True, 'end': True}); return
reclaim_sink() # pull back funds from prior demo spends -> self-sustaining
us, bal = balance_sat()
if bal < amount_sat + 500:
log('the 2-of-3 wallet needs confirmed signet coins. Send some to:', 'y'); log(' ' + signet_addr(), 'a')
send({'end': True}); return
step(1)
if surge:
log('SURGE: building a %d-sat PSBT — TOTP-authorised tier 2 (cap %d, velocity %d)…' % (amount_sat, SURGE['max_sat'], SURGE['per_period_sat']), 'c')
elif over:
log('building a PSBT that EXCEEDS the automated tier: %d sats > the %d-sat cap (no TOTP)…' % (amount_sat, POLICY['max_sat']), 'c')
else:
log('building a PSBT within policy: %d sats (limit %d)…' % (amount_sat, POLICY['max_sat']), 'c')
if dest_kind == 'custom' and custom_addr:
dest = custom_addr # the signed-in user's authorised (whitelisted) address
log('paying your authorised address %s (on the whitelist)…' % custom_addr, 'c')
elif off_list:
dest = other_addr() # NOT on the on-device whitelist -> the HSM should refuse the payee
log('paying an address that is NOT on the policy whitelist — the signers should refuse it…', 'c')
else:
dest = sink_addr() # EXTERNAL to the 2-of-3 (whitelisted) -> real outflow, policy bites
# Seed the spend with the LARGEST UTXO. Coldcard's HSM rejects ANY PSBT carrying a warning, and "Big Fee"
# fires when the network fee exceeds 5% of total value. Core's default minimal coin-selection would grab a
# tiny fragmented UTXO (fee then ~6%+), so we pin the biggest UTXO as the seed input (add_inputs tops up if
# needed) -> fee is a fraction of a percent. fee_rate=1 for extra headroom. include_unsafe: spend our own
# 0-conf coins (sink-reclaim returns funds as untrusted 0-conf).
seed = max(us, key=lambda u: u['amount'])
try:
funded = json.loads(bc('walletcreatefundedpsbt',
json.dumps([{'txid': seed['txid'], 'vout': seed['vout']}]),
json.dumps([{dest: round(amount_btc, 8)}]), '0',
json.dumps({'fee_rate': 1, 'change_type': 'bech32',
'include_unsafe': True, 'add_inputs': True}), wallet=WALLET))
except RuntimeError as e:
m = str(e)
if 'Insufficient' in m or 'mempool-chain' in m or 'too-long' in m or 'too long' in m:
log('the demo wallet is briefly tied up in unconfirmed transactions — give the next signet block ~10 min, or top up the address.', 'y')
else:
log('build error: ' + m[:140], 'e')
send({'end': True}); return
psbt = b64decode(funded['psbt']); done(1)
step(2); log('coordinator fans the PSBT to signers %s (it holds no keys)…' % '+'.join(LET[n] for n in signers), 'c'); time.sleep(0.4); done(2)
step(3)
if surge: log('relaying your TOTP authorisation to each signer (the coordinator never holds the secret)…', 'c')
sigs = []; refusals = 0
for n in signers:
log('coldcard %s · evaluating against its on-device policy…' % LET[n], 'c')
kind, payload = sign_hsm(n, psbt, code)
if kind == 'signed':
sigs.append(payload); log('coldcard %s · signed ✓' % LET[n], 'g')
else:
refusals += 1; log('coldcard %s · REFUSED ✗ — %s' % (LET[n], payload), 'y')
if refusals:
log('policy held: the spend was blocked on-device. No transaction created.', 'y')
log('// the device refused to sign — exactly what a programmable HSM is for.', 'c')
send({'refused': True, 'end': True}); return
done(3)
step(4); fin = json.loads(bc('finalizepsbt', bc('combinepsbt', json.dumps(sigs))))
if not fin.get('complete'):
log('ERROR: signatures did not finalize', 'e'); send({'end': True}); return
log('combined 2 partial signatures · transaction complete ✓', 'g'); done(4)
step(5); txid = bc('sendrawtransaction', fin['hex'])
if not off_list: ledger_append(amount_sat, txid) # only real broadcasts count toward the global cap
log('broadcast to signet ✓ txid ' + txid, 'a')
log('watch it confirm live → mempool.space/signet/tx/' + txid, 'g')
if surge:
log('// TOTP-authorised surge: 2 of 3 signed a larger spend with the owner in the loop — still bounded by the on-device surge ceiling.', 'c')
else:
log('// two independent Coldcards moved real signet coins under policy — no human in the loop.', 'c')
done(5); send({'end': True})
# ---- streamed message signing (2-of-3 proof of control, no funds moved) ------
def sign_msg_one(n, text):
r = subprocess.run([CKCC, '-c', SOCK[n], 'msg', text, '-p', MSG_PATH], capture_output=True, text=True, timeout=45)
if r.returncode != 0:
return ('fail', (r.stderr.strip() or r.stdout.strip() or 'refused')[:90])
lines = [l.strip() for l in r.stdout.replace('\r', '\n').splitlines() if l.strip() and 'Waiting' not in l]
sig = lines[-1] if lines else ''
addr = lines[-2] if len(lines) >= 2 else ''
return ('signed', (addr, sig))
def sse_signmsg(send, text):
def log(t, cls=''): send({'log': t, 'cls': cls})
text = (text or '').strip()[:140] or 'mineracks 2-of-3 proof-of-control'
avail = [n for n in ALL if ENABLED[n] and is_active(n)]
if len(avail) < 2:
log('quorum lost · only %d signer(s) online — need 2 keyholders to attest.' % len(avail), 'y'); send({'end': True}); return
signers = avail[:2]
log('$ coordinator sign-message', 'c')
log('challenge: "%s"' % text, 'a')
log('asking %s to each sign it with their key in the 2-of-3 wallet (HSM · no human)…' % '+'.join(LET[n] for n in signers), 'c')
got = 0
for n in signers:
kind, payload = sign_msg_one(n, text)
if kind == 'signed':
addr, sig = payload; got += 1
log('coldcard %s attested ✓' % LET[n], 'g')
log(' address ' + addr, 'a')
log(' signature ' + sig, 'a')
else:
log('coldcard %s could not sign — %s' % (LET[n], payload), 'y')
if got >= 2:
log('2 of 3 keyholders signed the same challenge ✓ — verifiable proof of 2-of-3 control, no funds moved.', 'g')
log('// verify each signature against its address with any Bitcoin signed-message tool.', 'c')
send({'end': True})
# ---- http --------------------------------------------------------------------
def status_json():
try: _, bal = balance_sat()
except Exception: bal = -1
devs = {}
for n in ALL:
h = STATUS_CACHE[n]
devs[str(n)] = {'on': ENABLED[n], 'active': h['active'], 'approved': h['approved'], 'refused': h['refused']}
online = sum(1 for n in ALL if ENABLED[n] and devs[str(n)]['active'])
return {'balance_sat': bal, 'devices': devs, 'quorum_online': online, 'quorum_needed': 2,
'policy': {'max_sat': POLICY['max_sat'], 'per_period_sat': POLICY['per_period_sat'], 'period_min': POLICY['period_min']},
'fund_addr': signet_addr(), 'sink_addr': sink_addr(), 'authorized': list(EXTRA_WL),
'global': {'per_period_sat': GLOBAL['per_period_sat'], 'period_min': GLOBAL['period_min'], 'spent': global_spent()},
'surge': {'max_sat': SURGE['max_sat'], 'per_period_sat': SURGE['per_period_sat'],
'global_per_period_sat': SURGE_GLOBAL['per_period_sat']}}
class H(BaseHTTPRequestHandler):
def _json(self, obj, code=200, set_cookie=None):
b = json.dumps(obj).encode(); self.send_response(code)
self.send_header('Content-Type', 'application/json'); self.send_header('Cache-Control', 'no-cache')
if set_cookie: self.send_header('Set-Cookie', set_cookie)
self.send_header('Content-Length', str(len(b))); self.end_headers(); self.wfile.write(b)
def _uid(self): return read_session(self.headers.get('Cookie', ''))
def _sse(self):
self.send_response(200); self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache'); self.end_headers()
def send(obj): self.wfile.write(('data: %s\n\n' % json.dumps(obj)).encode()); self.wfile.flush()
return send
def do_GET(self):
u = urllib.parse.urlparse(self.path); p = u.path.rstrip('/'); q = urllib.parse.parse_qs(u.query)
if p in ('', '/status'):
try: self._json(status_json())
except Exception as e: self._json({'error': str(e)[:120]}, 500)
return
if p == '/auth/me':
uid = self._uid(); self._json({'user': uid, 'pubkey': PUBKEYS.get(uid) if uid else None}); return
if p == '/auth/nostr/challenge':
self._json({'challenge': make_challenge()}); return
if p == '/totp_enroll':
if not self._uid(): self._json({'error': 'sign in required'}, 401); return
sec = totp_secret()
self._json({'secret': sec,
'otpauth': 'otpauth://totp/multisigHSM:owner?secret=%s&issuer=multisigHSM&period=30&digits=6' % sec}); return
if p == '/device':
n = int(q.get('n', [0])[0]); on = q.get('on', ['1'])[0] == '1'
if n in ALL: ENABLED[n] = on
self._json({'ok': True, 'devices': status_json()['devices']}); return
if p == '/globalpolicy':
if q.get('reset', [''])[0] == '1':
RESET_TS[0] = int(time.time()); _TX_CACHE['txs'] = None # move the window start to now (demo)
try: open(LEDGER_FILE, 'w').write('[]')
except Exception: pass
try: GLOBAL['per_period_sat'] = max(546, min(10**10, int(q.get('per_period_sat', [GLOBAL['per_period_sat']])[0])))
except Exception: pass
try: GLOBAL['period_min'] = max(1, min(10080, int(q.get('period_min', [GLOBAL['period_min']])[0])))
except Exception: pass
self._json({'ok': True, 'global': status_json()['global']}); return
if p == '/policy':
try: POLICY['max_sat'] = max(546, min(100000000, int(q.get('max_sat', [POLICY['max_sat']])[0])))
except Exception: pass
try: POLICY['per_period_sat'] = max(546, min(1000000000, int(q.get('per_period_sat', [POLICY['per_period_sat']])[0])))
except Exception: pass
try: POLICY['period_min'] = max(1, min(1440, int(q.get('period_min', [POLICY['period_min']])[0])))
except Exception: pass
send = self._sse()
try:
with LOCK: rearm_all(lambda t, c='': send({'log': t, 'cls': c}))
send({'end': True})
except Exception as e:
try: send({'log': 'policy re-arm error: %s' % str(e)[:160], 'cls': 'e'}); send({'end': True})
except Exception: pass
return
if p == '/surgepolicy':
send = self._sse()
if not self._uid():
send({'log': 'sign in with Nostr to configure the TOTP surge tier.', 'cls': 'y'}); send({'end': True}); return
try: SURGE['max_sat'] = max(POLICY['max_sat'], min(10**9, int(q.get('max_sat', [SURGE['max_sat']])[0])))
except Exception: pass
try: SURGE['per_period_sat'] = max(SURGE['max_sat'], min(10**10, int(q.get('per_period_sat', [SURGE['per_period_sat']])[0])))
except Exception: pass
try: SURGE_GLOBAL['per_period_sat'] = max(GLOBAL['per_period_sat'], min(10**10, int(q.get('global_per_period_sat', [SURGE_GLOBAL['per_period_sat']])[0])))
except Exception: pass
try:
with LOCK: rearm_all(lambda t, c='': send({'log': t, 'cls': c}))
send({'log': 'surge tier set · TOTP unlocks ≤%d sats/txn · ≤%d sats/period' % (SURGE['max_sat'], SURGE['per_period_sat']), 'cls': 'g'})
send({'end': True})
except Exception as e:
try: send({'log': 'surge re-arm error: %s' % str(e)[:160], 'cls': 'e'}); send({'end': True})
except Exception: pass
return
if p == '/authorize':
send = self._sse()
try:
uid = self._uid()
if not uid:
send({'log': 'sign in with Nostr first to authorise your own address.', 'cls': 'y'}); send({'end': True}); return
addr = q.get('addr', [''])[0].strip()
try: valid = json.loads(bc('validateaddress', addr)).get('isvalid', False)
except Exception: valid = False
if not valid:
send({'log': 'that is not a valid signet address.', 'cls': 'y'}); send({'end': True}); return
with LOCK:
add_wl(addr)
send({'log': 'adding %s to the policy whitelist on all 3 signers…' % addr, 'cls': 'c'})
rearm_all(lambda t, c='': send({'log': t, 'cls': c}))
send({'log': 'your address is now whitelisted — you can pay it; anonymous users still cannot.', 'cls': 'g'})
send({'authorized': addr, 'end': True})
except Exception as e:
try: send({'log': 'authorize error: %s' % str(e)[:160], 'cls': 'e'}); send({'end': True})
except Exception: pass
return
if p == '/reboot':
send = self._sse()
try:
n = int(q.get('n', [0])[0])
if n not in ALL: send({'log': 'bad signer', 'cls': 'e'}); send({'end': True}); return
send({'log': 'rebooting signer %s — power-cycling the device (loses HSM mode)…' % LET[n], 'cls': 'c'}); send({'step': 0})
HEAL[n] = 0 # let auto-heal act immediately
subprocess.run(['bash', DEMO_RIG, str(n)], capture_output=True, timeout=120)
send({'log': 'signer %s back online but NOT signing-ready (fresh boot, no HSM) — quorum is now %d/3.' % (LET[n], _quorum()), 'cls': 'y'})
send({'log': 'the coordinator detects it and AUTO-HEALS — re-registering the wallet, re-enrolling, re-entering HSM, unattended…', 'cls': 'c'})
ok = False
for _ in range(45):
time.sleep(1.5)
if STATUS_CACHE[n]['active']: ok = True; break
if ok:
send({'log': 'auto-heal complete: signer %s is signing-ready again ✓ — quorum restored to %d/3.' % (LET[n], _quorum()), 'cls': 'g'})
send({'log': '// boot-to-signing-ready: no human re-armed it. A rebooted signer self-rejoins the quorum.', 'cls': 'c'})
else:
send({'log': 'signer %s not back yet — auto-heal keeps retrying every ~10s in the background.' % LET[n], 'cls': 'y'})
send({'end': True})
except Exception as e:
try: send({'log': 'reboot error: %s' % str(e)[:120], 'cls': 'e'}); send({'end': True})
except Exception: pass
return
if p in ('/spend', '/signmsg'):
send = self._sse()
try:
if p == '/spend':
try: amount = int(q.get('amount', [DEFAULT_SAT])[0])
except Exception: amount = DEFAULT_SAT
dk = q.get('dest', ['sink'])[0]
code = (q.get('code', [''])[0] or '').strip() or None
uid = self._uid()
if code and not uid:
send({'log': 'sign in with Nostr to use a TOTP surge authorisation.', 'cls': 'y'}); send({'end': True})
elif code and not (code.isdigit() and len(code) == 6):
send({'log': 'a TOTP code is 6 digits.', 'cls': 'y'}); send({'end': True})
elif dk == 'custom':
addr = q.get('addr', [''])[0].strip()
if not uid or not addr:
send({'log': 'sign in with Nostr and authorise an address first.', 'cls': 'y'}); send({'end': True})
else:
with LOCK: sse_spend(send, amount, 'custom', addr, code)
elif dk == 'other':
with LOCK: sse_spend(send, amount, 'other', None, code)
else:
with LOCK: sse_spend(send, amount, 'sink', None, code)
else:
with LOCK: sse_signmsg(send, q.get('text', [''])[0])
except Exception as e:
try: send({'log': 'ERROR: %s' % str(e)[:200], 'cls': 'e'}); send({'end': True})
except Exception: pass
return
self.send_response(404); self.end_headers()
def do_POST(self):
p = urllib.parse.urlparse(self.path).path.rstrip('/')
n = int(self.headers.get('Content-Length', 0) or 0)
body = self.rfile.read(n) if n else b''
if p == '/auth/nostr/verify':
try: ev = json.loads(body)
except Exception: return self._json({'error': 'bad json'}, 400)
try:
if nostr_event_id(ev) != ev['id']: return self._json({'error': 'bad event id'}, 400)
if not check_challenge(ev.get('content', '')): return self._json({'error': 'stale or invalid challenge'}, 401)
ok = schnorr_verify(bytes.fromhex(ev['id']), bytes.fromhex(ev['pubkey']), bytes.fromhex(ev['sig']))
except (KeyError, ValueError): return self._json({'error': 'malformed event'}, 400)
if not ok: return self._json({'error': 'signature verification failed'}, 401)
uid = uid_for('nostr', ev['pubkey']); PUBKEYS[uid] = ev['pubkey']
cookie = '%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax' % (COOKIE, make_session(uid), SESSION_TTL)
return self._json({'user': uid, 'pubkey': ev['pubkey']}, 200, cookie)
if p == '/auth/logout':
return self._json({'ok': True}, 200, '%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax' % COOKIE)
self.send_response(404); self.end_headers()
def log_message(self, *a): pass
def startup():
time.sleep(8)
try: ensure_wallet()
except Exception as e: print('wallet init:', e, flush=True)
with LOCK:
try: arm_all(lambda *a, **k: None)
except Exception as e: print('startup arm:', e, flush=True)
if __name__ == '__main__':
threading.Thread(target=startup, daemon=True).start()
threading.Thread(target=status_poller, daemon=True).start()
threading.Thread(target=heal_loop, daemon=True).start()
print('interactive signet orchestrator on :8099', flush=True)
ThreadingHTTPServer(('127.0.0.1', 8099), H).serve_forever()

1
reference/policy.json Normal file
View File

@ -0,0 +1 @@
{"must_log": true, "period": 60, "msg_paths": ["any"], "rules": [{"max_amount": 8000, "per_period": 500000, "wallet": "ckms23", "whitelist": ["REPLACE_WITH_YOUR_WHITELISTED_DEPOSIT_ADDRESS"]}, {"max_amount": 20000, "per_period": 200000, "wallet": "ckms23", "whitelist": ["REPLACE_WITH_YOUR_WHITELISTED_DEPOSIT_ADDRESS"], "users": ["owner"], "min_users": 1}]}

23
reference/setup_btc.sh Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
exec > /tmp/btcsetup.log 2>&1
set -x
cd ~
VER=27.0
if [ ! -x ~/bitcoin-$VER/bin/bitcoind ]; then
wget -q https://bitcoincore.org/bin/bitcoin-core-$VER/bitcoin-$VER-x86_64-linux-gnu.tar.gz
tar xzf bitcoin-$VER-x86_64-linux-gnu.tar.gz
fi
DATA=~/cksim/regtest-data; mkdir -p "$DATA"
cat > "$DATA/bitcoin.conf" <<CONF
regtest=1
server=1
fallbackfee=0.0001
[regtest]
rpcuser=ck
rpcpassword=ckms
rpcport=18999
CONF
~/bitcoin-$VER/bin/bitcoind -datadir="$DATA" -daemon
sleep 4
~/bitcoin-$VER/bin/bitcoin-cli -datadir="$DATA" -rpcport=18999 -rpcuser=ck -rpcpassword=ckms getblockchaininfo | head -5
echo "BTC_SETUP_DONE"

View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
exec > /tmp/cfd_setup.log 2>&1
set -x
export DEBIAN_FRONTEND=noninteractive
if ! command -v cloudflared >/dev/null; then
curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o /tmp/cfd.deb
sudo dpkg -i /tmp/cfd.deb || sudo apt-get install -f -y
fi
cloudflared --version
sudo mkdir -p /etc/cloudflared
sudo cp /tmp/ms_tunnel_token.env /etc/cloudflared/token.env
sudo chmod 600 /etc/cloudflared/token.env; sudo chown root:root /etc/cloudflared/token.env
rm -f /tmp/ms_tunnel_token.env
sudo tee /etc/systemd/system/cloudflared-multisighsm.service >/dev/null <<'UNIT'
[Unit]
Description=cloudflared tunnel multisighsm
After=network-online.target
Wants=network-online.target
[Service]
EnvironmentFile=/etc/cloudflared/token.env
ExecStart=/usr/bin/cloudflared tunnel --no-autoupdate run
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared-multisighsm
sleep 6
echo "active=$(sudo systemctl is-active cloudflared-multisighsm)"
echo CFD_SETUP_DONE

7
reference/setup_gui.sh Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
exec > /tmp/gui_setup.log 2>&1
set -x
export DEBIAN_FRONTEND=noninteractive
sudo apt-get install -y -qq x11vnc websockify novnc fonts-dejavu-core >/dev/null 2>&1
which x11vnc websockify; ls /usr/share/novnc/vnc.html 2>/dev/null || ls /usr/share/webapps/novnc 2>/dev/null || echo "novnc path?"
echo GUI_DEPS_DONE

35
reference/setup_web.sh Normal file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
exec > /tmp/web_setup.log 2>&1
set -x
export DEBIAN_FRONTEND=noninteractive
sudo apt-get install -y -qq nginx
sudo mkdir -p /var/www/multisighsm
sudo cp /tmp/index.html /var/www/multisighsm/index.html
sudo tee /etc/nginx/ws_params > /dev/null <<'WS'
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
WS
sudo tee /etc/nginx/sites-available/multisighsm > /dev/null <<'NGINX'
server {
listen 8090;
server_name multisig-hsm.example.com;
root /var/www/multisighsm;
index index.html;
location / { try_files $uri $uri/ =404; add_header Cache-Control "no-cache"; }
location /novnc/ { alias /usr/share/novnc/; }
location /ws/dev1 { proxy_pass http://127.0.0.1:6911/; include /etc/nginx/ws_params; }
location /ws/dev2 { proxy_pass http://127.0.0.1:6912/; include /etc/nginx/ws_params; }
location /ws/dev3 { proxy_pass http://127.0.0.1:6913/; include /etc/nginx/ws_params; }
location /api/ { proxy_pass http://127.0.0.1:8099/; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }
}
NGINX
sudo ln -sfn /etc/nginx/sites-available/multisighsm /etc/nginx/sites-enabled/multisighsm
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -w "page=%{http_code} " http://127.0.0.1:8090/
curl -s -o /dev/null -w "novnc=%{http_code}\n" http://127.0.0.1:8090/novnc/vnc_lite.html
echo WEB_SETUP_DONE

30
reference/sign_ms.py Normal file
View File

@ -0,0 +1,30 @@
import sys, io, time
from base64 import b64decode, b64encode
from ckcc.client import ColdcardDevice
from ckcc.protocol import CCProtocolPacker
from ckcc.cli import real_file_upload
sock, inp, outp = sys.argv[1], sys.argv[2], sys.argv[3]
psbt = b64decode(open(inp).read().strip())
dev = ColdcardDevice(sn=sock)
txn_len, sha = real_file_upload(io.BytesIO(psbt), dev)
ok = dev.send_recv(CCProtocolPacker.sign_transaction(txn_len, sha, flags=0x0), timeout=None)
assert ok is None, ('sign_transaction rejected', ok)
def story():
s = dev.send_recv(b'EXEC' + b"RV.write(repr(sim_display.story))", encrypt=False)
return (s.decode() if isinstance(s,(bytes,bytearray)) else s)
print('STORY:', story()[:500])
done = None
for i in range(50):
try: dev.send_recv(CCProtocolPacker.sim_keypress(b'y'))
except Exception as e: pass
time.sleep(0.4)
d = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
if d is not None:
done = d; break
if not done:
print('NO RESULT after polling. STORY now:', story()[:500]); sys.exit(1)
result_len, result_sha = done
result = dev.download_file(result_len, result_sha, file_number=1)
open(outp,'wb').write(b64encode(result))
print('SIGNED_OK bytes=%d -> %s' % (result_len, outp))

View File

@ -0,0 +1,31 @@
import subprocess, json, os, time
V='27.0'; SDATA=os.path.expanduser('~/cksim/signet-data')
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
def bc(*a, wallet=None):
base=[CLI,'-datadir=%s'%SDATA,'-rpcport=38999','-rpcuser=ck','-rpcpassword=ckms']
if wallet: base.append('-rpcwallet=%s'%wallet)
r=subprocess.run(base+list(a),capture_output=True,text=True)
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()[:160]))
return r.stdout.strip()
for _ in range(40):
try: bc('getblockchaininfo'); break
except SystemExit: time.sleep(2)
TPUB={'00000001':'tpubREPLACE_WITH_YOUR_SIGNER_1_XPUB',
'00000002':'tpubREPLACE_WITH_YOUR_SIGNER_2_XPUB',
'00000003':'tpubREPLACE_WITH_YOUR_SIGNER_3_XPUB'}
def desc(b):
keys=','.join('[%s/48h/1h/0h/2h]%s/%d/*'%(x,p,b) for x,p in TPUB.items())
return 'wsh(sortedmulti(2,%s))'%keys
def ck(d): return json.loads(bc('getdescriptorinfo',d))['descriptor']
rcv,chg=ck(desc(0)),ck(desc(1))
wl=json.loads(bc('listwallets'))
if 'ckms23-signet' not in wl:
try: bc('createwallet','ckms23-signet','true','true','','false','true')
except SystemExit: bc('loadwallet','ckms23-signet')
bc('importdescriptors', json.dumps([{'desc':rcv,'active':True,'internal':False,'timestamp':'now','range':[0,30]},
{'desc':chg,'active':True,'internal':True,'timestamp':'now','range':[0,30]}]), wallet='ckms23-signet')
addr=bc('getnewaddress','','bech32',wallet='ckms23-signet')
open(os.path.expanduser('~/cksim/signet_addr.txt'),'w').write(addr+'\n')
print('SIGNET_RECEIVE_ADDRESS', addr)
bci=json.loads(bc('getblockchaininfo'))
print('SYNC blocks=%s headers=%s progress=%.4f'%(bci['blocks'],bci['headers'],bci['verificationprogress']))

23
reference/start_rig.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Start 3 headless Coldcard Mk5 sims, each with a distinct seed + isolated state + stable socket.
set -uo pipefail
export PATH="$HOME/gccshim:$PATH"
RUN=~/cksim
cd ~/coldcard-firmware/unix
pkill -f "simulator.py --mk5" 2>/dev/null || true; sleep 2
rm -f /tmp/ckcc-simulator-*.sock /tmp/cksim-*.sock
: > "$RUN/sims.manifest"
for n in 1 2 3; do
SEED=$(cat "$RUN/seeds/$n.txt")
setsid ~/ccsim-venv/bin/python simulator.py --mk5 --headless --segregate --seed "$SEED" \
>"$RUN/sim-$n.log" 2>&1 </dev/null &
for i in $(seq 1 25); do
cnt=$(ls /tmp/ckcc-simulator-*.sock 2>/dev/null | wc -l)
[ "$cnt" -ge "$n" ] && break; sleep 1
done
newsock=$(ls -t /tmp/ckcc-simulator-*.sock 2>/dev/null | head -1)
ln -sf "$newsock" "/tmp/cksim-$n.sock"
echo "$n $newsock /tmp/cksim-$n.sock" >> "$RUN/sims.manifest"
done
echo "=== manifest ==="; cat "$RUN/sims.manifest"
echo "=== stable sockets ==="; ls -l /tmp/cksim-*.sock 2>/dev/null

13
reference/wstest.py Normal file
View File

@ -0,0 +1,13 @@
import socket, base64, os
def test(port):
try:
s=socket.create_connection(('127.0.0.1',port),timeout=6)
key=base64.b64encode(os.urandom(16)).decode()
req=("GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\n\r\n")%key
s.sendall(req.encode())
resp=s.recv(200); s.close()
return resp.split(b"\r\n")[0].decode(errors="replace")
except Exception as e: return "ERR %s"%(repr(e)[:80])
for n,p in ((1,6911),(2,6912),(3,6913)):
print("dev%d port%d -> %s"%(n,p,test(p)))