proof-aware-crypto-tooling-.../tests/test_wallet_hardening.py

348 lines
16 KiB
Python
Raw Permalink Normal View History

warden hardening round: policy engine, ledger rotation, MCP UX, treasury LIVE, ops docs Tier 2: - request_signature decomposed into named gates (latch, freshness, intent, policy, signer, firewall) - ledger: O(1) tail-read appends under a dedicated lock file (survives rotation rename); hash-chained segment rotation at policy ledger.rotate_at; verify-ledger walks all segments to genesis; archive tampering detected (tested) - docs/threat-model.md (attacker matrix 1-9, proven-vs-trusted, design invariants) + docs/runbook-latch.md (diagnose-first recovery) - lecture 10: executable corrupt-a-member exercise (capsule pin catches one appended byte), honest note on what the pin does NOT stop Lightweight policy engine (POLICY_DENIED wired): - policy.json: per-request/per-day amount ceilings, counterparty allow/deny lists, per-identity overrides; rules make their intent fields mandatory; daily sums from the ledger - Agent UX: - signed refusal receipts travel inside MCP errors (receipt + receipt_path in structuredContent) - airgap over MCP: request_signature signer=airgap + request_id, new airgap_pending tool; park -> list -> device answers -> complete (tested end-to-end) - all 8 tools carry readOnly/destructive annotations - sliding-window rate limiter per tool class (custody/verify/liveness); RATE_LIMITED refusal code; surface control, not ledgered warden-treasury LIVE: - treasury.py: stdlib base58, compact-u16, legacy+v0 wire parsing; every required signature quorum-verified over exact message bytes; completeness gap named in every verdict; RPC fetch uses response as bytes only - - live-quorum test: synthetic Solana tx signed with wallet key -> authentic via 4 proven forks; flipped byte -> not authentic 100 tests green (was 85). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:38:42 +00:00
"""Hardening round: policy engine, ledger rotation, MCP rate limits +
receipt-bearing errors, airgap MCP flow, treasury parsing/verification.
"""
import base64
import hashlib
import json
import stat
from pathlib import Path
import pytest
from pacta.quorum import binary_path
from pacta.signing import generate_ed25519_keypair
from pacta.wallet import Refusal, Wallet
from pacta.walletmcp import WalletMCP, _RateLimiter
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _fake_member(path: Path, verdict: str) -> None:
code = {"accept": 0, "reject": 1}[verdict]
out = {"accept": "OK", "reject": "INVALID"}[verdict]
path.write_text(f"#!/bin/sh\necho {out}\nexit {code}\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
def _seal_wallet(tmp_path: Path, verdicts: dict, state_dir: Path) -> Wallet:
state_dir.mkdir(parents=True, exist_ok=True)
members = []
for name, verdict in verdicts.items():
binary = binary_path(name, state_dir)
_fake_member(binary, verdict)
members.append({
"backend": name, "component": f"{name}-ed25519-verified", "semantics": "test",
"entry_point": "test", "source_commit": "de" * 20, "repo_commit": "ca" * 20,
"binary_sha256": _sha256(binary.read_bytes()), "backend_cfg": "test", "risk_tier": "R4",
"evidence": {"leaf_hash": "00", "leaf_index": 0, "tree_size": 1, "inclusion_proof": [],
"sth": {"timestamp": "2099-01-01T00:00:00Z"}},
})
wallet = Wallet(tmp_path / "w")
for sub in (wallet.keys_dir, wallet.incidents_dir, wallet.receipts_dir,
wallet.quarantine_dir, wallet.airgap_dir / "outbox", wallet.airgap_dir / "inbox"):
sub.mkdir(parents=True, exist_ok=True)
capsule = {"type": "pacta.wallet.custody_capsule.v1", "created_at": "2026-07-06T00:00:00Z",
"members": members,
"policy": {"require_unanimity": True, "min_members": 2, "require_tier": "R4",
"freshness_max_age_days": 0},
"signing": {"backend": "test"}, "problems_at_init": []}
wallet.capsule_path.write_text(json.dumps(capsule, indent=2, sort_keys=True) + "\n")
wallet._append_ledger("genesis", {"capsule_sha256": "x"})
generate_ed25519_keypair(wallet.keys_dir / "warden.key.pem", wallet.keys_dir / "warden.pub.pem")
wallet._test_state_dir = state_dir # type: ignore[attr-defined]
return wallet
def _intent(payload: bytes, **extra) -> dict:
intent = {"purpose": "test", "payload_sha256": _sha256(payload)}
intent.update(extra)
return intent
def _needs_signer():
from pacta.dogfood import locate_verifier
if locate_verifier() is None:
pytest.skip("dogfood signer not built")
# -- policy engine -----------------------------------------------------------
def test_policy_absent_means_unrestricted(tmp_path):
_needs_signer()
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
payload = b"no policy"
result = wallet.request_signature(_intent(payload), payload, state_dir=wallet._test_state_dir)
assert not isinstance(result, Refusal)
def test_policy_amount_ceiling_denies(tmp_path):
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps(
{"outbound": {"max_amount_per_request": 50}}))
payload = b"big spender"
# missing amount -> denied (policy makes the field mandatory)
r1 = wallet.request_signature(_intent(payload), payload, state_dir=wallet._test_state_dir)
assert isinstance(r1, Refusal) and r1.code == "POLICY_DENIED"
# over ceiling -> denied
r2 = wallet.request_signature(_intent(payload, amount=51), payload, state_dir=wallet._test_state_dir)
assert isinstance(r2, Refusal) and r2.code == "POLICY_DENIED"
def test_policy_under_ceiling_releases_and_daily_cap_accumulates(tmp_path):
_needs_signer()
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps(
{"outbound": {"max_amount_per_request": 50, "max_amount_per_day": 80}}))
payload = b"pay 40"
ok1 = wallet.request_signature(_intent(payload, amount=40), payload, state_dir=wallet._test_state_dir)
assert not isinstance(ok1, Refusal)
ok2 = wallet.request_signature(_intent(payload, amount=40), payload, state_dir=wallet._test_state_dir)
assert not isinstance(ok2, Refusal)
# 80 spent; one more coin breaks the day
r = wallet.request_signature(_intent(payload, amount=1), payload, state_dir=wallet._test_state_dir)
assert isinstance(r, Refusal) and r.code == "POLICY_DENIED"
assert "daily ceiling" in r.reason
def test_policy_counterparty_lists(tmp_path):
_needs_signer()
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps(
{"outbound": {"counterparty_allowlist": ["alice"], "counterparty_denylist": ["mallory"]}}))
payload = b"to someone"
r1 = wallet.request_signature(_intent(payload), payload, state_dir=wallet._test_state_dir)
assert isinstance(r1, Refusal) and "names no counterparty" in r1.reason
r2 = wallet.request_signature(_intent(payload, counterparty="mallory"), payload, state_dir=wallet._test_state_dir)
assert isinstance(r2, Refusal) and "denylist" in r2.reason
r3 = wallet.request_signature(_intent(payload, counterparty="bob"), payload, state_dir=wallet._test_state_dir)
assert isinstance(r3, Refusal) and "allowlist" in r3.reason
ok = wallet.request_signature(_intent(payload, counterparty="alice"), payload, state_dir=wallet._test_state_dir)
assert not isinstance(ok, Refusal)
def test_policy_identity_override(tmp_path):
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps({
"outbound": {"max_amount_per_request": 100},
"identities": {"warden": {"max_amount_per_request": 10}},
}))
payload = b"identity override"
r = wallet.request_signature(_intent(payload, amount=50), payload, state_dir=wallet._test_state_dir)
assert isinstance(r, Refusal) and r.code == "POLICY_DENIED"
# -- ledger rotation + O(1) head ------------------------------------------------
def test_ledger_rotation_keeps_chain_verifiable(tmp_path):
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps({"ledger": {"rotate_at": 5}}))
for i in range(12):
wallet._append_ledger("stress", {"i": i})
archives = sorted(wallet.dir.glob("ledger-*.jsonl"))
assert archives, "rotation never happened"
ok, problems = wallet.verify_ledger()
assert ok, problems
# head is O(1)-readable and matches the last line
entries = wallet._ledger_entries()
assert wallet.ledger_head() == entries[-1]["entry_hash"]
# indices keep increasing across the rotation boundary
assert entries[-1]["index"] == 12 + len(archives) # genesis + 12 + rotation entries
def test_ledger_rotation_detects_archive_tampering(tmp_path):
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps({"ledger": {"rotate_at": 4}}))
for i in range(9):
wallet._append_ledger("stress", {"i": i})
archive = sorted(wallet.dir.glob("ledger-*.jsonl"))[0]
lines = archive.read_text().splitlines()
doctored = json.loads(lines[1])
doctored["body"]["i"] = 999
lines[1] = json.dumps(doctored, sort_keys=True)
archive.write_text("\n".join(lines) + "\n")
ok, problems = wallet.verify_ledger()
assert not ok and problems
# -- MCP: receipts in errors, airgap flow, rate limiting -------------------------
def _call(srv, method, params=None, msg_id=1):
return srv.handle({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params or {}})
def test_mcp_refusal_carries_signed_receipt(tmp_path):
_needs_signer()
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
(wallet.dir / "policy.json").write_text(json.dumps(
{"outbound": {"max_amount_per_request": 1}}))
srv = WalletMCP(wallet.dir, state_dir=wallet._test_state_dir)
res = _call(srv, "tools/call", {"name": "request_signature", "arguments": {
"payload_b64": base64.b64encode(b"x").decode(), "purpose": "test", "amount": 9,
}})["result"]
assert res["isError"]
body = res["structuredContent"]
assert body["code"] == "POLICY_DENIED"
# the signed receipt travels with the error
assert body["receipt"]["type"] == "pacta.wallet.refusal_receipt.v1"
assert body["receipt"]["signature"].get("scheme") == "ed25519-dogfood"
assert body["receipt_path"]
def test_mcp_airgap_park_list_complete(tmp_path):
_needs_signer()
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
srv = WalletMCP(wallet.dir, state_dir=wallet._test_state_dir)
payload = b"gap payload"
res = _call(srv, "tools/call", {"name": "request_signature", "arguments": {
"payload_b64": base64.b64encode(payload).decode(), "purpose": "gap test",
"signer": "airgap", "request_id": "reqmcp1",
}})["result"]
assert res["structuredContent"]["code"] == "PENDING_AIRGAP"
listing = _call(srv, "tools/call", {"name": "airgap_pending"})["result"]["structuredContent"]
assert listing["count"] == 1 and listing["pending"][0]["request_id"] == "reqmcp1"
assert listing["pending"][0]["device_answered"] is False
# the "device" signs (with the wallet's own key, for the test) and answers
from pacta.dogfood import locate_verifier, sign_payload_dogfood
signature = sign_payload_dogfood(payload, wallet.keys_dir / "warden.key.pem", locate_verifier())
(wallet.airgap_dir / "inbox" / "reqmcp1.response.json").write_text(
json.dumps({"signature_hex": signature.hex()}))
done = _call(srv, "tools/call", {"name": "request_signature", "arguments": {
"payload_b64": base64.b64encode(payload).decode(), "purpose": "gap test",
"signer": "airgap", "request_id": "reqmcp1",
}})["result"]
assert not done["isError"], done["structuredContent"]
assert done["structuredContent"]["firewall"]["classification"] == "unanimous-accept"
assert done["structuredContent"]["signer"] == "airgap"
def test_rate_limiter_trips_and_recovers():
limiter = _RateLimiter()
limiter.LIMITS = {"custody": 3, "verify": 120, "liveness": 240}
for _ in range(3):
limiter.check("custody")
from pacta.walletmcp import _ToolError
with pytest.raises(_ToolError) as excinfo:
limiter.check("custody")
assert excinfo.value.payload["code"] == "RATE_LIMITED"
def test_mcp_tools_have_annotations(tmp_path):
from pacta.walletmcp import TOOLS
for tool in TOOLS:
assert "annotations" in tool, tool["name"]
readonly = {t["name"] for t in TOOLS if t["annotations"].get("readOnlyHint")}
assert "wallet_status" in readonly and "custody_card" in readonly
assert "request_signature" not in readonly
# -- treasury -------------------------------------------------------------------
def _make_message(signer_key: bytes, extra_key: bytes, version0: bool = False) -> bytes:
header = bytes([1, 0, 1])
keys = bytes([2]) + signer_key + extra_key # compact-u16(2) then two keys
blockhash = bytes(range(32))
instructions = bytes([0]) # compact-u16(0): none
prefix = bytes([0x80]) if version0 else b""
return prefix + header + keys + blockhash + instructions
def _assemble_tx(signature: bytes, message: bytes) -> bytes:
return bytes([1]) + signature + message
def test_treasury_parse_roundtrip_legacy_and_v0():
from pacta.treasury import parse_transaction
signer, extra = bytes([7] * 32), bytes([9] * 32)
for v0 in (False, True):
message = _make_message(signer, extra, version0=v0)
tx = _assemble_tx(b"\xab" * 64, message)
parsed = parse_transaction(tx)
assert parsed.num_required_signatures == 1
assert parsed.account_keys[0] == signer
assert parsed.message == message
assert parsed.version == (0 if v0 else None)
def test_treasury_parse_rejects_garbage():
from pacta.treasury import parse_transaction
with pytest.raises(ValueError):
parse_transaction(b"")
with pytest.raises(ValueError):
parse_transaction(bytes([1]) + b"\x00" * 10) # truncated signature
# header/signature-count mismatch
signer, extra = bytes([7] * 32), bytes([9] * 32)
message = bytes([2, 0, 1]) + bytes([2]) + signer + extra + bytes(32) + bytes([0])
with pytest.raises(ValueError):
parse_transaction(_assemble_tx(b"\xab" * 64, message))
def test_treasury_b58decode_vectors():
from pacta.treasury import b58decode
assert b58decode("") == b""
assert b58decode("1") == b"\x00"
assert b58decode("2g") == b"a"
assert b58decode("ZiCa") == b"abc"
assert b58decode("11ZiCa") == b"\x00\x00abc"
with pytest.raises(ValueError):
b58decode("0OIl")
def test_treasury_verify_transaction_real_quorum(tmp_path):
"""The showcase: a synthetic Solana transaction signed with the wallet's
key, quorum-verified through the REAL four proven forks."""
if not all(binary_path(b).exists() for b in ("dalek", "anza")):
pytest.skip("real quorum not built")
_needs_signer()
from pacta.dogfood import locate_verifier, pem_public_key_to_raw, sign_payload_dogfood
from pacta.treasury import verify_transaction
# seal against the REAL binaries
real = {b: binary_path(b) for b in ("dalek", "anza", "risc0", "betrusted") if binary_path(b).exists()}
wallet = Wallet(tmp_path / "w")
for sub in (wallet.keys_dir, wallet.incidents_dir, wallet.receipts_dir,
wallet.quarantine_dir, wallet.airgap_dir / "outbox", wallet.airgap_dir / "inbox"):
sub.mkdir(parents=True, exist_ok=True)
members = [{
"backend": b, "component": f"{b}-ed25519-verified", "semantics": "t", "entry_point": "t",
"source_commit": "x", "repo_commit": "y", "binary_sha256": _sha256(p.read_bytes()),
"backend_cfg": "t", "risk_tier": "R4",
"evidence": {"leaf_hash": "00", "leaf_index": 0, "tree_size": 1, "inclusion_proof": [],
"sth": {"timestamp": "2099-01-01T00:00:00Z"}},
} for b, p in real.items()]
capsule = {"type": "pacta.wallet.custody_capsule.v1", "created_at": "2026-07-06T00:00:00Z",
"members": members,
"policy": {"require_unanimity": True, "min_members": 2, "require_tier": "R4",
"freshness_max_age_days": 0},
"signing": {"backend": "test"}, "problems_at_init": []}
wallet.capsule_path.write_text(json.dumps(capsule, indent=2, sort_keys=True) + "\n")
wallet._append_ledger("genesis", {"capsule_sha256": "x"})
generate_ed25519_keypair(wallet.keys_dir / "warden.key.pem", wallet.keys_dir / "warden.pub.pem")
signer_key = pem_public_key_to_raw(wallet.keys_dir / "warden.pub.pem")
message = _make_message(signer_key, bytes([9] * 32))
signature = sign_payload_dogfood(message, wallet.keys_dir / "warden.key.pem", locate_verifier())
verdict = verify_transaction(wallet, _assemble_tx(signature, message))
assert verdict.authentic
assert verdict.signer_results[0]["classification"] == "unanimous-accept"
# a flipped byte in the signature: not authentic
bad = verify_transaction(wallet, _assemble_tx(bytes([signature[0] ^ 1]) + signature[1:], message))
assert not bad.authentic
# and the checks are in the ledger with treasury context
contexts = [e["body"].get("context", "") for e in wallet._ledger_entries()
if e["entry_type"] == "inbound-verify"]
assert any("treasury" in c for c in contexts)