# Bitcoin Armory — Security Report

**Date:** 2026-06-22
**Scope:** Full codebase review of the Bitcoin Armory wallet (Tauri desktop shell + pure-Python Bitcoin backend) at commit `ad95c56`.
**Status of the product:** Pre-release prototype (v0.1.0), macOS-only.
**Audience:** Engineering leadership — to prioritize hardening work before this holds real funds.

> ⚠️ **One-line takeaway:** The architecture is sound and the team has made several good security choices, but **this app is not yet safe to secure meaningful Bitcoin.** Three things must be fixed before anyone relies on it: (1) the money-critical crypto is unverified against the official Bitcoin test vectors, (2) the tests don't run in CI, and (3) the app builds and runs unverified code downloaded from the internet at runtime.

---

## 1. Executive summary (non-technical)

Bitcoin Armory is a self-custody wallet. That means **the software is the only thing standing between a user and the irreversible loss of their money.** There is no bank to call, no chargeback, no password reset. This raises the security bar far above a normal app.

We reviewed the whole codebase. The good news: the fundamental design is right (private keys are kept on a separate "offline" machine that never touches the internet), the team avoided the classic dangerous mistakes (no code-injection holes, seeds are never copied to the clipboard, passwords are properly encrypted), and the code is clean and well-organized.

The concerning news falls into three buckets:

1. **"Is the math correct?"** — The wallet implements Bitcoin's cryptography from scratch, including the brand-new "Silent Payments" feature. The Silent Payments code has **never been checked against the official Bitcoin reference test data.** If the math is subtly wrong, money sent through it could be **permanently lost** — and we wouldn't find out until a real user lost real funds.

2. **"Is the supply chain trustworthy?"** — When the app sets up, it **downloads source code from the internet and runs it on the user's machine without verifying it hasn't been tampered with.** If any of those sources were compromised, an attacker's code runs on a machine that holds Bitcoin keys.

3. **"Are the safety nets in place?"** — The automated tests exist but **don't run automatically before a release ships.** A future change could silently break key generation or signing and reach users undetected.

On the "AI makes theft easier" point you raised: that's a real and correct instinct. AI lowers the cost of finding bugs and crafting targeted malware, but the attacks that actually drain wallets are overwhelmingly **endpoint compromise, supply-chain tampering, and clipboard/address-swapping malware** — exactly the categories where this app currently has gaps (items in §3 marked S1, S2, S5, S6). Hardening those is the highest-leverage defense, more so than worrying about the encryption algorithm itself (which is fine).

**Recommendation:** Treat this as a security-hardening sprint before any external/beta use. The prioritized roadmap is in §5. None of the issues are architectural dead-ends — they are all fixable with focused work.

---

## 2. What we reviewed and how

- **Cryptography & key handling** — `core/crypto.py`, `core/keys.py`, `core/encryption.py`, `core/entropy.py`, `core/silent_payment.py`
- **Desktop shell & OS attack surface** — `tauri/src-tauri/` (capabilities, config, Rust IPC commands, electrs/python launchers), frontend `tauri/src/app.js`
- **Network & data flow** — `online/rpc.py`, `online/electrs.py`, `ui/server.py`, remote-node config path
- **Supply chain & build** — `scripts/`, `Cargo.lock`, `.github/workflows/build-release.yml`
- **Testing** — `tests/`

Findings below cite concrete files and line numbers so engineers can go straight to them.

---

## 3. Findings by severity

Severity reflects **impact to a user holding real funds × likelihood**. "Remote node" findings are rated for the case where a user points the wallet at a node across the internet (a supported feature).

### 🔴 Critical / High

