Initial public release — multisig HSM reference + recipe book
Open-source 2-of-3 policy-enforced threshold HSM: auto-signs cold→hot treasury refills under on-device Coldcard policy, no human in the loop. Includes the full operator manual + quick-start, the reference coordinator/signing code, and a signer-host bootstrap. No keys, seeds, or secrets — placeholders only. Live signet demo: https://multisighsm.mineracks.com Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
7a17ffd12e
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Key material, addresses, and runtime artifacts — NEVER commit these.
|
||||||
|
*.secret
|
||||||
|
seeds/
|
||||||
|
*.seed
|
||||||
|
*_addr.txt
|
||||||
|
signet_addr.txt
|
||||||
|
sink_addr.txt
|
||||||
|
*.psbt
|
||||||
|
*.log
|
||||||
|
*.manifest
|
||||||
|
spend_ledger.json
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Mineracks Pty Ltd
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
87
README.md
Normal file
87
README.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Multisig HSM — a distributed, policy-enforced 2-of-3 threshold signer
|
||||||
|
|
||||||
|
**An automated treasury tier that signs its own cold→hot refills under on-device policy — with no human
|
||||||
|
in the loop, and no single machine able to move a satoshi.**
|
||||||
|
|
||||||
|
Three hardware signers (Coldcards in HSM mode), each on a separate host — ideally a separate site. A
|
||||||
|
**keyless** coordinator builds the refill transaction and fans it to any two of the three. Each device checks
|
||||||
|
the transaction against **its own on-device spending policy** (per-transaction cap, velocity limit, address
|
||||||
|
whitelist) and auto-signs or refuses. The signed refill broadcasts to your hot wallet. Lose any one signer
|
||||||
|
and refills carry on; lose two and funds simply freeze — safe by design.
|
||||||
|
|
||||||
|
The novel combination is **threshold + per-device programmable policy + unattended auto-signing** — an
|
||||||
|
automated treasury with cryptographic, physically-distributed guardrails, where the coordinator holds no keys
|
||||||
|
and the hardware is the final authority.
|
||||||
|
|
||||||
|
> 🔭 **Live reference deployment:** a working 2-of-3 runs on real Bitcoin **signet** at
|
||||||
|
> **[multisighsm.mineracks.com](https://multisighsm.mineracks.com)** — every spend is a genuine on-chain
|
||||||
|
> co-signature, and every policy decision is inspectable on a public explorer. *Simulators + signet only;
|
||||||
|
> never a real seed, no real funds.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two ways to use this
|
||||||
|
|
||||||
|
### 1 · Run it yourself — open source
|
||||||
|
Everything you need to build and operate this is in here: the **full recipe book**
|
||||||
|
([`docs/OPERATOR-MANUAL.md`](docs/OPERATOR-MANUAL.md) + [`docs/QUICK-START.md`](docs/QUICK-START.md)), the
|
||||||
|
reference coordinator + signing code ([`reference/`](reference/)), and a signer-host bootstrap
|
||||||
|
([`ansible/`](ansible/)). MIT-licensed — fork it, run it, harden it.
|
||||||
|
|
||||||
|
If it's useful to you, **support the project with sats** ⚡ — Lightning address
|
||||||
|
**`multisighsm@mineracks.com`** (self-hosted, no middleman). Thank you.
|
||||||
|
|
||||||
|
### 2 · Get a helping hand — mineracks consultancy
|
||||||
|
Standing this up for a **business treasury, exchange, or fund** and want it done right? **mineracks** offers
|
||||||
|
guided design + execution: failure-domain placement, policy/velocity sizing, the signer-agent + coordinator
|
||||||
|
deployment, backup/DR rehearsal, and an operations runbook tailored to you.
|
||||||
|
|
||||||
|
> **What we do *not* do — by design:**
|
||||||
|
> - **We do not supply the Coldcards.** You buy your own hardware, direct from the manufacturer.
|
||||||
|
> - **We never see, hold, or generate your keys or seeds.** They are created on *your* devices, by *you*,
|
||||||
|
> and never leave their secure elements. Our work is the architecture, policy, and operations *around*
|
||||||
|
> your hardware — never the key material itself.
|
||||||
|
|
||||||
|
📧 **[hello@mineracks.com](mailto:hello@mineracks.com?subject=Multisig%20HSM%20consultancy)** ·
|
||||||
|
📅 **book a call: [calendar.mineracks.com](https://calendar.mineracks.com)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's in here
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/ OPERATOR-MANUAL.md — the full security model + setup + operations guide
|
||||||
|
QUICK-START.md — the one-page provisioning + on-call checklist
|
||||||
|
reference/ The coordinator (orchestrator.py) + wallet/HSM/signing scripts and the
|
||||||
|
simulator demo rig — the working reference behind the live signet demo
|
||||||
|
ansible/ Signer-host bootstrap skeleton (Tailscale + ckcc-protocol + signer agent)
|
||||||
|
config.example.env Copy to .env and fill in your node RPC details
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start (read the manual first)
|
||||||
|
1. Read [`docs/OPERATOR-MANUAL.md`](docs/OPERATOR-MANUAL.md) §1–§3 — the **safety model is not optional
|
||||||
|
context**; it is the reason the design is shaped the way it is.
|
||||||
|
2. **Do the whole provisioning run on signet/testnet first.** Only move to mainnet after you have rehearsed
|
||||||
|
a refill, a failover, a policy change, and a restore-from-backup.
|
||||||
|
3. `cp config.example.env .env` and set your watch-only node's RPC host/credentials.
|
||||||
|
4. Build the `wsh(sortedmulti(2,k1,k2,k3))` descriptor from **your own** three signer xpubs, register it on
|
||||||
|
each Coldcard, load each device's HSM policy, and start the coordinator.
|
||||||
|
|
||||||
|
## You bring
|
||||||
|
- **3× Coldcard Mk5** (or Q) — bought by you, direct from Coinkite. HSM mode + multisig co-signing are
|
||||||
|
supported in firmware.
|
||||||
|
- **3 distinct seeds**, generated on the devices, steel-backed, geographically separated. Never in this repo,
|
||||||
|
never on the coordinator.
|
||||||
|
- A watch-only-capable **Bitcoin full node** (descriptor wallets).
|
||||||
|
|
||||||
|
## Security & disclaimers
|
||||||
|
- This repository contains **no keys, no seeds, and no secrets.** The reference code reads any secret material
|
||||||
|
from local files / environment at runtime (see [`.gitignore`](.gitignore)); the placeholders
|
||||||
|
(`tpubREPLACE_WITH_YOUR_SIGNER_n_XPUB`, the policy whitelist address) **must** be replaced with your own.
|
||||||
|
- The reference deployment uses Coldcard **firmware simulators** on **signet** — for functional validation,
|
||||||
|
not custody. **Never hold mainnet value on a single host running more than one signer.**
|
||||||
|
- *Coldcard® is a trademark of Coinkite. This is an independent project, not affiliated with or endorsed by
|
||||||
|
Coinkite.* Built by **[mineracks](https://mineracks.com)**.
|
||||||
|
|
||||||
|
## License
|
||||||
|
[MIT](LICENSE).
|
||||||
28
ansible/README.md
Normal file
28
ansible/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Signer-host bootstrap (Ansible)
|
||||||
|
|
||||||
|
A **starting-point** playbook for provisioning the three **signer hosts** — the small machines (NUC / Pi /
|
||||||
|
mini-PC) that each have a USB-attached Coldcard and run a thin **signer agent** over `ckcc-protocol`.
|
||||||
|
|
||||||
|
This bootstraps the *host*, not the device: it installs the prerequisites, joins the private network, and
|
||||||
|
lays down a signer-agent service unit. **It never touches keys or seeds** — those are created on the Coldcard
|
||||||
|
itself, by you. Treat this as a skeleton to adapt to your environment (it is intentionally minimal and not
|
||||||
|
opinionated about your network).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
- Installs Python + `ckcc-protocol` (the Coldcard CLI/library) and USB access for the device.
|
||||||
|
- Joins the host to your private mesh (Tailscale shown as an example — swap for your VPN/WG).
|
||||||
|
- Installs a `signer-agent` systemd unit (placeholder ExecStart — point it at your agent).
|
||||||
|
|
||||||
|
## What it deliberately does NOT do
|
||||||
|
- It does not generate, copy, or read any seed or key.
|
||||||
|
- It does not configure the on-device HSM policy (that is loaded onto the Coldcard directly — see
|
||||||
|
[`../docs/OPERATOR-MANUAL.md`](../docs/OPERATOR-MANUAL.md) §5, and mind the policy-vs-wallet ordering trap).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```bash
|
||||||
|
# inventory.ini: one host per signer, in independent failure domains (≥1 offsite)
|
||||||
|
ansible-playbook -i inventory.ini signer-host.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Failure-domain placement is the make-or-break decision** — read §3 of the Operator Manual before you
|
||||||
|
> place signers. Never co-locate two signers behind a shared PSU / switch / host / hypervisor.
|
||||||
68
ansible/signer-host.yml
Normal file
68
ansible/signer-host.yml
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
# Signer-host bootstrap — see ansible/README.md. Minimal, adapt to your environment.
|
||||||
|
# Never handles key material: seeds live on the Coldcard, created by you.
|
||||||
|
- name: Bootstrap a multisig-HSM signer host
|
||||||
|
hosts: signers
|
||||||
|
become: true
|
||||||
|
vars:
|
||||||
|
signer_user: signer
|
||||||
|
# Point this at your built signer agent (a thin authenticated service wrapping ckcc-protocol).
|
||||||
|
signer_agent_exec: "/usr/bin/python3 /opt/signer-agent/agent.py"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Install prerequisites
|
||||||
|
ansible.builtin.package:
|
||||||
|
name:
|
||||||
|
- python3
|
||||||
|
- python3-pip
|
||||||
|
- python3-venv
|
||||||
|
- libusb-1.0-0 # Coldcard USB access
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Install ckcc-protocol (Coldcard CLI / library)
|
||||||
|
ansible.builtin.pip:
|
||||||
|
name: ckcc-protocol
|
||||||
|
virtualenv: /opt/ckcc-venv
|
||||||
|
virtualenv_command: python3 -m venv
|
||||||
|
|
||||||
|
# udev rule so the signer user can talk to the Coldcard over USB (vendor 0d2b).
|
||||||
|
- name: Coldcard udev rule
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/udev/rules.d/51-coinkite.rules
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
SUBSYSTEMS=="usb", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0660"
|
||||||
|
notify: reload udev
|
||||||
|
|
||||||
|
# Example mesh join — swap Tailscale for your own VPN/WireGuard. Provide your own auth key out-of-band.
|
||||||
|
- name: Join private mesh (Tailscale example)
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >
|
||||||
|
Install your mesh VPN here (e.g. Tailscale/WireGuard) so the coordinator can reach this signer
|
||||||
|
agent privately. Signer agents are RPC *clients* — they do not bind a public port.
|
||||||
|
|
||||||
|
- name: Install signer-agent service
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/signer-agent.service
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Multisig-HSM signer agent (wraps ckcc-protocol; holds no keys)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart={{ signer_agent_exec }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
notify: reload systemd
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: reload udev
|
||||||
|
ansible.builtin.command: udevadm control --reload-rules
|
||||||
|
- name: reload systemd
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
14
config.example.env
Normal file
14
config.example.env
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Copy to .env and fill in. NEVER commit your real .env (see .gitignore).
|
||||||
|
#
|
||||||
|
# Watch-only Bitcoin node the coordinator uses to build PSBTs and broadcast.
|
||||||
|
# Use a watch-only-capable full node with descriptor wallets. Mainnet for
|
||||||
|
# production; signet/testnet for the lab. The coordinator holds NO keys — it
|
||||||
|
# only talks to this node and fans PSBTs to the signer agents.
|
||||||
|
|
||||||
|
RPC_HOST=127.0.0.1
|
||||||
|
RPC_PORT=38332
|
||||||
|
RPC_USER=rpcuser
|
||||||
|
RPC_PASS=
|
||||||
|
|
||||||
|
# Explorer base for the "watch it confirm" link (optional).
|
||||||
|
EXPLORER_TX_URL=https://mempool.space/signet/tx/
|
||||||
385
docs/OPERATOR-MANUAL.md
Normal file
385
docs/OPERATOR-MANUAL.md
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
# Operator's Manual — mineracks distributed policy-enforced multisig HSM
|
||||||
|
|
||||||
|
**A 2-of-3 threshold HSM that auto-signs cold→hot refills under on-device policy, with no human in the loop.**
|
||||||
|
|
||||||
|
This manual is everything you need to set the system up and **operate it safely**. Read §1–§3 before you
|
||||||
|
touch anything — the safety model is not optional context, it is the reason the design is shaped the way it
|
||||||
|
is, and operating it without understanding it will get funds lost.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Status** | A **live reference deployment** runs at `multisighsm.mineracks.com` on real Bitcoin signet — every spend is a genuine on-chain 2-of-3 co-signature enforced by on-device policy. This manual documents both the **reference configuration** (**[REF]**) and the **production hardware procedure** (**[PROD]**). |
|
||||||
|
| **Audience** | The operator running the treasury / refill tier (you), and a reviewer evaluating it. |
|
||||||
|
| **Companion docs** | Design & market rationale: [`README.md`](./README.md) · live demo: `multisighsm.mineracks.com` |
|
||||||
|
| **Scope** | The **cold/warm tier and the cold→hot refill pipe** — low-throughput, high-stakes. **NOT** your high-TPS hot-wallet signer (that's MPC's job — see §1.2). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Read this first — the security model
|
||||||
|
|
||||||
|
### 1.1 What the system is
|
||||||
|
|
||||||
|
Three independent **Coldcard** hardware signers, each in **HSM mode** under its own spending policy, each on a
|
||||||
|
**physically separate host** (ideally one genuinely offsite). A **keyless coordinator** watches your hot
|
||||||
|
wallet, builds a refill transaction when it runs low, fans the unsigned PSBT to **any two** of the three
|
||||||
|
signers, they **auto-sign under their on-device policy with no human**, and the coordinator combines,
|
||||||
|
finalizes and broadcasts. Any 2 of 3 can move funds; lose any one signer and you keep operating; lose two and
|
||||||
|
funds are **frozen safe**.
|
||||||
|
|
||||||
|
### 1.2 What it is NOT
|
||||||
|
|
||||||
|
It is **not a hot-wallet signing engine.** Coldcard signing is seconds-per-PSBT and is not built for the
|
||||||
|
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 <wallet.txt>` to each device (confirm on-device).
|
||||||
|
|
||||||
|
### Step 4 — Author the HSM policy (per device)
|
||||||
|
Policy is **JSON, versioned in this repo**, deployed to each device. Minimum viable policy (see §6 for the
|
||||||
|
full reference and the diverse-policy option):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"must_log": true,
|
||||||
|
"period": 60,
|
||||||
|
"msg_paths": ["any"],
|
||||||
|
"rules": [
|
||||||
|
{ "max_amount": 8000,
|
||||||
|
"per_period": 33333,
|
||||||
|
"wallet": "ckms23",
|
||||||
|
"whitelist": ["<hot-wallet deposit address 1>", "<...>"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`per_period` here is the **per-device** ceiling `V`; see §6.3 for why `V = ⅔ × global cap`.)
|
||||||
|
|
||||||
|
### Step 4b — Enrol the TOTP "owner" (only if you use the surge tier)
|
||||||
|
If your policy has a TOTP-gated **surge tier** (§6.2b — a rule with `users:["owner"], min_users:1`), enrol the
|
||||||
|
`owner` TOTP user on **each** Coldcard **before** loading the policy. All three signers must hold the **same**
|
||||||
|
secret (one authenticator code has to work for whichever two devices sign), so the device-picks-its-own-QR
|
||||||
|
path does **not** apply here — a shared secret has to be generated once and loaded onto all three.
|
||||||
|
|
||||||
|
> 🔑 **Who generates it matters (production vs demo).** **Production:** *the owner* generates the secret
|
||||||
|
> (in their authenticator app, or offline) and loads it onto each Coldcard **directly over USB during setup** —
|
||||||
|
> **never through the coordinator**. Then a fully-compromised coordinator can't mint surge codes; at spend time
|
||||||
|
> it only **relays** the owner's live 6-digit code (it never needs the secret). **Demo only:** the coordinator
|
||||||
|
> generates the secret + shows the enrolment QR for convenience — acceptable on signet with no real funds, but
|
||||||
|
> it raises a coordinator-compromise blast radius from tier-1 up to the surge ceiling, so don't do it in prod.
|
||||||
|
|
||||||
|
Mechanically: `create_user("owner", USER_AUTH_TOTP, <shared-secret>)` on each device (standard RFC-6238, so any
|
||||||
|
authenticator app works). The secret must persist so re-arming a rebooted signer re-enrols the same value.
|
||||||
|
|
||||||
|
### Step 5 — Load policy + start HSM mode (MIND THE ORDERING)
|
||||||
|
> 🪤 **Sharp edge (ckbunker issue #12):** loading an HSM policy can *delete a registered multisig wallet* on
|
||||||
|
> the Coldcard. **Order: register the multisig wallet (Step 3) → enrol the TOTP user (Step 4b, if any) → load
|
||||||
|
> the policy → verify the wallet still exists on the device.** Re-check `hsm_status` and the registered-wallet
|
||||||
|
> count after every policy load.
|
||||||
|
|
||||||
|
Load the policy and enter HSM mode on each device (the on-device approval is two-step: confirm, then a random
|
||||||
|
digit to save). HSM mode is a **one-way trip** until reboot — design for it.
|
||||||
|
|
||||||
|
### Step 6 — Configure the coordinator
|
||||||
|
- **Global velocity cap `G`** — the authoritative total-per-period (chain-derived; §6.2).
|
||||||
|
- **Per-device ceiling `V = ⅔ × G`** in each device policy (§6.3), and enable **round-robin** of the signer
|
||||||
|
pair so rotation stays even.
|
||||||
|
- **Whitelist** = your hot-wallet deposit addresses (anonymous/untrusted callers can never add to it).
|
||||||
|
- **Refill trigger** — the hot-wallet floor that starts a refill, and the refill amount.
|
||||||
|
- The coordinator's HMAC session secret lives **only on the host** (`chmod 600`, **never in git**).
|
||||||
|
|
||||||
|
### Step 7 — Verify before funding
|
||||||
|
Quorum check: **≥2 of 3** signers online **and** HSM-active **and** wallet-registered. Then, on signet/testnet:
|
||||||
|
run a within-policy refill (expect sign+broadcast), an over-cap spend (expect on-device refusal), an off-list
|
||||||
|
spend (expect refusal), and a velocity-exceeding burst (expect the coordinator to block at `G`). Confirm the
|
||||||
|
chain-derived counter increments on broadcast and the on-device refusal reasons read correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Policy reference
|
||||||
|
|
||||||
|
### 6.1 The four on-device gates (tamper-proof, per device)
|
||||||
|
| Gate | Policy field | What it does | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Per-txn cap | `max_amount` | refuses any single spend over the cap | sats |
|
||||||
|
| Velocity | `per_period` + top-level `period` | refuses once this device's signed total in the window exceeds it | **per-device**, counted locally — see §6.3 |
|
||||||
|
| Whitelist | `whitelist: [addr…]` | external outputs must be on this list; change back to `wallet` is exempt | the strongest control — confines *where* funds can go |
|
||||||
|
| Message paths | `msg_paths` | which derivation paths may sign messages (`["any"]` or specific) | proof-of-control without moving funds |
|
||||||
|
| (binding) | `wallet` | names the registered multisig so change is recognised as internal | required, or change trips the whitelist |
|
||||||
|
| (audit) | `must_log: true` | device writes a log entry per decision | feed into monitoring (§8) |
|
||||||
|
|
||||||
|
### 6.2 The coordinator global velocity cap (the authoritative limit)
|
||||||
|
On-device velocity counters **drift** under a rotating "any 2 of 3" (each device only counts what *it* signed)
|
||||||
|
and a device decrements its counter the moment it signs **even if the PSBT is later dropped and never
|
||||||
|
broadcast**. So no single device's counter reflects true global outflow. The coordinator therefore enforces
|
||||||
|
the real cap, and:
|
||||||
|
- it counts **only real broadcasts** — so dropped/refused PSBTs never burn budget;
|
||||||
|
- it **includes mempool (0-conf) sends** — they're counted the instant they broadcast, so a burst of spends
|
||||||
|
inside one ~10-min block interval can't slip past (abandoned/conflicted txns are excluded so a dropped tx
|
||||||
|
doesn't permanently burn budget);
|
||||||
|
- it is **chain-derived** — computed from the watch-only wallet's on-chain `send` total over the window
|
||||||
|
(`listtransactions`), not a single-host file, so any replica recomputes it and there is no ledger SPOF.
|
||||||
|
|
||||||
|
### 6.2b Surge tier — a TOTP-gated higher tier (human in the loop)
|
||||||
|
For occasional large moves, add a **second, ordered rule** that permits a higher `max_amount` + `per_period`
|
||||||
|
(and/or extra whitelist) but requires the owner's **TOTP** (`users:["owner"], min_users:1`). Coldcard
|
||||||
|
evaluates rules **first-match**, so routine spends match the automated tier-1 and a larger one falls through to
|
||||||
|
tier-2 and is refused unless a valid code is presented. **Secure model:** the owner reads the 6-digit code from
|
||||||
|
a normal authenticator app (standard RFC-6238); the **keyless coordinator only relays it** (`user_auth`) — the
|
||||||
|
secret lives on the devices + the owner's app, never on the coordinator, so a compromised coordinator can't
|
||||||
|
mint codes. The coordinator's global cap gets a matching surge ceiling. **Sizing:** the surge ceilings raise
|
||||||
|
the §1.4 blast-radius bound *when a code is present* — set them to the most you'd ever authorise in one
|
||||||
|
human-approved move, and treat entering a code as approving a spend up to the surge cap. *(Live for signed-in
|
||||||
|
users on the reference deployment; exercised end-to-end on real signet.)*
|
||||||
|
|
||||||
|
### 6.3 Sizing — the 1.5× rule (do not skip this)
|
||||||
|
A compromised coordinator that ignores `G` is bounded by the hardware ceilings. With any-2-of-3, worst-case
|
||||||
|
extractable = **`1.5 × V`** (each spend burns two signatures for one unit of value; `3V ÷ 2`). Therefore:
|
||||||
|
|
||||||
|
- **Set each device's `per_period` `V = ⅔ × G`.** Then worst-case `1.5 × ⅔G = G` — the hardware bound equals
|
||||||
|
your intended global velocity.
|
||||||
|
- **Round-robin the signer pair** so rotation is even, otherwise busy devices hit `⅔G` early and honest
|
||||||
|
refills stall (raising `V` for liveness margin pushes the worst case back above `G`).
|
||||||
|
- **Topology dial:**
|
||||||
|
- *any-2-of-3 auto-signers* → worst case **1.5×**, but "lose any one, keep running" unattended.
|
||||||
|
- *2 fixed auto-signers + 1 human break-glass (offline)* → every routine spend needs both online devices, so
|
||||||
|
`V ≥ G`, but the offline key can't be used unattended → worst case **1.0×**, at the cost of unattended
|
||||||
|
failover. Choose deliberately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Day-to-day operations
|
||||||
|
|
||||||
|
**Automated refill (the normal path, no human):** hot wallet drops below the floor → coordinator builds a PSBT
|
||||||
|
to a whitelisted address from the watch-only wallet → checks the global cap → fans to 2 of 3 → devices
|
||||||
|
auto-sign under policy → combine → broadcast. Watch it confirm on your explorer.
|
||||||
|
|
||||||
|
**Change a policy value (cap / velocity / period):** edit the versioned policy, push to each device, **re-arm**
|
||||||
|
all signers. Re-arming restarts the HSM session and **resets the per-device velocity counters** — expected.
|
||||||
|
Re-verify the registered wallet survived (§5 Step 5 trap).
|
||||||
|
|
||||||
|
**Add a whitelisted destination:** add the address to the policy `whitelist`, push, re-arm. Only the operator
|
||||||
|
can do this; untrusted callers can never extend the whitelist.
|
||||||
|
|
||||||
|
**Take a signer down for maintenance:** quorum tolerates **one** down — the other two keep signing. Bring it
|
||||||
|
back, re-arm it (§ boot-to-signing-ready), confirm quorum is 3 again. **Never** take a second one down while
|
||||||
|
one is already offline (that freezes spending until one returns).
|
||||||
|
|
||||||
|
**Boot-to-signing-ready:** a Coldcard needs PIN + HSM-mode entry after any power loss. Unattended operation
|
||||||
|
means the signer agent must restore the device to signing-ready automatically after a reboot, and monitoring
|
||||||
|
must confirm it did — a device that silently fails to return erodes quorum. Rehearse: reboot a signer host and
|
||||||
|
confirm the agent re-arms it and quorum self-heals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Monitoring & alerting (non-negotiable for unattended operation)
|
||||||
|
|
||||||
|
Wire these into your observability stack (your observability stack (e.g. Loki + Grafana + alerting)):
|
||||||
|
- **Quorum health** — are **≥2 of 3** signers online **and** HSM-active **and** wallet-registered? **Alert the
|
||||||
|
moment it drops to exactly 2** (one more failure = frozen).
|
||||||
|
- **Velocity near limit** — global cap approaching for the period; per-device counters near `V`.
|
||||||
|
- **Policy denials** — every on-device refusal (the `must_log` trail) → alert; a spike may signal an attack or
|
||||||
|
a misconfiguration.
|
||||||
|
- **USB / device health** — VMs surviving a host crash can carry latent USB/udev damage; don't repeat-restart,
|
||||||
|
restore from backup.
|
||||||
|
- **Refill anomalies** — refills outside expected cadence/size (a compromised coordinator's tell).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backup, recovery & DR
|
||||||
|
|
||||||
|
- **3 seeds**, each to steel, **geographically separated**. 2-of-3 survives losing **one** seed.
|
||||||
|
- **The descriptor is load-bearing.** Losing it makes recovery painful **even with all three seeds** — store
|
||||||
|
the wallet descriptor offsite, independently of the seeds (in your vault).
|
||||||
|
- **Rehearse recovery** before funding mainnet: reconstruct the watch-only wallet from the descriptor on a
|
||||||
|
fresh node, recover a signer from seed, re-register the multisig, reload policy, sign a test spend.
|
||||||
|
- **Coordinator state is disposable** — it's chain-derived; a replacement coordinator recomputes the velocity
|
||||||
|
counter from chain history with zero handed-over state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Incident response & break-glass
|
||||||
|
|
||||||
|
| Situation | Response |
|
||||||
|
|---|---|
|
||||||
|
| One signer down | Operate on the remaining two; restore + re-arm the third; do not drop a second. |
|
||||||
|
| Two signers down | Spending is **frozen (safe)**. Restore one to resume. This is the design working. |
|
||||||
|
| Coordinator compromised suspected | Funds are bounded by §1.4 (whitelist + `1.5V`). Rotate the coordinator host/secret; the hardware caps already contain the blast radius. Review the policy-denial + refill logs. |
|
||||||
|
| Large / exception spend needed | Use the **human break-glass** path (3rd human-held key / CKBunker), outside the automated policy. |
|
||||||
|
| Suspected key compromise (one device) | One key alone moves nothing. Rotate to a fresh 2-of-3 (new seeds + descriptor), sweep funds under the old policy to the new wallet. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Known sharp edges (read before production)
|
||||||
|
|
||||||
|
- **USB passthrough pins a VM to its host** — a VM with a physical USB Coldcard **cannot live-migrate**. These
|
||||||
|
signer VMs deliberately break the "freely migratable" model; the multisig *is* the HA.
|
||||||
|
- **HSM + multisig together is advanced / lightly-charted** — soak on signet for a long time before mainnet.
|
||||||
|
- **CKBunker is niche** (v0.9.1, "at your own risk") — keep it as the human break-glass surface only; the
|
||||||
|
automated path is the signer-agent over `ckcc-protocol`, not CKBunker.
|
||||||
|
- **The #12 ordering trap** (§5 Step 5) — register wallet → load policy → verify wallet survived.
|
||||||
|
- **Velocity counters don't compose across devices** — that's the whole reason the authoritative cap is the
|
||||||
|
coordinator's chain-derived one; per-device velocity is the hardware bound, not the operational truth.
|
||||||
|
- **Post-host-crash latent damage** — a signer VM that survived a host hard-crash can carry subclinical FS/USB
|
||||||
|
damage; restore from backup rather than repeat-restarting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Regulatory note
|
||||||
|
|
||||||
|
Running this for **your own** funds (treasury / your own refill tier) is **not** custody-of-others. Offering it
|
||||||
|
as **custody-as-a-service for third parties** is **regulated activity in AU (AUSTRAC / financial services)** —
|
||||||
|
get legal advice before productising. Same flag as a regulated swap service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
43
docs/QUICK-START.md
Normal file
43
docs/QUICK-START.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Quick-start & operator checklist — mineracks multisig HSM
|
||||||
|
|
||||||
|
*The one-page version. Full detail + the security model: [Operator Manual](./OPERATOR-MANUAL.md). Do the
|
||||||
|
entire provisioning run on **signet/testnet first**; only move to mainnet after rehearsing a refill, a
|
||||||
|
failover, a policy change, and a restore-from-backup.*
|
||||||
|
|
||||||
|
## Provision (once)
|
||||||
|
- [ ] **1. Seeds** — generate **3 distinct** seeds, one per Coldcard (never clone one to three). Steel-backup each; store **geographically separated**.
|
||||||
|
- [ ] **2. Wallet** — build the `wsh(sortedmulti(2,k1,k2,k3))` descriptor; create the **watch-only** wallet on the node (receive `/0/*` + change `/1/*`); **record the descriptor offsite** (it's load-bearing for recovery).
|
||||||
|
- [ ] **3. Register** the 2-of-3 wallet on **each** Coldcard (so change is recognised as internal).
|
||||||
|
- [ ] **4. Policy** — per-device HSM JSON: `max_amount`, `per_period` (= ⅔ × global cap), `whitelist` (your hot-wallet deposit addresses), `period`, `wallet`, `must_log:true`.
|
||||||
|
- [ ] **4b. TOTP (only if using the surge tier)** — enrol the **same** `owner` secret on **all 3** Coldcards. **Production: the owner loads it directly over USB — never via the coordinator** (which only relays the live code at spend time).
|
||||||
|
- [ ] **5. ORDER (the #12 trap):** register wallet → enrol TOTP (4b) → **load policy → VERIFY the wallet survived on the device.**
|
||||||
|
- [ ] **6. HSM start** on each device (two-step on-device approval).
|
||||||
|
- [ ] **7. Coordinator** — set global cap `G`; whitelist; refill floor + amount; **round-robin the signer pair**; session secret `chmod 600` (**never in git**).
|
||||||
|
- [ ] **8. Placement** — 3 signers in **independent failure domains**, **≥1 offsite**. Never two keys behind one PSU/switch/host.
|
||||||
|
- [ ] **9. Verify** — within-policy refill signs · over-cap refused · off-list refused · velocity burst blocked at `G` · restore-from-backup rehearsed.
|
||||||
|
|
||||||
|
## Daily / on-call check
|
||||||
|
- [ ] **Quorum = 3/3** online, HSM-active, wallet-registered. *(Alert fires at exactly 2 — one more failure = frozen.)*
|
||||||
|
- [ ] Global velocity has headroom for the period; **no unexpected policy denials**.
|
||||||
|
- [ ] All 3 signer hosts + coordinator replica(s) up; USB/device health green.
|
||||||
|
|
||||||
|
## Sizing (memorise)
|
||||||
|
- Worst case if the **coordinator is compromised = 1.5 × per-device velocity `V`** (2 sigs burned per 1× value).
|
||||||
|
- **Set `V = ⅔ × G`** ⇒ worst case = your intended global cap `G`.
|
||||||
|
- **any-2-of-3** = 1.5× worst case **but** unattended failover · **2-auto + 1 human break-glass** = 1.0× worst case **but** no unattended failover. Pick deliberately.
|
||||||
|
|
||||||
|
## Incident cards
|
||||||
|
| Situation | Do this |
|
||||||
|
|---|---|
|
||||||
|
| **One** signer down | Run on the other two; restore + re-arm it; **do not drop a second.** |
|
||||||
|
| **Two** signers down | Spending is **frozen — safe, by design.** Restore one to resume. |
|
||||||
|
| Coordinator compromise suspected | Funds are bounded (no keys · whitelist confines destination · ≤ `1.5V`). Rotate host + secret; review denial/refill logs. |
|
||||||
|
| Large / exception spend | Use the **human break-glass** (3rd key / CKBunker), off the automated path. |
|
||||||
|
| Suspected single-key compromise | One key moves nothing. Rotate to a fresh 2-of-3; sweep funds under the old policy to the new wallet. |
|
||||||
|
|
||||||
|
## Never
|
||||||
|
- Never clone one seed to multiple devices · never co-locate two keys on a shared PSU/switch/host/hypervisor.
|
||||||
|
- Never take a **second** signer offline while one is already down.
|
||||||
|
- Never **load policy before registering** the multisig wallet (it can delete the wallet).
|
||||||
|
- Never put the coordinator **secret / seeds / descriptor in git**.
|
||||||
|
- Never hold **mainnet value on a single host running more than one signer** (no failure-domain independence).
|
||||||
40
reference/build_wallet.py
Normal file
40
reference/build_wallet.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import subprocess, json, os, time
|
||||||
|
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
|
||||||
|
CLI=os.path.expanduser(f'~/bitcoin-{V}/bin/bitcoin-cli')
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base=[CLI,f'-datadir={DATA}','-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
|
||||||
|
if wallet is not None: base.append(f'-rpcwallet={wallet}')
|
||||||
|
r=subprocess.run(base+list(a),capture_output=True,text=True)
|
||||||
|
if r.returncode!=0: raise SystemExit(f'CLI {a} FAIL: {r.stderr.strip()}')
|
||||||
|
return r.stdout.strip()
|
||||||
|
TPUB={'00000001':'tpubREPLACE_WITH_YOUR_SIGNER_1_XPUB',
|
||||||
|
'00000002':'tpubREPLACE_WITH_YOUR_SIGNER_2_XPUB',
|
||||||
|
'00000003':'tpubREPLACE_WITH_YOUR_SIGNER_3_XPUB'}
|
||||||
|
def desc(branch):
|
||||||
|
keys=','.join(f'[{x}/48h/1h/0h/2h]{p}/{branch}/*' for x,p in TPUB.items())
|
||||||
|
return f'wsh(sortedmulti(2,{keys}))'
|
||||||
|
for i in range(40):
|
||||||
|
try: bc('getblockchaininfo'); break
|
||||||
|
except SystemExit: time.sleep(2)
|
||||||
|
def withck(d): return json.loads(bc('getdescriptorinfo', d))['descriptor']
|
||||||
|
rcv, chg = withck(desc(0)), withck(desc(1))
|
||||||
|
# miner wallet (has keys) to fund
|
||||||
|
wl=json.loads(bc('listwallets'))
|
||||||
|
if 'miner' not in wl: bc('createwallet','miner')
|
||||||
|
if 'ckms23-watch' not in wl: bc('createwallet','ckms23-watch','true','true','','false','true') # disable_privkeys, blank, ..., descriptors
|
||||||
|
bc('importdescriptors', json.dumps([{'desc':rcv,'active':True,'internal':False,'timestamp':'now','range':[0,20]},
|
||||||
|
{'desc':chg,'active':True,'internal':True,'timestamp':'now','range':[0,20]}]), wallet='ckms23-watch')
|
||||||
|
maddr=bc('getnewaddress','','bech32', wallet='ckms23-watch')
|
||||||
|
print('MULTISIG_ADDR', maddr)
|
||||||
|
mineaddr=bc('getnewaddress', wallet='miner')
|
||||||
|
bc('generatetoaddress','101',mineaddr, wallet='miner')
|
||||||
|
bc('sendtoaddress',maddr,'2.5', wallet='miner')
|
||||||
|
bc('generatetoaddress','1',mineaddr, wallet='miner')
|
||||||
|
bal=bc('getbalance', wallet='ckms23-watch')
|
||||||
|
print('WATCH_BALANCE', bal)
|
||||||
|
dest=bc('getnewaddress', wallet='miner')
|
||||||
|
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:1.0}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}), wallet='ckms23-watch'))
|
||||||
|
open(os.path.expanduser('~/cksim/unsigned.psbt'),'w').write(funded['psbt'])
|
||||||
|
print('UNSIGNED_PSBT_BYTES', len(funded['psbt']))
|
||||||
|
print('PSBT_HEAD', funded['psbt'][:40])
|
||||||
|
print('OK')
|
||||||
45
reference/demo_rig.sh
Executable file
45
reference/demo_rig.sh
Executable file
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Demo rig: GUI Coldcard Mk5 sims (segregated => distinct per-PID sockets), clipped + streamed via x11vnc +
|
||||||
|
# websockify for noVNC. Each signer N uses display :10N, VNC 591N, WS 691N, socket symlink /tmp/cksim-N.sock.
|
||||||
|
# demo_rig.sh -> (re)start ALL 3 signers (used by /policy + every re-arm)
|
||||||
|
# demo_rig.sh 2 -> (re)start ONLY signer 2 (boot-to-signing-ready / reboot demo; others keep running)
|
||||||
|
set -uo pipefail
|
||||||
|
export PATH="$HOME/gccshim:$PATH"
|
||||||
|
CLIP="${CLIP:-192x328+96+90}"
|
||||||
|
RUN=~/cksim
|
||||||
|
TARGETS="${*:-1 2 3}"
|
||||||
|
cd ~/coldcard-firmware/unix
|
||||||
|
|
||||||
|
# --- kill ONLY the targeted signers' processes (so a single-N restart leaves the others alone) ---
|
||||||
|
for n in $TARGETS; do
|
||||||
|
DISP=$((100+n)); VNC=$((5910+n)); WS=$((6910+n))
|
||||||
|
oldsock=$(readlink "/tmp/cksim-$n.sock" 2>/dev/null || true)
|
||||||
|
pkill -f -- "--seed $(cat "$RUN/seeds/$n.txt")" 2>/dev/null || true
|
||||||
|
pkill -f "rfbport $VNC" 2>/dev/null || true
|
||||||
|
pkill -f "websockify $WS" 2>/dev/null || true
|
||||||
|
kill "$(cat "/tmp/.X$DISP-lock" 2>/dev/null)" 2>/dev/null || true
|
||||||
|
rm -f "/tmp/.X$DISP-lock" "/tmp/cksim-$n.sock" ${oldsock:+"$oldsock"}
|
||||||
|
done
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# --- (re)launch the targeted signers ---
|
||||||
|
for n in $TARGETS; do
|
||||||
|
DISP=$((100+n)); VNC=$((5910+n)); WS=$((6910+n))
|
||||||
|
Xvfb :$DISP -screen 0 900x720x24 >/tmp/xvfb$n.log 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
SEED=$(cat "$RUN/seeds/$n.txt")
|
||||||
|
before=$(ls /tmp/ckcc-simulator-*.sock 2>/dev/null | sort)
|
||||||
|
DISPLAY=:$DISP setsid ~/ccsim-venv/bin/python simulator.py --mk5 --segregate --seed "$SEED" >/tmp/guisim$n.log 2>&1 &
|
||||||
|
sock=""
|
||||||
|
for i in $(seq 1 25); do
|
||||||
|
now=$(ls /tmp/ckcc-simulator-*.sock 2>/dev/null | sort)
|
||||||
|
sock=$(comm -13 <(echo "$before") <(echo "$now") | grep -v '^$' | head -1)
|
||||||
|
[ -n "$sock" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
ln -sf "$sock" "/tmp/cksim-$n.sock"
|
||||||
|
x11vnc -display :$DISP -clip "$CLIP" -rfbport $VNC -forever -shared -nopw -quiet -bg >/tmp/x11vnc$n.log 2>&1
|
||||||
|
setsid websockify $WS localhost:$VNC </dev/null >/tmp/ws$n.log 2>&1 &
|
||||||
|
echo "$n $sock /tmp/cksim-$n.sock :$DISP $WS"
|
||||||
|
done
|
||||||
|
echo DEMO_RIG_UP
|
||||||
18
reference/diag_hsm.py
Normal file
18
reference/diag_hsm.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import os, io, time, json
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
from ckcc.cli import real_file_upload
|
||||||
|
d=ColdcardDevice(sn='/tmp/cksim-3.sock')
|
||||||
|
def E(c):
|
||||||
|
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
def K(k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
|
||||||
|
def st():
|
||||||
|
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r; return json.loads(r)
|
||||||
|
S=lambda: E("RV.write(repr(sim_display.story))")
|
||||||
|
pf=os.path.expanduser('~/cksim/policy.json')
|
||||||
|
flen,sha=real_file_upload(open(pf,'rb'),d)
|
||||||
|
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.5)
|
||||||
|
print('SCREEN1:', S()[:600])
|
||||||
|
K('y'); time.sleep(1.5)
|
||||||
|
print('SCREEN2:', S()[:600])
|
||||||
|
print('ACTIVE_after_y:', st().get('active'))
|
||||||
6
reference/enable_hsm.py
Normal file
6
reference/enable_hsm.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
for n in (1,2,3):
|
||||||
|
d=ColdcardDevice(sn='/tmp/cksim-%d.sock'%n)
|
||||||
|
d.send_recv(b'EXEC'+b"settings.put('hsmcmd', True); settings.save(); RV.write('ok')", encrypt=False)
|
||||||
|
r=d.send_recv(b'EVAL'+b"settings.get('hsmcmd', False)")
|
||||||
|
print('sim%d hsmcmd=%s'%(n, (r.decode() if isinstance(r,(bytes,bytearray)) else r).strip()))
|
||||||
30
reference/enroll.py
Normal file
30
reference/enroll.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import sys, time, subprocess, os
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
|
||||||
|
sock, wallet = sys.argv[1], sys.argv[2]
|
||||||
|
ckcc = os.path.expanduser('~/ccsim-venv/bin/ckcc')
|
||||||
|
|
||||||
|
up = subprocess.run([ckcc, '-c', sock, 'upload', '-m', wallet], capture_output=True, text=True)
|
||||||
|
print('UPLOAD:', (up.stdout + up.stderr).strip()[:200])
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
dev = ColdcardDevice(sn=sock)
|
||||||
|
def E(cmd):
|
||||||
|
r = dev.send_recv(b'EXEC' + cmd.encode(), encrypt=False)
|
||||||
|
return r.decode() if isinstance(r, (bytes, bytearray)) else r
|
||||||
|
def V(cmd):
|
||||||
|
r = dev.send_recv(b'EVAL' + cmd.encode())
|
||||||
|
return r.decode() if isinstance(r, (bytes, bytearray)) else r
|
||||||
|
def K(k):
|
||||||
|
dev.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
|
||||||
|
|
||||||
|
print('STORY0:', E("RV.write(repr(sim_display.story))")[:400])
|
||||||
|
for i in range(8):
|
||||||
|
c = V("len(settings.get('multisig', []))").strip()
|
||||||
|
print('iter %d: ms_count=%r' % (i, c))
|
||||||
|
if c not in ('0', ''):
|
||||||
|
break
|
||||||
|
K('y'); time.sleep(1.0)
|
||||||
|
print('FINAL ms_count:', V("len(settings.get('multisig', []))").strip())
|
||||||
|
print('STORYZ:', E("RV.write(repr(sim_display.story))")[:200])
|
||||||
21
reference/finish.py
Normal file
21
reference/finish.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import subprocess, json, os
|
||||||
|
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
|
||||||
|
CLI=os.path.expanduser(f'~/bitcoin-{V}/bin/bitcoin-cli')
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base=[CLI,f'-datadir={DATA}','-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
|
||||||
|
if wallet: base.append(f'-rpcwallet={wallet}')
|
||||||
|
r=subprocess.run(base+list(a),capture_output=True,text=True)
|
||||||
|
if r.returncode: raise SystemExit(f'{a} FAIL {r.stderr.strip()}')
|
||||||
|
return r.stdout.strip()
|
||||||
|
s1=open(os.path.expanduser('~/cksim/signed1.psbt')).read().strip()
|
||||||
|
s2=open(os.path.expanduser('~/cksim/signed2.psbt')).read().strip()
|
||||||
|
comb=bc('combinepsbt', json.dumps([s1,s2]))
|
||||||
|
fin=json.loads(bc('finalizepsbt', comb))
|
||||||
|
print('PSBT_COMPLETE:', fin['complete'])
|
||||||
|
txid=bc('sendrawtransaction', fin['hex'])
|
||||||
|
print('BROADCAST_TXID:', txid)
|
||||||
|
mineaddr=bc('getnewaddress', wallet='miner')
|
||||||
|
bc('generatetoaddress','1',mineaddr, wallet='miner')
|
||||||
|
tx=json.loads(bc('gettransaction', txid, wallet='ckms23-watch'))
|
||||||
|
print('CONFIRMATIONS:', tx.get('confirmations'))
|
||||||
|
print('WATCH_BALANCE_AFTER:', bc('getbalance', wallet='ckms23-watch'))
|
||||||
18
reference/gui_sim.sh
Normal file
18
reference/gui_sim.sh
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
exec > /tmp/guisim.log 2>&1
|
||||||
|
set -x
|
||||||
|
export PATH="$HOME/gccshim:$PATH"
|
||||||
|
N="${1:-1}"; DISP=$((100+N)); VNC=$((5910+N)); WS=$((6910+N))
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq scrot >/dev/null 2>&1
|
||||||
|
pkill -f "Xvfb :$DISP" 2>/dev/null; pkill -f "x11vnc.*$VNC" 2>/dev/null; sleep 1
|
||||||
|
Xvfb :$DISP -screen 0 900x720x24 >/dev/null 2>&1 &
|
||||||
|
sleep 2
|
||||||
|
SEED=$(cat ~/cksim/seeds/$N.txt)
|
||||||
|
cd ~/coldcard-firmware/unix
|
||||||
|
DISPLAY=:$DISP ~/ccsim-venv/bin/python simulator.py --mk5 --seed "$SEED" >/tmp/guisim$N.log 2>&1 &
|
||||||
|
sleep 12
|
||||||
|
DISPLAY=:$DISP scrot /tmp/cc$N.png 2>&1
|
||||||
|
ls -l /tmp/cc$N.png
|
||||||
|
x11vnc -display :$DISP -rfbport $VNC -forever -shared -nopw -quiet -bg >/tmp/x11vnc$N.log 2>&1
|
||||||
|
setsid websockify $WS localhost:$VNC </dev/null >/tmp/ws$N.log 2>&1 &
|
||||||
|
echo "GUI_SIM_DONE disp=$DISP vnc=$VNC ws=$WS"
|
||||||
61
reference/hsm_2of3.py
Normal file
61
reference/hsm_2of3.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import subprocess, json, os, io, time, re
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
from ckcc.cli import real_file_upload
|
||||||
|
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
|
||||||
|
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base=[CLI,'-datadir=%s'%DATA,'-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
|
||||||
|
if wallet: base.append('-rpcwallet=%s'%wallet)
|
||||||
|
r=subprocess.run(base+list(a),capture_output=True,text=True)
|
||||||
|
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()))
|
||||||
|
return r.stdout.strip()
|
||||||
|
def dev(n): return ColdcardDevice(sn='/tmp/cksim-%d.sock'%n)
|
||||||
|
def E(d,c):
|
||||||
|
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
def K(d,k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
|
||||||
|
def hstat(d):
|
||||||
|
try:
|
||||||
|
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
return json.loads(r)
|
||||||
|
except Exception as e: return {'err':str(e)[:60]}
|
||||||
|
def story(d): return E(d,"RV.write(repr(sim_display.story))")
|
||||||
|
def activate(d):
|
||||||
|
pf=os.path.expanduser('~/cksim/policy.json')
|
||||||
|
if hstat(d).get('active'): return True
|
||||||
|
flen,sha=real_file_upload(open(pf,'rb'),d)
|
||||||
|
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.4)
|
||||||
|
for _ in range(20):
|
||||||
|
if hstat(d).get('active'): return True
|
||||||
|
s=story(d)
|
||||||
|
m=re.search(r'Press \((\d)\) to save', s)
|
||||||
|
if m: K(d,m.group(1)); time.sleep(1.6); continue
|
||||||
|
if 'Press OK to enable' in s or s.startswith("('Start HSM?'"): K(d,'y'); time.sleep(1.6); continue
|
||||||
|
time.sleep(0.5)
|
||||||
|
return hstat(d).get('active')
|
||||||
|
d1,d2=dev(1),dev(2)
|
||||||
|
print('sim1 active(before)=', hstat(d1).get('active'))
|
||||||
|
print('sim2 activate ->', activate(d2))
|
||||||
|
print('sim1 active(now)=', hstat(d1).get('active'), ' sim2 active(now)=', hstat(d2).get('active'))
|
||||||
|
print('== build PSBT + AUTO-SIGN (no keypress) on sims 1 & 2 ==')
|
||||||
|
dest=bc('getnewaddress',wallet='miner')
|
||||||
|
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:0.4}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}),wallet='ckms23-watch'))
|
||||||
|
psbt=b64decode(funded['psbt']); sigs=[]
|
||||||
|
for n,d in ((1,d1),(2,d2)):
|
||||||
|
flen,sha=real_file_upload(io.BytesIO(psbt),d)
|
||||||
|
d.send_recv(CCProtocolPacker.sign_transaction(flen,sha,flags=0x0),timeout=None)
|
||||||
|
done=None
|
||||||
|
for i in range(40):
|
||||||
|
time.sleep(0.5)
|
||||||
|
r=d.send_recv(CCProtocolPacker.get_signed_txn(),timeout=None)
|
||||||
|
if r is not None: done=r; break
|
||||||
|
if not done: print('sim%d TIMEOUT hsm=%s'%(n,hstat(d).get('active'))); continue
|
||||||
|
rl,rsha=done; res=d.download_file(rl,rsha,file_number=1)
|
||||||
|
sigs.append(b64encode(res).decode()); print('sim%d AUTO-SIGNED no-keypress bytes=%d'%(n,rl))
|
||||||
|
fin=json.loads(bc('finalizepsbt', bc('combinepsbt',json.dumps(sigs))))
|
||||||
|
print('PSBT_COMPLETE:', fin['complete'])
|
||||||
|
if fin['complete']:
|
||||||
|
txid=bc('sendrawtransaction',fin['hex']); print('TXID:',txid)
|
||||||
|
bc('generatetoaddress','1',bc('getnewaddress',wallet='miner'),wallet='miner')
|
||||||
|
print('BALANCE_AFTER:',bc('getbalance',wallet='ckms23-watch'))
|
||||||
67
reference/hsm_autosign.py
Normal file
67
reference/hsm_autosign.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import subprocess, json, os, io, time, re
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
from ckcc.cli import real_file_upload
|
||||||
|
|
||||||
|
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
|
||||||
|
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base=[CLI,'-datadir=%s'%DATA,'-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
|
||||||
|
if wallet: base.append('-rpcwallet=%s'%wallet)
|
||||||
|
r=subprocess.run(base+list(a),capture_output=True,text=True)
|
||||||
|
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()))
|
||||||
|
return r.stdout.strip()
|
||||||
|
SK={1:'/tmp/cksim-1.sock',2:'/tmp/cksim-2.sock',3:'/tmp/cksim-3.sock'}
|
||||||
|
def dev(n): return ColdcardDevice(sn=SK[n])
|
||||||
|
def E(d,c):
|
||||||
|
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
def K(d,k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
|
||||||
|
def hstat(d):
|
||||||
|
try:
|
||||||
|
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
return json.loads(r)
|
||||||
|
except Exception as e: return {'err':str(e)[:80]}
|
||||||
|
def story(d): return E(d,"RV.write(repr(sim_display.story))")
|
||||||
|
|
||||||
|
policy={"must_log":False,"period":60,"rules":[{"max_amount":300000000,"per_period":300000000,"wallet":"ckms23"}]}
|
||||||
|
pf=os.path.expanduser('~/cksim/policy.json'); open(pf,'w').write(json.dumps(policy))
|
||||||
|
|
||||||
|
print('== hsm-start on sims 1,2 (2-step approval: OK then random digit) ==')
|
||||||
|
for n in (1,2):
|
||||||
|
d=dev(n)
|
||||||
|
flen,sha=real_file_upload(open(pf,'rb'),d)
|
||||||
|
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.2)
|
||||||
|
s1=story(d)
|
||||||
|
K(d,'y'); time.sleep(1.3) # screen 1: Press OK
|
||||||
|
s2=story(d)
|
||||||
|
m=re.search(r'Press \((\d)\) to save policy', s2)
|
||||||
|
cc=m.group(1) if m else None
|
||||||
|
if cc: K(d,cc); time.sleep(1.5) # screen 2: the random digit
|
||||||
|
st=hstat(d)
|
||||||
|
print(' sim%d confirm_digit=%s HSM_active=%s' % (n, cc, st.get('active')))
|
||||||
|
|
||||||
|
print('== fund + build fresh PSBT ==')
|
||||||
|
dest=bc('getnewaddress',wallet='miner')
|
||||||
|
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:0.5}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}),wallet='ckms23-watch'))
|
||||||
|
psbt=b64decode(funded['psbt']); sigs=[]
|
||||||
|
print('== sign with NO keypress (HSM auto-cosign) ==')
|
||||||
|
for n in (1,2):
|
||||||
|
d=dev(n)
|
||||||
|
flen,sha=real_file_upload(io.BytesIO(psbt),d)
|
||||||
|
d.send_recv(CCProtocolPacker.sign_transaction(flen,sha,flags=0x0),timeout=None)
|
||||||
|
done=None
|
||||||
|
for i in range(40):
|
||||||
|
time.sleep(0.5)
|
||||||
|
r=d.send_recv(CCProtocolPacker.get_signed_txn(),timeout=None)
|
||||||
|
if r is not None: done=r; break
|
||||||
|
if not done: print(' sim%d AUTO-SIGN TIMEOUT (hsm=%s)'%(n,hstat(d).get('active'))); continue
|
||||||
|
rl,rsha=done; res=d.download_file(rl,rsha,file_number=1)
|
||||||
|
sigs.append(b64encode(res).decode())
|
||||||
|
print(' sim%d AUTO-SIGNED, NO keypress, bytes=%d' % (n, rl))
|
||||||
|
print('== combine + broadcast ==')
|
||||||
|
comb=bc('combinepsbt',json.dumps(sigs))
|
||||||
|
fin=json.loads(bc('finalizepsbt',comb)); print(' PSBT_COMPLETE:',fin['complete'])
|
||||||
|
txid=bc('sendrawtransaction',fin['hex']); print(' TXID:',txid)
|
||||||
|
bc('generatetoaddress','1',bc('getnewaddress',wallet='miner'),wallet='miner')
|
||||||
|
print(' BALANCE_AFTER:',bc('getbalance',wallet='ckms23-watch'))
|
||||||
64
reference/hsm_finish.py
Normal file
64
reference/hsm_finish.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import subprocess, json, os, io, time, re
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
from ckcc.cli import real_file_upload
|
||||||
|
V='27.0'; DATA=os.path.expanduser('~/cksim/regtest-data')
|
||||||
|
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base=[CLI,'-datadir=%s'%DATA,'-rpcport=18999','-rpcuser=ck','-rpcpassword=ckms']
|
||||||
|
if wallet: base.append('-rpcwallet=%s'%wallet)
|
||||||
|
r=subprocess.run(base+list(a),capture_output=True,text=True)
|
||||||
|
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()))
|
||||||
|
return r.stdout.strip()
|
||||||
|
SK={1:'/tmp/cksim-1.sock',3:'/tmp/cksim-3.sock'}
|
||||||
|
def dev(n): return ColdcardDevice(sn=SK[n])
|
||||||
|
def E(d,c):
|
||||||
|
r=d.send_recv(b'EXEC'+c.encode(),encrypt=False); return r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
def K(d,k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
|
||||||
|
def hstat(d):
|
||||||
|
try:
|
||||||
|
r=d.send_recv(CCProtocolPacker.hsm_status()); r=r.decode() if isinstance(r,(bytes,bytearray)) else r
|
||||||
|
return json.loads(r)
|
||||||
|
except Exception as e: return {'err':str(e)[:60]}
|
||||||
|
def story(d): return E(d,"RV.write(repr(sim_display.story))")
|
||||||
|
def approve(d):
|
||||||
|
for _ in range(14):
|
||||||
|
if hstat(d).get('active'): return True
|
||||||
|
s=story(d)
|
||||||
|
if 'save policy' in s:
|
||||||
|
m=re.search(r'Press \((\d)\) to save', s)
|
||||||
|
if m: K(d,m.group(1)); time.sleep(1.6); continue
|
||||||
|
if 'Press OK to enable' in s or s.startswith("('Start HSM?'"):
|
||||||
|
K(d,'y'); time.sleep(1.6); continue
|
||||||
|
time.sleep(0.5)
|
||||||
|
return hstat(d).get('active')
|
||||||
|
pf=os.path.expanduser('~/cksim/policy.json')
|
||||||
|
DEVS={}
|
||||||
|
for n in (1,3):
|
||||||
|
d=dev(n); DEVS[n]=d
|
||||||
|
flen,sha=real_file_upload(open(pf,'rb'),d)
|
||||||
|
d.send_recv(CCProtocolPacker.hsm_start(flen,sha)); time.sleep(1.2)
|
||||||
|
act=approve(d)
|
||||||
|
print('sim%d HSM_active=%s' % (n, act))
|
||||||
|
print('== build PSBT, AUTO-SIGN with NO keypress ==')
|
||||||
|
dest=bc('getnewaddress',wallet='miner')
|
||||||
|
funded=json.loads(bc('walletcreatefundedpsbt','[]',json.dumps([{dest:0.4}]),'0',json.dumps({'fee_rate':2,'change_type':'bech32'}),wallet='ckms23-watch'))
|
||||||
|
psbt=b64decode(funded['psbt']); sigs=[]
|
||||||
|
for n in (1,3):
|
||||||
|
d=DEVS[n]
|
||||||
|
flen,sha=real_file_upload(io.BytesIO(psbt),d)
|
||||||
|
d.send_recv(CCProtocolPacker.sign_transaction(flen,sha,flags=0x0),timeout=None)
|
||||||
|
done=None
|
||||||
|
for i in range(40):
|
||||||
|
time.sleep(0.5)
|
||||||
|
r=d.send_recv(CCProtocolPacker.get_signed_txn(),timeout=None)
|
||||||
|
if r is not None: done=r; break
|
||||||
|
if not done: print('sim%d AUTO-SIGN TIMEOUT hsm=%s'%(n,hstat(d).get('active'))); continue
|
||||||
|
rl,rsha=done; res=d.download_file(rl,rsha,file_number=1)
|
||||||
|
sigs.append(b64encode(res).decode()); print('sim%d AUTO-SIGNED (no keypress) bytes=%d'%(n,rl))
|
||||||
|
comb=bc('combinepsbt',json.dumps(sigs))
|
||||||
|
fin=json.loads(bc('finalizepsbt',comb)); print('PSBT_COMPLETE:',fin['complete'])
|
||||||
|
txid=bc('sendrawtransaction',fin['hex']); print('TXID:',txid)
|
||||||
|
bc('generatetoaddress','1',bc('getnewaddress',wallet='miner'),wallet='miner')
|
||||||
|
print('BALANCE_AFTER:',bc('getbalance',wallet='ckms23-watch'))
|
||||||
775
reference/orchestrator.py
Normal file
775
reference/orchestrator.py
Normal file
@ -0,0 +1,775 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reference demo orchestrator for the mineracks multisig HSM — interactive REAL-SIGNET policy HSM.
|
||||||
|
|
||||||
|
All 3 sims are HSM policy signers (any 2 sign → real 2-of-3 failover). Chain access is the fleet's
|
||||||
|
synced signet node (RPC over your private network). Keyless coordinator; on-device policy gates.
|
||||||
|
|
||||||
|
Endpoints (nginx maps /api/ -> :8099/):
|
||||||
|
GET /status -> JSON {balance_sat, devices, quorum, policy, sink_addr}
|
||||||
|
GET /spend?amount=SATS&dest=sink|other -> SSE; builds+signs a real-signet spend. dest=other pays an
|
||||||
|
off-whitelist address (HSM refuses); over max_amount refuses.
|
||||||
|
GET /signmsg?text=... -> SSE; 2-of-3 sign a message (proof of control, no funds move)
|
||||||
|
GET /device?n=N&on=0|1 -> toggle a signer on/off (failover / quorum demo)
|
||||||
|
GET /policy?max_sat=&per_period_sat=&period_min= -> set policy (max-amount + velocity) + RE-ARM all sims
|
||||||
|
"""
|
||||||
|
import json, os, io, time, re, subprocess, threading, urllib.parse, hashlib, hmac, secrets
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from base64 import b64decode, b64encode, b32decode, b32encode
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
from ckcc.constants import USER_AUTH_TOTP
|
||||||
|
from ckcc.cli import real_file_upload
|
||||||
|
|
||||||
|
HOME = os.path.expanduser('~'); V = '27.0'
|
||||||
|
CLI = HOME + '/bitcoin-%s/bin/bitcoin-cli' % V
|
||||||
|
CKCC = HOME + '/ccsim-venv/bin/ckcc'
|
||||||
|
WALLET_FILE = HOME + '/cksim/ckms23.txt'; POLICY_FILE = HOME + '/cksim/policy.json'
|
||||||
|
DEMO_RIG = HOME + '/cksim/demo_rig.sh'
|
||||||
|
WALLET = 'ckms23-signet'
|
||||||
|
RPC_HOST = os.environ.get('RPC_HOST', '127.0.0.1'); RPC_PORT = os.environ.get('RPC_PORT', '38332')
|
||||||
|
RPC_USER = os.environ.get('RPC_USER', 'rpcuser'); RPC_PASS = os.environ.get('RPC_PASS', '')
|
||||||
|
SOCK = {1: '/tmp/cksim-1.sock', 2: '/tmp/cksim-2.sock', 3: '/tmp/cksim-3.sock'}
|
||||||
|
LET = {1: 'A', 2: 'B', 3: 'C'}; ALL = (1, 2, 3)
|
||||||
|
DEFAULT_SAT = 2000; MSG_PATH = "m/48h/1h/0h/2h/0/0"
|
||||||
|
TPUB = {'00000001': 'tpubREPLACE_WITH_YOUR_SIGNER_1_XPUB',
|
||||||
|
'00000002': 'tpubREPLACE_WITH_YOUR_SIGNER_2_XPUB',
|
||||||
|
'00000003': 'tpubREPLACE_WITH_YOUR_SIGNER_3_XPUB'}
|
||||||
|
LOCK = threading.Lock()
|
||||||
|
ENABLED = {1: True, 2: True, 3: True}
|
||||||
|
POLICY = {'max_sat': 8000, 'per_period_sat': 500000, 'period_min': 60}
|
||||||
|
_WALLET_READY = [False]
|
||||||
|
|
||||||
|
# Coordinator-side GLOBAL velocity cap — the AUTHORITATIVE spend limit across ALL signers, and the epic's
|
||||||
|
# hardest open problem. On-device per-signer velocity (POLICY['per_period_sat']) is only a backstop: under a
|
||||||
|
# rotating "any 2 of 3" the three devices' local counters DRIFT (each tracks only the txns IT signed), and a
|
||||||
|
# Coldcard decrements its counter the moment it signs — even if that PSBT is later dropped and never
|
||||||
|
# broadcast. So no device's counter reflects true global outflow. The real cap therefore lives HERE in the
|
||||||
|
# keyless coordinator and counts ONLY real broadcasts (the ledger) — so dropped PSBTs never burn budget.
|
||||||
|
GLOBAL = {'per_period_sat': 50000, 'period_min': 60}
|
||||||
|
# TOTP-gated SURGE tier: a signed-in owner supplies a TOTP code to unlock a higher on-device tier (bigger
|
||||||
|
# per-txn cap + velocity) WITH a human in the loop — routine flows stay automated. The coordinator only
|
||||||
|
# RELAYS the code (via user_auth) to the signers; the secret lives on the devices + the owner's authenticator.
|
||||||
|
SURGE = {'max_sat': 200000, 'per_period_sat': 2000000} # tier-2 device limits (TOTP required)
|
||||||
|
SURGE_GLOBAL = {'per_period_sat': 1000000} # coordinator global cap when a TOTP is present
|
||||||
|
TOTP_SECRET_FILE = HOME + '/cksim/multisig-totp.secret' # owner's TOTP secret (chmod 600, NOT in git)
|
||||||
|
LEDGER_FILE = HOME + '/cksim/spend_ledger.json'
|
||||||
|
RESET_TS = [0] # demo-only: "Reset counter" moves the window start to now (production never resets)
|
||||||
|
_TX_CACHE = {'t': 0.0, 'txs': None}
|
||||||
|
def ledger_load():
|
||||||
|
try: return json.load(open(LEDGER_FILE))
|
||||||
|
except Exception: return []
|
||||||
|
def ledger_append(amount_sat, txid):
|
||||||
|
led = (ledger_load() + [{'ts': int(time.time()), 'amount': int(amount_sat), 'txid': txid}])[-5000:]
|
||||||
|
try: open(LEDGER_FILE, 'w').write(json.dumps(led))
|
||||||
|
except Exception: pass
|
||||||
|
_TX_CACHE['txs'] = None # force the next global_spent to re-read the chain (so the gate sees this spend)
|
||||||
|
def _recent_sends():
|
||||||
|
# cache the node's recent wallet txns ~4s so /status polling doesn't hammer RPC
|
||||||
|
if time.time() - _TX_CACHE['t'] < 4 and _TX_CACHE['txs'] is not None:
|
||||||
|
return _TX_CACHE['txs']
|
||||||
|
try: txs = json.loads(bc('listtransactions', '*', '300', '0', 'true', wallet=WALLET))
|
||||||
|
except Exception: return None
|
||||||
|
_TX_CACHE['t'] = time.time(); _TX_CACHE['txs'] = txs; return txs
|
||||||
|
def global_spent():
|
||||||
|
# CHAIN-DERIVED authoritative period outflow. The watch-only wallet's on-chain 'send' total over the
|
||||||
|
# window IS ground truth, so ANY coordinator replica on ANY host recomputes the same number straight from
|
||||||
|
# the blockchain — no single-host ledger to lose (no SPOF) and nothing to tamper. The local ledger is only
|
||||||
|
# a cache, used if the node RPC is briefly unreachable. (Change back to the 2-of-3 is recognised as
|
||||||
|
# internal by the descriptor wallet, so only real external sends — the refills — are counted.)
|
||||||
|
#
|
||||||
|
# MEMPOOL counts too: an unconfirmed (0-conf) send appears in listtransactions the moment it broadcasts,
|
||||||
|
# so a burst of spends inside one block interval can't slip past the cap. We exclude only abandoned /
|
||||||
|
# conflicted (confirmations < 0) sends, so a genuinely dropped tx doesn't permanently burn budget.
|
||||||
|
cut = max(int(time.time()) - GLOBAL['period_min'] * 60, RESET_TS[0])
|
||||||
|
txs = _recent_sends()
|
||||||
|
if txs is not None:
|
||||||
|
return sum(abs(int(round(t.get('amount', 0) * 1e8))) for t in txs
|
||||||
|
if t.get('category') == 'send' and t.get('time', 0) >= cut
|
||||||
|
and not t.get('abandoned') and t.get('confirmations', 0) >= 0)
|
||||||
|
return sum(int(e.get('amount', 0)) for e in ledger_load() if e.get('ts', 0) >= cut)
|
||||||
|
|
||||||
|
# ---- Nostr (NIP-07) sign-in -------------------------------------------------
|
||||||
|
# Optional opt-in identity (same pattern as walletplayground): anonymous use is unaffected. A signed-in user
|
||||||
|
# may authorise their OWN signet address onto every signer's on-device whitelist (a re-arm), instead of being
|
||||||
|
# confined to the sink. Self-contained pure-Python BIP-340 verify (no crypto dep). The rig is shared, so the
|
||||||
|
# authorised set is global; it's capped and the page says so. The HMAC secret lives only on the VM (chmod
|
||||||
|
# 600, NOT in git): a leak only lets someone forge a session, never move funds (the keys are on the devices).
|
||||||
|
SECRET_FILE = HOME + '/cksim/multisig-auth.secret'
|
||||||
|
COOKIE = 'ms_session'; CHALLENGE_TTL = 300; SESSION_TTL = 60 * 60 * 24 * 30
|
||||||
|
EXTRA_WL = [] # authorised custom destination addresses (in addition to the sink); capped, global
|
||||||
|
PUBKEYS = {} # uid -> nostr pubkey (in-memory, display only)
|
||||||
|
|
||||||
|
def _load_secret():
|
||||||
|
try:
|
||||||
|
s = open(SECRET_FILE, 'rb').read().strip()
|
||||||
|
if len(s) >= 32: return s
|
||||||
|
except FileNotFoundError: pass
|
||||||
|
s = secrets.token_hex(32).encode()
|
||||||
|
fd = os.open(SECRET_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, 'wb') as f: f.write(s)
|
||||||
|
return s
|
||||||
|
SECRET = _load_secret()
|
||||||
|
|
||||||
|
# secp256k1 BIP-340 schnorr verify (verify-only reference impl)
|
||||||
|
_p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||||
|
_n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||||
|
_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
|
||||||
|
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
|
||||||
|
def _modinv(a, m): return pow(a, m - 2, m)
|
||||||
|
def _padd(P1, P2):
|
||||||
|
if P1 is None: return P2
|
||||||
|
if P2 is None: return P1
|
||||||
|
if P1[0] == P2[0] and P1[1] != P2[1]: return None
|
||||||
|
if P1 == P2: lam = (3 * P1[0] * P1[0] * _modinv(2 * P1[1], _p)) % _p
|
||||||
|
else: lam = ((P2[1] - P1[1]) * _modinv(P2[0] - P1[0], _p)) % _p
|
||||||
|
x3 = (lam * lam - P1[0] - P2[0]) % _p
|
||||||
|
return (x3, (lam * (P1[0] - x3) - P1[1]) % _p)
|
||||||
|
def _pmul(P, k):
|
||||||
|
R = None
|
||||||
|
while k:
|
||||||
|
if k & 1: R = _padd(R, P)
|
||||||
|
P = _padd(P, P); k >>= 1
|
||||||
|
return R
|
||||||
|
def _lift_x(x):
|
||||||
|
if x >= _p: return None
|
||||||
|
y_sq = (pow(x, 3, _p) + 7) % _p
|
||||||
|
y = pow(y_sq, (_p + 1) // 4, _p)
|
||||||
|
if pow(y, 2, _p) != y_sq: return None
|
||||||
|
return (x, y if y % 2 == 0 else _p - y)
|
||||||
|
def _tagged(tag, msg):
|
||||||
|
th = hashlib.sha256(tag.encode()).digest()
|
||||||
|
return hashlib.sha256(th + th + msg).digest()
|
||||||
|
def schnorr_verify(msg32, pub32, sig64):
|
||||||
|
if len(msg32) != 32 or len(pub32) != 32 or len(sig64) != 64: return False
|
||||||
|
P = _lift_x(int.from_bytes(pub32, 'big'))
|
||||||
|
if P is None: return False
|
||||||
|
r = int.from_bytes(sig64[:32], 'big'); s = int.from_bytes(sig64[32:], 'big')
|
||||||
|
if r >= _p or s >= _n: return False
|
||||||
|
e = int.from_bytes(_tagged('BIP0340/challenge', sig64[:32] + pub32 + msg32), 'big') % _n
|
||||||
|
R = _padd(_pmul(_G, s), _pmul(P, _n - e))
|
||||||
|
return R is not None and R[1] % 2 == 0 and R[0] == r
|
||||||
|
def nostr_event_id(ev):
|
||||||
|
s = json.dumps([0, ev['pubkey'], ev['created_at'], ev['kind'], ev['tags'], ev['content']],
|
||||||
|
separators=(',', ':'), ensure_ascii=False)
|
||||||
|
return hashlib.sha256(s.encode('utf-8')).hexdigest()
|
||||||
|
def _mac(msg): return hmac.new(SECRET, msg.encode(), hashlib.sha256).hexdigest()[:32]
|
||||||
|
def make_challenge():
|
||||||
|
nonce = secrets.token_hex(16); ts = str(int(time.time()))
|
||||||
|
return '%s.%s.%s' % (nonce, ts, _mac('%s.%s' % (nonce, ts)))
|
||||||
|
def check_challenge(ch):
|
||||||
|
try: nonce, ts, mac = ch.split('.')
|
||||||
|
except (ValueError, AttributeError): return False
|
||||||
|
if not hmac.compare_digest(mac, _mac('%s.%s' % (nonce, ts))): return False
|
||||||
|
return abs(int(time.time()) - int(ts)) <= CHALLENGE_TTL
|
||||||
|
def make_session(uid):
|
||||||
|
exp = str(int(time.time()) + SESSION_TTL)
|
||||||
|
return '%s.%s.%s' % (uid, exp, _mac('%s.%s' % (uid, exp)))
|
||||||
|
def read_session(cookie_header):
|
||||||
|
if not cookie_header: return None
|
||||||
|
c = SimpleCookie()
|
||||||
|
try: c.load(cookie_header)
|
||||||
|
except Exception: return None
|
||||||
|
if COOKIE not in c: return None
|
||||||
|
try: uid, exp, mac = c[COOKIE].value.split('.')
|
||||||
|
except ValueError: return None
|
||||||
|
if not hmac.compare_digest(mac, _mac('%s.%s' % (uid, exp))): return None
|
||||||
|
if int(time.time()) > int(exp): return None
|
||||||
|
return uid
|
||||||
|
def uid_for(provider, subject):
|
||||||
|
return provider + ':' + hashlib.sha256(('%s:%s' % (provider, subject)).encode()).hexdigest()[:24]
|
||||||
|
def add_wl(addr):
|
||||||
|
if addr not in EXTRA_WL:
|
||||||
|
EXTRA_WL.append(addr)
|
||||||
|
while len(EXTRA_WL) > 25: EXTRA_WL.pop(0)
|
||||||
|
def totp_secret():
|
||||||
|
# owner TOTP shared secret — generated once, persisted on the VM only (chmod 600, never in git), enrolled
|
||||||
|
# on each signer + shown to the signed-in owner for their authenticator. (Demo simplification: production
|
||||||
|
# enrols via the device's own QR so the coordinator never sees the secret.)
|
||||||
|
try:
|
||||||
|
s = open(TOTP_SECRET_FILE).read().strip()
|
||||||
|
if s: return s
|
||||||
|
except Exception: pass
|
||||||
|
s = b32encode(secrets.token_bytes(20)).decode()
|
||||||
|
fd = os.open(TOTP_SECRET_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, 'w') as f: f.write(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base = [CLI, '-signet', '-rpcconnect=%s' % RPC_HOST, '-rpcport=%s' % RPC_PORT,
|
||||||
|
'-rpcuser=%s' % RPC_USER, '-rpcpassword=%s' % RPC_PASS]
|
||||||
|
if wallet: base.append('-rpcwallet=%s' % wallet)
|
||||||
|
r = subprocess.run(base + list(a), capture_output=True, text=True, timeout=120)
|
||||||
|
if r.returncode: raise RuntimeError('bitcoin-cli %s: %s' % (a[:1], r.stderr.strip()[:160]))
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
def descstr(b):
|
||||||
|
keys = ','.join('[%s/48h/1h/0h/2h]%s/%d/*' % (x, p, b) for x, p in TPUB.items())
|
||||||
|
return 'wsh(sortedmulti(2,%s))' % keys
|
||||||
|
def _ck(d): return json.loads(bc('getdescriptorinfo', d))['descriptor']
|
||||||
|
def ensure_wallet():
|
||||||
|
if _WALLET_READY[0]: return
|
||||||
|
if WALLET not in json.loads(bc('listwallets')):
|
||||||
|
try: bc('loadwallet', WALLET)
|
||||||
|
except RuntimeError:
|
||||||
|
bc('createwallet', WALLET, 'true', 'true', '', 'false', 'true')
|
||||||
|
ts = int(time.time()) - 14400
|
||||||
|
bc('importdescriptors', json.dumps([
|
||||||
|
{'desc': _ck(descstr(0)), 'active': True, 'internal': False, 'timestamp': ts, 'range': [0, 30]},
|
||||||
|
{'desc': _ck(descstr(1)), 'active': True, 'internal': True, 'timestamp': ts, 'range': [0, 30]}]), wallet=WALLET)
|
||||||
|
_WALLET_READY[0] = True
|
||||||
|
def signet_addr():
|
||||||
|
try: return open(HOME + '/cksim/signet_addr.txt').read().strip()
|
||||||
|
except Exception: return '(addr missing)'
|
||||||
|
def balance_sat():
|
||||||
|
ensure_wallet()
|
||||||
|
us = json.loads(bc('listunspent', '0', wallet=WALLET))
|
||||||
|
return us, int(round(sum(u['amount'] for u in us) * 1e8))
|
||||||
|
|
||||||
|
# A single-sig "sink" wallet on the fleet node receives the demo spends (EXTERNAL to the 2-of-3, so the
|
||||||
|
# Coldcard counts real outflow and max_amount actually bites). Each run first sweeps the sink back to the
|
||||||
|
# 2-of-3 funding address, so funds circulate and the demo is self-sustaining (depletes only by fees).
|
||||||
|
SINK = 'ckms-sink'; SINK_ADDR_FILE = HOME + '/cksim/sink_addr.txt'
|
||||||
|
def ensure_sink():
|
||||||
|
if SINK not in json.loads(bc('listwallets')):
|
||||||
|
try: bc('loadwallet', SINK)
|
||||||
|
except RuntimeError: bc('createwallet', SINK)
|
||||||
|
def sink_addr():
|
||||||
|
# STABLE whitelisted destination — reused (persisted) so it can live in each Coldcard's on-device
|
||||||
|
# address whitelist; an anonymous demo spend may only pay this address.
|
||||||
|
try:
|
||||||
|
a = open(SINK_ADDR_FILE).read().strip()
|
||||||
|
if a: return a
|
||||||
|
except Exception: pass
|
||||||
|
ensure_sink(); a = bc('getnewaddress', wallet=SINK); open(SINK_ADDR_FILE, 'w').write(a); return a
|
||||||
|
def other_addr():
|
||||||
|
# A fresh sink-wallet address deliberately NOT on the whitelist (the whitelist-block demo). The spend to
|
||||||
|
# it is refused on-device, so no coins ever land here.
|
||||||
|
ensure_sink(); return bc('getnewaddress', wallet=SINK)
|
||||||
|
def reclaim_sink():
|
||||||
|
# Sweep CONFIRMED sink coins back (minconf=1 -> trusted, so no untrusted-0-conf "-6" errors and no
|
||||||
|
# ever-growing unconfirmed chain). Sink funds become reclaimable one signet block after each demo spend.
|
||||||
|
try:
|
||||||
|
ensure_sink()
|
||||||
|
if json.loads(bc('listunspent', '1', wallet=SINK)):
|
||||||
|
bc('-named', 'sendall', 'recipients=' + json.dumps([signet_addr()]),
|
||||||
|
'fee_rate=1', 'options=' + json.dumps({'send_max': True}), wallet=SINK)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---- device helpers ----------------------------------------------------------
|
||||||
|
# The ckcc simulator client BINDS a per-(simpid,ourpid) unix socket with only 5 instance slots; a leaked
|
||||||
|
# (un-closed) ColdcardDevice holds its slot until process exit (atexit), so opening many without closing
|
||||||
|
# eventually exhausts the slots -> "[Errno 98] Address already in use". So EVERY device is opened via this
|
||||||
|
# context manager and closed immediately after use — never leak a ColdcardDevice in a long-running process.
|
||||||
|
@contextmanager
|
||||||
|
def dev(n):
|
||||||
|
d = ColdcardDevice(sn=SOCK[n])
|
||||||
|
try:
|
||||||
|
yield d
|
||||||
|
finally:
|
||||||
|
try: d.close()
|
||||||
|
except Exception: pass
|
||||||
|
def is_active(n):
|
||||||
|
with dev(n) as d:
|
||||||
|
return bool(hstat_raw(d).get('active'))
|
||||||
|
def E(d, c):
|
||||||
|
r = d.send_recv(b'EXEC' + c.encode(), encrypt=False)
|
||||||
|
return r.decode() if isinstance(r, (bytes, bytearray)) else r
|
||||||
|
def Vv(d, c):
|
||||||
|
r = d.send_recv(b'EVAL' + c.encode())
|
||||||
|
return (r.decode() if isinstance(r, (bytes, bytearray)) else r).strip()
|
||||||
|
def K(d, k): d.send_recv(CCProtocolPacker.sim_keypress(k.encode('ascii')))
|
||||||
|
def hstat_raw(d):
|
||||||
|
try:
|
||||||
|
r = d.send_recv(CCProtocolPacker.hsm_status()); r = r.decode() if isinstance(r, (bytes, bytearray)) else r
|
||||||
|
return json.loads(r)
|
||||||
|
except Exception: return {}
|
||||||
|
def hstat(n):
|
||||||
|
with dev(n) as d:
|
||||||
|
s = hstat_raw(d)
|
||||||
|
return {'active': bool(s.get('active')),
|
||||||
|
'approved': s.get('approvals', s.get('approved', 0)),
|
||||||
|
'refused': s.get('refusals', s.get('refused', 0))}
|
||||||
|
|
||||||
|
# The sim sockets are single-client, so concurrent device reads (status poll vs a running spend) collide
|
||||||
|
# and make a live signer look offline. A single background poller reads the devices under LOCK and caches
|
||||||
|
# the result; the /status endpoint serves the cache (never touches a device). During a spend (which holds
|
||||||
|
# LOCK) the poller simply waits — no contention.
|
||||||
|
STATUS_CACHE = {n: {'active': False, 'approved': 0, 'refused': 0} for n in ALL}
|
||||||
|
HEAL = {n: 0.0 for n in ALL} # last auto-heal attempt per signer (cooldown)
|
||||||
|
_LAST_HEALTH = ['']
|
||||||
|
def _quorum():
|
||||||
|
return sum(1 for n in ALL if ENABLED[n] and STATUS_CACHE[n]['active'])
|
||||||
|
def status_poller():
|
||||||
|
tick = 0
|
||||||
|
while True:
|
||||||
|
time.sleep(2); tick += 1
|
||||||
|
with LOCK:
|
||||||
|
for n in ALL:
|
||||||
|
try: STATUS_CACHE[n] = hstat(n)
|
||||||
|
except Exception: # unreachable/dead signer -> mark inactive so quorum + heal see the truth
|
||||||
|
STATUS_CACHE[n] = {'active': False, 'approved': STATUS_CACHE[n]['approved'], 'refused': STATUS_CACHE[n]['refused']}
|
||||||
|
# quorum-health line for Loki/Grafana/Sentinel — on change, plus a ~30s heartbeat
|
||||||
|
q = _quorum(); ready = sum(1 for n in ALL if STATUS_CACHE[n]['active'])
|
||||||
|
lvl = 'OK' if q >= 3 else ('WARN' if q == 2 else 'CRIT')
|
||||||
|
msg = 'multisig-health quorum=%d needed=2 signers_ready=%d/3 status=%s' % (q, ready, lvl)
|
||||||
|
if msg != _LAST_HEALTH[0] or tick % 15 == 0:
|
||||||
|
_LAST_HEALTH[0] = msg; print(msg, flush=True)
|
||||||
|
|
||||||
|
def heal_loop():
|
||||||
|
# Boot-to-signing-ready auto-heal: re-arm any ENABLED signer that is reachable but not HSM-active (e.g. it
|
||||||
|
# rebooted and came back online). The quorum self-heals unattended. Cooldown avoids hammering; a signer
|
||||||
|
# whose process is gone can't be healed here (it raises) — the rig must relaunch it first.
|
||||||
|
time.sleep(24)
|
||||||
|
while True:
|
||||||
|
time.sleep(10)
|
||||||
|
for n in ALL:
|
||||||
|
if not ENABLED[n] or STATUS_CACHE[n]['active'] or time.time() - HEAL[n] < 25: continue
|
||||||
|
HEAL[n] = time.time()
|
||||||
|
try:
|
||||||
|
with LOCK:
|
||||||
|
if STATUS_CACHE[n]['active']: continue
|
||||||
|
print('auto-heal: signer %d not signing-ready — re-arming…' % n, flush=True)
|
||||||
|
arm_one(n, lambda *a, **k: None)
|
||||||
|
print('auto-heal: signer %d re-armed to signing-ready' % n, flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print('auto-heal: signer %d still unreachable (%s)' % (n, str(e)[:60]), flush=True)
|
||||||
|
def story(d): return E(d, "RV.write(repr(sim_display.story))")
|
||||||
|
def ms_count(d):
|
||||||
|
try: return int(Vv(d, "len(settings.get('multisig',[]))") or 0)
|
||||||
|
except Exception: return 0
|
||||||
|
|
||||||
|
def write_policy():
|
||||||
|
# Rule dimensions, all enforced ON-DEVICE: max_amount (per-txn), per_period+period (velocity),
|
||||||
|
# whitelist (allowed destinations — change back to ckms23 is auto-recognised and not gated by it).
|
||||||
|
# Two-tier, FIRST-MATCH ordered: rule#1 = automated baseline (no users); rule#2 = TOTP-gated surge
|
||||||
|
# (higher cap + velocity, requires the "owner" to present a code). A small spend matches rule#1 and
|
||||||
|
# auto-signs; one that exceeds it falls through to rule#2 and needs the TOTP.
|
||||||
|
wl = [sink_addr()] + list(EXTRA_WL)
|
||||||
|
pol = {"must_log": True, "period": POLICY['period_min'],
|
||||||
|
"msg_paths": ["any"], # allow on-device message signing under HSM (proof-of-control demo)
|
||||||
|
"rules": [
|
||||||
|
{"max_amount": POLICY['max_sat'], "per_period": POLICY['per_period_sat'],
|
||||||
|
"wallet": "ckms23", "whitelist": wl},
|
||||||
|
{"max_amount": SURGE['max_sat'], "per_period": SURGE['per_period_sat'],
|
||||||
|
"wallet": "ckms23", "whitelist": wl, "users": ["owner"], "min_users": 1}]}
|
||||||
|
open(POLICY_FILE, 'w').write(json.dumps(pol))
|
||||||
|
|
||||||
|
def arm_one(n, log):
|
||||||
|
with dev(n) as d:
|
||||||
|
if hstat_raw(d).get('active'): return
|
||||||
|
need_reg = ms_count(d) < 1
|
||||||
|
if need_reg:
|
||||||
|
log('coldcard %s · registering the 2-of-3 wallet…' % LET[n], 'c')
|
||||||
|
subprocess.run([CKCC, '-c', SOCK[n], 'upload', '-m', WALLET_FILE], capture_output=True)
|
||||||
|
time.sleep(1.2)
|
||||||
|
with dev(n) as d2:
|
||||||
|
for _ in range(10):
|
||||||
|
if ms_count(d2) >= 1: break
|
||||||
|
K(d2, 'y'); time.sleep(0.8)
|
||||||
|
with dev(n) as d:
|
||||||
|
E(d, "settings.put('hsmcmd', True); settings.save(); RV.write('ok')")
|
||||||
|
try: # enrol the "owner" TOTP user (referenced by the surge rule) — before HSM start, fresh device
|
||||||
|
d.send_recv(CCProtocolPacker.create_user(b'owner', USER_AUTH_TOTP, b32decode(totp_secret(), casefold=True)))
|
||||||
|
except Exception: pass
|
||||||
|
log('coldcard %s · entering HSM mode (auto ≤%d · surge ≤%d w/ TOTP)…' % (LET[n], POLICY['max_sat'], SURGE['max_sat']), 'c')
|
||||||
|
flen, sha = real_file_upload(open(POLICY_FILE, 'rb'), d)
|
||||||
|
d.send_recv(CCProtocolPacker.hsm_start(flen, sha)); time.sleep(1.3)
|
||||||
|
for _ in range(18):
|
||||||
|
if hstat_raw(d).get('active'): break
|
||||||
|
s = story(d); m = re.search(r'Press \((\d)\) to save', s)
|
||||||
|
if m: K(d, m.group(1)); time.sleep(1.4); continue
|
||||||
|
if 'Press OK to enable' in s or s.startswith("('Start HSM?'"): K(d, 'y'); time.sleep(1.4); continue
|
||||||
|
time.sleep(0.4)
|
||||||
|
log('coldcard %s · HSM ACTIVE ✓' % LET[n], 'g')
|
||||||
|
|
||||||
|
def arm_all(log):
|
||||||
|
write_policy()
|
||||||
|
for n in ALL: arm_one(n, log)
|
||||||
|
|
||||||
|
def rearm_all(log):
|
||||||
|
write_policy()
|
||||||
|
log('restarting all signers to load the new policy…', 'c')
|
||||||
|
subprocess.run(['bash', DEMO_RIG], capture_output=True, timeout=150)
|
||||||
|
time.sleep(3)
|
||||||
|
for n in ALL: arm_one(n, log)
|
||||||
|
log('policy applied · ≤%d sats/txn · ≤%d sats per %d min · whitelist enforced on all signers'
|
||||||
|
% (POLICY['max_sat'], POLICY['per_period_sat'], POLICY['period_min']), 'g')
|
||||||
|
|
||||||
|
def _reason(d, fallback):
|
||||||
|
# The device records WHY it refused in hsm_status.last_refusal (e.g. "over per-txn limit",
|
||||||
|
# "exceeds velocity", "address not allowed", "has 1 warning(s)"). Surface it — far clearer than the
|
||||||
|
# generic ckcc "You refused permission" string, and it makes the policy dimension obvious in the demo.
|
||||||
|
try:
|
||||||
|
time.sleep(0.2)
|
||||||
|
r = re.sub(r'^\s*Rejected:\s*', '', hstat_raw(d).get('last_refusal') or '').strip()
|
||||||
|
if r: return r[:90]
|
||||||
|
except Exception: pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def sign_hsm(n, psbt, code=None):
|
||||||
|
with dev(n) as d:
|
||||||
|
if code: # relay the owner's TOTP so the device's surge rule (min_users) is satisfied
|
||||||
|
try: d.send_recv(CCProtocolPacker.user_auth(b'owner', code.encode('ascii'), int(time.time()) // 30))
|
||||||
|
except Exception: pass
|
||||||
|
before = hstat_raw(d).get('refusals', hstat_raw(d).get('refused', 0))
|
||||||
|
flen, sha = real_file_upload(io.BytesIO(psbt), d)
|
||||||
|
try:
|
||||||
|
d.send_recv(CCProtocolPacker.sign_transaction(flen, sha, flags=0x0), timeout=None)
|
||||||
|
except Exception as e:
|
||||||
|
return ('refused', _reason(d, str(e)[:80]))
|
||||||
|
for _ in range(40):
|
||||||
|
time.sleep(0.4)
|
||||||
|
try:
|
||||||
|
r = d.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
|
||||||
|
except Exception as e:
|
||||||
|
return ('refused', _reason(d, str(e)[:80]))
|
||||||
|
if r is not None:
|
||||||
|
rl, rsha = r
|
||||||
|
return ('signed', b64encode(d.download_file(rl, rsha, file_number=1)).decode())
|
||||||
|
cur = hstat_raw(d)
|
||||||
|
if cur.get('refusals', cur.get('refused', 0)) > before:
|
||||||
|
return ('refused', _reason(d, 'on-device policy denied the spend'))
|
||||||
|
return ('refused', 'no signature (policy)')
|
||||||
|
|
||||||
|
# ---- streamed spend ----------------------------------------------------------
|
||||||
|
def sse_spend(send, amount_sat, dest_kind='sink', custom_addr=None, code=None):
|
||||||
|
def log(t, cls=''): send({'log': t, 'cls': cls})
|
||||||
|
def step(n): send({'step': n});
|
||||||
|
def done(n): send({'done_step': n})
|
||||||
|
amount_sat = max(546, int(amount_sat))
|
||||||
|
amount_btc = amount_sat / 1e8
|
||||||
|
surge = bool(code)
|
||||||
|
over = amount_sat > POLICY['max_sat']
|
||||||
|
off_list = dest_kind == 'other'
|
||||||
|
|
||||||
|
avail = [n for n in ALL if ENABLED[n] and is_active(n)]
|
||||||
|
if len(avail) < 2:
|
||||||
|
log('quorum lost · only %d policy signer(s) online — a 2-of-3 spend needs 2.' % len(avail), 'y')
|
||||||
|
log('(this is the failure-domain property: lose any one signer and you keep operating; lose two and funds are safe but frozen.)', 'c')
|
||||||
|
send({'end': True}); return
|
||||||
|
signers = avail[:2]
|
||||||
|
|
||||||
|
# COORDINATOR global velocity gate — the authoritative cap, enforced BEFORE any signer is asked, and
|
||||||
|
# counting only real broadcasts. Off-list spends are skipped (the devices refuse them on the whitelist).
|
||||||
|
if not off_list:
|
||||||
|
cap = SURGE_GLOBAL['per_period_sat'] if surge else GLOBAL['per_period_sat']
|
||||||
|
spent = global_spent()
|
||||||
|
if spent + amount_sat > cap:
|
||||||
|
log('coordinator %s velocity: this %d-sat spend would push the %d-min total to %d > the %d-sat cap.'
|
||||||
|
% ('surge' if surge else 'global', amount_sat, GLOBAL['period_min'], spent + amount_sat, cap), 'y')
|
||||||
|
log('blocked at the keyless coordinator, before any signer is asked — per-device velocity is only a backstop; the real cap is global and counts only broadcast txns, so dropped PSBTs never burn budget.', 'c')
|
||||||
|
send({'refused': True, 'end': True}); return
|
||||||
|
|
||||||
|
reclaim_sink() # pull back funds from prior demo spends -> self-sustaining
|
||||||
|
us, bal = balance_sat()
|
||||||
|
if bal < amount_sat + 500:
|
||||||
|
log('the 2-of-3 wallet needs confirmed signet coins. Send some to:', 'y'); log(' ' + signet_addr(), 'a')
|
||||||
|
send({'end': True}); return
|
||||||
|
|
||||||
|
step(1)
|
||||||
|
if surge:
|
||||||
|
log('SURGE: building a %d-sat PSBT — TOTP-authorised tier 2 (cap %d, velocity %d)…' % (amount_sat, SURGE['max_sat'], SURGE['per_period_sat']), 'c')
|
||||||
|
elif over:
|
||||||
|
log('building a PSBT that EXCEEDS the automated tier: %d sats > the %d-sat cap (no TOTP)…' % (amount_sat, POLICY['max_sat']), 'c')
|
||||||
|
else:
|
||||||
|
log('building a PSBT within policy: %d sats (limit %d)…' % (amount_sat, POLICY['max_sat']), 'c')
|
||||||
|
if dest_kind == 'custom' and custom_addr:
|
||||||
|
dest = custom_addr # the signed-in user's authorised (whitelisted) address
|
||||||
|
log('paying your authorised address %s (on the whitelist)…' % custom_addr, 'c')
|
||||||
|
elif off_list:
|
||||||
|
dest = other_addr() # NOT on the on-device whitelist -> the HSM should refuse the payee
|
||||||
|
log('paying an address that is NOT on the policy whitelist — the signers should refuse it…', 'c')
|
||||||
|
else:
|
||||||
|
dest = sink_addr() # EXTERNAL to the 2-of-3 (whitelisted) -> real outflow, policy bites
|
||||||
|
# Seed the spend with the LARGEST UTXO. Coldcard's HSM rejects ANY PSBT carrying a warning, and "Big Fee"
|
||||||
|
# fires when the network fee exceeds 5% of total value. Core's default minimal coin-selection would grab a
|
||||||
|
# tiny fragmented UTXO (fee then ~6%+), so we pin the biggest UTXO as the seed input (add_inputs tops up if
|
||||||
|
# needed) -> fee is a fraction of a percent. fee_rate=1 for extra headroom. include_unsafe: spend our own
|
||||||
|
# 0-conf coins (sink-reclaim returns funds as untrusted 0-conf).
|
||||||
|
seed = max(us, key=lambda u: u['amount'])
|
||||||
|
try:
|
||||||
|
funded = json.loads(bc('walletcreatefundedpsbt',
|
||||||
|
json.dumps([{'txid': seed['txid'], 'vout': seed['vout']}]),
|
||||||
|
json.dumps([{dest: round(amount_btc, 8)}]), '0',
|
||||||
|
json.dumps({'fee_rate': 1, 'change_type': 'bech32',
|
||||||
|
'include_unsafe': True, 'add_inputs': True}), wallet=WALLET))
|
||||||
|
except RuntimeError as e:
|
||||||
|
m = str(e)
|
||||||
|
if 'Insufficient' in m or 'mempool-chain' in m or 'too-long' in m or 'too long' in m:
|
||||||
|
log('the demo wallet is briefly tied up in unconfirmed transactions — give the next signet block ~10 min, or top up the address.', 'y')
|
||||||
|
else:
|
||||||
|
log('build error: ' + m[:140], 'e')
|
||||||
|
send({'end': True}); return
|
||||||
|
psbt = b64decode(funded['psbt']); done(1)
|
||||||
|
|
||||||
|
step(2); log('coordinator fans the PSBT to signers %s (it holds no keys)…' % '+'.join(LET[n] for n in signers), 'c'); time.sleep(0.4); done(2)
|
||||||
|
|
||||||
|
step(3)
|
||||||
|
if surge: log('relaying your TOTP authorisation to each signer (the coordinator never holds the secret)…', 'c')
|
||||||
|
sigs = []; refusals = 0
|
||||||
|
for n in signers:
|
||||||
|
log('coldcard %s · evaluating against its on-device policy…' % LET[n], 'c')
|
||||||
|
kind, payload = sign_hsm(n, psbt, code)
|
||||||
|
if kind == 'signed':
|
||||||
|
sigs.append(payload); log('coldcard %s · signed ✓' % LET[n], 'g')
|
||||||
|
else:
|
||||||
|
refusals += 1; log('coldcard %s · REFUSED ✗ — %s' % (LET[n], payload), 'y')
|
||||||
|
if refusals:
|
||||||
|
log('policy held: the spend was blocked on-device. No transaction created.', 'y')
|
||||||
|
log('// the device refused to sign — exactly what a programmable HSM is for.', 'c')
|
||||||
|
send({'refused': True, 'end': True}); return
|
||||||
|
done(3)
|
||||||
|
|
||||||
|
step(4); fin = json.loads(bc('finalizepsbt', bc('combinepsbt', json.dumps(sigs))))
|
||||||
|
if not fin.get('complete'):
|
||||||
|
log('ERROR: signatures did not finalize', 'e'); send({'end': True}); return
|
||||||
|
log('combined 2 partial signatures · transaction complete ✓', 'g'); done(4)
|
||||||
|
|
||||||
|
step(5); txid = bc('sendrawtransaction', fin['hex'])
|
||||||
|
if not off_list: ledger_append(amount_sat, txid) # only real broadcasts count toward the global cap
|
||||||
|
log('broadcast to signet ✓ txid ' + txid, 'a')
|
||||||
|
log('watch it confirm live → mempool.space/signet/tx/' + txid, 'g')
|
||||||
|
if surge:
|
||||||
|
log('// TOTP-authorised surge: 2 of 3 signed a larger spend with the owner in the loop — still bounded by the on-device surge ceiling.', 'c')
|
||||||
|
else:
|
||||||
|
log('// two independent Coldcards moved real signet coins under policy — no human in the loop.', 'c')
|
||||||
|
done(5); send({'end': True})
|
||||||
|
|
||||||
|
# ---- streamed message signing (2-of-3 proof of control, no funds moved) ------
|
||||||
|
def sign_msg_one(n, text):
|
||||||
|
r = subprocess.run([CKCC, '-c', SOCK[n], 'msg', text, '-p', MSG_PATH], capture_output=True, text=True, timeout=45)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return ('fail', (r.stderr.strip() or r.stdout.strip() or 'refused')[:90])
|
||||||
|
lines = [l.strip() for l in r.stdout.replace('\r', '\n').splitlines() if l.strip() and 'Waiting' not in l]
|
||||||
|
sig = lines[-1] if lines else ''
|
||||||
|
addr = lines[-2] if len(lines) >= 2 else ''
|
||||||
|
return ('signed', (addr, sig))
|
||||||
|
|
||||||
|
def sse_signmsg(send, text):
|
||||||
|
def log(t, cls=''): send({'log': t, 'cls': cls})
|
||||||
|
text = (text or '').strip()[:140] or 'mineracks 2-of-3 proof-of-control'
|
||||||
|
avail = [n for n in ALL if ENABLED[n] and is_active(n)]
|
||||||
|
if len(avail) < 2:
|
||||||
|
log('quorum lost · only %d signer(s) online — need 2 keyholders to attest.' % len(avail), 'y'); send({'end': True}); return
|
||||||
|
signers = avail[:2]
|
||||||
|
log('$ coordinator sign-message', 'c')
|
||||||
|
log('challenge: "%s"' % text, 'a')
|
||||||
|
log('asking %s to each sign it with their key in the 2-of-3 wallet (HSM · no human)…' % '+'.join(LET[n] for n in signers), 'c')
|
||||||
|
got = 0
|
||||||
|
for n in signers:
|
||||||
|
kind, payload = sign_msg_one(n, text)
|
||||||
|
if kind == 'signed':
|
||||||
|
addr, sig = payload; got += 1
|
||||||
|
log('coldcard %s attested ✓' % LET[n], 'g')
|
||||||
|
log(' address ' + addr, 'a')
|
||||||
|
log(' signature ' + sig, 'a')
|
||||||
|
else:
|
||||||
|
log('coldcard %s could not sign — %s' % (LET[n], payload), 'y')
|
||||||
|
if got >= 2:
|
||||||
|
log('2 of 3 keyholders signed the same challenge ✓ — verifiable proof of 2-of-3 control, no funds moved.', 'g')
|
||||||
|
log('// verify each signature against its address with any Bitcoin signed-message tool.', 'c')
|
||||||
|
send({'end': True})
|
||||||
|
|
||||||
|
# ---- http --------------------------------------------------------------------
|
||||||
|
def status_json():
|
||||||
|
try: _, bal = balance_sat()
|
||||||
|
except Exception: bal = -1
|
||||||
|
devs = {}
|
||||||
|
for n in ALL:
|
||||||
|
h = STATUS_CACHE[n]
|
||||||
|
devs[str(n)] = {'on': ENABLED[n], 'active': h['active'], 'approved': h['approved'], 'refused': h['refused']}
|
||||||
|
online = sum(1 for n in ALL if ENABLED[n] and devs[str(n)]['active'])
|
||||||
|
return {'balance_sat': bal, 'devices': devs, 'quorum_online': online, 'quorum_needed': 2,
|
||||||
|
'policy': {'max_sat': POLICY['max_sat'], 'per_period_sat': POLICY['per_period_sat'], 'period_min': POLICY['period_min']},
|
||||||
|
'fund_addr': signet_addr(), 'sink_addr': sink_addr(), 'authorized': list(EXTRA_WL),
|
||||||
|
'global': {'per_period_sat': GLOBAL['per_period_sat'], 'period_min': GLOBAL['period_min'], 'spent': global_spent()},
|
||||||
|
'surge': {'max_sat': SURGE['max_sat'], 'per_period_sat': SURGE['per_period_sat'],
|
||||||
|
'global_per_period_sat': SURGE_GLOBAL['per_period_sat']}}
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def _json(self, obj, code=200, set_cookie=None):
|
||||||
|
b = json.dumps(obj).encode(); self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json'); self.send_header('Cache-Control', 'no-cache')
|
||||||
|
if set_cookie: self.send_header('Set-Cookie', set_cookie)
|
||||||
|
self.send_header('Content-Length', str(len(b))); self.end_headers(); self.wfile.write(b)
|
||||||
|
def _uid(self): return read_session(self.headers.get('Cookie', ''))
|
||||||
|
def _sse(self):
|
||||||
|
self.send_response(200); self.send_header('Content-Type', 'text/event-stream')
|
||||||
|
self.send_header('Cache-Control', 'no-cache'); self.end_headers()
|
||||||
|
def send(obj): self.wfile.write(('data: %s\n\n' % json.dumps(obj)).encode()); self.wfile.flush()
|
||||||
|
return send
|
||||||
|
def do_GET(self):
|
||||||
|
u = urllib.parse.urlparse(self.path); p = u.path.rstrip('/'); q = urllib.parse.parse_qs(u.query)
|
||||||
|
if p in ('', '/status'):
|
||||||
|
try: self._json(status_json())
|
||||||
|
except Exception as e: self._json({'error': str(e)[:120]}, 500)
|
||||||
|
return
|
||||||
|
if p == '/auth/me':
|
||||||
|
uid = self._uid(); self._json({'user': uid, 'pubkey': PUBKEYS.get(uid) if uid else None}); return
|
||||||
|
if p == '/auth/nostr/challenge':
|
||||||
|
self._json({'challenge': make_challenge()}); return
|
||||||
|
if p == '/totp_enroll':
|
||||||
|
if not self._uid(): self._json({'error': 'sign in required'}, 401); return
|
||||||
|
sec = totp_secret()
|
||||||
|
self._json({'secret': sec,
|
||||||
|
'otpauth': 'otpauth://totp/multisigHSM:owner?secret=%s&issuer=multisigHSM&period=30&digits=6' % sec}); return
|
||||||
|
if p == '/device':
|
||||||
|
n = int(q.get('n', [0])[0]); on = q.get('on', ['1'])[0] == '1'
|
||||||
|
if n in ALL: ENABLED[n] = on
|
||||||
|
self._json({'ok': True, 'devices': status_json()['devices']}); return
|
||||||
|
if p == '/globalpolicy':
|
||||||
|
if q.get('reset', [''])[0] == '1':
|
||||||
|
RESET_TS[0] = int(time.time()); _TX_CACHE['txs'] = None # move the window start to now (demo)
|
||||||
|
try: open(LEDGER_FILE, 'w').write('[]')
|
||||||
|
except Exception: pass
|
||||||
|
try: GLOBAL['per_period_sat'] = max(546, min(10**10, int(q.get('per_period_sat', [GLOBAL['per_period_sat']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
try: GLOBAL['period_min'] = max(1, min(10080, int(q.get('period_min', [GLOBAL['period_min']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
self._json({'ok': True, 'global': status_json()['global']}); return
|
||||||
|
if p == '/policy':
|
||||||
|
try: POLICY['max_sat'] = max(546, min(100000000, int(q.get('max_sat', [POLICY['max_sat']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
try: POLICY['per_period_sat'] = max(546, min(1000000000, int(q.get('per_period_sat', [POLICY['per_period_sat']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
try: POLICY['period_min'] = max(1, min(1440, int(q.get('period_min', [POLICY['period_min']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
send = self._sse()
|
||||||
|
try:
|
||||||
|
with LOCK: rearm_all(lambda t, c='': send({'log': t, 'cls': c}))
|
||||||
|
send({'end': True})
|
||||||
|
except Exception as e:
|
||||||
|
try: send({'log': 'policy re-arm error: %s' % str(e)[:160], 'cls': 'e'}); send({'end': True})
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
if p == '/surgepolicy':
|
||||||
|
send = self._sse()
|
||||||
|
if not self._uid():
|
||||||
|
send({'log': 'sign in with Nostr to configure the TOTP surge tier.', 'cls': 'y'}); send({'end': True}); return
|
||||||
|
try: SURGE['max_sat'] = max(POLICY['max_sat'], min(10**9, int(q.get('max_sat', [SURGE['max_sat']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
try: SURGE['per_period_sat'] = max(SURGE['max_sat'], min(10**10, int(q.get('per_period_sat', [SURGE['per_period_sat']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
try: SURGE_GLOBAL['per_period_sat'] = max(GLOBAL['per_period_sat'], min(10**10, int(q.get('global_per_period_sat', [SURGE_GLOBAL['per_period_sat']])[0])))
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
with LOCK: rearm_all(lambda t, c='': send({'log': t, 'cls': c}))
|
||||||
|
send({'log': 'surge tier set · TOTP unlocks ≤%d sats/txn · ≤%d sats/period' % (SURGE['max_sat'], SURGE['per_period_sat']), 'cls': 'g'})
|
||||||
|
send({'end': True})
|
||||||
|
except Exception as e:
|
||||||
|
try: send({'log': 'surge re-arm error: %s' % str(e)[:160], 'cls': 'e'}); send({'end': True})
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
if p == '/authorize':
|
||||||
|
send = self._sse()
|
||||||
|
try:
|
||||||
|
uid = self._uid()
|
||||||
|
if not uid:
|
||||||
|
send({'log': 'sign in with Nostr first to authorise your own address.', 'cls': 'y'}); send({'end': True}); return
|
||||||
|
addr = q.get('addr', [''])[0].strip()
|
||||||
|
try: valid = json.loads(bc('validateaddress', addr)).get('isvalid', False)
|
||||||
|
except Exception: valid = False
|
||||||
|
if not valid:
|
||||||
|
send({'log': 'that is not a valid signet address.', 'cls': 'y'}); send({'end': True}); return
|
||||||
|
with LOCK:
|
||||||
|
add_wl(addr)
|
||||||
|
send({'log': 'adding %s to the policy whitelist on all 3 signers…' % addr, 'cls': 'c'})
|
||||||
|
rearm_all(lambda t, c='': send({'log': t, 'cls': c}))
|
||||||
|
send({'log': 'your address is now whitelisted — you can pay it; anonymous users still cannot.', 'cls': 'g'})
|
||||||
|
send({'authorized': addr, 'end': True})
|
||||||
|
except Exception as e:
|
||||||
|
try: send({'log': 'authorize error: %s' % str(e)[:160], 'cls': 'e'}); send({'end': True})
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
if p == '/reboot':
|
||||||
|
send = self._sse()
|
||||||
|
try:
|
||||||
|
n = int(q.get('n', [0])[0])
|
||||||
|
if n not in ALL: send({'log': 'bad signer', 'cls': 'e'}); send({'end': True}); return
|
||||||
|
send({'log': 'rebooting signer %s — power-cycling the device (loses HSM mode)…' % LET[n], 'cls': 'c'}); send({'step': 0})
|
||||||
|
HEAL[n] = 0 # let auto-heal act immediately
|
||||||
|
subprocess.run(['bash', DEMO_RIG, str(n)], capture_output=True, timeout=120)
|
||||||
|
send({'log': 'signer %s back online but NOT signing-ready (fresh boot, no HSM) — quorum is now %d/3.' % (LET[n], _quorum()), 'cls': 'y'})
|
||||||
|
send({'log': 'the coordinator detects it and AUTO-HEALS — re-registering the wallet, re-enrolling, re-entering HSM, unattended…', 'cls': 'c'})
|
||||||
|
ok = False
|
||||||
|
for _ in range(45):
|
||||||
|
time.sleep(1.5)
|
||||||
|
if STATUS_CACHE[n]['active']: ok = True; break
|
||||||
|
if ok:
|
||||||
|
send({'log': 'auto-heal complete: signer %s is signing-ready again ✓ — quorum restored to %d/3.' % (LET[n], _quorum()), 'cls': 'g'})
|
||||||
|
send({'log': '// boot-to-signing-ready: no human re-armed it. A rebooted signer self-rejoins the quorum.', 'cls': 'c'})
|
||||||
|
else:
|
||||||
|
send({'log': 'signer %s not back yet — auto-heal keeps retrying every ~10s in the background.' % LET[n], 'cls': 'y'})
|
||||||
|
send({'end': True})
|
||||||
|
except Exception as e:
|
||||||
|
try: send({'log': 'reboot error: %s' % str(e)[:120], 'cls': 'e'}); send({'end': True})
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
if p in ('/spend', '/signmsg'):
|
||||||
|
send = self._sse()
|
||||||
|
try:
|
||||||
|
if p == '/spend':
|
||||||
|
try: amount = int(q.get('amount', [DEFAULT_SAT])[0])
|
||||||
|
except Exception: amount = DEFAULT_SAT
|
||||||
|
dk = q.get('dest', ['sink'])[0]
|
||||||
|
code = (q.get('code', [''])[0] or '').strip() or None
|
||||||
|
uid = self._uid()
|
||||||
|
if code and not uid:
|
||||||
|
send({'log': 'sign in with Nostr to use a TOTP surge authorisation.', 'cls': 'y'}); send({'end': True})
|
||||||
|
elif code and not (code.isdigit() and len(code) == 6):
|
||||||
|
send({'log': 'a TOTP code is 6 digits.', 'cls': 'y'}); send({'end': True})
|
||||||
|
elif dk == 'custom':
|
||||||
|
addr = q.get('addr', [''])[0].strip()
|
||||||
|
if not uid or not addr:
|
||||||
|
send({'log': 'sign in with Nostr and authorise an address first.', 'cls': 'y'}); send({'end': True})
|
||||||
|
else:
|
||||||
|
with LOCK: sse_spend(send, amount, 'custom', addr, code)
|
||||||
|
elif dk == 'other':
|
||||||
|
with LOCK: sse_spend(send, amount, 'other', None, code)
|
||||||
|
else:
|
||||||
|
with LOCK: sse_spend(send, amount, 'sink', None, code)
|
||||||
|
else:
|
||||||
|
with LOCK: sse_signmsg(send, q.get('text', [''])[0])
|
||||||
|
except Exception as e:
|
||||||
|
try: send({'log': 'ERROR: %s' % str(e)[:200], 'cls': 'e'}); send({'end': True})
|
||||||
|
except Exception: pass
|
||||||
|
return
|
||||||
|
self.send_response(404); self.end_headers()
|
||||||
|
def do_POST(self):
|
||||||
|
p = urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||||
|
n = int(self.headers.get('Content-Length', 0) or 0)
|
||||||
|
body = self.rfile.read(n) if n else b''
|
||||||
|
if p == '/auth/nostr/verify':
|
||||||
|
try: ev = json.loads(body)
|
||||||
|
except Exception: return self._json({'error': 'bad json'}, 400)
|
||||||
|
try:
|
||||||
|
if nostr_event_id(ev) != ev['id']: return self._json({'error': 'bad event id'}, 400)
|
||||||
|
if not check_challenge(ev.get('content', '')): return self._json({'error': 'stale or invalid challenge'}, 401)
|
||||||
|
ok = schnorr_verify(bytes.fromhex(ev['id']), bytes.fromhex(ev['pubkey']), bytes.fromhex(ev['sig']))
|
||||||
|
except (KeyError, ValueError): return self._json({'error': 'malformed event'}, 400)
|
||||||
|
if not ok: return self._json({'error': 'signature verification failed'}, 401)
|
||||||
|
uid = uid_for('nostr', ev['pubkey']); PUBKEYS[uid] = ev['pubkey']
|
||||||
|
cookie = '%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax' % (COOKIE, make_session(uid), SESSION_TTL)
|
||||||
|
return self._json({'user': uid, 'pubkey': ev['pubkey']}, 200, cookie)
|
||||||
|
if p == '/auth/logout':
|
||||||
|
return self._json({'ok': True}, 200, '%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax' % COOKIE)
|
||||||
|
self.send_response(404); self.end_headers()
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
|
||||||
|
def startup():
|
||||||
|
time.sleep(8)
|
||||||
|
try: ensure_wallet()
|
||||||
|
except Exception as e: print('wallet init:', e, flush=True)
|
||||||
|
with LOCK:
|
||||||
|
try: arm_all(lambda *a, **k: None)
|
||||||
|
except Exception as e: print('startup arm:', e, flush=True)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
threading.Thread(target=startup, daemon=True).start()
|
||||||
|
threading.Thread(target=status_poller, daemon=True).start()
|
||||||
|
threading.Thread(target=heal_loop, daemon=True).start()
|
||||||
|
print('interactive signet orchestrator on :8099', flush=True)
|
||||||
|
ThreadingHTTPServer(('127.0.0.1', 8099), H).serve_forever()
|
||||||
1
reference/policy.json
Normal file
1
reference/policy.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"must_log": true, "period": 60, "msg_paths": ["any"], "rules": [{"max_amount": 8000, "per_period": 500000, "wallet": "ckms23", "whitelist": ["REPLACE_WITH_YOUR_WHITELISTED_DEPOSIT_ADDRESS"]}, {"max_amount": 20000, "per_period": 200000, "wallet": "ckms23", "whitelist": ["REPLACE_WITH_YOUR_WHITELISTED_DEPOSIT_ADDRESS"], "users": ["owner"], "min_users": 1}]}
|
||||||
23
reference/setup_btc.sh
Normal file
23
reference/setup_btc.sh
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
exec > /tmp/btcsetup.log 2>&1
|
||||||
|
set -x
|
||||||
|
cd ~
|
||||||
|
VER=27.0
|
||||||
|
if [ ! -x ~/bitcoin-$VER/bin/bitcoind ]; then
|
||||||
|
wget -q https://bitcoincore.org/bin/bitcoin-core-$VER/bitcoin-$VER-x86_64-linux-gnu.tar.gz
|
||||||
|
tar xzf bitcoin-$VER-x86_64-linux-gnu.tar.gz
|
||||||
|
fi
|
||||||
|
DATA=~/cksim/regtest-data; mkdir -p "$DATA"
|
||||||
|
cat > "$DATA/bitcoin.conf" <<CONF
|
||||||
|
regtest=1
|
||||||
|
server=1
|
||||||
|
fallbackfee=0.0001
|
||||||
|
[regtest]
|
||||||
|
rpcuser=ck
|
||||||
|
rpcpassword=ckms
|
||||||
|
rpcport=18999
|
||||||
|
CONF
|
||||||
|
~/bitcoin-$VER/bin/bitcoind -datadir="$DATA" -daemon
|
||||||
|
sleep 4
|
||||||
|
~/bitcoin-$VER/bin/bitcoin-cli -datadir="$DATA" -rpcport=18999 -rpcuser=ck -rpcpassword=ckms getblockchaininfo | head -5
|
||||||
|
echo "BTC_SETUP_DONE"
|
||||||
32
reference/setup_cloudflared.sh
Normal file
32
reference/setup_cloudflared.sh
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
exec > /tmp/cfd_setup.log 2>&1
|
||||||
|
set -x
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if ! command -v cloudflared >/dev/null; then
|
||||||
|
curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o /tmp/cfd.deb
|
||||||
|
sudo dpkg -i /tmp/cfd.deb || sudo apt-get install -f -y
|
||||||
|
fi
|
||||||
|
cloudflared --version
|
||||||
|
sudo mkdir -p /etc/cloudflared
|
||||||
|
sudo cp /tmp/ms_tunnel_token.env /etc/cloudflared/token.env
|
||||||
|
sudo chmod 600 /etc/cloudflared/token.env; sudo chown root:root /etc/cloudflared/token.env
|
||||||
|
rm -f /tmp/ms_tunnel_token.env
|
||||||
|
sudo tee /etc/systemd/system/cloudflared-multisighsm.service >/dev/null <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=cloudflared tunnel multisighsm
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/cloudflared/token.env
|
||||||
|
ExecStart=/usr/bin/cloudflared tunnel --no-autoupdate run
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now cloudflared-multisighsm
|
||||||
|
sleep 6
|
||||||
|
echo "active=$(sudo systemctl is-active cloudflared-multisighsm)"
|
||||||
|
echo CFD_SETUP_DONE
|
||||||
7
reference/setup_gui.sh
Normal file
7
reference/setup_gui.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
exec > /tmp/gui_setup.log 2>&1
|
||||||
|
set -x
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
sudo apt-get install -y -qq x11vnc websockify novnc fonts-dejavu-core >/dev/null 2>&1
|
||||||
|
which x11vnc websockify; ls /usr/share/novnc/vnc.html 2>/dev/null || ls /usr/share/webapps/novnc 2>/dev/null || echo "novnc path?"
|
||||||
|
echo GUI_DEPS_DONE
|
||||||
35
reference/setup_web.sh
Normal file
35
reference/setup_web.sh
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
exec > /tmp/web_setup.log 2>&1
|
||||||
|
set -x
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
sudo apt-get install -y -qq nginx
|
||||||
|
sudo mkdir -p /var/www/multisighsm
|
||||||
|
sudo cp /tmp/index.html /var/www/multisighsm/index.html
|
||||||
|
sudo tee /etc/nginx/ws_params > /dev/null <<'WS'
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
WS
|
||||||
|
sudo tee /etc/nginx/sites-available/multisighsm > /dev/null <<'NGINX'
|
||||||
|
server {
|
||||||
|
listen 8090;
|
||||||
|
server_name multisig-hsm.example.com;
|
||||||
|
root /var/www/multisighsm;
|
||||||
|
index index.html;
|
||||||
|
location / { try_files $uri $uri/ =404; add_header Cache-Control "no-cache"; }
|
||||||
|
location /novnc/ { alias /usr/share/novnc/; }
|
||||||
|
location /ws/dev1 { proxy_pass http://127.0.0.1:6911/; include /etc/nginx/ws_params; }
|
||||||
|
location /ws/dev2 { proxy_pass http://127.0.0.1:6912/; include /etc/nginx/ws_params; }
|
||||||
|
location /ws/dev3 { proxy_pass http://127.0.0.1:6913/; include /etc/nginx/ws_params; }
|
||||||
|
location /api/ { proxy_pass http://127.0.0.1:8099/; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; }
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
sudo ln -sfn /etc/nginx/sites-available/multisighsm /etc/nginx/sites-enabled/multisighsm
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
curl -s -o /dev/null -w "page=%{http_code} " http://127.0.0.1:8090/
|
||||||
|
curl -s -o /dev/null -w "novnc=%{http_code}\n" http://127.0.0.1:8090/novnc/vnc_lite.html
|
||||||
|
echo WEB_SETUP_DONE
|
||||||
30
reference/sign_ms.py
Normal file
30
reference/sign_ms.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import sys, io, time
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from ckcc.client import ColdcardDevice
|
||||||
|
from ckcc.protocol import CCProtocolPacker
|
||||||
|
from ckcc.cli import real_file_upload
|
||||||
|
|
||||||
|
sock, inp, outp = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
psbt = b64decode(open(inp).read().strip())
|
||||||
|
dev = ColdcardDevice(sn=sock)
|
||||||
|
txn_len, sha = real_file_upload(io.BytesIO(psbt), dev)
|
||||||
|
ok = dev.send_recv(CCProtocolPacker.sign_transaction(txn_len, sha, flags=0x0), timeout=None)
|
||||||
|
assert ok is None, ('sign_transaction rejected', ok)
|
||||||
|
def story():
|
||||||
|
s = dev.send_recv(b'EXEC' + b"RV.write(repr(sim_display.story))", encrypt=False)
|
||||||
|
return (s.decode() if isinstance(s,(bytes,bytearray)) else s)
|
||||||
|
print('STORY:', story()[:500])
|
||||||
|
done = None
|
||||||
|
for i in range(50):
|
||||||
|
try: dev.send_recv(CCProtocolPacker.sim_keypress(b'y'))
|
||||||
|
except Exception as e: pass
|
||||||
|
time.sleep(0.4)
|
||||||
|
d = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
|
||||||
|
if d is not None:
|
||||||
|
done = d; break
|
||||||
|
if not done:
|
||||||
|
print('NO RESULT after polling. STORY now:', story()[:500]); sys.exit(1)
|
||||||
|
result_len, result_sha = done
|
||||||
|
result = dev.download_file(result_len, result_sha, file_number=1)
|
||||||
|
open(outp,'wb').write(b64encode(result))
|
||||||
|
print('SIGNED_OK bytes=%d -> %s' % (result_len, outp))
|
||||||
31
reference/signet_wallet.py
Normal file
31
reference/signet_wallet.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import subprocess, json, os, time
|
||||||
|
V='27.0'; SDATA=os.path.expanduser('~/cksim/signet-data')
|
||||||
|
CLI=os.path.expanduser('~/bitcoin-%s/bin/bitcoin-cli'%V)
|
||||||
|
def bc(*a, wallet=None):
|
||||||
|
base=[CLI,'-datadir=%s'%SDATA,'-rpcport=38999','-rpcuser=ck','-rpcpassword=ckms']
|
||||||
|
if wallet: base.append('-rpcwallet=%s'%wallet)
|
||||||
|
r=subprocess.run(base+list(a),capture_output=True,text=True)
|
||||||
|
if r.returncode: raise SystemExit('%s FAIL %s'%(a,r.stderr.strip()[:160]))
|
||||||
|
return r.stdout.strip()
|
||||||
|
for _ in range(40):
|
||||||
|
try: bc('getblockchaininfo'); break
|
||||||
|
except SystemExit: time.sleep(2)
|
||||||
|
TPUB={'00000001':'tpubREPLACE_WITH_YOUR_SIGNER_1_XPUB',
|
||||||
|
'00000002':'tpubREPLACE_WITH_YOUR_SIGNER_2_XPUB',
|
||||||
|
'00000003':'tpubREPLACE_WITH_YOUR_SIGNER_3_XPUB'}
|
||||||
|
def desc(b):
|
||||||
|
keys=','.join('[%s/48h/1h/0h/2h]%s/%d/*'%(x,p,b) for x,p in TPUB.items())
|
||||||
|
return 'wsh(sortedmulti(2,%s))'%keys
|
||||||
|
def ck(d): return json.loads(bc('getdescriptorinfo',d))['descriptor']
|
||||||
|
rcv,chg=ck(desc(0)),ck(desc(1))
|
||||||
|
wl=json.loads(bc('listwallets'))
|
||||||
|
if 'ckms23-signet' not in wl:
|
||||||
|
try: bc('createwallet','ckms23-signet','true','true','','false','true')
|
||||||
|
except SystemExit: bc('loadwallet','ckms23-signet')
|
||||||
|
bc('importdescriptors', json.dumps([{'desc':rcv,'active':True,'internal':False,'timestamp':'now','range':[0,30]},
|
||||||
|
{'desc':chg,'active':True,'internal':True,'timestamp':'now','range':[0,30]}]), wallet='ckms23-signet')
|
||||||
|
addr=bc('getnewaddress','','bech32',wallet='ckms23-signet')
|
||||||
|
open(os.path.expanduser('~/cksim/signet_addr.txt'),'w').write(addr+'\n')
|
||||||
|
print('SIGNET_RECEIVE_ADDRESS', addr)
|
||||||
|
bci=json.loads(bc('getblockchaininfo'))
|
||||||
|
print('SYNC blocks=%s headers=%s progress=%.4f'%(bci['blocks'],bci['headers'],bci['verificationprogress']))
|
||||||
23
reference/start_rig.sh
Executable file
23
reference/start_rig.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start 3 headless Coldcard Mk5 sims, each with a distinct seed + isolated state + stable socket.
|
||||||
|
set -uo pipefail
|
||||||
|
export PATH="$HOME/gccshim:$PATH"
|
||||||
|
RUN=~/cksim
|
||||||
|
cd ~/coldcard-firmware/unix
|
||||||
|
pkill -f "simulator.py --mk5" 2>/dev/null || true; sleep 2
|
||||||
|
rm -f /tmp/ckcc-simulator-*.sock /tmp/cksim-*.sock
|
||||||
|
: > "$RUN/sims.manifest"
|
||||||
|
for n in 1 2 3; do
|
||||||
|
SEED=$(cat "$RUN/seeds/$n.txt")
|
||||||
|
setsid ~/ccsim-venv/bin/python simulator.py --mk5 --headless --segregate --seed "$SEED" \
|
||||||
|
>"$RUN/sim-$n.log" 2>&1 </dev/null &
|
||||||
|
for i in $(seq 1 25); do
|
||||||
|
cnt=$(ls /tmp/ckcc-simulator-*.sock 2>/dev/null | wc -l)
|
||||||
|
[ "$cnt" -ge "$n" ] && break; sleep 1
|
||||||
|
done
|
||||||
|
newsock=$(ls -t /tmp/ckcc-simulator-*.sock 2>/dev/null | head -1)
|
||||||
|
ln -sf "$newsock" "/tmp/cksim-$n.sock"
|
||||||
|
echo "$n $newsock /tmp/cksim-$n.sock" >> "$RUN/sims.manifest"
|
||||||
|
done
|
||||||
|
echo "=== manifest ==="; cat "$RUN/sims.manifest"
|
||||||
|
echo "=== stable sockets ==="; ls -l /tmp/cksim-*.sock 2>/dev/null
|
||||||
13
reference/wstest.py
Normal file
13
reference/wstest.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import socket, base64, os
|
||||||
|
def test(port):
|
||||||
|
try:
|
||||||
|
s=socket.create_connection(('127.0.0.1',port),timeout=6)
|
||||||
|
key=base64.b64encode(os.urandom(16)).decode()
|
||||||
|
req=("GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||||
|
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\n\r\n")%key
|
||||||
|
s.sendall(req.encode())
|
||||||
|
resp=s.recv(200); s.close()
|
||||||
|
return resp.split(b"\r\n")[0].decode(errors="replace")
|
||||||
|
except Exception as e: return "ERR %s"%(repr(e)[:80])
|
||||||
|
for n,p in ((1,6911),(2,6912),(3,6913)):
|
||||||
|
print("dev%d port%d -> %s"%(n,p,test(p)))
|
||||||
Loading…
Reference in New Issue
Block a user