111 lines
4.6 KiB
Python
111 lines
4.6 KiB
Python
"""B-89 (SEC-49, SEC-50, SEC-09): the master key must be the one that produced the database."""
|
|||
|
|
|
||
|
|
import hashlib
|
||
|
|
import os
|
||
|
|
import sqlite3
|
||
|
|
import stat
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from cryptography.fernet import Fernet
|
||
|
|
from embit import script
|
||
|
|
|
||
|
|
from app.config import ConfigError, settings
|
||
|
|
from app.wallet import hd
|
||
|
|
from app.wallet.keystore import encrypt_round_seed
|
||
|
|
from app.wallet.plm_network import PLM_MAINNET
|
||
|
|
from app.wallet.verify import load_sample, sample_problems, verify_master_key
|
||
|
|
|
||
|
|
|
||
|
|
def _database(tmp_path, monkeypatch, users=(), rounds=()):
|
||
|
|
"""A file DB with just the columns the check reads."""
|
||
|
|
path = tmp_path / "check.db"
|
||
|
|
db = sqlite3.connect(path)
|
||
|
|
db.execute("CREATE TABLE users (derivation_index INTEGER, address TEXT)")
|
||
|
|
db.execute(
|
||
|
|
"CREATE TABLE rounds (id INTEGER, pool_address TEXT, server_seed_enc TEXT, server_seed TEXT, server_seed_hash TEXT)"
|
||
|
|
)
|
||
|
|
db.executemany("INSERT INTO users VALUES (?, ?)", users)
|
||
|
|
db.executemany("INSERT INTO rounds VALUES (?, ?, ?, ?, ?)", rounds)
|
||
|
|
db.commit()
|
||
|
|
db.close()
|
||
|
|
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{path}")
|
||
|
|
|
||
|
|
|
||
|
|
def _addr(path):
|
||
|
|
return script.p2wpkh(hd._load_account_key().derive(path).to_public()).address(network=PLM_MAINNET)
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_fresh_instance_only_needs_the_key_to_decrypt(tmp_path, monkeypatch):
|
||
|
|
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/absent.db")
|
||
|
|
verify_master_key()
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_matching_key_passes(tmp_path, monkeypatch):
|
||
|
|
_database(
|
||
|
|
tmp_path,
|
||
|
|
monkeypatch,
|
||
|
|
users=[(i, _addr(f"0/{i}")) for i in range(25)],
|
||
|
|
rounds=[(1, _addr("1/0"), None, None, None), (2, _addr("2/2"), None, None, None)],
|
||
|
|
)
|
||
|
|
verify_master_key()
|
||
|
|
|
||
|
|
|
||
|
|
def test_another_master_key_is_refused_without_printing_it(tmp_path, monkeypatch):
|
||
|
|
"""SEC-49: the rows came from a different xprv (the two-keys incident)."""
|
||
|
|
_database(tmp_path, monkeypatch, users=[(0, _addr("0/0")), (1, _addr("0/1"))])
|
||
|
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "other.xprv.enc"))
|
||
|
|
hd._account_key = None
|
||
|
|
hd.generate_master_key()
|
||
|
|
with pytest.raises(ConfigError, match="derivation index 0") as excinfo:
|
||
|
|
verify_master_key()
|
||
|
|
assert "xprv" not in str(excinfo.value).lower().replace("master key", "")
|
||
|
|
|
||
|
|
|
||
|
|
def test_only_the_edges_of_the_user_table_are_sampled(tmp_path, monkeypatch):
|
||
|
|
users = [(i, _addr(f"0/{i}")) for i in range(40)]
|
||
|
|
_database(tmp_path, monkeypatch, users=users)
|
||
|
|
assert [i for i, _ in load_sample().users] == list(range(10)) + list(range(30, 40))
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_round_pool_address_must_be_its_own_or_the_reserve(tmp_path, monkeypatch):
|
||
|
|
_database(tmp_path, monkeypatch, rounds=[(5, _addr("2/9"), None, None, None)])
|
||
|
|
with pytest.raises(ConfigError, match="round 5"):
|
||
|
|
verify_master_key()
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_wrong_encryption_key_is_refused_at_startup(tmp_path, monkeypatch):
|
||
|
|
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||
|
|
hd._account_key = None
|
||
|
|
with pytest.raises(ConfigError, match="cannot be loaded"):
|
||
|
|
verify_master_key()
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unrevealed_secret_must_decrypt_and_match_its_hash(tmp_path, monkeypatch):
|
||
|
|
secret = "ab" * 32
|
||
|
|
good = (1, "", encrypt_round_seed(secret), None, hashlib.sha256(bytes.fromhex(secret)).hexdigest())
|
||
|
|
bad_hash = (2, "", encrypt_round_seed(secret), None, "00" * 32)
|
||
|
|
revealed = (3, "", "not-a-token", secret, "00" * 32) # already revealed: not checked
|
||
|
|
_database(tmp_path, monkeypatch, rounds=[good, bad_hash, revealed])
|
||
|
|
problems = sample_problems(hd._load_account_key(), load_sample(), settings.xprv_encryption_key)
|
||
|
|
assert problems == ["secret of round 2 does not match its published hash"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_key_file_is_written_0600(tmp_path, monkeypatch):
|
||
|
|
path = tmp_path / "k.xprv.enc"
|
||
|
|
monkeypatch.setattr(settings, "master_key_path", str(path))
|
||
|
|
os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o644)) # a leftover world-readable file
|
||
|
|
hd.generate_master_key(overwrite=True)
|
||
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_import_script_refuses_an_xprv_that_does_not_match_the_database(tmp_path, monkeypatch):
|
||
|
|
"""SEC-05 / SEC-50: a Bitcoin-looking xprv passes the version check, the addresses do not."""
|
||
|
|
from embit.bip32 import HDKey
|
||
|
|
|
||
|
|
from app.wallet.verify import account_key
|
||
|
|
|
||
|
|
_database(tmp_path, monkeypatch, users=[(0, _addr("0/0"))])
|
||
|
|
other = HDKey.from_seed(b"\x09" * 32, version=PLM_MAINNET["xprv"]).to_base58(version=PLM_MAINNET["xprv"])
|
||
|
|
assert sample_problems(account_key(other), load_sample())
|
||
|
|
mine = hd._load_account_key()
|
||
|
|
assert sample_problems(mine, load_sample()) == []
|