| ID | Finding | Why it matters | Location |
|----|---------|----------------|----------|
| **S1** | **Silent Payments not validated against official BIP-352 test vectors.** The send and scan paths are tested only against each other, not the network reference. | A consistent bug in both paths passes all tests but is incompatible with every other wallet → **funds sent via Silent Payments could be unspendable/unrecoverable.** This is the single biggest risk. | `core/silent_payment.py`; `tests/test_silent_payment.py` (no `send_and_receive_test_vectors.json`) |
| **S2** | **Runtime build of `electrs` from an unpinned, unverified source.** `install_electrs` does `git clone` of upstream `master` with no pinned commit/tag and no signature check, compiles it, and auto-executes the result. Triggerable from the UI. | A compromise of the upstream repo (or any of its dependencies, or an on-path attacker) results in **arbitrary code execution on a machine adjacent to Bitcoin keys.** | `tauri/src-tauri/src/commands_online.rs:697-828` (clone `:731-737`) |
| **S3** | **Bundled Python runtime and `cryptography` library are downloaded without checksum/signature verification.** | Same supply-chain class as S2: a tampered download = malicious code shipped inside the wallet. | `scripts/bundle-python.sh:14-18`, `scripts/build-macos.sh:28-36` |
| **S4** | **Tests do not run in CI.** The release workflow builds and notarizes the app but never runs the test suite; there is no test/lint gate at all. | A regression in key generation, signing, or encryption can ship to users **with zero automated detection.** Removes the safety net that protects every other finding. | `.github/workflows/build-release.yml` |
| **S5** | **Remote-node feature sends RPC credentials and all wallet queries in plaintext, and stores the RPC password unencrypted on disk.** RPC is plain HTTP (not HTTPS); the password is persisted in WebView `localStorage` in cleartext. | If a user uses a remote node, their node password and entire address/transaction graph are exposed to anyone on the network path, and the password sits in cleartext on disk. | `online/rpc.py:66,79-82`; `tauri/src/app.js:3234,432` |
| **S6** | **Webview CSP permits `unsafe-inline` scripts while the UI builds HTML from network-derived data.** Inline scripts are allowed and Tauri's auto-hardening is explicitly disabled for `script-src`. | Any unescaped attacker-controlled field (e.g. an explorer response, address label) becomes **cross-site scripting that reaches the privileged IPC layer** (clipboard, shell, process spawning). | `tauri/src-tauri/tauri.conf.json:23-26`; sinks at `tauri/src/app.js:3514,3578,3617` |
| **S7** | **Pure-Python elliptic-curve fallback is silently reachable for real keys.** When the native `libsecp256k1` is unavailable the app falls back (on a mere warning) to non-constant-time pure-Python EC, which has no signature-verification implementation. Public-child key derivation uses the pure-Python path even when the native library is present. | Non-constant-time crypto leaks key material via timing; missing verify means broken signatures could be produced and broadcast undetected. Should be a hard failure, not a warning. | `core/crypto.py:52,89,344,397-406`; `core/keys.py:210` |
| **S8** | **`shell:allow-execute` is granted to the webview with no command allowlist/scope.** Appears currently unused, but combined with S6 it is a direct path from XSS to arbitrary command execution. | Unnecessary high-value attack surface. | `tauri/src-tauri/capabilities/default.json:8` |

### 🟠 Medium

| ID | Finding | Location |
|----|---------|----------|
| **S9** | **Local HTTP API server has no authentication, no CSRF protection, and no Origin/Host validation.** Binds to `127.0.0.1` (good), but exposes unlock, password-change, send, broadcast, and import endpoints to any local process or a DNS-rebinding web page. | `ui/server.py` (handlers `:73-239`, sensitive endpoints `:207-237,422-456`) |
| **S10** | **Silencing/correctness of sighash unverified.** Transaction signing (BIP143/BIP341) is not checked against Bitcoin Core or reference vectors — only that "a signature was added." | `tests/test_signer.py:40-45` |
| **S11** | **Secrets passed to child processes via command-line arguments and environment variables** (wallet password, mnemonic). These are visible to other local processes via `ps`. | `tauri/src-tauri/src/commands_offline.rs:188-190,220,257-258,422-424` |
| **S12** | **`export_watch_only` writes to a frontend-controlled file path with no validation, and the `fs` capability is unscoped.** A compromised renderer could write to an arbitrary path. | `commands_offline.rs:333-349`; `capabilities/default.json:14-15` |
| **S13** | **`find_binary` executes whatever `electrs` binary sits at user-writable paths** (`~/Armory/bin/electrs`, etc.) after only a `--version` check. A local attacker who can write that file gets it run by the wallet. | `tauri/src-tauri/src/electrs.rs:73-104` |
| **S14** | **Password KDF cost is on the low side** for guarding a Bitcoin seed: scrypt N=2¹⁴ (~16 MB). Defensible for interactive unlock, but should be raised (or moved to argon2id). | `core/encryption.py:16-22` |
| **S15** | **`online/setup.py` is a 2,721-line god-object.** Not a vulnerability per se, but its size makes security review and safe change difficult, and concentrates the most sensitive online logic in one hard-to-audit file. | `online/setup.py` |

### 🟡 Low / Hardening

| ID | Finding | Location |
|----|---------|----------|
| **S16** | `clipboard-manager:allow-read-text` granted but unused — drop it (reduces exfiltration surface). Write-only is sufficient. | `capabilities/default.json:13` |
| **S17** | `shell.open: true` is unrestricted (no URL allowlist); release falls back to a PATH-resolved `python3` if the bundled interpreter is missing. | `tauri.conf.json:50`; `python.rs:21-23` |
| **S18** | Dice entropy and PSBTs are written to predictable, world-readable temp file paths (deleted afterward, but briefly exposed and race-prone). | `commands_offline.rs:247-249,315-321` |
| **S19** | Secrets (mnemonic, `master_xprv`) live in Python `str`/`bytes` with no zeroization — can linger in memory/swap. Inherent to a pure-Python wallet; document as a known limitation. | `core/wallet.py:305-315` |

