├── .gitignore ├── COPYING-CC ├── LICENSE ├── Makefile ├── README.md ├── chain.py ├── chrono.py ├── conn.py ├── data ├── .gitignore └── README.md ├── docs ├── CNAME ├── README.md ├── examples.md ├── hacking.md ├── img │ ├── animated-captcha.gif │ ├── bk-setup-tab.png │ ├── bunker-local-conf.png │ ├── cc-setup-tab.png │ ├── hsm-local-code.png │ ├── simple-captcha.png │ ├── snap-login.png │ ├── snap-msg-sign.png │ ├── snap-other-policy.png │ ├── snap-paths.png │ ├── snap-psbt-after.png │ ├── snap-psbt-start.png │ ├── snap-psbt-uploaded.png │ ├── snap-psbt-visualized.png │ ├── snap-rules.png │ ├── snap-users-empty.png │ └── snap-users.png ├── index.md ├── install.md ├── msg-signing.md ├── policy.md ├── psbt.md ├── screen-shot.jpg └── setup.md ├── example-settings.yaml ├── main.py ├── make_captcha.py ├── objstruct.py ├── persist.py ├── policy.py ├── requirements.txt ├── setup.py ├── static ├── ext │ ├── README.md │ ├── base64js.min.js │ ├── jquery-3.6.4.min.js │ ├── lato-font.css │ ├── lato │ │ ├── README.md │ │ ├── S6u8w4BMUTPHjxsAXC-v.ttf │ │ ├── S6u9w4BMUTPHh6UVSwiPHA.ttf │ │ ├── S6u_w4BMUTPHjxsI5wq_Gwfo.ttf │ │ └── S6uyw4BMUTPHjx4wWw.ttf │ ├── livestamp.min.js │ ├── lodash.js │ ├── lodash.min.js │ ├── moment.min.js │ ├── semantic-fonts │ │ ├── brand-icons.eot │ │ ├── brand-icons.svg │ │ ├── brand-icons.ttf │ │ ├── brand-icons.woff │ │ ├── brand-icons.woff2 │ │ ├── icons.eot │ │ ├── icons.otf │ │ ├── icons.svg │ │ ├── icons.ttf │ │ ├── icons.woff │ │ ├── icons.woff2 │ │ ├── outline-icons.eot │ │ ├── outline-icons.svg │ │ ├── outline-icons.ttf │ │ ├── outline-icons.woff │ │ └── outline-icons.woff2 │ ├── semantic-ubuntu.min.css │ ├── semantic.min.css │ ├── semantic.min.js │ ├── ubuntu-font.css │ ├── ubuntu │ │ ├── 4iCp6KVjbNBYlgoKejZPslyPN4Q.ttf │ │ ├── 4iCs6KVjbNBYlgoKfw7z.ttf │ │ ├── 4iCu6KVjbNBYlgoKej70l0w.ttf │ │ └── 4iCv6KVjbNBYlgoCxCvjsGyI.ttf │ ├── vue.js │ └── vue.min.js ├── favicon.png ├── fonts │ ├── proximanova-semibold.ttf │ └── ransom-note.ttf ├── html │ ├── login.html │ └── logout.html ├── html5shiv.js ├── navicon.png ├── project.js └── style.css ├── status.py ├── templates ├── base.html ├── basic.html ├── bunker │ └── index.html ├── help.html ├── login.html ├── macros.html ├── navpage.html ├── setup │ ├── hsm-rule-component.html │ ├── hsm-rule.html │ ├── index.html │ ├── misc.html │ ├── onion.html │ ├── paths.html │ ├── rules.html │ └── users.html ├── tools │ └── index.html └── txn │ └── index.html ├── torsion.py ├── utils.py ├── version.py └── webapp.py /.gitignore: -------------------------------------------------------------------------------- 1 | ENV 2 | .DS_Store 3 | 4 | *.log 5 | *.swp 6 | 7 | __pycache__/ 8 | *.pyc 9 | 10 | .tags 11 | pp 12 | history.yaml 13 | 14 | bunker.egg-info/ 15 | -------------------------------------------------------------------------------- /COPYING-CC: -------------------------------------------------------------------------------- 1 | (c) Copyright 2020 by Coinkite Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject 9 | to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 18 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 19 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | 23 | "Commons Clause" License Condition v1.0 24 | 25 | The Software is provided to you by the Licensor under the License, 26 | as defined below, subject to the following condition. 27 | 28 | Without limiting other conditions in the License, the grant of 29 | rights under the License will not include, and the License does not 30 | grant to you, the right to Sell the Software. 31 | 32 | For purposes of the foregoing, "Sell" means practicing any or all 33 | of the rights granted to you under the License to provide to third 34 | parties, for a fee or other consideration (including without 35 | limitation fees for hosting or consulting/ support services related 36 | to the Software), a product or service whose value derives, entirely 37 | or substantially, from the functionality of the Software. Any license 38 | notice or attribution required by the License must also include 39 | this Commons Clause License Condition notice. 40 | 41 | Software: All Coldcard associated files. 42 | License: MIT 43 | Licensor: Coinkite Inc. 44 | 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | COPYING-CC -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | @echo "No default" 3 | 4 | 5 | tags: 6 | ctags -f .tags *.py */*.py 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CKBunker 2 | 3 | ![Screen Shot of CKBunker](docs/screen-shot.jpg) 4 | 5 | - [CKBunker preview screencast (youtube)](https://www.youtube.com/watch?v=0bHhZbYOiSM) 6 | - [Usage examples](https://github.com/Coldcard/ckbunker/blob/master/docs/examples.md) for HSM/CKBunker. 7 | - [CKBunker Documentation Website](https://ckbunker.com) 8 | - [Github for CKBunker](https://github.com/Coldcard/ckbunker) 9 | - [HSM Feature (on Coldcard) Docs](https://coldcardwallet.com/docs/ckbunker-hsm) 10 | 11 | ## Full Documentation 12 | 13 | 1. [Installation](https://github.com/Coldcard/ckbunker/blob/master/docs/install.md) 14 | 2. [Setup Bunker](https://github.com/Coldcard/ckbunker/blob/master/docs/setup.md) 15 | 2. [HSM Policy](https://github.com/Coldcard/ckbunker/blob/master/docs/policy.md) 16 | 2. [PSBT Signing](https://github.com/Coldcard/ckbunker/blob/master/docs/psbt.md) 17 | 2. [Message Signing](https://github.com/Coldcard/ckbunker/blob/master/docs/msg-signing.md) 18 | 2. [Contributing Code](https://github.com/Coldcard/ckbunker/blob/master/docs/hacking.md) 19 | 20 | ## What is the Coinkite Bunker? 21 | 22 | It's a python program that you run on a computer attached to a 23 | Coldcard. It will setup and operate the Coldcard in "HSM Mode" where 24 | it signs without a human pressing the OK key. To keep your 25 | funds safe, the Coldcard implements a complex set of spending rules 26 | which cannot be changed once HSM mode is started. 27 | 28 | Using the `tord` (Tor deamon) you already have, the CK Bunker can 29 | make itself available as a hidden service for remote access over 30 | Tor. A pretty website for setup and operation allows access to all 31 | HSM-related Coldcard features, including: 32 | 33 | - transaction signing, by uploading a PSBT; can broadcast signed txn using Blockstream.info (onion) 34 | - define policy rules, spending limits, velocity controls, logging policy 35 | - user setup (TOTP QR scan to enroll on Coldcard, or random passwords (Coldcard) or known password 36 | 37 | The bunker encrypts its own settings and stores the private key for 38 | that inside Coldcard's storage locker (which is kept inside the 39 | secure element of the Coldcard). The private key for the onion 40 | service, for example, is protected by that key. 41 | 42 | ## What is Coldcard? 43 | 44 | Coldcard is a Cheap, Ultra-secure & Opensource Hardware Wallet for Bitcoin. 45 | Get yours at [ColdcardWallet.com](http://coldcardwallet.com) 46 | 47 | Learn more about the [Coldcard HSM-related features](https://coldcardwallet.com/docs/ckbunker-hsm). 48 | 49 | [Follow @COLDCARDwallet on Twitter](https://twitter.com/coldcardwallet) to keep up 50 | with the latest updates and security alerts. 51 | 52 | ## FAQ 53 | 54 | ### Will HSM mode be supported on Mk1 or Mk2? 55 | 56 | Sorry no. CK Bunker only works on Mk3 because we need the extra RAM 57 | and the newer features of the 608 secure element. 58 | 59 | ### What is HSM? 60 | 61 | "Hardware Security Module" 62 | 63 | Learn more about the [Coldcard in HSM Mode](https://coldcardwallet.com/docs/ckbunker-hsm) 64 | 65 | ## Quotes 66 | 67 | > "Basically the cost of a Bitcoin HSM with custom policies is now the cost of a coldcard and you don't need a thirty party to maintain it." - Francis P. 68 | -------------------------------------------------------------------------------- /chain.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. 2 | # 3 | # chain.py --- API to blockstream stuff 4 | # 5 | import sys, os, asyncio, logging, requests 6 | from objstruct import ObjectStruct 7 | from persist import settings 8 | from status import STATUS 9 | from binascii import b2a_hex, a2b_hex 10 | from utils import json_loads 11 | 12 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 13 | 14 | def broadcast_txn(txn): 15 | # take bytes and get them shared over P2P to the world 16 | # - raise w/ text about what happened if it fails 17 | # - limited docs: 18 | ses = requests.session() 19 | ses.proxies = dict(http=settings.TOR_SOCKS) 20 | ses.headers.clear() # hide user-agent 21 | 22 | url = settings.EXPLORA 23 | url += '/api/tx' if not STATUS.is_testnet else '/testnet/api/tx' 24 | 25 | assert '.onion/' in url, 'dude, your privacy' 26 | logging.warning(f"Sending txn via: {url}") 27 | resp = ses.post(url, data=b2a_hex(txn).decode('ascii')) 28 | 29 | msg = resp.text 30 | 31 | if not resp.ok: 32 | # content is like: 33 | # sendrawtransaction RPC error: {"code":-22,"message":"TX decode failed"} 34 | # which is a text thing, including some JSON from bitcoind? 35 | 36 | if '"message":' in msg: 37 | try: 38 | prefix, rest = msg.split(': ', 1) 39 | j = json_loads(rest) 40 | if prefix == 'sendrawtransaction RPC error': 41 | msg = j.message 42 | else: 43 | msg = prefix + ': ' + j.message 44 | except: 45 | pass 46 | 47 | msg = f"Transaction broadcast FAILED: {msg}" 48 | logging.error(msg) 49 | return msg 50 | 51 | # untested 52 | msg = f"Transaction broadcast success: {msg}" 53 | logging.info(msg) 54 | 55 | return msg 56 | 57 | def link_to_txn(txn_hash): 58 | path = '/tx/' if not STATUS.is_testnet else '/testnet/tx/' 59 | assert len(txn_hash) == 64 60 | return settings.EXPLORA + path + txn_hash 61 | 62 | 63 | if __name__ == '__main__': 64 | # test code 65 | r = broadcast_txn(b'sdhffkhjkdfshdfshjdfshdfkshdfs') 66 | 67 | # EOF 68 | -------------------------------------------------------------------------------- /chrono.py: -------------------------------------------------------------------------------- 1 | # 2 | # Time/data related functions. Trying hard not to rewrite existing things. 3 | # 4 | # Everything should be stored/calculated in UTC. 5 | # 6 | import time, datetime, calendar, pendulum 7 | from email.utils import formatdate as rfc_format 8 | 9 | def NOW(): 10 | # Use this for everything. 11 | return pendulum.now() 12 | 13 | def TIME_AGO(**kws): 14 | # Return a time in past, like TIME_AGO(hours=3) 15 | return pendulum.now() - datetime.timedelta(**kws) 16 | 17 | def TIME_FUTURE(**kws): 18 | # Return a time in future, like TIME_FUTURE(hours=3) 19 | return pendulum.now() + datetime.timedelta(**kws) 20 | 21 | def as_time_t(dt): 22 | " convert datetime into unix timestamp (all UTC)" 23 | if hasattr(dt, 'timestamp'): 24 | # expected case for Pendulum values 25 | return dt.timestamp() 26 | else: 27 | return calendar.timegm(dt.utctimetuple()) 28 | 29 | def from_time_t(time_t): 30 | " convert unix timestamp into datetime (all UTC)" 31 | return pendulum.from_timestamp(time_t) 32 | 33 | def from_iso(s): 34 | # Convert from ISO8601, and capture TZ 35 | return pendulum.parse(s) 36 | 37 | # EOF 38 | -------------------------------------------------------------------------------- /conn.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. 2 | # 3 | # Connection to Coldcard (and/or simulator). 4 | # 5 | import asyncio, logging, os 6 | from utils import Singleton, xfp2str, json_loads, json_dumps 7 | from status import STATUS 8 | from persist import settings, BP 9 | from binascii import a2b_hex 10 | import policy 11 | from objstruct import ObjectStruct 12 | from hmac import HMAC 13 | from hashlib import sha256 14 | from concurrent.futures import ThreadPoolExecutor 15 | 16 | from ckcc.protocol import CCProtocolPacker, CCFramingError 17 | from ckcc.protocol import CCProtoError, CCUserRefused 18 | from ckcc.constants import USB_NCRY_V2 19 | from ckcc.client import ColdcardDevice 20 | from ckcc.constants import (USER_AUTH_TOTP, USER_AUTH_HMAC, USER_AUTH_SHOW_QR, MAX_USERNAME_LEN) 21 | from ckcc.utils import calc_local_pincode 22 | 23 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 24 | 25 | executor = ThreadPoolExecutor(max_workers=5) 26 | 27 | # if you see this, it means the USB plug is fell out! 28 | class MissingColdcard(RuntimeError): 29 | pass 30 | 31 | #logging.info("fd = %d" % open('/dev/null').fileno()) 32 | 33 | class Connection(metaclass=Singleton): 34 | 35 | def __init__(self, serial): 36 | self.serial = serial 37 | self.dev = None 38 | self.dev_key = None 39 | self.lock = asyncio.Lock() 40 | self.sign_lock = asyncio.Lock() 41 | self._conn_broken(setup_time=True) 42 | 43 | async def run(self): 44 | # connect to, and maintain a connection to a single Coldcard 45 | 46 | logging.info("Connecting to Coldcard.") 47 | 48 | while 1: 49 | try: 50 | if not self.serial and os.path.exists(settings.SIMULATOR_SOCK): 51 | # if simulator is running, just use it. 52 | sn = settings.SIMULATOR_SOCK 53 | else: 54 | sn = self.serial 55 | 56 | ncry_ver = settings.USB_NCRY_VERSION 57 | d = ColdcardDevice(sn=sn, ncry_ver=ncry_ver) 58 | logging.info(f"Found Coldcard {d.serial}. USB encryption version: {ncry_ver}") 59 | 60 | await asyncio.get_running_loop().run_in_executor(executor, d.check_mitm) 61 | 62 | async with self.lock: 63 | self.dev = d 64 | except: 65 | logging.error("Cannot connect to Coldcard (will retry)", exc_info=0) 66 | await asyncio.sleep(settings.RECONNECT_DELAY) 67 | continue 68 | 69 | # stay connected, and check we are working periodically 70 | logging.info(f"Connected to Coldcard {self.dev.serial}.") 71 | 72 | STATUS.connected = True 73 | 74 | # read static info about coldcard 75 | STATUS.xfp = xfp2str(self.dev.master_fingerprint) 76 | STATUS.serial_number = self.dev.serial 77 | STATUS.is_testnet = (self.dev.master_xpub[0] == 't') 78 | STATUS.hsm = {} 79 | STATUS.reset_pending_auth() 80 | STATUS.notify_watchers() 81 | await self.hsm_status() 82 | 83 | while 1: 84 | await asyncio.sleep(settings.PING_RATE) 85 | try: 86 | # use long timeout here, even tho simple command, because the CC may 87 | # we working on something else right now (thinking). 88 | h = await self.send_recv(CCProtocolPacker.hsm_status(), timeout=20000) 89 | logging.info("ping ok") 90 | await self.hsm_status(h) 91 | except MissingColdcard: 92 | self._conn_broken() 93 | break 94 | except: 95 | logging.error("Ping failed", exc_info=1) 96 | 97 | def _conn_broken(self, setup_time=False): 98 | # our connection is lost, so clear/reset system state 99 | if self.dev: 100 | self.dev.close() 101 | self.dev = None 102 | 103 | STATUS.connected = False 104 | STATUS.xfp = None 105 | STATUS.serial_number = None 106 | STATUS.is_testnet = False 107 | STATUS.hsm = {} 108 | STATUS.reset_pending_auth() 109 | 110 | if not setup_time: 111 | BP.reset() 112 | 113 | STATUS.notify_watchers() 114 | 115 | async def activated_hsm(self): 116 | # just connected to a Coldcard w/ HSM active already 117 | # - ready storage locker, decrypt and use those settings 118 | logging.info("Coldcard now in HSM mode. Fetching storage locker.") 119 | 120 | try: 121 | sl = await self.get_storage_locker() 122 | except CCProtoError as exc: 123 | if 'consumed' in str(exc): 124 | import os, sys 125 | msg = "Coldcard refused access to storage locker. Reboot it and enter HSM again" 126 | logging.error(msg) 127 | print(msg, file=sys.stderr) 128 | sys.exit(1) 129 | else: 130 | raise 131 | 132 | try: 133 | import policy 134 | xk = policy.decode_sl(sl) 135 | except: 136 | logging.error("Unable to parse contents of storage locker: %r" % sl) 137 | return 138 | 139 | if BP.open(xk): 140 | # unable to read our settings specific to this CC? Go to defaults 141 | # or continue? 142 | logging.error("Unable to read bunker settings for this Coldcard; forging on") 143 | else: 144 | STATUS.sl_loaded = True 145 | 146 | if BP.get('tor_enabled', False) and not (STATUS.force_local_mode or STATUS.setup_mode): 147 | # get onto Tor as a HS 148 | from torsion import TOR 149 | STATUS.tor_enabled = True 150 | logging.info(f"Starting hidden service: %s" % BP['onion_addr']) 151 | asyncio.create_task(TOR.start_tunnel()) 152 | 153 | h = STATUS.hsm 154 | if ('summary' in h) and h.summary and not BP.get('priv_over_ux') and not BP.get('summary'): 155 | logging.info("Captured CC's summary of the policy") 156 | BP['summary'] = h.summary 157 | BP.save() 158 | 159 | STATUS.reset_pending_auth() 160 | STATUS.notify_watchers() 161 | 162 | async def send_recv(self, msg, **kws): 163 | # a more-async version of ColdcardDevice.send_recv? 164 | 165 | if not self.dev or not STATUS.connected: 166 | raise MissingColdcard 167 | 168 | try: 169 | def doit(): 170 | return self.dev.send_recv(msg, **kws) 171 | 172 | # we do need this lock 173 | async with self.lock: 174 | return await asyncio.get_running_loop().run_in_executor(executor, doit) 175 | 176 | except CCFramingError: 177 | self._conn_broken() 178 | raise MissingColdcard 179 | except (CCProtoError, CCUserRefused): 180 | raise 181 | except BaseException as exc: 182 | logging.error(f"Error from Coldcard: {exc} (for msg: {msg!r}") 183 | self._conn_broken() 184 | raise MissingColdcard 185 | 186 | async def hsm_status(self, h=None): 187 | # refresh HSM status 188 | b4 = STATUS.hsm.get('active', False) 189 | 190 | try: 191 | b4_nlc = STATUS.hsm.get('next_local_code') 192 | h = h or (await self.send_recv(CCProtocolPacker.hsm_status())) 193 | STATUS.hsm = h = json_loads(h) 194 | STATUS.notify_watchers() 195 | except MissingColdcard: 196 | h = {} 197 | 198 | if h.get('next_local_code') and STATUS.psbt_hash: 199 | if b4_nlc != h.next_local_code: 200 | STATUS.local_code = calc_local_pincode(a2b_hex(STATUS.psbt_hash), h.next_local_code) 201 | else: 202 | # won't be required 203 | STATUS.local_code = None 204 | 205 | # has it just transitioned into HSM mode? 206 | if STATUS.connected and STATUS.hsm.active and not b4: 207 | await self.activated_hsm() 208 | 209 | return STATUS.hsm 210 | 211 | async def hsm_start(self, new_policy=None): 212 | args = [] 213 | if new_policy is not None: 214 | # must upload it first 215 | data = json_dumps(new_policy).encode('utf8') 216 | args = self.dev.upload_file(data) 217 | 218 | # save a trimmed copy of some details, if they want that 219 | bk = policy.desensitize(new_policy) 220 | BP['summary'] = None 221 | if not bk.get('priv_over_ux'): 222 | BP['priv_over_ux'] = False 223 | BP['policy'] = bk # full copy 224 | BP['xfp'] = xfp2str(self.dev.master_fingerprint) 225 | BP['serial'] = self.dev.serial 226 | else: 227 | BP['priv_over_ux'] = True 228 | BP['policy'] = None 229 | BP['xfp'] = None 230 | BP['serial'] = None 231 | 232 | BP.save() 233 | 234 | try: 235 | await self.send_recv(CCProtocolPacker.hsm_start(*args)) 236 | except CCProtoError as exc: 237 | msg = str(exc) 238 | logging.error("Coldcard didn't like policy: %s" % msg) 239 | raise RuntimeError(str(msg)) 240 | 241 | async def delete_user(self, username): 242 | await self.send_recv(CCProtocolPacker.delete_user(username)) 243 | 244 | async def create_user(self, username, authmode, new_pw=None): 245 | # typically we'll let Coldcard pick password 246 | if authmode == USER_AUTH_HMAC and new_pw: 247 | secret = self.dev.hash_password(new_pw.encode('utf8')) 248 | else: 249 | secret = b'' 250 | 251 | await self.send_recv(CCProtocolPacker.create_user(username, authmode, secret)) 252 | 253 | async def user_auth(self, username, token, totp, psbt_hash): 254 | if len(token) == 6 and token.isdigit(): 255 | # assume TOTP if token (password) is 6-numeric digits 256 | totp_time = totp or int(time.time() // 30) 257 | token = token.encode('ascii') 258 | else: 259 | # assume it's a raw password. need to hash it up 260 | # TODO: move this hashing into browser 261 | secret = self.dev.hash_password(token.encode('utf8')) 262 | token = HMAC(secret, msg=psbt_hash, digestmod=sha256).digest() 263 | totp_time = 0 264 | 265 | await self.send_recv(CCProtocolPacker.user_auth(username.encode('ascii'), token, totp_time)) 266 | 267 | async def get_storage_locker(self): 268 | return await self.send_recv(CCProtocolPacker.get_storage_locker()) 269 | 270 | async def sign_psbt(self, data, finalize=False, flags=0x0): 271 | # upload it first 272 | 273 | async with self.sign_lock: 274 | sz, chk = self.dev.upload_file(data) 275 | assert chk == a2b_hex(STATUS.psbt_hash) 276 | 277 | await self.send_recv(CCProtocolPacker.sign_transaction(sz, chk, finalize, flags)) 278 | 279 | # wait for it to finish 280 | return await self.wait_and_download(CCProtocolPacker.get_signed_txn()) 281 | 282 | async def wait_and_download(self, req, fn=1): 283 | # Wait for user action (sic) on the device... by polling w/ indicated request 284 | # - also download resulting file 285 | 286 | while 1: 287 | await asyncio.sleep(0.250) 288 | done = await self.send_recv(req, timeout=None) 289 | if done == None: 290 | continue 291 | break 292 | 293 | if len(done) != 2: 294 | logging.error('Coldcard failed: %r' % done) 295 | raise RuntimeError(done) 296 | 297 | result_len, result_sha = done 298 | 299 | # download the result. 300 | result = self.dev.download_file(result_len, result_sha, file_number=fn) 301 | 302 | return result 303 | 304 | async def sign_text_msg(self, msg, subpath, addr_fmt): 305 | # send text and path to sign with; no policy check 306 | 307 | msg = msg.encode('ascii') 308 | 309 | async with self.sign_lock: 310 | try: 311 | await self.send_recv(CCProtocolPacker.sign_message(msg, subpath, addr_fmt)) 312 | 313 | while 1: 314 | await asyncio.sleep(0.250) 315 | done = await self.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None) 316 | if done == None: 317 | continue 318 | break 319 | 320 | except CCUserRefused: 321 | raise RuntimeError("Coldcard refused request based on policy.") 322 | 323 | if len(done) != 2: 324 | logging.error('Coldcard failed: %r' % done) 325 | raise RuntimeError(done) 326 | 327 | addr, sig = done 328 | 329 | return sig, addr 330 | 331 | 332 | 333 | # EOF 334 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | *.dat 2 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Data Files 3 | 4 | - this directory will hold data files for the Bunker 5 | - they are encrypted with a private key held in the "storage locker" of a Coldcard 6 | - contents include Tor hidden service private key and settings for Bunker 7 | - filename based on key 8 | - you may see unused junk accumulate in this directory; those are random keys that 9 | never got saved as a policy file for any Coldcard 10 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | ckbunker.com -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | 2 | # CKBunker Docs 3 | 4 | This directory holds the documentation for CKBunker. 5 | 6 | Start reading with [index.md](index.md). 7 | 8 | We welcome contributions to these docs and code! 9 | 10 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | ## Example CKBunker Use Cases 2 | 3 | ### Sign small transactions with just a password 4 | 5 | You can set a password and have your Coldcard sign anything below a custom amount provided you enter that password. 6 | Or instead require you to type a pin into the Coldcard for verification. [@6102](https://twitter.com/6102bitcoin/status/1228425672827293696) 7 | 8 | ### 2/2 multisig with geographic separation and specific spending rules 9 | 10 | You can use another Coldcard and program it to make sure no transactions get through that don't adhere to a set of rules you set in advance. This uses 2/2 multisig (meaning your regular wallet is the first and the Coldcard is the second, both needed to send any transaction). 11 | 12 | Your regular wallet will sign any transaction you send, but the Coldcard is like a security guard that won't also sign the transaciton unless it follows the rules you set. Rules can include where the bitcoins go (so if an attacker tries to steal your BTC using your main wallet, the Coldcard won't sign). 13 | 14 | Rules could also include a time of day or a max amount per day (or any other period of time). For a long-term hodl you might not want to let anyone be able to send the whole amount at once. 15 | 16 | What's cool is that this Coldcard could be located anywhere in the world and still sign your transaction. 17 | 18 | Because we give the Coldcard a set of rules we can let it execute automatically and sign any transaction that your main wallet presents, as long as the transaction follows whatever rules you set. An attacker would have to nab both your main wallet and the Coldcard (and possibly even reprogram the CC). 19 | 20 | While a geographically distributed 2/2 multi-sig is already possible today, it would require someone on the other end to click the buttons and co-sign. As this is pre-programmed it can run automatically and sign any TX within the rules. 21 | 22 | [Ben Prentice](https://twitter.com/mrcoolbp/status/1228868296486924289) 23 | 24 | ### Geographic separation 25 | 26 | Advanced: Your Coldcard could be in another country; you can lock Coldcard (boot-to-HSM™ feature). Remote hands can do power cycles if needed & keep Bunker running. Video-conference with them to send 6-digit code to complete a PSBT authentication (entered on Coldcard keypad). [@DocHex](https://twitter.com/DocHex/status/1228392653592649728) 27 | 28 | 29 | ### Freeze your warm wallet 30 | 31 | The HSM policy on your Coldcard could enable spending to just one single cold-storage address (via a whitelist). When your warm wallet is in danger, pull this cord to collect all UTXOs and send them to safety, signed, unattended, by the COLDCARDwallet 32 | [@DocHex](https://twitter.com/DocHex/status/1228394738841157632) 33 | 34 | ### Meet-me-in-the-Bunker™ 35 | 36 | Time-based 2FA code from the phones of 3 of these 5 executives needed to authorize spending; Each exec connects to Bunker at the same time, checks proposed transaction and adds their OTP code. Only the Coldcard and exec's phone knows the shared 2FA secret. [@DocHex](https://twitter.com/DocHex/status/1228395590662397953) 37 | · 38 | 39 | ### Text message signing 40 | 41 | You can disable PSBT signing completely and allow automatic signatures on text messages. This turn the Coldcard wallet 42 | into an HSM for Bitcoin-based auth/attestations. Can be limited to specific BIP32 subpath derivations. Same with address generation/derived XPUBS. [@DocHex](https://twitter.com/DocHex/status/1228396805102194688) 43 | 44 | 45 | ### Storage Locker™ 46 | 47 | There is a locker of about 400 bytes of secret storage in the Mk3 secure element. CK Bunker uses this to hold the secret that encrypts bunker's settings (when at rest), such as the private key for the address of the Tor hidden service. So a corrupt LEA can't impersonate your bunker after capture. [@DocHex](https://twitter.com/DocHex/status/1228398842313310208) 48 | 49 | ### Multsig 50 | 51 | Heard you like co-signing! All the Bunker/HSM features work with multisig (P2SH / P2WSH) so maybe you're automatically co-signing some complex multisig from a CasaHODL quorum. [@DocHex](https://twitter.com/DocHex/status/1228403787955687427) 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /docs/hacking.md: -------------------------------------------------------------------------------- 1 | 2 | # Hacking CKBunker 3 | 4 | So you want to improve CKBunker? Sure. Here are some starting points. 5 | 6 | ## Structure 7 | 8 | It's a python program, based on `aiohttp` for async http operation. The web UI is 9 | provided using _Semantic UI_ and _Vue_ for model/view management. HTML pages 10 | are constructed using Jinja templates. Data between 11 | the browser and backend is communicated mainly via a websocket that 12 | stays open the entire time a page is shown in the browser. 13 | 14 | ## Important Dependancies 15 | 16 | See `requirements.txt` for complete list, but in summary, here are the major Python packages 17 | we are using. 18 | 19 | - `stem` 20 | - `aiohttp` 21 | - `aiohttp-jinja2` 22 | - `ckcc-protocol` 23 | - `pynacl` 24 | - `click` 25 | - `pendulum` 26 | - `requests[socks]` 27 | 28 | ## Major Files 29 | 30 | webapp.py - Web backend 31 | chain.py - API access for sending transactions 32 | chrono.py - Time related stuff 33 | conn.py - Connection to a Coldcard, somewhat async wrapping for ckcc-protocol 34 | main.py - Startup code 35 | persist.py - Data persistance and default settings 36 | policy.py - Manage HSM policy details. 37 | status.py - Live state information about the system and attached Coldcard 38 | torsion.py - Manage Tor hidden service connection (via stem) 39 | utils.py - My favourite type of code. 40 | make_captcha.py - Construct the capatcha. 41 | setup.py - Pip/Pypi glue 42 | 43 | templates/ - Jinja HTML templates, with JS and Vue code mixed in 44 | static/ - static CSS, JS and font resources (web) 45 | data/ - encrypted Bunker settings saved at run time. 46 | docs/ - these docs 47 | 48 | # Project Ideas 49 | 50 | Looking for something to do? 51 | Here are some loose ends or ideas we haven't been able start: 52 | 53 | - Integrate [PSBT faker](https://github.com/Coldcard/psbt_faker) for testing policy. 54 | 55 | - Recovery Tool: This will provide a means for you to construct a 56 | PSBT which moves all the funds the system can find on the blockchain 57 | to a new address. Use an onion-enabled block explorer to find UTXO 58 | or maybe some other backend. 59 | 60 | - Address Generator: Use this tool to make deposit addresses for 61 | your Coldcard's wallets. 62 | 63 | 64 | # Code Submission Guidelines 65 | 66 | PR's are welcome but... 67 | 68 | - Please think of other users: don't remove existing use cases. 69 | - Don't add weird dependancies if easy to avoid. 70 | - Try to match existing coding style. 71 | - Large diffs are hard to accept with security-sensitive projects like this. 72 | - Feel free to start your own fork and own it... we love that too! 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /docs/img/animated-captcha.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/animated-captcha.gif -------------------------------------------------------------------------------- /docs/img/bk-setup-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/bk-setup-tab.png -------------------------------------------------------------------------------- /docs/img/bunker-local-conf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/bunker-local-conf.png -------------------------------------------------------------------------------- /docs/img/cc-setup-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/cc-setup-tab.png -------------------------------------------------------------------------------- /docs/img/hsm-local-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/hsm-local-code.png -------------------------------------------------------------------------------- /docs/img/simple-captcha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/simple-captcha.png -------------------------------------------------------------------------------- /docs/img/snap-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-login.png -------------------------------------------------------------------------------- /docs/img/snap-msg-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-msg-sign.png -------------------------------------------------------------------------------- /docs/img/snap-other-policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-other-policy.png -------------------------------------------------------------------------------- /docs/img/snap-paths.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-paths.png -------------------------------------------------------------------------------- /docs/img/snap-psbt-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-psbt-after.png -------------------------------------------------------------------------------- /docs/img/snap-psbt-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-psbt-start.png -------------------------------------------------------------------------------- /docs/img/snap-psbt-uploaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-psbt-uploaded.png -------------------------------------------------------------------------------- /docs/img/snap-psbt-visualized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-psbt-visualized.png -------------------------------------------------------------------------------- /docs/img/snap-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-rules.png -------------------------------------------------------------------------------- /docs/img/snap-users-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-users-empty.png -------------------------------------------------------------------------------- /docs/img/snap-users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/img/snap-users.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # CKBunker 2 | 3 | ![Screen Shot of CKBunker](screen-shot.jpg) 4 | 5 | - [CKBunker preview screencast (youtube)](https://www.youtube.com/watch?v=0bHhZbYOiSM) 6 | - [Video: How To Use CKBUNKER Part 1: Install + Setup](https://www.youtube.com/watch?v=UVcnVb41NWQ) 7 | - [Video: How To Use CKBUNKER Part 2: Multi-Sig Policy](https://www.youtube.com/watch?v=_Jc7sLTT6ls) 8 | - [Usage examples](examples.md) for HSM/CKBunker. 9 | - [Documentation Website](https://ckbunker.com) 10 | - [Github for CKBunker](https://github.com/Coldcard/ckbunker) 11 | - [HSM Feature (on Coldcard) Docs](https://coldcardwallet.com/docs/ckbunker-hsm) 12 | 13 | ## Full Documentation 14 | 15 | 1. [Installation](install.md) 16 | 2. [Setup Bunker](setup.md) 17 | 2. [HSM Policy](policy.md) 18 | 2. [PSBT Signing](psbt.md) 19 | 2. [Message Signing](msg-signing.md) 20 | 2. [Contributing Code](hacking.md) 21 | 2. [Usage Examples](examples.md) 22 | 23 | ## What is the Coinkite Bunker? 24 | 25 | It's a python program that you run on a computer attached to a 26 | Coldcard. It will setup and operate the Coldcard in "HSM Mode" where 27 | it signs without a human pressing the OK key. To keep your 28 | funds safe, the Coldcard implements a complex set of spending rules 29 | which cannot be changed once HSM mode is started. 30 | 31 | Using the `tord` (Tor deamon) you already have, the CK Bunker can 32 | make itself available as a hidden service for remote access over 33 | Tor. A pretty website for setup and operation allows access to all 34 | HSM-related Coldcard features, including: 35 | 36 | - transaction signing, by uploading a PSBT; can broadcast signed txn using Blockstream.info (onion) 37 | - define policy rules, spending limits, velocity controls, logging policy 38 | - user setup (TOTP QR scan to enroll on Coldcard, or random passwords (Coldcard) or known password 39 | 40 | The bunker encrypts its own settings and stores the private key for 41 | that inside Coldcard's storage locker (which is kept inside the 42 | secure element of the Coldcard). The private key for the onion 43 | service, for example, is protected by that key. 44 | 45 | ## What is Coldcard? 46 | 47 | Coldcard is a Cheap, Ultra-secure & Opensource Hardware Wallet for Bitcoin. 48 | Get yours at [ColdcardWallet.com](http://coldcardwallet.com) 49 | 50 | Learn more about the [Coldcard HSM-related features](https://coldcardwallet.com/docs/ckbunker-hsm). 51 | 52 | [Follow @COLDCARDwallet on Twitter](https://twitter.com/coldcardwallet) to keep up 53 | with the latest updates and security alerts. 54 | 55 | ## FAQ 56 | 57 | ### Will HSM mode be supported on Mk1 or Mk2? 58 | 59 | Sorry no. CK Bunker only works on Mk3 because we need the extra RAM 60 | and the newer features of the 608 secure element. 61 | 62 | ### What is HSM? 63 | 64 | "Hardware Security Module" 65 | 66 | Learn more about the [Coldcard in HSM Mode](https://coldcardwallet.com/docs/ckbunker-hsm) 67 | 68 | ## Quotes 69 | 70 | > "Basically the cost of a Bitcoin HSM with custom policies is now the cost of a coldcard and you don't need a thirty party to maintain it." - Francis P. 71 | 72 | 73 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Check-out and Setup 4 | 5 | Do a checkout, recursively to get all the submodules: 6 | 7 | git clone --recursive https://github.com/Coldcard/ckbunker.git 8 | 9 | Then: 10 | 11 | - `virtualenv -p python3 ENV` (Python 3.7 or higher is required) 12 | - `source ENV/bin/activate` (or `source ENV/bin/activate.csh` based on shell preference) 13 | - `pip install -r requirements.txt` 14 | - `pip install --editable .` 15 | 16 | ## Operational Requirements 17 | 18 | You will need: 19 | 20 | - this code (see above) 21 | - a Mk3 or Mk4 Coldcard connected via USB, running 22 | [firmware version 3.1.0 or later](https://coldcardwallet.com/docs/upgrade) 23 | - `tord` (Tor program) 24 | - an Internet connection 25 | - a Tor-capable browser, like "Tor Browser" or Tails. 26 | - (optional) a microSD card, for logging of transactions on Coldcard 27 | - (optional, recommended) a mobile phone with TOTP 2FA app, like Google Authenticator or FreeOTP 28 | 29 | ## Usage 30 | 31 | The executable is called `ckbunker`: 32 | 33 | ```sh 34 | $ ckbunker --help 35 | Usage: ckbunker [OPTIONS] COMMAND [ARGS]... 36 | 37 | Options: 38 | -s, --serial HEX Operate on specific unit (default: first found) 39 | --help Show this message and exit. 40 | 41 | Commands: 42 | list List all attached Coldcard devices 43 | example Show an example config file, using the default values 44 | run Start the CKBunker for normal operation 45 | setup Configure your transaction signing policy, install it and then... 46 | ``` 47 | 48 | There are two modes for the Bunker: "setup" and "run mode". In setup 49 | mode, Tor connections are disabled, as is the login screen. There is no 50 | security and it's meant for initial setup of the Coldcard and Bunker. 51 | 52 | You would typically use the setup mode for picking the onion address, the 53 | master login password and all the details of the HSM policy. 54 | 55 | ```sh 56 | $ ckbunker setup 57 | ``` 58 | 59 | Open this URL in your local web browser (must be same machine): 60 | 61 | 62 | Once the Coldcard is running in HSM mode, with your policy installed, 63 | it makes sense to operate in normal "run" mode. This enables a simple 64 | login screen to keep out visitors: 65 | 66 | ```sh 67 | $ ckbunker run 68 | ``` 69 | 70 | You may also run with remote connections (and login) disabled. This would be useful 71 | if you have some existing web proxy already in place. 72 | 73 | ```sh 74 | $ ckbunker --local run 75 | ``` 76 | 77 | ## Tor Use 78 | 79 | To access over Tor as a hidden service, you must have `tord` running 80 | on the same machine. For desktop systems, keeping TorBrowser open 81 | is enough to acheive this. On servers, start tord with default options, 82 | and ckbunker will use the control port (localhost port 9051 or 9151). 83 | 84 | If you use the bunker to broadcast the final (signed) transaction, 85 | the socks proxy of tord (port 9050) will also be used. 86 | 87 | 88 | 89 | ## Other Command Line Options 90 | 91 | ```sh 92 | % ckbunker run --help 93 | Usage: ckbunker run [OPTIONS] 94 | 95 | Start the CKBunker for normal operation 96 | 97 | Options: 98 | -l, --local Don't enable Tor (onion) access: just be on localhost 99 | -f, --psbt filename.psbt Preload first PSBT to be signed 100 | -c, --config-file FILENAME 101 | --help Show this message and exit. 102 | 103 | ``` 104 | 105 | You can specify a PSBT file for immediate use. That file will be "uploaded" 106 | and be ready to sign, but the system operates normally from there. You can 107 | upload further PSBT files and so on. 108 | 109 | ```sh 110 | % ckbunker setup --help 111 | Usage: ckbunker setup [OPTIONS] 112 | 113 | Configure your transaction signing policy, install it and then operate. 114 | 115 | Options: 116 | -l, --local Don't enable Tor (onion) access: just be on localhost 117 | -c, --config-file FILENAME 118 | --help Show this message and exit. 119 | ``` 120 | 121 | Both forms take an optional config file. It's simple YAML and allows 122 | you to change the web server port number and similar values. 123 | The values that can be configured are defined in `persist.py` in 124 | the `Settings` class. See also `example-settings.yaml`. 125 | 126 | 127 | 128 | # Next Steps 129 | 130 | [Bunker setup](setup.md) 131 | -------------------------------------------------------------------------------- /docs/msg-signing.md: -------------------------------------------------------------------------------- 1 | 2 | # Text Message Signing 3 | 4 | Visit the tab "Tools". You can type in a short text message (single line is best) and 5 | select a derivation path to be used for signing. Segwit or classic bitcoin address 6 | is an option as well. 7 | 8 | Base64 signature in standard form is provided. The address is underneath. 9 | 10 | ![signing text msg](img/snap-msg-sign.png) 11 | 12 | Keep in mind when you share a signature, you are also sharing the public key for 13 | that address. This is a little more information than just a payment address. 14 | 15 | # Next Steps 16 | 17 | [Contributing code](hacking.md) 18 | -------------------------------------------------------------------------------- /docs/policy.md: -------------------------------------------------------------------------------- 1 | # HSM Policy Config 2 | 3 | ![Coldcard Setup screen shot](img/cc-setup-tab.png) 4 | 5 | 6 | # Spending Rules 7 | 8 | Multiple spending rules can be defined. The system scans the rules 9 | starting from the first one, and will test each rule. The first 10 | rule that is satisfied is applied and following rules are not considered. 11 | 12 | We recommend putting the most narrow rules first. Catch-all rules, which 13 | might move more money should be later in the list. 14 | 15 | By combining multiple rules, with diffent restrictions, it's possible 16 | to create a secure and yet flexible policy. 17 | 18 | 19 | ![Spending Rules screen shot](img/snap-rules.png) 20 | 21 | 22 | ## Velocity Time Period 23 | 24 | To implement spending limits based on time, the Coldcard requires you to define 25 | a period. This period, expressed in minutes, applies to all rules. Any rule with 26 | a defined "Per-Period Amount" value, will be affected. 27 | 28 | The period starts when it is first used. There is no absolute concept of 29 | time on the Coldcard because it doesn't have a real time clock. There is only one 30 | period, so it will begin as soon as any rule using a velocity limit 31 | is applied successfully. 32 | 33 | At the end of the time period, the totals are reset to zero. 34 | 35 | ## Individual Rules 36 | 37 | Each rule consists of these values, which are all considered at the same time. 38 | 39 | - _Max Amount_: max BTC per transaction, that this rule can apply to (independent of period) 40 | - _Per-Period Amount_: total BTC that can move thu this rule in the period 41 | - _Destination Whitelist_: a list of specific addresses which are allowed as destinations 42 | - _Multisig Wallet Name_: either name of a multisig wallet, or 43 | `1` indicating rule only applies to non-multisig wallets. 44 | - _Authorizing Users_: a list of users that are able to approve the transaction (N) 45 | _ _Minimum Users Needed_: number of users (M) needed to approve (from list of users 46 | for this rule, not the system) 47 | - _Local Confirmation Code needed_: a local user must (also) approve (via 6-digits entered on keypad) 48 | 49 | When an element of the rule is has no value, then the restriction 50 | does not apply. For example, if _Destination Whitelist_ is empty, 51 | then the Coldcard will not consider the destination address when 52 | considering the rule. 53 | 54 | If no rules are defined, then no PSBT will be signed. This can be 55 | useful for text message signing applications. On the other hand, 56 | a single empty rule, allows any transaction to be signed, so be careful! 57 | 58 | ### Max Transaction Amount 59 | 60 | Max amount per transaction is seems less useful because a number of transactions 61 | could be put together to "work around" this rule. However, if there is natural 62 | rate-limiting in your system, for example, by requiring a 63 | local operator to enter a code eeach time, then this is still helpful. 64 | 65 | ### Authorizing Users 66 | 67 | You can list username in the `users` field. If defined the `min_users` controls 68 | how many of those are required. By default (if `min_users` isn't defined), all users 69 | listed must confirm the operation. You can achieve 2-of-5 and similar setups 70 | using `min_users`. All users listed must already be defined on the Coldcard 71 | before the policy is activated. 72 | 73 | ### Limit to Named Wallet 74 | 75 | The `wallet` field can be omitted, or set to the name of a multisig wallet. If set 76 | to the string `1`, it indicates this rule only applies to the non-multisig wallet. 77 | 78 | ### Destination Whitelist 79 | 80 | You may specify a list of addresses in the whitelist field. The Coldcard will 81 | only apply the rule if all destination addresses of the PSBT transaction are 82 | included in the whitelist. This is a powerful feature when your target wallets 83 | that you control, such as emergency cold wallets. 84 | 85 | 86 | 87 | # User Management 88 | 89 | ![user management](img/snap-users.png) 90 | 91 | 92 | To support use of the Coldcard in HSM mode, the Coldcard can hold 93 | usernames and their shared secrets for authentication purposes. At 94 | present, this is only useful for use in HSM mode. The user's login 95 | data (secrets) are stored exclusively on the Coldcard, and are never 96 | stored in the CKBunker. 97 | 98 | Two methods are offered: shared password (ie. classic "something 99 | you know") or TOTP (time-based one-time pass) 2FA authentication, 100 | compatible with [RFC6238](https://tools.ietf.org/html/rfc6238). Most 101 | people will already have an app on their mobile phone to hold 102 | the shared secrets and simplify the number-calculating process. 103 | 104 | Creating new users can **only** be done over USB protocol with the 105 | help of CKBunker or `ckcc` programs. However, once the user is 106 | established, you may view it and remove them from the menu system 107 | on the Coldcard, in the Advanced menu, under "User Management". 108 | 109 | The best practice is for the Coldcard to generate the password or 110 | TOTP secret and display it on-screen in a QR code. If you are using 111 | a TOTP app, such as Google Authenticator or FreeOTP, then you can 112 | scan the screen of the Coldcard to install the code. Unfortunately, 113 | due to limited screen space, there isn't room for the meta data 114 | such as username or specific Coldcard number: your app will only 115 | show "CC". 116 | 117 | It is possible to send a user-provided password over USB, in which 118 | case, the QR code is not shown. This requires trust of the attached 119 | computer during this operation, and so we do not recommend it. 120 | 121 | Click the (X) beside a username to remove it. This will invalid your 122 | HSM policy if that user is involved with a rule. You'll need to change 123 | the rule, or create a new user with the old name. 124 | 125 | 126 | # Derivation Paths 127 | 128 | This section controls message signing and derivation paths allowed for 129 | sharing addresses and derived xpubs. 130 | 131 | ![derivation paths](img/snap-paths.png) 132 | 133 | ## Message Signing 134 | 135 | To enable text message signing, list one or more BIP32 derivation paths in this section. 136 | You can use the special value `any` to allow all signing. You may also use a star 137 | in the last position of a path, like these examples: 138 | 139 | - `m/84'/0'/0/*` 140 | - `m/84'/0'/0'/*'` 141 | - `m/9984/*` 142 | 143 | The star allows any number in the final position (only). It does not allow deeper paths. 144 | 145 | ## Sharing Xpubs 146 | 147 | The Coldcard can calculate XPUB values for derived paths, if desired. 148 | You can limit this feature by giving a list of permitted paths, or 149 | the keyword `any` to allow any subpath. The master xpub (`m`) 150 | is always available over USB protocol and cannot be disabled. 151 | 152 | ## Share Derived Addresses 153 | 154 | Similarly, the Coldcard can calculate wallet addresses, if this 155 | setting contains a list of whitelisted derivation paths. Star 156 | patterns, and the keyword `any` can be used, as well as the keyword 157 | `p2sh` which allows addresses in multisig wallets to be shared. 158 | 159 | In the case of multisig wallets, we do not check the script provided, 160 | beyond the normal checks for inclusion into a known multisig wallet. 161 | 162 | # Other Policy 163 | 164 | This section covers global policy choices. Most settings are simple booleans. 165 | 166 | ![other policy](img/snap-other-policy.png) 167 | 168 | 169 | ## Logging to MicroSD Card 170 | 171 | Two setting affect logging: _Must log_ and _Never log_. By default, 172 | the Coldcard will log if a card is inserted. It does not fail if 173 | the card is missing. If that is an issue for you, then set _Must log_ 174 | and transactions will be refused if the card isn't installed and 175 | working. _Never log_ is useful when you don't want to keep records 176 | at the Coldcard's location. 177 | 178 | ## Warnings Okay? 179 | 180 | This boolean allows the Coldcard to sign PSBT files that have 181 | warnings. Typically warning are generated by overly-large fees or 182 | weird path derivations. Since we don't expect warnings, any 183 | transactions with a warning is normally refused. 184 | 185 | ## Privacy over UX 186 | 187 | During development of the Bunker, we found there were numerous 188 | status and informational values being shared over USB that, to some 189 | degree, assist potential attackers. However, those values are needed 190 | to provide a usable interface and a nice user experience (UX). 191 | 192 | If you set _Privacy over UX_, the following values will not be 193 | shared over [USB in the HSM status response](protocol): 194 | 195 | - text summary of the spending policy 196 | - count of approvals / refusals 197 | - the number of time the storage locker has been read 198 | - the period length 199 | - when the period will end 200 | - how much each rule has spent in current period 201 | - system uptime 202 | - list of usernames 203 | - number of users which have provided auth credentials for current PSBT 204 | 205 | The CKBunker can operate in either mode, but you will find it harder 206 | to use, as it's not possible to know where you stand in terms of 207 | velocity spending and user authorization. 208 | 209 | ## Boot to HSM 210 | 211 | This feature forces the Coldcard to start in HSM mode immediately 212 | after boot up (after entry of the master PIN). 213 | 214 | You specify a 6-digit numeric code and if that code is provided in 215 | the first 60 seconds after startup, the Coldcard will leave HSM 216 | mode. Alternatively, you may 217 | set _Do not accept any code_, and the Coldcard can never leave HSM mode. 218 | 219 | !!! warning "Bricking Hazard" 220 | 221 | No changes to firmware, HSM policy, Coldcard settings will be possible—ever again. 222 | 223 | Not even the master PIN holder can change HSM policy nor escape HSM 224 | mode! Firmware upgrades are not possible. 225 | 226 | Boot-to-HSM is mainly useful mainly if the local operator does not 227 | have the authority to spend the funds, but does know the PIN code 228 | so they can assist with powerfail restart. In most applications, we 229 | expect someone with PIN knowledge and also the spending authority 230 | to power-up and enable HSM mode. 231 | 232 | ## Notes 233 | 234 | This is simply free-form text shown on the Coldcard when approving HSM Policy. 235 | Up to 80 characters allowed. You could put the master password and/or 236 | onion address here for documentation purposes. 237 | 238 | # Save and Start Policy 239 | 240 | When you are happy with your HSM policy, press "Save and Start 241 | Policy". You may want to capture a copy of the settings for later 242 | use. The CKBunker does not save the details (but may same a text 243 | summary) so it cannot re-create the policy rules completely. Coldcard 244 | saves the details but does not offer a means to share them later. 245 | 246 | Once you've hit the button, the attached Coldcard will prompt you for 247 | approval. You will get to see the policy as the Coldcard understands it, 248 | and you should read that careful to confirm it matches you intentions. 249 | 250 | After final approval, the HSM policy file is written into the 251 | Coldcard's flash memory and it enters HSM mode. On subsequent 252 | reboots, you will be prompted to start HSM mode on each time. 253 | 254 | # Next Steps 255 | 256 | [Signing a PSBT file](psbt.md) 257 | 258 | -------------------------------------------------------------------------------- /docs/psbt.md: -------------------------------------------------------------------------------- 1 | 2 | # Daily Operation: Signing PSBT Files 3 | 4 | ## PSBT Files 5 | 6 | You need to construct the PSBT file on another system. 7 | The CKBunker does not track the blockchain or know your UTXO. We will not make 8 | any assumptions about how you create PSBT files, and there are a growing number of 9 | wallets that can do it: BitcoinCore and Electrum for example. 10 | 11 | For testing purposes, we recommend 12 | [`psbt_faker`](https://github.com/Coldcard/psbt_faker) which will 13 | take your XPUB, and make arbitrary fake transactions immediately 14 | suitable for signing as PSBT files. 15 | This is a good way to test your policy choices with specific values and other what-ifs. 16 | 17 | ## Sign Transaction Tab 18 | 19 | Select "Sign Transaction" from the top bar, and you will see something similar to this: 20 | 21 | ![PSBT start](img/snap-psbt-start.png) 22 | 23 | The top is status dashboard, showing details of the Coldcard's current state. Hover 24 | over those fields for additional detail, such as a breakdown of spending per rule. 25 | 26 | At the bottom, a text summary, original from the Coldcard, shows the current 27 | policy in effect. For this example, the policy is as follows: 28 | 29 | ```text 30 | Transactions: 31 | - Rule #1: Up to 1 XTN per period will be approved 32 | - Rule #2: Any amount will be approved if local user confirms 33 | 34 | Velocity Period: 35 | 60 minutes 36 | = 1 hrs 37 | 38 | Message signing: 39 | - Allowed if path matches: (any path) 40 | 41 | Other policy: 42 | - MicroSD card will receive log entries. 43 | - Storage Locker will be updated, and can be read 13 times. 44 | ``` 45 | 46 | The next step is to upload a PSBT file. Binary, hex or base64 encoded files are 47 | accepted. When you've done that, the screen will change: 48 | 49 | ![PSBT start](img/snap-psbt-uploaded.png) 50 | 51 | If you want to know what the PSBT transaction will do, based on the Coldcard's 52 | understanding of it, press the "Display" button. The PSBT is sent to the Coldcard, 53 | and the text summary of what it does will be shown on your web browser. This is 54 | the same text that would be shown on-screen if the Coldcard was not in HSM mode. 55 | 56 | ![PSBT start](img/snap-psbt-visualized.png) 57 | 58 | Depending on your goals, there are three toggles to consider now: 59 | 60 | - Broadcast transaction immediately: CKBunker will send the finalized transaction to the 61 | Bitcoin P2P network for possible inclusion in the next block. Implies finalize. 62 | - Finalize transaction: In addition to PSBT signing, ask the Coldcard to finalize 63 | the transaction. Resulting file will be hex, ready for broadcast. 64 | - Download signed PSBT or transaction. Send the file to your browser immediately. You may not 65 | need the file if it's broadcast already. 66 | 67 | Adjust those to your needs, and press "Sign Transaction". 68 | 69 | If the transaction is acceptable to the policy, then it will be 70 | signed and broadcast directly, and/or downloaded to your browser. 71 | 72 | The dashboard updates immediately after signing, and in this case, shows the 73 | updated velocity limits. 74 | 75 | ![After PSBT signed](img/snap-psbt-after.png) 76 | 77 | ## Local User Confirmation Code 78 | 79 | The only local physical interaction possible with a Coldcard in HSM 80 | mode is to enter a local authorization code. This can be required 81 | by specific HSM policy rules, but is completely optional. 82 | 83 | As the local operator enters the 6-digit numeric code, the digits 84 | are shown in the top right corner of the screen. Press OK to apply 85 | them, or X to clear and start over. Codes are always 6 digits. There 86 | is no indication the code worked or failed, in part because it isn't 87 | tested until the PSBT is given for signing, which could be some 88 | time later. 89 | 90 | ![entering local code](img/hsm-local-code.png) 91 | 92 | The required code is a combination of the specific bytes 93 | of the PSBT file being approved, and also a salt value picked by 94 | the Coldcard. 95 | 96 | When the PSBT file has been uploaded, use the CKBunker updates the local code: 97 | 98 | ![bunker shows local code](img/bunker-local-conf.png) 99 | 100 | How you get this code into the Coldcard left to you. Please note 101 | the local operator must enter the code _before_ you press the "Sign Transaction" 102 | button on the CKBunker. 103 | 104 | A different code will be required for each attempted signing (because 105 | Coldcard changes the salt value) and for every PSBT file (because 106 | the hash of the PSBT is a factor in this number). 107 | 108 | ## User Authorization 109 | 110 | Depending on your configuration, you may see a number of username/password 111 | fields, in the top right area. 112 | 113 | ![user list](img/snap-users-empty.png) 114 | 115 | Enter the TOTP 2FA code (6 digits) from your app. If you are using 116 | passwords, enter that instead. If you have enabled "Privacy 117 | over UX", you will need to enter the username on the left side as well. 118 | Obviously, both the username and password must be correct. 119 | 120 | When you press "Sign Transaction", all the OTP/password data you 121 | have entered is submitted before the PSBT is considered by the 122 | Coldcard. If do you provide codes/passwords, they must be right, or 123 | the Coldcard will not proceed. When values are omitted (left blank) 124 | then it is not an error, but of course, rules that require the 125 | user's authorization will not be accepted. 126 | 127 | ## "Meet Me In The Bunker" Usage Case 128 | 129 | When multiple people need to authorize a transaction, they can all 130 | meet in the bunker: each user, from anywhere in the world, connects 131 | to the Bunker via Tor. Each will see the same state on the _Sign Transaction_ page. 132 | One can can upload a transaction (PSBT) and 133 | press the visualize button. All the screens update at the 134 | same time. When each person is satisfied, they may enter their OTP code 135 | or password to help approve the transaction. The other users will 136 | see this happening in real time. 137 | 138 | The CKBunker stores the current PSBT under consideration in RAM, 139 | so there is no need for this interaction to happen simultaneously. 140 | Users can come and go as needed to complete this process serially. 141 | 142 | # Next Steps 143 | 144 | [Msg Signing](msg-signing.md) 145 | -------------------------------------------------------------------------------- /docs/screen-shot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/docs/screen-shot.jpg -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup of Your CKBunker 2 | 3 | Step 1 is to install the bunker code and get it running. 4 | 5 | Once it's running, use any ordinary browser to connect to: 6 | 7 | http://localhost:9823/setup 8 | 9 | At this point, you should be focused on the two tabs "Coldcard Setup" 10 | and "Bunker Setup" tabs. 11 | 12 | ## Coldcard Setup tab 13 | 14 | ![Coldcard Setup screen shot](img/cc-setup-tab.png) 15 | 16 | "Coldcard Setup" is devoted to creating an HSM spending policy. These settings 17 | are held in a JSON file to be uploaded and confirmed on the Coldcard. 18 | 19 | Typically the Bunker will send the file to the Coldcard when you 20 | press "Save & Start Policy" but you have the option of downloading 21 | the file, and/or importing the JSON file before that point. This is useful if you 22 | want to hand-edit the data, or keep a backup/restore a backup. When you 23 | send the file to the Coldcard, your browser will download a copy of the file, 24 | if the checkbox "Download (sanitized) copy" is set. This version of the policy 25 | file will have the following sensitive fields stripped out: 26 | 27 | - `boot_to_hsm` - unlock code for 28 | - `set_sl`, `allow_sl` - details of the storage locker 29 | 30 | Continue reading [here for details about HSM rules and policy.](policy.md) 31 | 32 | ### Using Coldcard Setup _without_ a Coldcard 33 | 34 | By using the "Download Policy" button you can use the pretty web 35 | interface to build your policy file without using any other part 36 | of the CKBunker. In fact a Coldcard does not need to be connected. 37 | 38 | One limitation of this mode is the _Users_ section. It communicates 39 | directly with the Coldcard to read the list of users and add/remove them. 40 | If you are using those features, you may need to edit the JSON. The same 41 | is true for the names of your multisig wallets. 42 | 43 | If you use the Bunker to upload your policy, the `allow_sl` and 44 | `set_sl` fields will be overriden by the Bunker and replaced. You 45 | can use `ckcc hsm-start file.json` to upload the JSON policy file, 46 | and start HSM mode on the command line. 47 | 48 | ## Bunker Setup tab 49 | 50 | ![Bunker Setup screen shot](img/bk-setup-tab.png) 51 | 52 | There are only a few settings for the Bunker itself: 53 | 54 | - Enable or disable Tor Hidden Service: Once enabled, you may 55 | generate a different onion address by picking "Spin Again". 56 | 57 | - Master Login password: this password is needed to get in to the CKBunker 58 | over the Tor network. There are no usernames. Cannot be shorter than 4 characters. 59 | 60 | - Simple captcha: on the login form, there are two possible styles of captcha. 61 | 62 | - Simple Captcha 63 | 64 | ![simple captcha](img/simple-captcha.png) 65 | 66 | - Animated Captcha 67 | 68 | ![animated captcha](img/animated-captcha.gif) 69 | 70 | - "Allow Bunker to be restarted without requiring a restart of the Coldcard": 71 | This setting should be configured before you save and apply your HSM policy 72 | on the "Coldcard Setup" page. It controls the number `allow_sl` inside 73 | the policy, and sets that value to `1` or `13`. If it's `1`, then the 74 | Coldcard will allow only a single read of the Storage Locker, and the 75 | effect of that is the CKBunker can only be reset/started once without 76 | knowing the PIN of the Coldcard. 77 | 78 | # Login Screen 79 | 80 | For your reference, the login screen presented to visitors looks like this 81 | with the animated captcha. 82 | 83 | ![login screen](img/snap-login.png) 84 | 85 | ### How Bunker Settings are Saved 86 | 87 | In our security model, we assume the CKBunker may be "captured" by 88 | your adversaries. The CKBunker is not the last line of defense---that's the Coldcard 89 | and your HSM policy. 90 | 91 | If the Bunker is captured while it is turned on and running, then 92 | in the worst case, the attackers can read all of main memory, and 93 | will get the Tor Hidden service private key. This will allow them 94 | to impersonate your Bunker in the future. Presumably they can view 95 | your PSBT file and all other web interactions while you work with the Bunker. 96 | 97 | However, they cannot change the HSM policy of the Coldcard. They 98 | do not know the TOTP/2FA secrets, and cannot generate OTP codes 99 | (only the Coldcard knows those) and so they cannot authorize spending 100 | that way and impersonate users. If the attackers are remote, relative 101 | to the location of the Coldcard, the "local confirmation code" feature 102 | will also protect you, since the attackers would need to convince 103 | your remote hands to enter a specific code. 104 | 105 | When the data associated with the Bunker is "at rest", meaning the 106 | system is not running, we have good protections in place. All Bunker 107 | settings are saved to `./data` directory, as encrypted files. The 108 | name of the file is a hash of the private key, and the contents are 109 | a NaCl secret box (Curve25519). We store the private key for that 110 | file on the Coldcard itself, in the storage locker. 111 | 112 | When the Bunker starts, it searchs for a Coldcard on USB (and it also 113 | looks for the simulator). If it finds one already in HSM mode, then 114 | it reads the storage locker and uses the NaCl private key (32 bytes) 115 | to select and open the corresponding Bunker settings file. Therefore, 116 | each Coldcard has it's own settings for the Bunker. 117 | 118 | In setup mode, bunker settings are effectively not saved until 119 | the NaCL secret is saved into the policy of a Coldcard and saved 120 | there. 121 | 122 | 123 | #### Other Notes 124 | 125 | - PSBT files are never saved to disk. They stay in-memory only. 126 | - HSM Policy files are not saved to disk, except as part of the settings. 127 | - If you don't choose _Privacy over UX_ then many key details of your HSM 128 | policy are captured and saved into the encrypted settings. This includes details 129 | like the HSM text summary, user names, and other details that are know only 130 | when the policy is created. 131 | - USB Encryption should be set to version 2 if firmware supports this . 132 | For now default version is still 1. To enable version 2: 133 | `echo "USB_NCRY_VERSION: 2" > /tmp/ckbunker_ncryV2.yaml; ckbunker setup -c /tmp/ckbunker_ncryV2.yaml`. With version 2 134 | enabled, in case of any ckbunker or communication failure, one needs to re-login to Coldcard 135 | 136 | # Next Steps 137 | 138 | [HSM policy setup](policy.md) 139 | -------------------------------------------------------------------------------- /example-settings.yaml: -------------------------------------------------------------------------------- 1 | # Example configuration file. See persist.py for variable names and default values. 2 | # 3 | # Usage: 4 | # 5 | # ckbunker setup -c example-settings.yaml 6 | # ckbunker run -c example-settings.yaml 7 | # 8 | PORT_NUMBER: 9800 9 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. 4 | # 5 | # Main entry-point for project. 6 | # 7 | # To use this, install with: 8 | # 9 | # pip install --editable . 10 | # 11 | # That will create the command "ckbunker" in your path. Use it! 12 | # 13 | import os, sys, click, hid, asyncio, logging 14 | from pprint import pformat, pprint 15 | 16 | global force_serial 17 | force_serial = None 18 | 19 | from ckcc.protocol import CCProtocolPacker 20 | from ckcc.protocol import CCProtoError, CCUserRefused, CCBusyError 21 | from ckcc.client import ColdcardDevice, COINKITE_VID, CKCC_PID 22 | 23 | 24 | # Options we want for all commands 25 | @click.group() 26 | @click.option('--serial', '-s', default=None, metavar="HEX", 27 | help="Operate on specific unit (default: first found)") 28 | def main(serial): 29 | global force_serial 30 | force_serial = serial 31 | 32 | @main.command('list') 33 | def _list(): 34 | "List all attached Coldcard devices" 35 | 36 | count = 0 37 | for info in hid.enumerate(COINKITE_VID, CKCC_PID): 38 | #click.echo("\nColdcard {serial_number}:\n{nice}".format( 39 | # nice=pformat(info, indent=4)[1:-1], **info)) 40 | click.echo(info['serial_number']) 41 | count += 1 42 | 43 | if not count: 44 | click.echo("(none found)") 45 | 46 | @main.command('example') 47 | def example_config(): 48 | "Show an example config file, using the default values" 49 | 50 | from persist import Settings 51 | 52 | click.echo(Settings.make_sample()) 53 | 54 | @main.command('run') 55 | @click.option('--local', '-l', default=False, is_flag=True, 56 | help="Don't enable Tor (onion) access: just be on localhost") 57 | @click.option('--psbt', '-f', metavar="filename.psbt", 58 | help="Preload first PSBT to be signed", default=None, 59 | type=click.File('rb')) 60 | @click.option('--config-file', '-c', type=click.File('rt'), required=False) 61 | def start_service(local=False, config_file=None, psbt=None): 62 | "Start the CKBunker for normal operation" 63 | 64 | if psbt: 65 | psbt = psbt.read() 66 | 67 | asyncio.run(startup(False, local, config_file, psbt), debug=True) 68 | 69 | @main.command('setup') 70 | @click.option('--local', '-l', default=False, is_flag=True, 71 | help="Don't enable Tor (onion) access: just be on localhost") 72 | @click.option('--config-file', '-c', type=click.File('rt'), required=False) 73 | def setup_hsm(local=False, config_file=None): 74 | "Configure your transaction signing policy, install it and then operate." 75 | 76 | asyncio.run(startup(True, local, config_file, None), debug=True) 77 | 78 | async def startup(setup_mode, force_local_mode, config_file, first_psbt): 79 | # All startup/operation code 80 | 81 | loop = asyncio.get_running_loop() 82 | if loop.get_debug(): 83 | # quiet noise about slow stuff 84 | loop.slow_callback_duration = 10 85 | 86 | from utils import setup_logging 87 | setup_logging() 88 | 89 | from persist import Settings 90 | Settings.startup(config_file) 91 | 92 | aws = [] 93 | 94 | # copy some args into status area 95 | from status import STATUS 96 | STATUS.force_local_mode = force_local_mode 97 | STATUS.setup_mode = setup_mode 98 | 99 | # preload the contents of a PSBT 100 | if first_psbt: 101 | STATUS.import_psbt(first_psbt) 102 | 103 | from torsion import TOR 104 | aws.append(TOR.startup()) 105 | 106 | from conn import Connection 107 | aws.append(Connection(force_serial).run()) 108 | 109 | import webapp 110 | aws.append(webapp.startup(setup_mode)) 111 | 112 | 113 | await asyncio.gather(*aws) 114 | 115 | 116 | # EOF 117 | -------------------------------------------------------------------------------- /make_captcha.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Draw a Captchas ... not meant to be hard, but easier to replace out, and challenging 4 | # to read image itself.. 5 | # 6 | import random, os, io 7 | from PIL import Image, ImageDraw, ImageFont 8 | 9 | # Avoid similar-looking letters/numbers. 10 | TOKEN_CHARS = 'abcdefghkmnpqrstuvwxyz23456789' 11 | 12 | class CaptchaMaker: 13 | size = (256, 64) # limited by iphone case. esp. when entering value 14 | 15 | def __init__(self, seed=None): 16 | self.rng = random.Random(seed) 17 | 18 | 19 | def draw(self, token): 20 | # draw some crazy captcha for indicated token and return as a 21 | # tuple: extension, raw_data 22 | raise NotImplementedError 23 | 24 | def get_font(self, size=40, which='nova'): 25 | fn = { 'ransom': 'static/fonts/ransom-note.ttf', 26 | 'nova': 'static/fonts/proximanova-semibold.ttf' 27 | } 28 | return ImageFont.truetype(fn[which], size) 29 | 30 | class RansomCaptcha(CaptchaMaker): 31 | # 32 | # Just the simple ransom note that most people expect today. 33 | # 34 | def draw(self, token, foreground='#000', seed=None): 35 | fn = self.get_font(size=40, which='ransom') 36 | w,h = self.size 37 | _,_,dx,dy = fn.getbbox('W') 38 | 39 | im = Image.new('RGBA', self.size) 40 | dr = ImageDraw.Draw(im) 41 | 42 | # some of the lower-case letters are confusing, so replace them 43 | remap = dict(x = 'X', s='S', a='A', g='G', q='Q') 44 | 45 | x = 10 46 | ix = (w - (x*2)) * (len(token)-2) / dx 47 | for ch in token: 48 | ch = remap.get(ch, ch) 49 | y = self.rng.randint(-5, h-dy+5) 50 | dr.text( (x, y), ch, fill=foreground, font=fn) 51 | x += ix 52 | 53 | data = io.BytesIO() 54 | im.save(data, format='png') 55 | 56 | return 'png', data.getvalue() 57 | 58 | class MegaGifCaptcha(CaptchaMaker): 59 | # These are fun, but too large to be practical? Keep in toolbox for later. 60 | 61 | def draw(self, token, foreground='#fff', background='white'): 62 | 63 | token = ' '.join(token.upper()) 64 | 65 | fn = self.get_font(size=30, which='nova') 66 | w,h = self.size 67 | _,_,dx,dy = fn.getbbox('W') 68 | 69 | randint = self.rng.randint 70 | sample = self.rng.sample 71 | 72 | actual = [] 73 | pass_thru = set() 74 | frames = [] 75 | 76 | ans_y = self.rng.randint(3, h-dy-3) 77 | ans_w = fn.getbbox(token)[2] 78 | count = 15 79 | 80 | for fr_num in range(count): 81 | im = Image.new('P', self.size, background) 82 | dr = ImageDraw.Draw(im) 83 | 84 | # give them noise chars, except they are mostly correct so that 85 | # the order is not so clear at all. don't want to just be able to 86 | # pick the most common chars observed 87 | charset = set(token) 88 | while len(charset) < len(token) + 4: 89 | charset.add(sample(TOKEN_CHARS, 1)[0]) 90 | 91 | for k in range(int(w * 1.5/dx)): 92 | #ch = ''.join(self.rng.sample(TOKEN_CHARS, 1)).upper() 93 | ch = ''.join(sample(list(charset), 1)).upper() 94 | x = randint(-dx, w) 95 | y = ans_y + randint(int(-dy*3/4), int(dy*3/4)) 96 | dr.text( (x,y), ch, fill=foreground, font=fn) 97 | 98 | frames.append(im) 99 | 100 | x = (w-ans_w)*fr_num / count 101 | dr.text( (x, ans_y), token, fill=foreground, font=fn) 102 | 103 | data = io.BytesIO() 104 | 105 | frames[0].save(data, format='gif', save_all=True, loop=0, 106 | append_images=frames + list(reversed(frames[1:-1]))) 107 | 108 | return 'gif', data.getvalue() 109 | 110 | # EOF 111 | -------------------------------------------------------------------------------- /objstruct.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. 2 | # 3 | # objstruct.py 4 | # 5 | 6 | class ObjectStruct(dict): 7 | '''An object like both a dict and also an object that you can 8 | easily use attr reference to get members. Construct with a 9 | dict or like a dict. 10 | ''' 11 | 12 | def __getattr__(self, name): 13 | if name in self: 14 | return self[name] 15 | else: 16 | # Do not return a default here because it breaks things 17 | raise AttributeError('No such attribute: %s' % name) 18 | 19 | def __setattr__(self, name, value): 20 | self[name] = value 21 | 22 | def __delattr__(self, name): 23 | del self[name] 24 | 25 | def __repr__(self): 26 | ret = '<%s:' % self.__class__.__name__ 27 | for k,v in self.items(): 28 | ret += ' %s=%r' % (k, v) 29 | return ret + '>' 30 | 31 | @classmethod 32 | def promote(cls, x): 33 | # Often I get a dict() from an API wrapper that's taken some json and 34 | # run it thru json.loads(). It would be better to have that as nested 35 | # ObjectStruct (which is easily done with some arguments to loads, but 36 | # usually they don't provide that feature)... so call this function 37 | # 38 | 39 | if isinstance(x, list): 40 | return [cls.promote(i) for i in x] 41 | 42 | if isinstance(x, dict): 43 | x = cls(x) 44 | for k in x: 45 | x[k] = cls.promote(x[k]) 46 | 47 | return x 48 | 49 | class DefaultObjectStruct(ObjectStruct): 50 | ''' Same, but can provide a default value if get_default is overriden''' 51 | def get_default(self, fldname): 52 | # override me 53 | return None 54 | 55 | def __getattr__(self, name): 56 | if name in self: 57 | return self[name] 58 | else: 59 | # sometimes you do want a default. 60 | return self.get_default(name) 61 | 62 | # EOF 63 | -------------------------------------------------------------------------------- /persist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Persistent data for Bunker itself. Trying to minimize this for privacy. 4 | # 5 | import os, yaml, nacl.secret, logging 6 | from utils import Singleton, xfp2str, json_dumps, json_loads, WatchableMixin 7 | from hashlib import sha256 8 | from objstruct import ObjectStruct 9 | 10 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 11 | 12 | # globals, used system-wide 13 | settings = None 14 | BP = None 15 | 16 | # System-wide settings for Bunker itself. 17 | # 18 | class Settings(metaclass=Singleton): 19 | 20 | # web server port 21 | PORT_NUMBER = 9823 22 | 23 | # session idle time, before we kick you out and require re-auth (seconds) 24 | MAX_IDLE_TIME = 10*60 25 | 26 | # max time between showing login page, and the would-be user entering something useful (seconds) 27 | MAX_LOGIN_WAIT_TIME = 5*60 28 | 29 | # bogus fixed password to get started 30 | MASTER_PW = 'test1234' 31 | 32 | # default is harder captcha 33 | EASY_CAPTCHA = False 34 | 35 | # default for "allow reboot of bunker" 36 | # - can you restart the bunker w/o restarting the Coldcard HSM? 37 | ALLOW_REBOOTS = True 38 | 39 | # path to data files 40 | DATA_FILES = './data' 41 | 42 | # endpoint to use for sending txn; we assume it's Explora protocol (Blockstream.info) 43 | EXPLORA = 'http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion' 44 | 45 | # port number for local instance of tord 46 | # - will try 9051 and 9151 47 | # - but first /var/run/tor/control as unix socket 48 | TORD_PORT = 'default' 49 | 50 | # for broadcasting, socks proxy via Tord 51 | TOR_SOCKS = 'socks5h://127.0.0.1:9150' 52 | 53 | # unix pipe for local Coldcard Simulator 54 | SIMULATOR_SOCK = '/tmp/ckcc-simulator.sock' 55 | 56 | # delay between retries connecting to missing/awol Coldcard 57 | RECONNECT_DELAY = 10 # seconds between retries 58 | PING_RATE = 15 # seconds between pings (CC status checks) 59 | USB_NCRY_VERSION = 0x01 # default ncry version is 1 60 | 61 | # USB encryption versions (default 1) 62 | # 63 | # V2 introduces a new ncry version to close a potential attack vector: 64 | # 65 | # A malicious program may re-initialize the connection encryption by sending the ncry command a second time during USB operation. 66 | # This may prove particularly harmful in HSM mode. 67 | # 68 | # Sending version 0x02 changes the behavior in two ways: 69 | # * All future commands must be encrypted 70 | # * Returns an error if the ncry command is sent again for the duration of the power cycle 71 | # 72 | # If using 0x02 and ckbunker is killed - you also need to re-login to Coldcard 73 | 74 | def read(self, fobj): 75 | t = yaml.safe_load(fobj) 76 | if not t: return 77 | 78 | for k,v in t.items(): 79 | if k.upper() != k or k[0]=='_': 80 | logging.error(f"{k}: must be upper case") 81 | continue 82 | if not hasattr(self, k): 83 | logging.error(f"{k}: unknown setting") 84 | continue 85 | 86 | setattr(self, k, v) 87 | 88 | @classmethod 89 | def make_sample(cls): 90 | # produce an example config file 91 | d = {} 92 | x = cls() 93 | for k in dir(x): 94 | if k.upper() != k or k[0]=='_': continue 95 | d[k] = getattr(x, k) 96 | 97 | return yaml.safe_dump(d) 98 | 99 | @classmethod 100 | def startup(cls, config_file=None): 101 | # creates singleton 102 | global settings, BP 103 | 104 | # only safe place to create singletons is here 105 | assert not settings and not BP 106 | 107 | settings = Settings() 108 | if config_file: 109 | settings.read(config_file) 110 | 111 | # load defaults into BP 112 | BP = BunkerPersistance() 113 | BP.reset() 114 | 115 | # Store some state, encrypted. 116 | # - inial values are the settings, but lower case for some reason 117 | # - some are adjustable on "Bunker Setup" page 118 | class BunkerPersistance(WatchableMixin, dict, metaclass=Singleton): 119 | fields = ['tor_enabled', 'onion_pk', 'onion_addr', 'allow_reboots', 120 | 'easy_captcha', 'master_pw'] 121 | 122 | def __init__(self): 123 | super(BunkerPersistance, self).__init__() 124 | self.filename = None 125 | self.reset() 126 | 127 | def reset(self): 128 | self.clear() 129 | self.set_secret(os.urandom(32)) 130 | self.set_defaults() 131 | 132 | def set_defaults(self): 133 | # defaults here 134 | for fn in self.fields: 135 | if fn not in self: 136 | self[fn] = getattr(settings, fn.upper(), None) 137 | 138 | def set_secret(self, key): 139 | # setup for reading/writing using indicated key 140 | assert len(key) == 32 141 | 142 | self.key = key 143 | self.box = nacl.secret.SecretBox(self.key) 144 | 145 | # calc filename 146 | bn = 'bp-%s.dat' % sha256(sha256(b'salty' + self.key).digest()).hexdigest()[-16:].lower() 147 | self.filename = os.path.join(settings.DATA_FILES, bn) 148 | 149 | def open(self, key): 150 | # Given a private key (via storage locker) open a Nacl secret box 151 | # and use that for the data. 152 | self.set_secret(key) 153 | 154 | try: 155 | with open(self.filename, 'rb') as fp: 156 | d = self.box.decrypt(fp.read()) 157 | d = json_loads(d) 158 | except FileNotFoundError: 159 | logging.info("%s: not found (probably fine)" % self.filename) 160 | return True 161 | 162 | self.update(d) 163 | 164 | # copy a setting to status (XXX feels wrong) 165 | from status import STATUS 166 | STATUS.tor_enabled = self.get('tor_enabled', False) 167 | 168 | logging.info(f"Got bunker settings from: {self.filename}") 169 | 170 | def save(self): 171 | fn = self.filename 172 | tmp = fn + '.tmp' 173 | with open(tmp, 'wb') as fp: 174 | d = json_dumps(dict(self)).encode('utf8') 175 | d = self.box.encrypt(d) 176 | fp.write(d) 177 | 178 | os.rename(tmp, fn) 179 | logging.info(f"Saved bunker settings to: {fn}") 180 | 181 | self.notify_watchers() 182 | 183 | def delete_file(self): 184 | # useful when changing keys; old file won't be readable 185 | try: 186 | os.unlink(self.filename) 187 | logging.info(f"Deleted bunker settings in: {self.filename}") 188 | except: 189 | pass 190 | 191 | 192 | # EOF 193 | -------------------------------------------------------------------------------- /policy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # policy.py -- code which knows various details about HSM policy as defined by Coldcard. 4 | # 5 | import re, logging 6 | from decimal import Decimal 7 | from objstruct import ObjectStruct 8 | from persist import BP, settings 9 | from base64 import b64encode, b64decode 10 | 11 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 12 | 13 | def invalid_pincode(code): 14 | return (not code) or (len(code) != 6) or (not code.isdigit()) 15 | 16 | def web_cleanup(p): 17 | # takes policy details from Vue/Semantic/web browser format into proper JSON-able dict 18 | # - final product should serialize into something the Coldcard will accept 19 | 20 | def relist(n): 21 | # split on spaces or commas, assume values don't have either; trim whitespace 22 | if n is None: return n 23 | return [i for i in re.split(r' |,|\n', n) if i] 24 | 25 | for fn in ['msg_paths', 'share_xpubs', 'share_addrs']: 26 | p[fn] = relist(p.get(fn, None)) 27 | 28 | p.period = int(p.period) if p.period else None 29 | 30 | for idx, rule in enumerate(p.rules): 31 | for fn in ['whitelist', 'users']: 32 | rule[fn] = relist(rule[fn]) 33 | 34 | # change from BTC to satoshis (send as string here) 35 | for fn in ['per_period', 'max_amount']: 36 | v = rule.get(fn, None) or None 37 | if v is not None: 38 | try: 39 | v = Decimal(v) 40 | except: 41 | raise ValueError(f"Rule #{idx+1} field {fn} is invalid: {rule[fn]}") 42 | rule[fn] = int(v * Decimal('1E8')) 43 | else: 44 | # cleans up empty strings 45 | rule[fn] = None 46 | 47 | # text to number 48 | if not rule.users: 49 | rule.pop('min_users') 50 | else: 51 | rule.min_users = len(rule.users) if rule.min_users == 'all' else int(rule.min_users) 52 | 53 | if p.pop('ewaste_enable', False): 54 | p.boot_to_hsm = 'xyzzy' # impossible to enter 55 | assert invalid_pincode(p.boot_to_hsm) 56 | else: 57 | p.boot_to_hsm = p.get('boot_to_hsm') or None 58 | if p.boot_to_hsm: 59 | assert not invalid_pincode(p.boot_to_hsm), \ 60 | "Boot to HSM code must be 6 numeric digits." 61 | 62 | return p 63 | 64 | def web_cookup(proposed): 65 | # converse of above: take Coldcard policy file, and rework it so 66 | # Vue can display on webpage 67 | 68 | p = ObjectStruct.promote(proposed) 69 | 70 | def unlist(n): 71 | if not n: return '' 72 | return ','.join(n) 73 | 74 | for fn in ['msg_paths', 'share_xpubs', 'share_addrs']: 75 | p[fn] = unlist(p.get(fn)) 76 | 77 | for rule in p.rules: 78 | for fn in ['whitelist', 'users']: 79 | rule[fn] = unlist(rule.get(fn)) 80 | 81 | for fn in ['per_period', 'max_amount']: 82 | if rule[fn] is not None: 83 | rule[fn] = str(Decimal(rule[fn]) / Decimal('1E8')) 84 | 85 | if 'min_users' not in rule: 86 | rule.min_users = 'all' 87 | else: 88 | rule.min_users = str(rule.min_users) 89 | 90 | if ('boot_to_hsm' in p) and p.boot_to_hsm and invalid_pincode(p.boot_to_hsm): 91 | p.ewaste_enable = True 92 | else: 93 | p.ewaste_enable = False 94 | 95 | return p 96 | 97 | 98 | def desensitize(policy): 99 | # remove the most sensitive stuff in the policy. 100 | bk = policy.copy() 101 | bk.pop('set_sl', None) 102 | bk.pop('allow_sl', None) 103 | bk.pop('boot_to_hsm', None) 104 | 105 | return bk 106 | 107 | def decode_sl(xk): 108 | # Unpack what we saved into the Storage Locker 109 | # - 32 bytes of nacl secret box for BunkerPersistance, plus "Bunk" prefix => 36 bytes 110 | # - base64 encoded => 48 bytes (and has no padding) 111 | assert len(xk) == 48, repr(xk) 112 | xk = b64decode(xk) 113 | assert xk[0:4] == b'Bunk' 114 | rv = xk[4:] 115 | assert len(rv) == 32 116 | 117 | return rv 118 | 119 | def update_sl(proposed): 120 | # We control the set_sl/allow_sl values solely for bunker purposes (sl=storage locker) 121 | 122 | # try to use any value already provided (but unlikely) 123 | xk = proposed.get('set_sl', None) or None 124 | if xk: 125 | try: 126 | xk = decode_sl(xk) 127 | except: 128 | logging.error("Unable to decode existing storage locker; replacing", exc_info=1) 129 | xk = None 130 | 131 | if not xk: 132 | # capture settings key 133 | xk = BP.key 134 | 135 | assert len(xk) == 32 136 | proposed['set_sl'] = b64encode(b'Bunk' + xk).decode('ascii') 137 | 138 | if xk != BP.key: 139 | # re-use existing key, and switch over to using new/eixsting key 140 | BP.delete_file() 141 | BP.set_secret(xk) 142 | BP.save() 143 | else: 144 | logging.info("Re-using old secret for holding Bunker settings") 145 | 146 | # simple fixed value for how many times we can re-read the storage locker 147 | proposed['allow_sl'] = 13 if BP.get('allow_reboots', True) else 1 148 | 149 | 150 | # EOF 151 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # See also setup.py install_requires 2 | # 3 | stem==1.8.0 4 | aiohttp>=3.7.4 5 | aiohttp-jinja2>=1.2 6 | ckcc-protocol>=1.0.1 7 | pynacl==1.3.0 8 | aiohttp_session 9 | click 10 | pendulum==2.0.3 11 | pyyaml 12 | Pillow 13 | pytest 14 | requests[socks] 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. 2 | # 3 | # based on 4 | # 5 | # To use this, install with: 6 | # 7 | # pip install --editable . 8 | 9 | from setuptools import setup 10 | 11 | with open("README.md", "r") as fh: 12 | long_description = fh.read() 13 | 14 | setup( 15 | name='bunker', 16 | version='0.1', 17 | license='MIT+CC', 18 | python_requires='>=3.7.0', 19 | url='https://github.com/Coldcard/ckbunker', 20 | author='Coinkite Inc.', 21 | author_email='support@coinkite.com', 22 | description="Submit PSBT files automatically to your Coldcard for signing", 23 | long_description=long_description, 24 | long_description_content_type="text/markdown", 25 | install_requires=[ # see requirements.txt tho. 26 | 'Click', 27 | 'stem', 28 | 'aiohttp', 29 | 'aiohttp-jinja2', 30 | 'ckcc-protocol>=1.3.2', 31 | 'pyyaml', 32 | 'pynacl==1.3.0', 33 | 'pendulum==2.0.3', 34 | 'aiohttp_session', 35 | 'requests[socks]', 36 | ], 37 | entry_points=''' 38 | [console_scripts] 39 | ckbunker=main:main 40 | ''', 41 | classifiers=[ 42 | 'Operating System :: POSIX :: Linux', 43 | 'Operating System :: Microsoft :: Windows', 44 | 'Operating System :: MacOS :: MacOS X', 45 | ], 46 | packages=[], 47 | ) 48 | -------------------------------------------------------------------------------- /static/ext/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Background 3 | 4 | - Semantic UI parts taken from v2.4.1 at 5 | 6 | - one long path is remapped (rather than edit min.css files) 7 | /static/ext/themes/default/assets/fonts/ => /static/semantic-fonts 8 | -------------------------------------------------------------------------------- /static/ext/base64js.min.js: -------------------------------------------------------------------------------- 1 | (function(r){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=r()}else if(typeof define==="function"&&define.amd){define([],r)}else{var e;if(typeof window!=="undefined"){e=window}else if(typeof global!=="undefined"){e=global}else if(typeof self!=="undefined"){e=self}else{e=this}e.base64js=r()}})(function(){var r,e,n;return function(){function d(a,f,i){function u(n,r){if(!f[n]){if(!a[n]){var e="function"==typeof require&&require;if(!r&&e)return e(n,!0);if(v)return v(n,!0);var t=new Error("Cannot find module '"+n+"'");throw t.code="MODULE_NOT_FOUND",t}var o=f[n]={exports:{}};a[n][0].call(o.exports,function(r){var e=a[n][1][r];return u(e||r)},o,o.exports,d,a,f,i)}return f[n].exports}for(var v="function"==typeof require&&require,r=0;r0){throw new Error("Invalid string. Length must be a multiple of 4")}var n=r.indexOf("=");if(n===-1)n=e;var t=n===e?0:4-n%4;return[n,t]}function f(r){var e=c(r);var n=e[0];var t=e[1];return(n+t)*3/4-t}function h(r,e,n){return(e+n)*3/4-n}function i(r){var e;var n=c(r);var t=n[0];var o=n[1];var a=new d(h(r,t,o));var f=0;var i=o>0?t-4:t;var u;for(u=0;u>16&255;a[f++]=e>>8&255;a[f++]=e&255}if(o===2){e=v[r.charCodeAt(u)]<<2|v[r.charCodeAt(u+1)]>>4;a[f++]=e&255}if(o===1){e=v[r.charCodeAt(u)]<<10|v[r.charCodeAt(u+1)]<<4|v[r.charCodeAt(u+2)]>>2;a[f++]=e>>8&255;a[f++]=e&255}return a}function s(r){return u[r>>18&63]+u[r>>12&63]+u[r>>6&63]+u[r&63]}function l(r,e,n){var t;var o=[];for(var a=e;ai?i:f+a))}if(t===1){e=r[n-1];o.push(u[e>>2]+u[e<<4&63]+"==")}else if(t===2){e=(r[n-2]<<8)+r[n-1];o.push(u[e>>10]+u[e>>4&63]+u[e<<2&63]+"=")}return o.join("")}},{}]},{},[])("/")}); 2 | -------------------------------------------------------------------------------- /static/ext/lato-font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Lato'; 3 | font-style: italic; 4 | font-weight: 400; 5 | src: local('Lato Italic'), local('Lato-Italic'), url(lato/S6u8w4BMUTPHjxsAXC-v.ttf) format('truetype'); 6 | } 7 | @font-face { 8 | font-family: 'Lato'; 9 | font-style: italic; 10 | font-weight: 700; 11 | src: local('Lato Bold Italic'), local('Lato-BoldItalic'), url(lato/S6u_w4BMUTPHjxsI5wq_Gwfo.ttf) format('truetype'); 12 | } 13 | @font-face { 14 | font-family: 'Lato'; 15 | font-style: normal; 16 | font-weight: 400; 17 | src: local('Lato Regular'), local('Lato-Regular'), url(lato/S6uyw4BMUTPHjx4wWw.ttf) format('truetype'); 18 | } 19 | @font-face { 20 | font-family: 'Lato'; 21 | font-style: normal; 22 | font-weight: 700; 23 | src: local('Lato Bold'), local('Lato-Bold'), url(lato/S6u9w4BMUTPHh6UVSwiPHA.ttf) format('truetype'); 24 | } 25 | -------------------------------------------------------------------------------- /static/ext/lato/README.md: -------------------------------------------------------------------------------- 1 | 2 | Based on files referenced from the response to: 3 | 4 | 5 | 6 | As requested by semantic.min.css 7 | -------------------------------------------------------------------------------- /static/ext/lato/S6u8w4BMUTPHjxsAXC-v.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/lato/S6u8w4BMUTPHjxsAXC-v.ttf -------------------------------------------------------------------------------- /static/ext/lato/S6u9w4BMUTPHh6UVSwiPHA.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/lato/S6u9w4BMUTPHh6UVSwiPHA.ttf -------------------------------------------------------------------------------- /static/ext/lato/S6u_w4BMUTPHjxsI5wq_Gwfo.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/lato/S6u_w4BMUTPHjxsI5wq_Gwfo.ttf -------------------------------------------------------------------------------- /static/ext/lato/S6uyw4BMUTPHjx4wWw.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/lato/S6uyw4BMUTPHjx4wWw.ttf -------------------------------------------------------------------------------- /static/ext/livestamp.min.js: -------------------------------------------------------------------------------- 1 | // Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License 2 | (function(d,g){var h=1E3,i=!1,e=d([]),j=function(b,a){var c=b.data("livestampdata");"number"==typeof a&&(a*=1E3);b.removeAttr("data-livestamp").removeData("livestamp");a=g(a);g.isMoment(a)&&!isNaN(+a)&&(c=d.extend({},{original:b.contents()},c),c.moment=g(a),b.data("livestampdata",c).empty(),e.push(b[0]))},k=function(){i||(f.update(),setTimeout(k,h))},f={update:function(){d("[data-livestamp]").each(function(){var a=d(this);j(a,a.data("livestamp"))});var b=[];e.each(function(){var a=d(this),c=a.data("livestampdata"); 3 | if(void 0===c)b.push(this);else if(g.isMoment(c.moment)){var e=a.html(),c=c.moment.fromNow();if(e!=c){var f=d.Event("change.livestamp");a.trigger(f,[e,c]);f.isDefaultPrevented()||a.html(c)}}});e=e.not(b)},pause:function(){i=!0},resume:function(){i=!1;k()},interval:function(b){if(void 0===b)return h;h=b}},l={add:function(b,a){"number"==typeof a&&(a*=1E3);a=g(a);g.isMoment(a)&&!isNaN(+a)&&(b.each(function(){j(d(this),a)}),f.update());return b},destroy:function(b){e=e.not(b);b.each(function(){var a= 4 | d(this),c=a.data("livestampdata");if(void 0===c)return b;a.html(c.original?c.original:"").removeData("livestampdata")});return b},isLivestamp:function(b){return void 0!==b.data("livestampdata")}};d.livestamp=f;d(function(){f.resume()});d.fn.livestamp=function(b,a){l[b]||(a=b,b="add");return l[b](this,a)}})(jQuery,moment); 5 | -------------------------------------------------------------------------------- /static/ext/semantic-fonts/brand-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/brand-icons.eot -------------------------------------------------------------------------------- /static/ext/semantic-fonts/brand-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/brand-icons.ttf -------------------------------------------------------------------------------- /static/ext/semantic-fonts/brand-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/brand-icons.woff -------------------------------------------------------------------------------- /static/ext/semantic-fonts/brand-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/brand-icons.woff2 -------------------------------------------------------------------------------- /static/ext/semantic-fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/icons.eot -------------------------------------------------------------------------------- /static/ext/semantic-fonts/icons.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/icons.otf -------------------------------------------------------------------------------- /static/ext/semantic-fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/icons.ttf -------------------------------------------------------------------------------- /static/ext/semantic-fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/icons.woff -------------------------------------------------------------------------------- /static/ext/semantic-fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/icons.woff2 -------------------------------------------------------------------------------- /static/ext/semantic-fonts/outline-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/outline-icons.eot -------------------------------------------------------------------------------- /static/ext/semantic-fonts/outline-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/outline-icons.ttf -------------------------------------------------------------------------------- /static/ext/semantic-fonts/outline-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/outline-icons.woff -------------------------------------------------------------------------------- /static/ext/semantic-fonts/outline-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/semantic-fonts/outline-icons.woff2 -------------------------------------------------------------------------------- /static/ext/ubuntu-font.css: -------------------------------------------------------------------------------- 1 | /* from https://fonts.googleapis.com/css?family=Ubuntu:400,400i,700,700i&display=swap */ 2 | @font-face { 3 | font-family: 'Ubuntu'; 4 | font-style: italic; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: local('Ubuntu Italic'), local('Ubuntu-Italic'), url(ubuntu/4iCu6KVjbNBYlgoKej70l0w.ttf) format('truetype'); 8 | } 9 | @font-face { 10 | font-family: 'Ubuntu'; 11 | font-style: italic; 12 | font-weight: 700; 13 | font-display: swap; 14 | src: local('Ubuntu Bold Italic'), local('Ubuntu-BoldItalic'), url(ubuntu/4iCp6KVjbNBYlgoKejZPslyPN4Q.ttf) format('truetype'); 15 | } 16 | @font-face { 17 | font-family: 'Ubuntu'; 18 | font-style: normal; 19 | font-weight: 400; 20 | font-display: swap; 21 | src: local('Ubuntu Regular'), local('Ubuntu-Regular'), url(ubuntu/4iCs6KVjbNBYlgoKfw7z.ttf) format('truetype'); 22 | } 23 | @font-face { 24 | font-family: 'Ubuntu'; 25 | font-style: normal; 26 | font-weight: 700; 27 | font-display: swap; 28 | src: local('Ubuntu Bold'), local('Ubuntu-Bold'), url(ubuntu/4iCv6KVjbNBYlgoCxCvjsGyI.ttf) format('truetype'); 29 | } 30 | -------------------------------------------------------------------------------- /static/ext/ubuntu/4iCp6KVjbNBYlgoKejZPslyPN4Q.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/ubuntu/4iCp6KVjbNBYlgoKejZPslyPN4Q.ttf -------------------------------------------------------------------------------- /static/ext/ubuntu/4iCs6KVjbNBYlgoKfw7z.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/ubuntu/4iCs6KVjbNBYlgoKfw7z.ttf -------------------------------------------------------------------------------- /static/ext/ubuntu/4iCu6KVjbNBYlgoKej70l0w.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/ubuntu/4iCu6KVjbNBYlgoKej70l0w.ttf -------------------------------------------------------------------------------- /static/ext/ubuntu/4iCv6KVjbNBYlgoCxCvjsGyI.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/ext/ubuntu/4iCv6KVjbNBYlgoCxCvjsGyI.ttf -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/favicon.png -------------------------------------------------------------------------------- /static/fonts/proximanova-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/fonts/proximanova-semibold.ttf -------------------------------------------------------------------------------- /static/fonts/ransom-note.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coldcard/ckbunker/852675568a7c8a5ba27b80f648376ac51d612049/static/fonts/ransom-note.ttf -------------------------------------------------------------------------------- /static/html/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Login 4 | 58 | 59 |
60 |
61 | Captcha image loading... 62 | 64 | 65 | 66 |
67 |
68 | -------------------------------------------------------------------------------- /static/html/logout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Logged Out 4 | 5 |
 6 | You are logged out.
 7 | 
 8 | 
 9 | In 3 seconds this tab will clear itself... But you should still close it.
