commit 7a17ffd12ee869ccde55eaf20dab4442043ca484 Author: mineracks Date: Fri Jun 26 13:56:51 2026 +1000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98d0078 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7f56ac --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df12863 --- /dev/null +++ b/README.md @@ -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). diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..00ce835 --- /dev/null +++ b/ansible/README.md @@ -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. diff --git a/ansible/signer-host.yml b/ansible/signer-host.yml new file mode 100644 index 0000000..460f5e1 --- /dev/null +++ b/ansible/signer-host.yml @@ -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 diff --git a/config.example.env b/config.example.env new file mode 100644 index 0000000..7b98481 --- /dev/null +++ b/config.example.env @@ -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/ diff --git a/docs/OPERATOR-MANUAL.md b/docs/OPERATOR-MANUAL.md new file mode 100644 index 0000000..5dcd2bc --- /dev/null +++ b/docs/OPERATOR-MANUAL.md @@ -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 +hundreds–thousands 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 ` 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": ["", "<...>"] } + ] +} +``` +(`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, )` 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. + +--- + diff --git a/docs/QUICK-START.md b/docs/QUICK-START.md new file mode 100644 index 0000000..8a494f7 --- /dev/null +++ b/docs/QUICK-START.md @@ -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). diff --git a/reference/build_wallet.py b/reference/build_wallet.py new file mode 100644 index 0000000..77a4322 --- /dev/null +++ b/reference/build_wallet.py @@ -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') diff --git a/reference/demo_rig.sh b/reference/demo_rig.sh new file mode 100755 index 0000000..bc8f829 --- /dev/null +++ b/reference/demo_rig.sh @@ -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 /tmp/ws$n.log 2>&1 & + echo "$n $sock /tmp/cksim-$n.sock :$DISP $WS" +done +echo DEMO_RIG_UP diff --git a/reference/diag_hsm.py b/reference/diag_hsm.py new file mode 100644 index 0000000..bc6685d --- /dev/null +++ b/reference/diag_hsm.py @@ -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')) diff --git a/reference/enable_hsm.py b/reference/enable_hsm.py new file mode 100644 index 0000000..ba113d6 --- /dev/null +++ b/reference/enable_hsm.py @@ -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())) diff --git a/reference/enroll.py b/reference/enroll.py new file mode 100644 index 0000000..f3a3edf --- /dev/null +++ b/reference/enroll.py @@ -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]) diff --git a/reference/finish.py b/reference/finish.py new file mode 100644 index 0000000..d8cd198 --- /dev/null +++ b/reference/finish.py @@ -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')) diff --git a/reference/gui_sim.sh b/reference/gui_sim.sh new file mode 100644 index 0000000..81df1fd --- /dev/null +++ b/reference/gui_sim.sh @@ -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 /tmp/ws$N.log 2>&1 & +echo "GUI_SIM_DONE disp=$DISP vnc=$VNC ws=$WS" diff --git a/reference/hsm_2of3.py b/reference/hsm_2of3.py new file mode 100644 index 0000000..bc3ab7c --- /dev/null +++ b/reference/hsm_2of3.py @@ -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')) diff --git a/reference/hsm_autosign.py b/reference/hsm_autosign.py new file mode 100644 index 0000000..65ee493 --- /dev/null +++ b/reference/hsm_autosign.py @@ -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')) diff --git a/reference/hsm_finish.py b/reference/hsm_finish.py new file mode 100644 index 0000000..b57c254 --- /dev/null +++ b/reference/hsm_finish.py @@ -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')) diff --git a/reference/orchestrator.py b/reference/orchestrator.py new file mode 100644 index 0000000..66a2144 --- /dev/null +++ b/reference/orchestrator.py @@ -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() diff --git a/reference/policy.json b/reference/policy.json new file mode 100644 index 0000000..6ea7de3 --- /dev/null +++ b/reference/policy.json @@ -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}]} \ No newline at end of file diff --git a/reference/setup_btc.sh b/reference/setup_btc.sh new file mode 100644 index 0000000..6ab9b9f --- /dev/null +++ b/reference/setup_btc.sh @@ -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" < /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 diff --git a/reference/setup_gui.sh b/reference/setup_gui.sh new file mode 100644 index 0000000..bb8d622 --- /dev/null +++ b/reference/setup_gui.sh @@ -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 diff --git a/reference/setup_web.sh b/reference/setup_web.sh new file mode 100644 index 0000000..088c87e --- /dev/null +++ b/reference/setup_web.sh @@ -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 diff --git a/reference/sign_ms.py b/reference/sign_ms.py new file mode 100644 index 0000000..4539419 --- /dev/null +++ b/reference/sign_ms.py @@ -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)) diff --git a/reference/signet_wallet.py b/reference/signet_wallet.py new file mode 100644 index 0000000..90d5a2b --- /dev/null +++ b/reference/signet_wallet.py @@ -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'])) diff --git a/reference/start_rig.sh b/reference/start_rig.sh new file mode 100755 index 0000000..a0ac099 --- /dev/null +++ b/reference/start_rig.sh @@ -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 | 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 diff --git a/reference/wstest.py b/reference/wstest.py new file mode 100644 index 0000000..03201d2 --- /dev/null +++ b/reference/wstest.py @@ -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)))