---

## 4. What the team got right (credit where due)

These are real strengths and should be preserved through any refactor:

- **Sound architecture:** the offline-signer / online-watch-only split with a PSBT hand-off is the correct design for a self-custody wallet. The online machine never holds spending keys.
- **Deterministic signing:** when the native library is present, signing uses libsecp256k1's RFC6979 nonces — **no raw randomness in nonce generation** (the classic catastrophic wallet bug is absent). `core/crypto.py:280`
- **Strong entropy design:** dice rolls are mixed with `os.urandom` via HMAC-SHA256 — secure if *either* source is good. `core/entropy.py:54-61`
- **Proper at-rest encryption:** scrypt + AES-256-GCM (authenticated encryption), with per-save random salt/nonce. `core/encryption.py`
- **BIP32/BIP39 are tested against real published test vectors** (Trezor/BIP vectors). `tests/test_keys.py:46-95`
- **No injection holes:** no `eval`/`exec`/`shell=True`; Python is invoked via argv (not a shell string); inline Python scripts pass user data via env vars, not string interpolation; RPC params are passed as a JSON list. No SQL/shell/Python injection found.
- **Seeds and private keys are never copied to the clipboard** — only display-rendered. This correctly defends against clipboard-stealing malware for the most sensitive data.
- **Local server binds to `127.0.0.1`, not `0.0.0.0`** — not exposed to the LAN.
- **Rust dependencies are pinned** via a committed `Cargo.lock` (502 crates, reproducible).
- **In local-node mode, no data goes to any third-party server** — good privacy posture by default.
- **Wallet import explicitly rejects private-key files** on the online side. `ui/server.py:353-367`

---

## 5. Recommended roadmap

Ordered by risk-reduction per unit of effort. Suggested ownership split in brackets.

### Phase 1 — Must do before *any* real funds or external beta
1. **[Crypto] Validate Silent Payments against the official BIP-352 `send_and_receive_test_vectors.json`.** (S1) — until this passes, gate or disable SP send in the UI.
2. **[CI/DevOps] Add a CI job that runs the full test suite and fails the build on any failure.** (S4) — this protects everything else.
3. **[Supply chain] Pin and verify all downloads.** Pin `electrs` to a specific reviewed commit/tag and verify it (or ship a pre-built, signed binary instead of building at runtime); add SHA256 verification to the Python runtime download; pin `cryptography` with `--require-hashes`. (S2, S3)
4. **[Crypto] Make the pure-Python EC fallback a hard error for any real key operation,** and give `verify()` a real implementation (or refuse to sign when verify is unavailable). (S7)

### Phase 2 — Before general release
5. **[Crypto] Validate transaction sighash (BIP143/BIP341) against Bitcoin Core / reference vectors.** (S10)
6. **[Frontend/Tauri] Remove `unsafe-inline` from CSP** (move inline handlers to `addEventListener`, escape all network-derived data before it hits `innerHTML`), and **remove the unused `shell:allow-execute` capability.** (S6, S8)
7. **[Network] Secure the remote-node path:** require TLS or an SSH/Tor tunnel for remote RPC, warn the user clearly, and **stop storing the RPC password in cleartext** — use the OS keychain. (S5)
8. **[Backend] Add a per-session auth token + Origin/Host validation to the local HTTP server.** (S9)

### Phase 3 — Hardening
9. **[Tauri] Scope the `fs` capability and validate the `export_watch_only` path; drop clipboard read.** (S12, S16)
10. **[Backend] Stop passing secrets via argv/env;** use stdin or a file descriptor. (S11)
11. **[Crypto] Raise the KDF cost** (scrypt N or argon2id). (S14)
12. **[Refactor] Decompose `online/setup.py`** to make ongoing security review tractable. (S15)
13. **[Tauri] Integrity-check the `electrs` binary before execution; harden temp-file handling.** (S13, S18)

### Process recommendation (covers the "AI threat" concern)
14. **Get a third-party security audit before mainnet release.** Hand-rolled Bitcoin crypto securing real money is exactly the case where an independent expert review is worth the cost. The internal review here finds structural issues but is not a substitute for a focused crypto audit.
15. **Adopt a "no unverified code" policy** for the build and runtime — every binary and dependency either pinned-and-hashed or built reproducibly in CI. This is the most effective defense against the AI-accelerated supply-chain and malware threats you raised.

---

## 6. Bottom line for leadership

The product is **a well-built prototype, not yet a vault.** The path to "safe for real money" is clear and finite — it's roughly Phase 1 (≈2–4 focused engineering-weeks) to remove the catastrophic risks, plus an external crypto audit before launch. Nothing requires re-architecting. The biggest mistake would be to let the polished UI and clean code create false confidence and ship before Phase 1 is done — because the failure mode for a wallet isn't a crash, it's a user's irreversible loss of funds.
