Verify the master key against the database at startup (B-89, SEC-49, SEC-50, SEC-09)
The lifespan decrypts the key and compares a fixed sample (reserve/round pool addresses, the 10 lowest and highest user addresses, unrevealed round secrets) with the database, and refuses to serve on any mismatch without printing key material. The key scripts refuse a new or imported key that would not reproduce the stored addresses, and write 0600. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32df50cb75
commit
55d852fe08
14 files changed
+281
-46
No files matched your search
@@ -32,6 +32,7 @@ from app.tx.broadcast import RbfBumper
|
||||
from app.tx.confirmation import ConfirmationPoller
|
||||
from app.tx.locks import UserLocks
|
||||
from app.tx.reconcile import PendingTransactionReconciler
|
||||
from app.wallet.verify import verify_master_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +54,7 @@ async def lifespan(app: FastAPI):
|
||||
settings.electrum_cert_pin,
|
||||
)
|
||||
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
||||
verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses
|
||||
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
||||
|
||||
listener = ElectrumListener(
|
||||
|
||||
+2
-3
@@ -5,7 +5,7 @@ from embit.bip32 import HDKey
|
||||
from embit.ec import PrivateKey
|
||||
|
||||
from app.config import settings
|
||||
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
|
||||
from app.wallet.keystore import decrypt_xprv, encrypt_xprv, write_secret_file
|
||||
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
|
||||
|
||||
_account_key: HDKey | None = None
|
||||
@@ -17,8 +17,7 @@ def generate_master_key(overwrite: bool = False) -> None:
|
||||
if os.path.exists(settings.master_key_path) and not overwrite:
|
||||
raise FileExistsError(f"{settings.master_key_path} already exists")
|
||||
root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"])
|
||||
with open(settings.master_key_path, "wb") as f:
|
||||
f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
|
||||
write_secret_file(settings.master_key_path, encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
|
||||
|
||||
|
||||
def _load_account_key() -> HDKey:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
@@ -20,3 +22,12 @@ def encrypt_round_seed(server_seed_hex: str) -> str:
|
||||
|
||||
def decrypt_round_seed(token: str) -> str:
|
||||
return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode()
|
||||
|
||||
|
||||
def write_secret_file(path: str, data: bytes) -> None:
|
||||
"""B-89 (SEC-50): created `0600` from the start (never through a wider mode) and
|
||||
rewritten with the same mode, so a key file is not readable by other users."""
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
f.write(data)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""B-89 (SEC-49, SEC-50): is the master key the one that produced what the database holds?
|
||||
|
||||
A different xprv, or a wrong encryption key, used to surface only at the first
|
||||
registration or payout — and a different xprv never did: it quietly moved the pool
|
||||
address. The check compares a fixed sample of stored addresses with what the key derives,
|
||||
reading the database with plain `sqlite3` (read-only), so the startup check and the
|
||||
key scripts share it without an event loop."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import NamedTuple
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from embit import script
|
||||
from embit.bip32 import HDKey
|
||||
|
||||
from app.config import ConfigError, settings
|
||||
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAMPLE_SIZE = 10
|
||||
|
||||
|
||||
class Sample(NamedTuple):
|
||||
users: list[tuple[int, str]] # (derivation_index, address)
|
||||
rounds: list[tuple[int, str]] # (id, pool_address) of the last rounds with one
|
||||
seeds: list[tuple[int, str, str]] # (id, server_seed_enc, server_seed_hash), unrevealed
|
||||
|
||||
|
||||
def database_file(url: str | None = None) -> str | None:
|
||||
"""The SQLite file `DATABASE_URL` points at, or None for anything else."""
|
||||
url = url or settings.database_url
|
||||
prefix = "sqlite+aiosqlite:///"
|
||||
return url[len(prefix):] if url.startswith(prefix) else None
|
||||
|
||||
|
||||
def load_sample(path: str | None = None) -> Sample:
|
||||
"""The lowest and highest `_SAMPLE_SIZE` users, the last rounds that have a pool
|
||||
address and every round whose secret is still unrevealed. A missing file or missing
|
||||
tables (a fresh instance) is an empty sample: there is nothing to compare."""
|
||||
path = path or database_file()
|
||||
empty = Sample([], [], [])
|
||||
if path is None:
|
||||
return empty
|
||||
try:
|
||||
db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
except sqlite3.Error:
|
||||
return empty
|
||||
try:
|
||||
users = {
|
||||
index: address
|
||||
for order in ("ASC", "DESC")
|
||||
for index, address in db.execute(
|
||||
f"SELECT derivation_index, address FROM users ORDER BY derivation_index {order} LIMIT ?",
|
||||
(_SAMPLE_SIZE,),
|
||||
)
|
||||
}
|
||||
rounds = db.execute(
|
||||
"SELECT id, pool_address FROM rounds WHERE pool_address != '' ORDER BY id DESC LIMIT ?",
|
||||
(_SAMPLE_SIZE,),
|
||||
).fetchall()
|
||||
seeds = db.execute(
|
||||
"SELECT id, server_seed_enc, server_seed_hash FROM rounds "
|
||||
"WHERE server_seed_enc IS NOT NULL AND server_seed IS NULL"
|
||||
).fetchall()
|
||||
return Sample(sorted(users.items()), rounds, seeds)
|
||||
except sqlite3.OperationalError: # no such table
|
||||
return empty
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def account_key(xprv_base58: str) -> HDKey:
|
||||
return HDKey.from_base58(xprv_base58).derive(ACCOUNT_PATH)
|
||||
|
||||
|
||||
def _address(account: HDKey, path: str) -> str:
|
||||
return script.p2wpkh(account.derive(path).to_public()).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def sample_problems(account: HDKey, sample: Sample, encryption_key: str | None = None) -> list[str]:
|
||||
"""One line per check that failed; never any key material. With `encryption_key`
|
||||
the unrevealed round secrets are checked as well (they depend on it, not on the xprv)."""
|
||||
problems = []
|
||||
for index, stored in sample.users:
|
||||
if _address(account, f"0/{index}") != stored:
|
||||
problems.append(f"user address at derivation index {index} differs from the one derived")
|
||||
if sample.rounds:
|
||||
reserve = _address(account, "1/0")
|
||||
for round_id, stored in sample.rounds:
|
||||
if stored not in (reserve, _address(account, f"2/{round_id}")):
|
||||
problems.append(f"pool address of round {round_id} is neither its own nor the reserve address")
|
||||
if encryption_key is not None:
|
||||
fernet = Fernet(encryption_key)
|
||||
for round_id, token, committed in sample.seeds:
|
||||
try:
|
||||
secret = fernet.decrypt(token.encode()).decode()
|
||||
except Exception:
|
||||
problems.append(f"secret of round {round_id} does not decrypt")
|
||||
continue
|
||||
if hashlib.sha256(bytes.fromhex(secret)).hexdigest() != committed:
|
||||
problems.append(f"secret of round {round_id} does not match its published hash")
|
||||
return problems
|
||||
|
||||
|
||||
def verify_master_key() -> None:
|
||||
"""Called from the lifespan, before any task starts: decrypt the key and compare the
|
||||
sample. Raises `ConfigError` (the process exits) on the first sign of a mismatch."""
|
||||
from app.wallet import hd
|
||||
|
||||
try:
|
||||
account = hd._load_account_key()
|
||||
except Exception as exc:
|
||||
raise ConfigError(
|
||||
f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): "
|
||||
"check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY"
|
||||
) from exc
|
||||
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
|
||||
if problems:
|
||||
raise ConfigError(
|
||||
"the master key does not match the database — refusing to serve: " + "; ".join(problems)
|
||||
)
|
||||
logger.info("Master key verified against the database sample")
|
||||
Reference in new issue
Block a user