10 | 
11 | 18 | 19 | -------------------------------------------------------------------------------- /static/html5shiv.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @preserve HTML5 Shiv v3.7.0 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed 3 | */ 4 | ;(function(window, document) { 5 | /*jshint evil:true */ 6 | /** version */ 7 | var version = '3.7.0'; 8 | 9 | /** Preset options */ 10 | var options = window.html5 || {}; 11 | 12 | /** Used to skip problem elements */ 13 | var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i; 14 | 15 | /** Not all elements can be cloned in IE **/ 16 | var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i; 17 | 18 | /** Detect whether the browser supports default html5 styles */ 19 | var supportsHtml5Styles; 20 | 21 | /** Name of the expando, to work with multiple documents or to re-shiv one document */ 22 | var expando = '_html5shiv'; 23 | 24 | /** The id for the the documents expando */ 25 | var expanID = 0; 26 | 27 | /** Cached data for each document */ 28 | var expandoData = {}; 29 | 30 | /** Detect whether the browser supports unknown elements */ 31 | var supportsUnknownElements; 32 | 33 | (function() { 34 | try { 35 | var a = document.createElement('a'); 36 | a.innerHTML = ''; 37 | //if the hidden property is implemented we can assume, that the browser supports basic HTML5 Styles 38 | supportsHtml5Styles = ('hidden' in a); 39 | 40 | supportsUnknownElements = a.childNodes.length == 1 || (function() { 41 | // assign a false positive if unable to shiv 42 | (document.createElement)('a'); 43 | var frag = document.createDocumentFragment(); 44 | return ( 45 | typeof frag.cloneNode == 'undefined' || 46 | typeof frag.createDocumentFragment == 'undefined' || 47 | typeof frag.createElement == 'undefined' 48 | ); 49 | }()); 50 | } catch(e) { 51 | // assign a false positive if detection fails => unable to shiv 52 | supportsHtml5Styles = true; 53 | supportsUnknownElements = true; 54 | } 55 | 56 | }()); 57 | 58 | /*--------------------------------------------------------------------------*/ 59 | 60 | /** 61 | * Creates a style sheet with the given CSS text and adds it to the document. 62 | * @private 63 | * @param {Document} ownerDocument The document. 64 | * @param {String} cssText The CSS text. 65 | * @returns {StyleSheet} The style element. 66 | */ 67 | function addStyleSheet(ownerDocument, cssText) { 68 | var p = ownerDocument.createElement('p'), 69 | parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement; 70 | 71 | p.innerHTML = 'x'; 72 | return parent.insertBefore(p.lastChild, parent.firstChild); 73 | } 74 | 75 | /** 76 | * Returns the value of `html5.elements` as an array. 77 | * @private 78 | * @returns {Array} An array of shived element node names. 79 | */ 80 | function getElements() { 81 | var elements = html5.elements; 82 | return typeof elements == 'string' ? elements.split(' ') : elements; 83 | } 84 | 85 | /** 86 | * Returns the data associated to the given document 87 | * @private 88 | * @param {Document} ownerDocument The document. 89 | * @returns {Object} An object of data. 90 | */ 91 | function getExpandoData(ownerDocument) { 92 | var data = expandoData[ownerDocument[expando]]; 93 | if (!data) { 94 | data = {}; 95 | expanID++; 96 | ownerDocument[expando] = expanID; 97 | expandoData[expanID] = data; 98 | } 99 | return data; 100 | } 101 | 102 | /** 103 | * returns a shived element for the given nodeName and document 104 | * @memberOf html5 105 | * @param {String} nodeName name of the element 106 | * @param {Document} ownerDocument The context document. 107 | * @returns {Object} The shived element. 108 | */ 109 | function createElement(nodeName, ownerDocument, data){ 110 | if (!ownerDocument) { 111 | ownerDocument = document; 112 | } 113 | if(supportsUnknownElements){ 114 | return ownerDocument.createElement(nodeName); 115 | } 116 | if (!data) { 117 | data = getExpandoData(ownerDocument); 118 | } 119 | var node; 120 | 121 | if (data.cache[nodeName]) { 122 | node = data.cache[nodeName].cloneNode(); 123 | } else if (saveClones.test(nodeName)) { 124 | node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode(); 125 | } else { 126 | node = data.createElem(nodeName); 127 | } 128 | 129 | // Avoid adding some elements to fragments in IE < 9 because 130 | // * Attributes like `name` or `type` cannot be set/changed once an element 131 | // is inserted into a document/fragment 132 | // * Link elements with `src` attributes that are inaccessible, as with 133 | // a 403 response, will cause the tab/window to crash 134 | // * Script elements appended to fragments will execute when their `src` 135 | // or `text` property is set 136 | return node.canHaveChildren && !reSkip.test(nodeName) ? data.frag.appendChild(node) : node; 137 | } 138 | 139 | /** 140 | * returns a shived DocumentFragment for the given document 141 | * @memberOf html5 142 | * @param {Document} ownerDocument The context document. 143 | * @returns {Object} The shived DocumentFragment. 144 | */ 145 | function createDocumentFragment(ownerDocument, data){ 146 | if (!ownerDocument) { 147 | ownerDocument = document; 148 | } 149 | if(supportsUnknownElements){ 150 | return ownerDocument.createDocumentFragment(); 151 | } 152 | data = data || getExpandoData(ownerDocument); 153 | var clone = data.frag.cloneNode(), 154 | i = 0, 155 | elems = getElements(), 156 | l = elems.length; 157 | for(;i " + action); 35 | 36 | window.WEBSOCKET('api', req); 37 | }); 38 | 39 | $('.js-api-clear-all').on('click', function() { 40 | $('input[type=checkbox].js-api-picker').prop('checked', false); 41 | }); 42 | $('.js-api-set-all').on('click', function() { 43 | $('input[type=checkbox].js-api-picker').prop('checked', true); 44 | }); 45 | 46 | $('body').on('click', '.js-clickable', function(evt) { 47 | var el = $(this) 48 | var target = el.data("href"); 49 | if(!target) return; 50 | 51 | 52 | // special case cell, probably has input 53 | var cell = $(evt.target); 54 | if(cell.hasClass('js-not-clickable')) return; 55 | var td = $(evt.target).parents('td'); 56 | if(td && td.hasClass('js-not-clickable')) return; 57 | 58 | var tabname = el.data("tabname"); 59 | if(tabname) { 60 | window.open(target, tabname); 61 | } else { 62 | window.location = target; 63 | } 64 | }); 65 | 66 | 67 | // Semantic UI modules that we use 68 | $('.ui.dropdown').dropdown({fullTextSearch: true}); 69 | $('.ui.checkbox').checkbox(); 70 | $('.ui.accordion').accordion(); 71 | $('.ui.popup').popup(); 72 | $('.js-long-popup').popup({ inline: true }); 73 | 74 | $('.message .close').on('click', function() { 75 | $(this) 76 | .closest('.message') 77 | .transition('slide down') 78 | ; 79 | }); 80 | 81 | 82 | if(window.WEBSOCKET_URL) { 83 | 84 | var WS = new WebSocket( (location.protocol == 'http:' ? 'ws://' : 'wss://') 85 | + location.host + window.WEBSOCKET_URL); 86 | var keepalive = 0; 87 | 88 | WS.onopen = function(e) { 89 | 90 | console.log("websocket ready"); 91 | keepalive = window.setInterval(function() { 92 | WS.send(JSON.stringify({_ping: 1})); 93 | }, 10000); 94 | 95 | 96 | WS.send(JSON.stringify({action: '_connected', args: [window.location.pathname]})); 97 | } 98 | 99 | WS.onmessage = function(e) { 100 | var r = JSON.parse(e.data); 101 | if(r.keepalive) return; 102 | 103 | if(r.show_modal) { 104 | // show a modal 105 | var el = $(r.selector); 106 | el.find('.content').html(r.html); 107 | el.modal('show'); 108 | } else if(r.html && r.selector) { 109 | // XXX bad idea, delete? 110 | var el = $(r.selector); 111 | if(el) el.html(r.html); 112 | } 113 | if(r.show_flash_msg) { 114 | // update content in a message and show it 115 | var el = $('#js-flash-msg'); 116 | 117 | el.find('.js-content').text(r.show_flash_msg); 118 | 119 | if(!el.transition('is visible')) { 120 | el.transition('slide down'); 121 | } 122 | } 123 | if(r.redirect) { 124 | location.href = r.redirect; 125 | } 126 | if(r.reload) { 127 | setTimeout(function() { location.reload() }, 100); 128 | } 129 | if(r.local_download) { 130 | // trigger download/save-as to user's system 131 | data = r.local_download.data 132 | if(r.is_b64) { 133 | data = base64js.fromByteArray(data); 134 | } 135 | download(r.local_download.filename, data) 136 | } 137 | 138 | // send data back to VUE code 139 | if(r.vue_app_cb) { 140 | window.vue_app_cb(r.vue_app_cb) 141 | } 142 | 143 | if(r.cb) { 144 | // obsolete 145 | window.ws_cb(r) 146 | } 147 | }; 148 | function done(e) { 149 | // show we are broken. 150 | console.log("websocket broken"); 151 | window.clearInterval(keepalive); 152 | $('#ws_fail_msg').show(); 153 | $('.ui.main.container input,select').attr('disabled', true); 154 | $('.field').addClass('disabled'); 155 | } 156 | WS.onerror = done; 157 | WS.onclose = done; 158 | 159 | window.WEBSOCKET = function(action) { // accepts varargs 160 | let args = Array.prototype.slice.call(arguments, 1); 161 | WS.send(JSON.stringify({action: action, args: args})); 162 | } 163 | } 164 | 165 | }); 166 | 167 | function download(filename, text) { 168 | // from 169 | 170 | var element = document.createElement('a'); 171 | element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); 172 | element.setAttribute('download', filename); 173 | 174 | element.style.display = 'none'; 175 | document.body.appendChild(element); 176 | 177 | element.click(); 178 | 179 | document.body.removeChild(element); 180 | } 181 | 182 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #FFFFFF; 3 | display: flex; 4 | min-height: 100vh; 5 | flex-direction: column; 6 | } 7 | .ui.menu .item img.logo { 8 | margin-right: 1.5em; 9 | } 10 | 11 | .main.container { 12 | margin-top: 6em; 13 | } 14 | 15 | @media only screen and (max-width: 767px) { 16 | .main.container { 17 | margin-top: 13em; 18 | } 19 | } 20 | 21 | .main-content-wrapper { 22 | margin-top: 7em; 23 | flex: 1; 24 | } 25 | .ui.menu .item img.logo { 26 | margin-right: 1.5em; 27 | } 28 | 29 | .ui.footer.segment { 30 | margin: 9em 0em 0em; 31 | } 32 | 33 | /* mobile hacks */ 34 | @media only screen and (max-width: 767px) { 35 | .seg7-container { 36 | } 37 | .seg7-row { 38 | font-size: 2.5em; 39 | } 40 | .indicators { 41 | font-size: 0.5em; 42 | width: 2em; 43 | margin-top: -5.5em; 44 | } 45 | } 46 | 47 | .js-expr-help-btn { 48 | margin-left: 0.10em; 49 | cursor: pointer; 50 | } 51 | 52 | /* compress search results */ 53 | .js-key-search-results { 54 | overflow-y: auto; 55 | max-height: 500px; 56 | } 57 | 58 | .ui.category.search >.results .category .result { 59 | padding: 2px 12px; 60 | } 61 | .ui.search>.results .result .title { 62 | padding: 0px; 63 | } 64 | 65 | a.extlink { 66 | color: #4d4d4d; 67 | } 68 | .inverted a.extlink { 69 | color: #eee; 70 | } 71 | 72 | /* accordion headings, make big, more like H2 they are */ 73 | .ui.accordion .title.bigger { 74 | font-size: 1.8em !important; 75 | } 76 | .ui.accordion span.my_sub { 77 | font-size: 60%; 78 | margin-left: 2em; 79 | float: right; 80 | margin: 9px 0 0 0 ; 81 | } 82 | 83 | hr.my_hr { 84 | border-color: rgba(255,255,255,.5); 85 | } 86 | 87 | input.inputfile { 88 | width: 0.1px; 89 | height: 0.1px; 90 | opacity: 0; 91 | overflow: hidden; 92 | position: absolute; 93 | z-index: -1; 94 | } 95 | 96 | 97 | .tt-font { 98 | font-family: courier !important; 99 | } 100 | 101 | .tt-font-small { 102 | font-family: courier !important; 103 | font-size: 13px !important; 104 | } 105 | 106 | 107 | pre.wordwrap { 108 | white-space: pre-wrap; 109 | } 110 | 111 | /* keep at bottom */ 112 | /* see https://stackoverflow.com/questions/36186831 */ 113 | [v-cloak] { 114 | display: none !important; 115 | } 116 | 117 | .bignum { 118 | font-size: 180%; 119 | line-height: 160%; 120 | } 121 | 122 | .btcnum { 123 | font-size: 120%; 124 | line-height: 150%; 125 | } 126 | 127 | code.sha256 { 128 | /*border: #ccc solid 0.5px; 129 | border-radius: 3px; */ 130 | padding: 2px 6px; 131 | font-weight: 600; 132 | } 133 | 134 | input.width-fix { 135 | width: 12em !important; 136 | } 137 | 138 | /* Firefox bugfixes */ 139 | .ui.toggle.checkbox label { 140 | cursor: pointer; 141 | } 142 | 143 | -------------------------------------------------------------------------------- /status.py: -------------------------------------------------------------------------------- 1 | # 2 | # Store and watch all **status** values in system. 3 | # 4 | import sys, logging, asyncio 5 | from pprint import pprint, pformat 6 | from decimal import Decimal 7 | from chrono import NOW 8 | from objstruct import ObjectStruct 9 | from hashlib import sha256 10 | from copy import deepcopy 11 | from utils import WatchableMixin 12 | 13 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 14 | 15 | class SystemStatus(WatchableMixin, ObjectStruct): 16 | 17 | def __init__(self): 18 | # define all values here. keep simple, small! 19 | # - values must be JSON-able. 20 | super(SystemStatus, self).__init__() 21 | 22 | self.connected = False 23 | self.serial_number = None 24 | 25 | #self.xfp = None 26 | 27 | self.hsm = dict(users=[], wallets=[]) # short for "hsm_status" 28 | self.is_testnet = False 29 | 30 | # storage locker has been read ok. 31 | self.sl_loaded = False 32 | 33 | # user doesn't want Tor regardless of other settings (also disables login process) 34 | self.force_local_mode = False 35 | 36 | # we are in setup mode 37 | self.setup_mode = False 38 | 39 | # PSBT related 40 | self._pending_psbt = None # raw binary 41 | self.psbt_hash = None # hex digits (sha256) 42 | self.psbt_size = None # size of binary 43 | self.local_code = None # string of 6 digits 44 | self.psbt_preview = None # text 45 | self.busy_signing = False 46 | 47 | # tor related 48 | self.tord_good = False # local tord control connection good 49 | self.onion_addr = None # our present onion addr, if any 50 | self.tor_enabled = False # config calls for tor (ie. BP['tor_enabled']) 51 | 52 | # list of structs about creditials given by remote users 53 | self.pending_auth = [] 54 | 55 | def reset_pending_auth(self): 56 | # clear and setup pending auth list 57 | from persist import BP 58 | 59 | # make a list of users that might need to auth 60 | ul = self.hsm.get('users') 61 | if not ul: 62 | if BP.get('policy'): 63 | ul = set() 64 | try: 65 | for r in BP['policy']['rules']: 66 | ul.union(r.users) 67 | except KeyError: pass 68 | ul = list(sorted(ul)) 69 | 70 | # they might have picked privacy over UX, so provide some "slots" 71 | # regardless of above. 72 | if not ul: 73 | ul = ['' for i in range(5)] 74 | 75 | # construct an obj for UX purposes, but keep the actual secrets separate 76 | self.pending_auth = [ObjectStruct(name=n, has_name=bool(n), 77 | has_guess='', totp=0) for n in ul] 78 | self._auth_guess = [None]*len(ul) 79 | 80 | 81 | def clear_psbt(self): 82 | # wipe knowledge of PSBT 83 | self._pending_psbt = None 84 | self.psbt_hash = None 85 | self.psbt_size = None 86 | self.local_code = None 87 | self.psbt_preview = None 88 | 89 | def import_psbt(self, psbt): 90 | from ckcc.utils import calc_local_pincode 91 | from utils import cleanup_psbt 92 | from binascii import b2a_hex 93 | 94 | self.clear_psbt() 95 | 96 | self._pending_psbt = cleanup_psbt(psbt) 97 | 98 | self.psbt_size = len(self._pending_psbt) 99 | 100 | hh = sha256(self._pending_psbt).digest() 101 | self.psbt_hash = b2a_hex(hh).decode('ascii') 102 | 103 | # local PIN code will be wrong/stale now. 104 | if self.hsm and self.hsm.get('next_local_code'): 105 | self.local_code = calc_local_pincode(hh, self.hsm.next_local_code) 106 | 107 | logging.info("Imported PSBT with hash: " + self.psbt_hash) 108 | 109 | def as_dict(self): 110 | # we stream changes to web clients, so provide JSON 111 | return dict((k, deepcopy(self[k])) 112 | for k in self.keys() if k[0] != '_' and not callable(self[k])) 113 | 114 | 115 | # singleton 116 | STATUS = SystemStatus() 117 | 118 | # EOF 119 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block page_title %} 10 | CK Bunker 11 | {% endblock page_title %} 12 | 13 | 14 | {% block extra_page_meta %} 15 | {% endblock extra_page_meta %} 16 | 17 | {# TODO: add integretry values to these, "just in case" #} 18 | {# #} 19 | 20 | 23 | 24 | 25 | 26 | {# leave in dev mode #} 27 | 28 | {% block extra_head_code %} 29 | {% endblock extra_head_code %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | {% block head_style %} 37 | {% endblock head_style %} 38 | 39 | 40 | 41 | 42 | {% block body %}{% endblock body %} 43 | 44 | 45 | {% if ws_url %} 46 | 53 | 54 | 61 | 62 | 65 | {% endif %} 66 | 67 | {% block even_more_js %}{% endblock %} 68 | {% block endscript %}{% endblock %} 69 | 70 | 71 | -------------------------------------------------------------------------------- /templates/basic.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | -------------------------------------------------------------------------------- /templates/bunker/index.html: -------------------------------------------------------------------------------- 1 | {% extends "navpage.html" %} 2 | 3 | {% from "macros.html" import message_box, bool_choice, amount, info_hover, 4 | text_field, bool_choice, wrap_field %} 5 | 6 | {% block main_body %} 7 |
8 | 9 | {% call message_box('Tor Daemon Missing', '!STATUS.tord_good && !force_local_mode', 10 | icon='warning sign') %} 11 | The Bunker cannot communicate with tord (Tor daemon) which we need. It should 12 | be available on ports 9051 and 9151 of localhost. 13 | {% endcall %} 14 | 15 |

Bunker Setup & Configuration

16 | 17 |
18 | 19 | 40 | 41 | {% call bool_choice('allow_reboots', None) %} 42 | 46 | {% endcall %} 47 | 48 |
49 | 50 | 51 |
52 |
53 |
54 | 57 |
58 |
59 | 62 |   63 | 66 |
67 |
68 | 69 | 70 | {% call message_box('Write These Down!', '!force_local_mode', icon='edit', closable=1) %} 71 | We recommend you make note of the the onion address and/or master password. 72 | {% endcall %} 73 | 74 | {% if STATUS.setup_mode and not STATUS.force_local_mode %} 75 | {% call message_box('No Tor Connections during Setup Mode', 'tor_enabled && !STATUS.onion_addr && STATUS.tor_enabled', closable=1) %} 76 |
77 |
78 | To enable Tor (onion) connections, either 79 | restart bunker in normal mode (it is in 'setup mode' now) or use this button. 80 |
81 |
82 | 84 |
85 |
86 | {% endcall %} 87 | {% endif %} 88 | 89 | {% call message_box('Visit on Tor', '!using_onion && STATUS.onion_addr', icon='external link', closable=1) %} 90 | {% raw %} 91 | Go to {{STATUS.onion_addr}} 92 | {% endraw %} 93 | {% endcall %} 94 | 95 | 96 |
97 | {% endblock main_body %} 98 | 99 | 100 | {% block endscript %} 101 | 102 | 180 | 181 | 182 | {% endblock %} 183 | -------------------------------------------------------------------------------- /templates/help.html: -------------------------------------------------------------------------------- 1 | {% extends "navpage.html" %} 2 | 3 | {% block main_body %} 4 | 5 |

Help

6 | 7 |

Background

8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "basic.html" %} 2 | 3 | -------------------------------------------------------------------------------- /templates/macros.html: -------------------------------------------------------------------------------- 1 | 2 | {% macro ws_fail_message() %} 3 | 13 | {% endmacro %} 14 | 15 | {% macro flash_msg_holder() %} 16 | 26 | 27 | {% endmacro %} 28 | 29 | {# these values are mirroring real config values, must share same name; use in JS #} 30 | {% macro cfg_mirrors(fld_names) %} 31 | {% for fn in fld_names %} 32 | {{fn}}: {{ CFG.get(fn)|tojson }}, 33 | {% endfor %} 34 | {% endmacro %} 35 | 36 | {% macro select_field(title, cfgname, values, labels, default_idx=0, numeric=False, width='eight', hide_label=False) %} 37 |
38 | {% if not hide_label %}{% endif %} 39 | 52 |
53 | {% endmacro %} 54 | 55 | 56 | {% macro needs_coldcard_message() %} 57 |
58 |
59 | 60 |
61 |
62 | Coldcard USB Not Connected 63 |
64 |

This feature needs a USB connection to the Coldcard. Please 65 | check the USB cable is connected, and verify the Coldcard 66 | is unlocked with the master PIN code.

67 |
68 |
69 |
70 | {% endmacro %} 71 | 72 | {% macro message_box(title, show_if, icon='warning sign', closable=False) %} 73 |
74 | {% if closable %} {% endif %} 75 | 76 |
77 |
{{ title }}
78 |

{{ caller() }}

79 |
80 |
81 | {% endmacro %} 82 | 83 | {% macro needs_coldcard_callout(dir='left') %} 84 |
85 | Coldcard not connected 86 |
87 | {% endmacro %} 88 | 89 | {% macro select_field(title, cfgname, values, labels, default_idx=0, numeric=False, width='eight', hide_label=False) %} 90 |
91 | {% if not hide_label %}{% endif %} 92 | 105 |
106 | {% endmacro %} 107 | 108 | {% macro wrap_field(fldname, label, desc, disabled_if=None) %} 109 |
113 | {% if label %} 114 | 115 | {% endif %} 116 | {{ caller() }} 117 |
118 | {% endmacro %} 119 | 120 | {% macro amount(fldname, label, desc, placeholder="(any amount)", disabled_if=None) %} 121 | {% call wrap_field(fldname, label, desc, disabled_if) %} 122 |
123 | 126 |
{% raw %}{{ chain }}{% endraw %}
127 |
128 | {% endcall %} 129 | {% endmacro %} 130 | 131 | {% macro text_field(fldname, label, desc, placeholder="(not used)", extras="", rhs_label="tbd", disabled_if=None) %} 132 | {% call wrap_field(fldname, label, desc, disabled_if) %} 133 |
134 | 135 |
136 | {{rhs_label|safe}} 137 |
138 |
139 | {% endcall %} 140 | {% endmacro %} 141 | 142 | {% macro textarea(fldname, label, desc, placeholder, disabled_if=None, rows=1) %} 143 | {% call wrap_field(fldname, label, desc, disabled_if) %} 144 | 145 | {% endcall %} 146 | {% endmacro %} 147 | 148 | {% macro choice(fldname, label, desc, values=None, extras=[], multi=False, disabled_if=None, default_text='') %} 149 | {% call wrap_field(fldname, label, desc, disabled_if) %} 150 | 166 | {% endcall %} 167 | {% endmacro %} 168 | 169 | {% macro bool_choice(fldname, desc, disabled_if=None, style="toggle", fld_kls="field", readonly_if=None) %} 170 |
171 |
172 | 179 | {% if not caller %} 180 | 181 | {% else %} 182 | {{ caller() }} 183 | {% endif %} 184 |
185 |
186 | {% endmacro %} 187 | 188 | 189 | {% macro subhead(tt) %} 190 |

{{tt}}

191 | {% endmacro %} 192 | 193 | {% macro HR() %} 194 |
195 | {% endmacro %} 196 | 197 | {% macro fileupload(label, change, kls="ui large button") %} 198 | 200 | 204 | {% endmacro %} 205 | 206 | {% macro info_hover(msg) %} 207 | 208 | 209 | 210 | {% endmacro %} 211 | 212 | {% macro info_hover_long(pos=None) %} 213 | 214 | 217 | 220 | 221 | {% endmacro %} 222 | -------------------------------------------------------------------------------- /templates/navpage.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "macros.html" import ws_fail_message, flash_msg_holder %} 3 | 4 | {% block body %} 5 | 28 | 29 | {% block uncontained_body %} 30 | {% block pre_main_body %}{% endblock %} 31 | 32 |
33 | {{ flash_msg_holder() }} 34 | {{ ws_fail_message() }} 35 | 36 | {% block main_body %}{% endblock %} 37 |
38 | {% endblock %} 39 | 40 | {% block footer %} 41 |
42 |
43 |
44 |
45 | © 2020 by  {{ "https://coinkite.com" | extlink("Coinkite Inc.") }} 46 |
47 |
48 | {{ ''|link_to_explorer }} 49 |
50 |
51 | {{ "https://coldcardwallet.com/" | extlink("Coldcard Website") }} 52 |
53 |
54 |
55 |
56 | {% endblock footer %} 57 | 58 | 59 | {% endblock body %} 60 | -------------------------------------------------------------------------------- /templates/setup/hsm-rule-component.html: -------------------------------------------------------------------------------- 1 | {# implement a "vue component" for individual HSM rules 2 | 3 | limitation: 4 | - dropdown's do not track changes to the data, but the other direction (user makes choice) 5 | does get into the vue data model 6 | - other controls are bi-directional 7 | #} 8 | 9 | 12 | 13 | 50 | 51 | -------------------------------------------------------------------------------- /templates/setup/hsm-rule.html: -------------------------------------------------------------------------------- 1 | {% from "macros.html" import amount, textarea, choice, bool_choice %} 2 | 3 | {% macro mu_radio(label, desc) %} 4 | {{ choice('rule.min_users', label, desc, extras=[('', "(n/a)"), (1, 'Any One'), (2, 'Two Users'), (3, 'Three Users'), ('all', 'All Users')], disabled_if="rule.users.length == 0") }} 5 | {% endmacro %} 6 | 7 | 8 |
9 |
10 | {{amount('rule.max_amount', "Max Amount", 11 | "Max amount allowed per single transaction. Leave blank for no limit.")}} 12 | {{amount('rule.per_period', "Per-Period Amount", 13 | "Max amount in one period. Leave blank for no period limit.", 14 | disabled_if="no_period")}} 15 |
16 | 17 |
18 | {{textarea('rule.whitelist', "Destination Whitelist", 19 | "Can only send to these specific payment addresses.", 20 | "(base58 or segwit addresses)")}} 21 | {{ choice('rule.wallet', "Multisig Wallet Name", 22 | "Rule only applies to a specific multisig wallet, or only single-signer (not multisig), or all wallets.", 23 | "wallet_list", 24 | extras = [('', '(any wallet)'), ('1', '(single signer wallet)')], 25 | disabled_if="wallet_list.length==0") }} 26 |
27 | 28 |
29 | {{ choice('rule.users', "Authorizing Users", 30 | "These users must authenticate to allow the transaction, or blank for no user auth.", 31 | "user_list", multi=1, default_text="(no user auth)")}} 32 | {{ mu_radio("Minimum Users Needed", "How many of those users are required?") }} 33 |
34 | 35 | {{ bool_choice('rule.local_conf', "Unique 6-digit code (per transaction) must be entered on Coldcard keypad.") }} 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /templates/setup/index.html: -------------------------------------------------------------------------------- 1 | {% extends "navpage.html" %} 2 | {% from "macros.html" import needs_coldcard_callout %} 3 | {% from "macros.html" import fileupload, bool_choice %} 4 | 5 | {% macro accord(title, subtitle, active=False, pill=None) %} 6 |
7 | 8 | {{ title }} 9 | 10 | {% if pill %} 11 |
{{pill}}
12 | {% endif %} 13 | 14 | {{subtitle}} 15 |    16 |
17 |
18 |
19 |
20 | {{ caller() }} 21 |
22 | {% endmacro %} 23 | 24 | 25 | 26 | {% block main_body %} 27 | {% raw %} 28 |
29 | 30 |
31 | 32 |
33 |
34 | HSM Already Enabled 35 |
36 |

37 | Further changes to the policy are not possible, because 38 | the Coldcard is already in HSM mode. 39 |

40 | To exit HSM mode, the Coldcard must be power-cycled. 41 |

42 |
43 |
44 | 45 |
46 | 47 |
48 |
49 | Existing Policy 50 |
51 | 52 |

53 | Your Coldcard already holds a policy file from a previous setup. Click here 54 | to enable that policy and start the Coldcard in HSM mode. On-device 55 | confirmation will be required. 56 |

57 | 58 |

59 | 60 |

61 |
62 |
63 | {% endraw %} 64 | 65 |
66 | 67 | {% include 'setup/rules.html' with context %} 68 | {% include 'setup/users.html' with context %} 69 | {% include 'setup/paths.html' with context %} 70 | {% include 'setup/misc.html' with context %} 71 | {# include 'setup/onion.html' with context #} 72 | 73 |
74 | 75 |
76 |
77 | 78 |
79 | 83 | {{ needs_coldcard_callout() }} 84 |
85 | {{ bool_choice('wants_copy', "Download (sanitized) copy", style="") }} 86 |
87 |
88 | 89 |
90 |
91 | 94 | {{ fileupload("Import Policy", 'import_policy($event)', 'ui large button') }} 95 |
96 |

97 | These policy files (JSON) contain sensitive information, including private key 98 | for onion server and boot-to-HSM unlock code, when enabled. 99 |

100 |
101 |
102 | 103 | 104 | {% endblock main_body %} 105 | 106 | 107 | {% block endscript %} 108 | 109 | {% include "setup/hsm-rule-component.html" with context %} 110 | 111 | 266 | 267 | 268 | {% endblock %} 269 | -------------------------------------------------------------------------------- /templates/setup/misc.html: -------------------------------------------------------------------------------- 1 | {% from "macros.html" import bool_choice, textarea, subhead, text_field, HR %} 2 | 3 | {% call accord('Other Policy', 'Logging and other system-wide rules') %} 4 |
5 |

Logging

6 |
7 | {{ bool_choice('POLICY.must_log', "Fail transactions if we cannot log to MicroSD") }} 8 | {{ bool_choice('POLICY.never_log', "Do not log anything, even if MicroSD is inserted") }} 9 |
10 | 11 | {{HR()}} 12 | 13 |

Warnings

14 | 15 | {{ bool_choice('POLICY.warnings_ok', "Permit signing of transactions (PSBT) which have warnings (default: does not).") }} 16 | 17 | {{HR()}} 18 | 19 |

Privacy Vs. Easy of Use

20 | {{ bool_choice('POLICY.priv_over_ux', "If you prefer privacy over convenience, this causes Coldcard to be more secretive and makes the Bunker harder to use, because both will store less data about policy, usernames, and derivation paths.") }} 21 | 22 | {{HR()}} 23 |

Boot To HSM

24 | 25 |
26 | {{ text_field('POLICY.boot_to_hsm', 27 | "Coldcard will reboot directly to HSM mode, but will accept this 6-digit code to escape, if provided immediately.", 28 | placeholder="(optional)", extras='pattern="[0-9]{6}" required minlength=6 maxlength=6 ', 29 | rhs_label="6-digit code", disabled_if="POLICY.ewaste_enable") }} 30 | {{ bool_choice('POLICY.ewaste_enable', "Do not accept any code. Always boot to HSM mode.") }} 31 | {# bool_choice('POLICY.ewaste_enable', "Do not accept any code. Always stay in HSM mode and there is no way out. CAUTION: Even master PIN holder cannot change HSM policy nor escape HSM mode! Firmware upgrades are not possible.") #} 32 |
33 | 34 |
35 | 36 |
37 |
38 | Warning 39 |
40 |

41 | This setting is irreversible. 42 | No changes to firmware, HSM policy, Coldcard settings will be possible—ever again. 43 |
44 | Not even the master PIN holder can change HSM policy nor escape HSM mode! Firmware upgrades are not possible. 45 |

46 |
47 |
48 | 49 | 50 | 51 | {{HR()}} 52 |

Notes

53 | 54 | {{ textarea('POLICY.notes', "Free-form text shown on Coldcard when approving HSM Policy.", 55 | placeholder="(optional)", rows=3) }} 56 |
57 | {% endcall %} 58 | 59 | -------------------------------------------------------------------------------- /templates/setup/onion.html: -------------------------------------------------------------------------------- 1 | {% from "macros.html" import amount, text_field, bool_choice, wrap_field %} 2 | 3 | {% call accord('Onion Address', 'Enable Tor access to the Bunker') %} 4 |
5 | 6 |
7 |
8 |
9 | 10 | 11 |
12 |
13 | 14 | {% call wrap_field(None, 'Tor Login Password', disabled_if="!enable_onion") %} 15 |
16 | 18 | 19 |
20 | {% endcall %} 21 | 22 |
23 | 24 |
25 | {% raw %} 26 | http://{{onion_addr}} 27 | {% endraw %} 28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 | {% endcall %} 36 | 37 | -------------------------------------------------------------------------------- /templates/setup/paths.html: -------------------------------------------------------------------------------- 1 | {% from "macros.html" import textarea, subhead %} 2 | {% call accord('Derivation Paths', 'For message signing & address generation', pill="ADVANCED") %} 3 | 4 |
5 |
6 | 7 | {{ subhead("Derivation Path Whitelists") }} 8 | 9 |

10 | You may limit text message signing to specific derivation paths. Similarly, the Coldcard will 11 | not share calculated addresses or derived XPUB values, unless whitelisted here. 12 |

13 | Leave blank to block corresponding feature. Multiple values maybe be given, 14 | separated by spaces or commas. 15 |

16 | 17 | 18 | {{ textarea('POLICY.msg_paths', "Will only sign messages using indicated path(s)", 19 | placeholder="(signing not be allowed)") }} 20 |
21 | 22 |
23 | 24 | 25 | 27 |
Special Values 26 |
any Allow any path. 28 |
m/1/2/3/* Require that path but the last position is any value. 29 |
m/1/2/3/*' Last position must be hardened. 30 |
p2sh Allow P2SH (script) addresses for multisig wallets. 31 |
32 |
33 |
34 | 35 |
36 |
37 | {{ subhead("Other Whitelisted Derivation Paths") }} 38 | 39 |
40 | 41 | {{ textarea('POLICY.share_xpubs', "Share derived XPUB values on these paths (master xpub is always shared)", 42 | placeholder="(xpub derivation not allowed)") }} 43 | 44 | {{ textarea('POLICY.share_addrs', "Share these derived addresses (ie. deposit addresses)", 45 | placeholder="(address derivation not allowed)") }} 46 |
47 |
48 |
49 | {% endcall %} 50 | 51 | -------------------------------------------------------------------------------- /templates/setup/rules.html: -------------------------------------------------------------------------------- 1 | {% call accord('Spending Rules', 'When and how much can be sent out') %} 2 | 3 | {% from "macros.html" import subhead %} 4 | 5 |
6 | 7 | {{ subhead("Velocity Time Period") }} 8 | 9 |
10 |
11 |
12 | 15 |
16 | minutes 17 |
18 |
19 |
20 | 21 | {% raw %} 22 |
23 |
24 | = {{ period_hrs }} hours 25 |
26 |
27 | {% endraw %} 28 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 |
43 | 44 |

45 | Time period starts as soon as rule with a velocity limit is applied. 46 | The total amount sent is tracked, for each rule independently, until the period ends. 47 | All rules share the same time period for these velocity calculations. 48 |

49 | 50 | {{ subhead("Rules") }} 51 | 52 |

53 | Multiple spending rules can be defined. The first matching rule is applied 54 | when a PSBT is considered, and its velocity limit is the one affected, so place 55 | more restrictive rules first. All elements of the specific rule must be met 56 | before it is applied. 57 |

58 |

59 | 60 |

61 | 62 |
63 |

NOTE: You have no rules defined. It will not be possible to authorize transactions. 64 |

65 | 66 | 79 | 80 |
81 | {% endcall %} 82 | 83 | 84 | 85 | 96 | 97 | -------------------------------------------------------------------------------- /templates/setup/users.html: -------------------------------------------------------------------------------- 1 | {% call accord('Users', 'Add and remove Coldcard users', active=False) %} 2 | 3 |
4 |
5 |
6 | 7 |

Add New Users

8 |

9 | Add up to 30 user names here. These users can authorize 10 | spending based on the specific rules where they are named. Verification of the user is 11 | done via 2FA or passwords. 12 |

13 | 14 | {% raw %} 15 |
16 |
17 | 18 | 21 |
22 | 23 |
24 | 25 | 27 |
28 |
29 | 30 |
31 | 32 | 33 |
34 | 35 |
38 | 39 | 41 |
42 | 43 |
47 | 48 | 50 |
51 | 52 |
54 | 55 | 57 |
58 |
59 | 60 | 61 |
62 | 63 |
64 | 67 | {% endraw %} 68 | {{ needs_coldcard_callout() }} 69 | {% raw %} 70 |
71 |
72 | 73 |
74 | 75 | 76 | 78 | 79 | 85 | 86 | 88 |
Existing Coldcard Users 77 |
80 |    83 | {{name}} 84 |
No users yet. 87 |
89 |
90 |
91 |
92 | {% endraw %} 93 | {% endcall %} 94 | 95 | -------------------------------------------------------------------------------- /templates/tools/index.html: -------------------------------------------------------------------------------- 1 | {% extends "navpage.html" %} 2 | {% from "macros.html" import needs_coldcard_callout %} 3 | {% from "macros.html" import needs_coldcard_message, subhead, bool_choice %} 4 | 5 | {% block main_body %} 6 |
7 | 8 | {# needs_coldcard_message() #} 9 | 10 |
11 |

Text Message Signing

12 | 13 |
14 | Message signing is disabled by your HSM policy so it's not possible to sign messages. 15 |
16 | 17 |
18 |
19 | 20 | 22 |
23 | 24 |
25 |
26 | 27 | 28 | 29 | {% if msg_paths %} 30 | 39 | {% endif %} 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 |
51 |
52 | 53 | 54 |
55 |
56 |
57 |
58 | 59 | 60 |
61 | 65 | {{ needs_coldcard_callout('left') }} 66 |
67 | 68 | 69 |
70 | Signature & Address 71 |
72 | 73 |
74 | {# #} 75 | 76 |
77 | 78 |
79 |
80 | 81 |
82 |

Recovery Tool

83 | 84 |

This will provide a means for you to construct a PSBT 85 | which moves all the funds the system can find on the blockchain 86 | to a new address.

87 | 88 |
WIP
89 |
90 | 91 |
92 |

Address Generator

93 | 94 |

Use this tool to make deposit addresses for your Coldcard's wallets.

95 | 96 |
WIP
97 |
98 | 99 |
100 | {% endblock %} 101 | 102 | {% block endscript %} 103 | 164 | {% endblock %} 165 | -------------------------------------------------------------------------------- /templates/txn/index.html: -------------------------------------------------------------------------------- 1 | {% extends "navpage.html" %} 2 | {% from "macros.html" import needs_coldcard_message, subhead, bool_choice, info_hover, info_hover_long %} 3 | 4 | {% macro status_value(hdr) %} 5 | {{hdr}} 6 | 7 | {{ caller() }} 8 | 9 | 10 | {% endmacro %} 11 | 12 | {% block main_body %} 13 |
14 | 15 |
16 | 17 |
18 |
19 | HSM Not Enabled 20 |
21 |

22 | The Coldcard is not in HSM mode. 23 | Typically, you should enable a spending policy before using this page, 24 | but it is possible to upload and sign PSBT files as it is now. You will need to 25 | approve each transaction on the Coldcard's screen. 26 |

27 |
28 |
29 | 30 | {{ needs_coldcard_message() }} 31 | 32 |
33 | 34 |
35 | 36 | 37 | 38 | 39 | 42 | 45 | 48 | 51 | 52 | {% raw %} 53 | 54 | 57 | 60 | 64 | 86 | 87 |
40 | Approvals 41 | 43 | Refusals 44 | 46 | Period Ends 47 | 49 | Amount Spent 50 |
55 | {{ STATUS.hsm.approvals || 0 }} 56 | 58 | {{ STATUS.hsm.refusals || 0}} 59 | 61 | 62 | 63 | 65 | {{ total_spent || '—' }} 66 | {% endraw %} 67 | {% call info_hover_long('bottom right') %} 68 | {% raw %} 69 |

70 | No amounts spent in current period. 71 |

72 | 74 | 75 | 78 | 79 | 82 |
Rule 76 | Amount Spent 77 |
#{{idx+1}} 80 | {{ amt | btc_value }} 81 |
83 | {% endraw %} 84 | {% endcall %} 85 |
88 | 89 |
90 |
91 | 92 |

Transaction Signing

93 | 94 |
95 | 96 |
97 |
98 | 100 | 105 |
106 |
107 | {% raw %} 108 |
PSBT File
109 | 113 |

114 | {{STATUS.psbt_size }} bytes, with 115 | SHA256: {{STATUS.psbt_hash.substr(0, 6) }}⋯{{STATUS.psbt_hash.substr(64-6) }} 116 |

117 | 118 | {% endraw %} 119 | 120 |
121 | Transaction Preview 122 |
123 | 124 | 125 | {% raw %} 126 | 133 | 140 |
{{STATUS.psbt_preview }}
141 | 146 | {% endraw %} 147 | 148 |
149 | 150 | 178 |
179 | 180 |
181 | 182 | 183 | 190 | {% raw %} 191 | 192 | 203 | 204 | 206 | 211 | 225 | 226 | 228 | {% endraw %} 229 |
Authorizing User 184 | One-Time Code or Password 185 | {% call info_hover_long('top center') %} 186 | Depending on your spending policy rules, all or none of these users may be 187 | required to authorize spending. Leave blank those you are not using. 188 | {% endcall %} 189 |
Local Code 193 | 194 | {{STATUS.local_code}} 195 | TBD — need PSBT first 196 | {% endraw %} 197 | {% call info_hover_long('top center') %} 198 | When required, the local Coldcard operator should enter this 6-digit code, 199 | before you press the button on this page. 200 | {% endcall %} 201 | {% raw %} 202 |
{{pa.name}} 205 | 207 | 210 | 212 |
213 |
214 | 218 | 222 |
223 |
224 |
No user authorization needed. 227 |
230 |
231 |
232 | 233 | 234 | 235 |
236 |
237 | 238 | {{ subhead('Coldcard HSM Policy Summary') }} 239 | 240 | {% if policy_summary %} 241 |

242 | For your reference, here is the Coldcard's interpretation of 243 | the HSM policy when it was approved and installed. 244 |

245 | 246 |
{{- policy_summary -}}
247 | {% else %} 248 |

Not available. You have chosen privacy over user-experience.

249 | {% endif %} 250 |
251 |
252 | 253 | 254 |
255 | {% endblock main_body %} 256 | 257 | {% block endscript %} 258 | 375 | {% endblock %} 376 | 377 | {% block extra_head_code %} 378 | 379 | 380 | {% endblock extra_head_code %} 381 | -------------------------------------------------------------------------------- /torsion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Interface to STEM and from that to Tord and the Tor network. 4 | # 5 | # Refs: 6 | # - 7 | # 8 | import logging, asyncio 9 | from utils import json_loads, json_dumps, Singleton 10 | from concurrent.futures import ThreadPoolExecutor 11 | from persist import settings 12 | from status import STATUS 13 | 14 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 15 | 16 | executor = ThreadPoolExecutor(max_workers=10) 17 | 18 | class TorViaStem(metaclass=Singleton): 19 | def __init__(self): 20 | self.controller = None 21 | self.service = None 22 | 23 | async def startup(self): 24 | # just test if we can see tord 25 | await self.connect(raise_on_error=False) 26 | 27 | def get_current_addr(self): 28 | # return onion address we are currently on, or None 29 | if not self.service: 30 | return None 31 | return self.service.service_id + '.onion' 32 | 33 | async def connect(self, raise_on_error=True): 34 | from stem.connection import connect 35 | 36 | if self.controller: 37 | return self.controller 38 | 39 | def doit(): 40 | self.controller = connect(control_port=('127.0.0.1', settings.TORD_PORT)) 41 | 42 | if self.controller: 43 | logging.info("Tord version: " + str(self.controller.get_version())) 44 | else: 45 | logging.error("Unable to connect to local 'tord' server") 46 | if raise_on_error: 47 | raise RuntimeError("No local 'tord' server") 48 | 49 | 50 | return self.controller 51 | 52 | loop = asyncio.get_running_loop() 53 | rv = await loop.run_in_executor(executor, doit) 54 | 55 | STATUS.tord_good = bool(rv) 56 | STATUS.notify_watchers() 57 | 58 | async def pick_onion_addr(self): 59 | 60 | c = await self.connect() 61 | 62 | def doit(): 63 | # let Tor pick the key, since they don't document their tricky stuff 64 | s = self.controller.create_ephemeral_hidden_service({80: 1}, 65 | detached=False, 66 | await_publication=False, key_content='ED25519-V3') 67 | 68 | rv = (s.service_id+'.onion', s.private_key) 69 | 70 | # kill it immediately 71 | self.controller.remove_ephemeral_hidden_service(s.service_id) 72 | 73 | return rv 74 | 75 | loop = asyncio.get_running_loop() 76 | return await loop.run_in_executor(executor, doit) 77 | 78 | async def stop_tunnel(self): 79 | # hang up if running 80 | if not self.service: 81 | return 82 | 83 | def doit(): 84 | if self.service: 85 | logging.info(f"Disconnecting previous service at: {self.service.service_id}.onion") 86 | self.controller.remove_ephemeral_hidden_service(self.service.service_id) 87 | self.service = None 88 | 89 | STATUS.onion_addr = None 90 | STATUS.notify_watchers() 91 | 92 | loop = asyncio.get_running_loop() 93 | return await loop.run_in_executor(executor, doit) 94 | 95 | async def start_tunnel(self): 96 | from persist import BP, settings 97 | 98 | c = await self.connect() 99 | 100 | def doit(): 101 | if self.service: 102 | logging.info(f"Disconnecting previous service at: {self.service.service_id}.onion") 103 | self.controller.remove_ephemeral_hidden_service(self.service.service_id) 104 | self.service = None 105 | 106 | # give Tor the key from earlier run 107 | k = BP['onion_pk'] 108 | s = self.controller.create_ephemeral_hidden_service({80: settings.PORT_NUMBER}, 109 | detached=False, discard_key=True, 110 | await_publication=True, key_type='ED25519-V3', key_content=k) 111 | 112 | addr = s.service_id+'.onion' 113 | assert addr == BP['onion_addr'], f"Mismatch, got: {addr} not {BP.onion_addr} expected" 114 | 115 | self.service = s 116 | 117 | return addr 118 | 119 | loop = asyncio.get_running_loop() 120 | addr = await loop.run_in_executor(executor, doit) 121 | 122 | STATUS.onion_addr = addr 123 | STATUS.notify_watchers() 124 | 125 | 126 | TOR = TorViaStem() 127 | 128 | 129 | if __name__ == '__main__': 130 | controller = connect() 131 | 132 | if not controller: 133 | sys.exit(1) # unable to get a connection 134 | 135 | print('Your tord is version: %s' % controller.get_version()) 136 | 137 | #d = controller.get_hidden_service_descriptor('explorernuoc63nb.onion') 138 | d = controller.get_hidden_service_descriptor('explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion') 139 | print("obj = %r" % d) 140 | print("pubkey = %r" % d.permanent_key) 141 | print("published = %r" % d.published) 142 | 143 | service = controller.create_ephemeral_hidden_service({80: 5000}, await_publication = True, key_content = 'ED25519-V3') 144 | print("Started a new hidden service with the address of %s.onion" % service.service_id) 145 | 146 | print('%s %s' % (service.private_key_type, service.private_key)) 147 | 148 | 149 | controller.close() 150 | 151 | # EOF 152 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. 2 | # 3 | # utils.py 4 | # 5 | import json, struct, logging, asyncio 6 | from binascii import b2a_hex 7 | from objstruct import ObjectStruct 8 | from decimal import Decimal 9 | 10 | B2A = lambda x: b2a_hex(x).decode('ascii') 11 | 12 | def xfp2str(xfp): 13 | # Standardized way to show an xpub's fingerprint... it's a 4-byte string 14 | # and not really an integer. Used to show as '0x%08x' but that's wrong endian. 15 | return b2a_hex(struct.pack(' 41 | # use like this: 42 | # class Foo(metaclass=Singleton): ... 43 | # 44 | class Singleton(type): 45 | _instances = {} 46 | def __call__(cls, *args, **kwargs): 47 | if cls not in cls._instances: 48 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 49 | return cls._instances[cls] 50 | 51 | def setup_logging(level=logging.INFO, debug=True, syslog=False): 52 | # get logging working for simple test code 53 | # - also used for normal logging 54 | 55 | handlers = None 56 | if syslog: 57 | # this shows up in /var/log/user.log 58 | from logging.handlers import SysLogHandler 59 | handlers = [ SysLogHandler('/dev/log')] 60 | 61 | logging.basicConfig(format="%(asctime)-11s %(message)s", 62 | datefmt="[%d/%m/%Y-%H:%M:%S]", level=level, handlers=handlers) 63 | 64 | # maybe? 65 | #import warnings 66 | #warnings.simplefilter("ignore") 67 | 68 | # kill log noise about _UnixReadPipeTransport 69 | logging.getLogger('asyncio').setLevel(level=logging.WARN) 70 | 71 | # kill noise from STEM (tor wrapper) 72 | from stem.util.log import get_logger as sgl 73 | sgl().setLevel(level=logging.WARN) 74 | 75 | if 0: 76 | # disable access logging 77 | logging.getLogger('aiohttp.access').setLevel(level=logging.WARN) 78 | 79 | def cleanup_psbt(psbt): 80 | from base64 import b64decode 81 | from binascii import a2b_hex 82 | import re 83 | from hashlib import sha256 84 | 85 | # we have the bytes, but might be encoded as hex or base64 inside 86 | taste = psbt[0:10] 87 | if taste.lower() == b'70736274ff': 88 | # Looks hex encoded; make into binary again 89 | hx = ''.join(re.findall(r'[0-9a-fA-F]*', psbt.decode('ascii'))) 90 | psbt = a2b_hex(hx) 91 | elif taste[0:6] == b'cHNidP': 92 | # Base64 encoded input 93 | psbt = b64decode(psbt) 94 | 95 | if psbt[0:5] != b'psbt\xff': 96 | raise ValueError("File does not have PSBT magic number at start.") 97 | 98 | return psbt 99 | 100 | class WatchableMixin: 101 | # add a consistent way to block for changes on an object 102 | 103 | def __init__(self, *a, **k): 104 | self._update_event = asyncio.Event() 105 | super(WatchableMixin, self).__init__(*a,**k) 106 | 107 | def notify_watchers(self): 108 | # unblock anyone watching us 109 | 110 | self._update_event.set() 111 | self._update_event.clear() 112 | 113 | async def wait(self): 114 | await self._update_event.wait() 115 | return self 116 | 117 | 118 | # EOF 119 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | 2 | VERSION = 'v0.9.1' 3 | 4 | --------------------------------------------------------------------------------