mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
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>
1065 lines
46 KiB
Python
1065 lines
46 KiB
Python
"""warden - the verified-custody wallet core.
|
|
|
|
A wallet is a directory that holds no opinions, only evidence:
|
|
|
|
- ``capsule.json`` the custody capsule: which quorum members guard the
|
|
boundary, compiled from which attested sources, with
|
|
the transparency-log receipts that authorized them
|
|
(R4 gate), and the policy in force.
|
|
- ``ledger.jsonl`` append-only, hash-chained event log. Every inbound
|
|
verification, outbound signature, refusal, incident,
|
|
and latch transition is an entry; each entry commits
|
|
to its predecessor by SHA-256.
|
|
- ``keys/`` wallet identities (OpenSSL PEM). Private keys only
|
|
for the local signer; the airgap signer keeps its
|
|
seed on the device, where it belongs.
|
|
- ``incidents/`` quorum divergences and firewall quarantines, full
|
|
trails, numbered.
|
|
- ``receipts/`` refusal receipts - signed, machine-actionable
|
|
artifacts a refused agent can hand its principal.
|
|
- ``quarantine/`` signatures the outbound firewall refused to release.
|
|
- ``airgap/`` outbox/inbox exchange directory for the gap signer.
|
|
|
|
Trust posture, stated once and enforced everywhere: the INBOUND boundary
|
|
(and the outbound firewall's check) runs on quorum members whose verify
|
|
paths carry machine-checked correctness certificates - custody-grade.
|
|
The OUTBOUND signing step is declared trusted base (the attested artifact,
|
|
not a third implementation) until signing-side proofs land. A tamper-grade
|
|
quorum divergence latches custody shut, and refusals while latched are
|
|
honest enough to arrive unsigned: a wallet that no longer trusts its own
|
|
boundary does not pretend to certify its apologies.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .attestation import validate_attestation
|
|
from .claims import build_claim_card
|
|
from .config import load_config
|
|
from .dogfood import (
|
|
locate_verifier,
|
|
pem_public_key_to_raw,
|
|
sign_payload_dogfood,
|
|
)
|
|
from .quorum import (
|
|
QUORUM_BACKENDS,
|
|
QuorumResult,
|
|
QuorumVerifier,
|
|
member_provenance,
|
|
binary_path as quorum_binary_path,
|
|
)
|
|
from .risk import risk_at_least, score_claim_card
|
|
from .signing import generate_ed25519_keypair
|
|
from .sthstore import check_sth_freshness
|
|
from .transparency import verify_inclusion, verify_signed_tree_head
|
|
from .yamlio import load_data
|
|
|
|
CAPSULE_TYPE = "pacta.wallet.custody_capsule.v1"
|
|
LEDGER_GENESIS = "pacta.wallet.ledger_genesis.v1"
|
|
|
|
# Refusal codes: the machine-actionable vocabulary. Every refusal names one.
|
|
REFUSAL_CODES = (
|
|
"EVIDENCE_REQUIRED", # missing/insufficient transparency-log evidence
|
|
"POLICY_DENIED", # request violates capsule policy
|
|
"CUSTODY_LATCHED", # tamper latch is engaged; custody frozen
|
|
"EVIDENCE_STALE", # STH pin older than the freshness policy
|
|
"MALFORMED_INTENT", # intent envelope failed validation
|
|
"SIGNER_UNAVAILABLE", # signer backend cannot serve the request
|
|
"FIREWALL_QUARANTINE", # produced signature failed the quorum firewall
|
|
"PENDING_AIRGAP", # request parked in the airgap outbox
|
|
"RATE_LIMITED", # surface-level throttle (issued by the MCP layer)
|
|
)
|
|
|
|
|
|
class WalletError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Refusal:
|
|
code: str
|
|
reason: str
|
|
missing: list[str]
|
|
remediation: str
|
|
receipt_path: Path | None = None
|
|
receipt: dict[str, Any] | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"code": self.code,
|
|
"reason": self.reason,
|
|
"missing": self.missing,
|
|
"remediation": self.remediation,
|
|
"receipt_path": str(self.receipt_path) if self.receipt_path else None,
|
|
"receipt": self.receipt,
|
|
}
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _canonical(document: Any) -> bytes:
|
|
return json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signers
|
|
|
|
|
|
class LocalDogfoodSigner:
|
|
"""Signs with the attested dogfood binary; seed from a PEM on this host.
|
|
|
|
Honesty: the signing path is trusted base - but it is the merkleized,
|
|
attested artifact, and every signature it emits must still pass the
|
|
quorum firewall before release.
|
|
"""
|
|
|
|
name = "local-dogfood"
|
|
|
|
def __init__(self, binary: str | Path | None = None) -> None:
|
|
self.binary = Path(binary) if binary else locate_verifier()
|
|
if self.binary is None or not Path(self.binary).exists():
|
|
raise WalletError(
|
|
"dogfood signing binary not found; run `pacta dogfood-build` first"
|
|
)
|
|
|
|
def sign(self, payload: bytes, private_key_path: Path) -> bytes:
|
|
return sign_payload_dogfood(payload, private_key_path, self.binary)
|
|
|
|
|
|
class AirgapSigner:
|
|
"""Models the Precursor/Betrusted flow: the seed never touches this host.
|
|
|
|
A signing request is written to ``airgap/outbox/<id>.request.json``; a
|
|
human (or the device bridge) carries it across the gap, signs, and drops
|
|
``airgap/inbox/<id>.response.json`` containing ``signature_hex``. The
|
|
wallet picks it up on the next attempt with the same request id - and
|
|
the signature still has to pass the quorum firewall, which is exactly
|
|
the fault-injection countermeasure (verify-after-sign) the hardware
|
|
story needs.
|
|
"""
|
|
|
|
name = "airgap"
|
|
|
|
def __init__(self, airgap_dir: Path, wait_seconds: float = 0.0, poll: float = 0.2) -> None:
|
|
self.outbox = airgap_dir / "outbox"
|
|
self.inbox = airgap_dir / "inbox"
|
|
self.outbox.mkdir(parents=True, exist_ok=True)
|
|
self.inbox.mkdir(parents=True, exist_ok=True)
|
|
self.wait_seconds = wait_seconds
|
|
self.poll = poll
|
|
|
|
def request_path(self, request_id: str) -> Path:
|
|
return self.outbox / f"{request_id}.request.json"
|
|
|
|
def response_path(self, request_id: str) -> Path:
|
|
return self.inbox / f"{request_id}.response.json"
|
|
|
|
def sign(self, payload: bytes, private_key_path: Path, request_id: str | None = None,
|
|
intent: dict[str, Any] | None = None) -> bytes:
|
|
request_id = request_id or uuid.uuid4().hex[:16]
|
|
response = self.response_path(request_id)
|
|
if not response.exists():
|
|
request = {
|
|
"type": "pacta.wallet.airgap_request.v1",
|
|
"request_id": request_id,
|
|
"created_at": _now(),
|
|
"payload_hex": payload.hex(),
|
|
"payload_sha256": _sha256(payload),
|
|
"intent": intent or {},
|
|
"instructions": (
|
|
"Carry this file across the gap. Sign payload_hex with the device "
|
|
"key and write {\"signature_hex\": ...} to "
|
|
f"airgap/inbox/{request_id}.response.json"
|
|
),
|
|
}
|
|
self.request_path(request_id).write_text(
|
|
json.dumps(request, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
deadline = time.monotonic() + self.wait_seconds
|
|
while time.monotonic() < deadline:
|
|
if response.exists():
|
|
break
|
|
time.sleep(self.poll)
|
|
if not response.exists():
|
|
raise PendingAirgap(request_id)
|
|
body = json.loads(response.read_text(encoding="utf-8"))
|
|
signature = bytes.fromhex(str(body.get("signature_hex", "")))
|
|
if len(signature) != 64:
|
|
raise WalletError(f"airgap response {request_id} has no 64-byte signature_hex")
|
|
return signature
|
|
|
|
|
|
class PendingAirgap(Exception):
|
|
def __init__(self, request_id: str) -> None:
|
|
super().__init__(request_id)
|
|
self.request_id = request_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The wallet
|
|
|
|
|
|
class Wallet:
|
|
def __init__(self, wallet_dir: str | Path, state_dir: str | Path | None = None) -> None:
|
|
self.dir = Path(wallet_dir)
|
|
self.state_dir = state_dir # default quorum binary location override
|
|
self.capsule_path = self.dir / "capsule.json"
|
|
self.ledger_path = self.dir / "ledger.jsonl"
|
|
self.latch_path = self.dir / "latch.json"
|
|
self.keys_dir = self.dir / "keys"
|
|
self.incidents_dir = self.dir / "incidents"
|
|
self.receipts_dir = self.dir / "receipts"
|
|
self.quarantine_dir = self.dir / "quarantine"
|
|
self.airgap_dir = self.dir / "airgap"
|
|
self._quorum_cache: dict[str, QuorumVerifier] = {}
|
|
|
|
# -- init / R4 gate ------------------------------------------------------
|
|
|
|
@classmethod
|
|
def init(
|
|
cls,
|
|
wallet_dir: str | Path,
|
|
evidence_dir: str | Path,
|
|
log_public_key: str | Path,
|
|
repos_config: str | Path = Path("examples") / "repos.yaml",
|
|
trusted_provider: str | None = None,
|
|
backends: list[str] | None = None,
|
|
state_dir: str | Path | None = None,
|
|
require_tier: str = "R4",
|
|
min_members: int = 2,
|
|
freshness_max_age_days: int = 30,
|
|
identity_name: str = "warden",
|
|
) -> "Wallet":
|
|
"""Create a wallet - the R4 gate in executable form.
|
|
|
|
For every quorum member: the built binary's provenance must name an
|
|
attested source commit; the evidence dir must hold that component's
|
|
attestation + receipt; the attestation is re-validated locally
|
|
(observations, never verdicts), scored, and must reach
|
|
``require_tier``; the receipt's inclusion proof must verify against
|
|
its signed tree head under the log's public key; and the source
|
|
commit in the attestation must equal the commit the binary was
|
|
compiled from. Any miss refuses wallet creation - a custody wallet
|
|
that cannot show its evidence has no business existing.
|
|
"""
|
|
wallet = cls(wallet_dir)
|
|
if wallet.capsule_path.exists():
|
|
raise WalletError(f"wallet already exists at {wallet.dir}")
|
|
evidence = Path(evidence_dir)
|
|
config = load_config(repos_config)
|
|
names = backends or list(QUORUM_BACKENDS)
|
|
members: list[dict[str, Any]] = []
|
|
problems: list[str] = []
|
|
for name in names:
|
|
binary = quorum_binary_path(name, state_dir)
|
|
if not binary.exists():
|
|
problems.append(f"{name}: quorum binary not built ({binary})")
|
|
continue
|
|
prov = member_provenance(name, state_dir)
|
|
component = QUORUM_BACKENDS[name]["component"]
|
|
att_path = evidence / f"{component}.attestation.json"
|
|
rec_path = evidence / f"{component}.receipt.json"
|
|
if not att_path.exists() or not rec_path.exists():
|
|
problems.append(
|
|
f"{name}: missing evidence for {component} "
|
|
f"(need {att_path.name} and {rec_path.name})"
|
|
)
|
|
continue
|
|
attestation = load_data(att_path)
|
|
if isinstance(attestation, dict) and "attestation" in attestation:
|
|
attestation = attestation["attestation"]
|
|
receipt = load_data(rec_path)
|
|
try:
|
|
repo = config.repo_named(component)
|
|
except KeyError as exc:
|
|
problems.append(f"{name}: {exc}")
|
|
continue
|
|
if not trusted_provider:
|
|
raise WalletError(
|
|
"wallet init requires an explicit --trusted-provider: custody evidence "
|
|
"must name whose observations you are trusting (never inferred from the "
|
|
"attestation itself)"
|
|
)
|
|
result = validate_attestation(
|
|
attestation,
|
|
repo,
|
|
path=att_path,
|
|
trusted_provider=trusted_provider,
|
|
public_key_path=log_public_key,
|
|
allow_unsigned=False,
|
|
transparency_receipt_path=rec_path,
|
|
transparency_log_public_key_path=log_public_key,
|
|
)
|
|
if not result.accepted:
|
|
problems.append(
|
|
f"{name}: attestation not accepted: " + "; ".join(result.diagnostics[:3])
|
|
)
|
|
continue
|
|
card = build_claim_card(repo, wallet.dir, attestation=result)
|
|
assessment = score_claim_card(card)
|
|
if not risk_at_least(assessment.level, require_tier):
|
|
problems.append(
|
|
f"{name}: evidence tier {assessment.level} below required {require_tier}: "
|
|
+ "; ".join(assessment.blockers[:3])
|
|
)
|
|
continue
|
|
sth = receipt.get("sth") or {}
|
|
ok_sth, sth_diags, _ = verify_signed_tree_head(sth, log_public_key)
|
|
if not ok_sth:
|
|
problems.append(f"{name}: receipt STH failed verification: {sth_diags[:2]}")
|
|
continue
|
|
from .transparency import leaf_bytes_for_attestation
|
|
|
|
ok_incl = verify_inclusion(
|
|
leaf_bytes_for_attestation(attestation),
|
|
int(receipt.get("leaf_index", -1)),
|
|
int(receipt.get("tree_size", 0)),
|
|
[bytes.fromhex(h) for h in receipt.get("inclusion_proof", [])],
|
|
bytes.fromhex(str(sth.get("root_hash", ""))),
|
|
)
|
|
if not ok_incl:
|
|
problems.append(f"{name}: inclusion proof does not bind attestation to the log")
|
|
continue
|
|
att_commit = ((attestation.get("subject") or {}).get("repo_commit") or "")
|
|
src_commit = prov.get("source_commit") or ""
|
|
src_attested = ((attestation.get("subject") or {}).get("source_commit") or "")
|
|
# The attestation subject names the verification repo commit and
|
|
# (when present) the extracted source commit; the binary must be
|
|
# compiled from an attested source commit.
|
|
if src_attested and src_commit and src_attested != src_commit:
|
|
problems.append(
|
|
f"{name}: binary compiled from {src_commit[:12]} but attestation covers "
|
|
f"{src_attested[:12]}"
|
|
)
|
|
continue
|
|
members.append(
|
|
{
|
|
"backend": name,
|
|
"component": component,
|
|
"semantics": QUORUM_BACKENDS[name]["semantics"],
|
|
"entry_point": prov.get("entry_point"),
|
|
"source_commit": src_commit,
|
|
"repo_commit": att_commit,
|
|
"binary_sha256": prov.get("binary_sha256"),
|
|
"backend_cfg": prov.get("backend_cfg"),
|
|
"risk_tier": assessment.level,
|
|
"evidence": {
|
|
"leaf_hash": receipt.get("leaf_hash"),
|
|
"leaf_index": receipt.get("leaf_index"),
|
|
"tree_size": receipt.get("tree_size"),
|
|
"inclusion_proof": receipt.get("inclusion_proof"),
|
|
"sth": sth,
|
|
},
|
|
}
|
|
)
|
|
if len(members) < min_members:
|
|
raise WalletError(
|
|
"R4 gate refused wallet creation "
|
|
f"({len(members)}/{min_members} members eligible):\n- "
|
|
+ "\n- ".join(problems or ["no eligible members"])
|
|
)
|
|
# Signing binary provenance (outbound trusted base, attested artifact)
|
|
signing_binary = locate_verifier(state_dir=None)
|
|
from .dogfood import load_provenance
|
|
|
|
signing_prov = load_provenance(signing_binary) if signing_binary else {}
|
|
capsule = {
|
|
"type": CAPSULE_TYPE,
|
|
"created_at": _now(),
|
|
"members": members,
|
|
"policy": {
|
|
"require_unanimity": True,
|
|
"min_members": min_members,
|
|
"require_tier": require_tier,
|
|
"freshness_max_age_days": freshness_max_age_days,
|
|
},
|
|
"signing": {
|
|
"backend": "verified-dalek-serial (attested artifact; trusted base)",
|
|
"binary_sha256": _sha256(Path(signing_binary).read_bytes()) if signing_binary else None,
|
|
"source_commit": signing_prov.get("source_commit"),
|
|
"coverage_note": (
|
|
"verify path certificate-covered; signing path trusted base - "
|
|
"every outbound signature passes the quorum firewall before release"
|
|
),
|
|
},
|
|
"problems_at_init": problems,
|
|
}
|
|
wallet.dir.mkdir(parents=True, exist_ok=True)
|
|
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_bytes = _canonical(capsule)
|
|
wallet.capsule_path.write_text(json.dumps(capsule, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
genesis = {
|
|
"type": LEDGER_GENESIS,
|
|
"capsule_sha256": _sha256(capsule_bytes),
|
|
}
|
|
wallet._append_ledger("genesis", genesis)
|
|
generate_ed25519_keypair(
|
|
wallet.keys_dir / f"{identity_name}.key.pem",
|
|
wallet.keys_dir / f"{identity_name}.pub.pem",
|
|
)
|
|
os.chmod(wallet.keys_dir / f"{identity_name}.key.pem", 0o600)
|
|
return wallet
|
|
|
|
# -- capsule / state -----------------------------------------------------
|
|
|
|
def capsule(self) -> dict[str, Any]:
|
|
if not self.capsule_path.exists():
|
|
raise WalletError(f"no wallet at {self.dir} (missing capsule.json)")
|
|
return json.loads(self.capsule_path.read_text(encoding="utf-8"))
|
|
|
|
def latch_state(self) -> dict[str, Any]:
|
|
if self.latch_path.exists():
|
|
return json.loads(self.latch_path.read_text(encoding="utf-8"))
|
|
return {"latched": False}
|
|
|
|
def _latch(self, reason: str, incident_ref: str | None) -> None:
|
|
state = {"latched": True, "reason": reason, "incident": incident_ref, "at": _now()}
|
|
self.latch_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
self._append_ledger("latch", state)
|
|
|
|
def unlatch(self, operator_note: str) -> None:
|
|
"""Deliberately a human act: the CLI asks for a written note, and the
|
|
note lands in the permanent ledger next to the latch it releases."""
|
|
state = {"latched": False, "operator_note": operator_note, "at": _now()}
|
|
self.latch_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
self._append_ledger("unlatch", state)
|
|
|
|
# -- ledger ---------------------------------------------------------------
|
|
|
|
def _ledger_entries(self) -> list[dict[str, Any]]:
|
|
if not self.ledger_path.exists():
|
|
return []
|
|
return [
|
|
json.loads(line)
|
|
for line in self.ledger_path.read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
|
|
@staticmethod
|
|
def _last_ledger_line(handle: Any) -> dict[str, Any] | None:
|
|
"""Read only the final line - O(1) in ledger size, not O(n).
|
|
|
|
Seeks back from EOF in one chunk (ledger lines are well under 64 KiB;
|
|
a single quarantine-size body is ~2 KiB) and parses the last
|
|
newline-terminated record."""
|
|
handle.seek(0, os.SEEK_END)
|
|
size = handle.tell()
|
|
if size == 0:
|
|
return None
|
|
back = min(size, 65536)
|
|
handle.seek(size - back)
|
|
tail = handle.read(back)
|
|
lines = [line for line in tail.splitlines() if line.strip()]
|
|
return json.loads(lines[-1]) if lines else None
|
|
|
|
def _append_ledger(self, entry_type: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
# Single-flight: the chain is read-modify-append, so two concurrent
|
|
# writers (threads, or two processes sharing a wallet dir) could both
|
|
# read the same tail, compute the same prev_hash, and fork the chain.
|
|
# The exclusive lock lives on a dedicated lock FILE (not the ledger
|
|
# fd) so it survives the rotation rename; fsync so a crash can't
|
|
# leave a torn line that verify_ledger would read as tampering. Only
|
|
# the LAST line is read per append (O(1)); when a segment reaches
|
|
# the rotation threshold it is archived and a `rotation` entry
|
|
# carries the chain across files, keeping history verifiable end to
|
|
# end.
|
|
lock_path = self.dir / "ledger.lock"
|
|
with lock_path.open("w") as lock:
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
self.ledger_path.touch(exist_ok=True)
|
|
with self.ledger_path.open("rb") as handle:
|
|
last = self._last_ledger_line(handle)
|
|
prev_hash = last["entry_hash"] if last else "0" * 64
|
|
next_index = (last["index"] + 1) if last else 0
|
|
rotate_at = int((self.policy().get("ledger") or {}).get("rotate_at", 100_000))
|
|
if last is not None and next_index % rotate_at == 0 and last.get("entry_type") != "rotation":
|
|
archive = self.dir / f"ledger-{next_index:08d}-{prev_hash[:12]}.jsonl"
|
|
self.ledger_path.rename(archive)
|
|
rotation = {
|
|
"index": next_index,
|
|
"timestamp": _now(),
|
|
"entry_type": "rotation",
|
|
"body": {
|
|
"archived_file": archive.name,
|
|
"archived_head": prev_hash,
|
|
"archived_through_index": next_index - 1,
|
|
},
|
|
"prev_hash": prev_hash,
|
|
}
|
|
rotation["entry_hash"] = _sha256(_canonical(rotation))
|
|
with self.ledger_path.open("w", encoding="utf-8") as fresh:
|
|
fresh.write(json.dumps(rotation, sort_keys=True) + "\n")
|
|
fresh.flush()
|
|
os.fsync(fresh.fileno())
|
|
prev_hash = rotation["entry_hash"]
|
|
next_index += 1
|
|
with self.ledger_path.open("ab") as handle:
|
|
return self._write_entry(handle, entry_type, body, prev_hash, next_index)
|
|
|
|
@staticmethod
|
|
def _write_entry(handle: Any, entry_type: str, body: dict[str, Any], prev_hash: str, index: int) -> dict[str, Any]:
|
|
entry = {
|
|
"index": index,
|
|
"timestamp": _now(),
|
|
"entry_type": entry_type,
|
|
"body": body,
|
|
"prev_hash": prev_hash,
|
|
}
|
|
entry["entry_hash"] = _sha256(_canonical(entry))
|
|
handle.seek(0, os.SEEK_END)
|
|
handle.write((json.dumps(entry, sort_keys=True) + "\n").encode("utf-8"))
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
return entry
|
|
|
|
def verify_ledger(self) -> tuple[bool, list[str]]:
|
|
"""Re-check the full chain, walking archived segments through their
|
|
`rotation` links, oldest first back to genesis."""
|
|
problems: list[str] = []
|
|
segments: list[list[dict[str, Any]]] = []
|
|
entries = self._ledger_entries()
|
|
while True:
|
|
segments.insert(0, entries)
|
|
first = entries[0] if entries else None
|
|
if not first or first.get("entry_type") != "rotation":
|
|
break
|
|
archive = self.dir / str((first.get("body") or {}).get("archived_file", ""))
|
|
if not archive.is_file():
|
|
problems.append(f"rotation at index {first.get('index')}: archive {archive.name} missing")
|
|
break
|
|
entries = [
|
|
json.loads(line)
|
|
for line in archive.read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
prev = "0" * 64
|
|
for entry in (e for segment in segments for e in segment):
|
|
claimed = entry.get("entry_hash")
|
|
body = {k: v for k, v in entry.items() if k != "entry_hash"}
|
|
if entry.get("prev_hash") != prev:
|
|
problems.append(f"entry {entry.get('index')}: chain break")
|
|
if _sha256(_canonical(body)) != claimed:
|
|
problems.append(f"entry {entry.get('index')}: hash mismatch")
|
|
prev = claimed or prev
|
|
return (not problems), problems
|
|
|
|
def ledger_head(self) -> str:
|
|
if not self.ledger_path.exists():
|
|
return "0" * 64
|
|
with self.ledger_path.open("rb") as handle:
|
|
last = self._last_ledger_line(handle)
|
|
return last["entry_hash"] if last else "0" * 64
|
|
|
|
# -- quorum assembly -------------------------------------------------------
|
|
|
|
def quorum(self, state_dir: str | Path | None = None) -> QuorumVerifier:
|
|
# Memoized per state_dir: assembling the quorum re-reads and SHA-256s
|
|
# every member binary (a swap-detection control). Doing that on every
|
|
# verify would hash ~4 binaries per operation; instead we pin once at
|
|
# first assembly and hold the verifier. A binary swapped mid-process
|
|
# is therefore caught at the next assembly (restart / re-init), which
|
|
# is the right granularity: an attacker who can rewrite the on-disk
|
|
# binary already outranks this check, and re-hashing per call buys
|
|
# nothing against them while taxing every honest verification.
|
|
state_dir = state_dir if state_dir is not None else self.state_dir
|
|
cache_key = str(state_dir) if state_dir is not None else "__default__"
|
|
cached = self._quorum_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
capsule = self.capsule()
|
|
members: dict[str, Path] = {}
|
|
for member in capsule["members"]:
|
|
name = member["backend"]
|
|
binary = quorum_binary_path(name, state_dir)
|
|
if not binary.exists():
|
|
raise WalletError(f"quorum member binary missing: {binary}")
|
|
actual = _sha256(binary.read_bytes())
|
|
if member.get("binary_sha256") and actual != member["binary_sha256"]:
|
|
raise WalletError(
|
|
f"quorum member {name} binary hash changed since the capsule was sealed "
|
|
f"({actual[:12]} != {member['binary_sha256'][:12]}); rebuild or re-init"
|
|
)
|
|
members[name] = binary
|
|
verifier = QuorumVerifier(members, min_members=int(capsule["policy"]["min_members"]))
|
|
self._quorum_cache[cache_key] = verifier
|
|
return verifier
|
|
|
|
# -- inbound ---------------------------------------------------------------
|
|
|
|
def verify_inbound(
|
|
self,
|
|
payload: bytes,
|
|
signature: bytes,
|
|
public_key: bytes,
|
|
context: str = "",
|
|
state_dir: str | Path | None = None,
|
|
) -> QuorumResult:
|
|
result = self.quorum(state_dir).verify(payload, signature, public_key)
|
|
record = {
|
|
"context": context,
|
|
"payload_sha256": _sha256(payload),
|
|
"public_key_hex": public_key.hex(),
|
|
"signature_hex": signature.hex(),
|
|
"result": result.to_dict(),
|
|
}
|
|
self._append_ledger("inbound-verify", record)
|
|
if result.incident:
|
|
ref = self._write_incident(result.incident)
|
|
if result.incident.get("severity") == "tamper":
|
|
self._latch("unexplained quorum divergence on inbound verification", ref)
|
|
return result
|
|
|
|
def _write_incident(self, incident: dict[str, Any]) -> str:
|
|
existing = sorted(self.incidents_dir.glob("*.json"))
|
|
ref = f"{len(existing):04d}"
|
|
body = dict(incident)
|
|
body["incident_ref"] = ref
|
|
body["recorded_at"] = _now()
|
|
(self.incidents_dir / f"{ref}.json").write_text(
|
|
json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
self._append_ledger("incident", {"incident_ref": ref, "severity": incident.get("severity")})
|
|
return ref
|
|
|
|
# -- refusal receipts --------------------------------------------------------
|
|
|
|
def _refuse(
|
|
self,
|
|
code: str,
|
|
reason: str,
|
|
missing: list[str],
|
|
remediation: str,
|
|
request: dict[str, Any] | None = None,
|
|
sign: bool = True,
|
|
) -> Refusal:
|
|
assert code in REFUSAL_CODES, code
|
|
receipt = {
|
|
"type": "pacta.wallet.refusal_receipt.v1",
|
|
"code": code,
|
|
"reason": reason,
|
|
"missing": missing,
|
|
"remediation": remediation,
|
|
"request_sha256": _sha256(_canonical(request or {})),
|
|
"ledger_head": self.ledger_head(),
|
|
"issued_at": _now(),
|
|
}
|
|
latched = self.latch_state().get("latched", False)
|
|
if sign and not latched:
|
|
try:
|
|
signature, identity = self._sign_as_wallet(_canonical(receipt))
|
|
receipt["signature"] = {
|
|
"identity": identity,
|
|
"signature_hex": signature.hex(),
|
|
"scheme": "ed25519-dogfood",
|
|
}
|
|
except Exception as exc: # noqa: BLE001 - refusals must never fail to issue
|
|
receipt["signature"] = {"status": "unavailable", "detail": str(exc)}
|
|
else:
|
|
receipt["signature"] = {
|
|
"status": "unsigned",
|
|
"detail": (
|
|
"custody latch engaged - a wallet that no longer trusts its own "
|
|
"boundary does not certify its refusals" if latched else "signing skipped"
|
|
),
|
|
}
|
|
existing = sorted(self.receipts_dir.glob("*.json"))
|
|
ref = f"{len(existing):04d}"
|
|
path = self.receipts_dir / f"{ref}.json"
|
|
path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
self._append_ledger("refusal", {"receipt_ref": ref, "code": code, "reason": reason})
|
|
return Refusal(code, reason, missing, remediation, path, receipt)
|
|
|
|
def _sign_as_wallet(self, payload: bytes) -> tuple[bytes, str]:
|
|
keys = sorted(self.keys_dir.glob("*.key.pem"))
|
|
if not keys:
|
|
raise WalletError("wallet has no identity key")
|
|
key = keys[0]
|
|
identity = key.name.removesuffix(".key.pem")
|
|
signer = LocalDogfoodSigner()
|
|
signature = signer.sign(payload, key)
|
|
return signature, identity
|
|
|
|
# -- outbound: intent → sign → firewall → release ------------------------------
|
|
|
|
def request_signature(
|
|
self,
|
|
intent: dict[str, Any],
|
|
payload: bytes,
|
|
signer: Any = None,
|
|
key_name: str = "warden",
|
|
request_id: str | None = None,
|
|
state_dir: str | Path | None = None,
|
|
) -> dict[str, Any] | Refusal:
|
|
"""The outbound path. Returns a release dict on success, or a Refusal.
|
|
|
|
Gate order is deliberate and each gate is its own method: latch,
|
|
evidence freshness, intent binding, spending policy, signer
|
|
resolution, signing, then the firewall - the quorum verifies the
|
|
fresh signature and only unanimity releases it. A signature that
|
|
fails the firewall is quarantined and never returned to the caller;
|
|
that is the verify-after-sign fault-injection countermeasure,
|
|
custody-grade.
|
|
"""
|
|
request = {"intent": intent, "payload_sha256": _sha256(payload), "key_name": key_name}
|
|
refusal = self._gate_latch(request)
|
|
if refusal is not None:
|
|
return refusal
|
|
refusal = self._check_freshness(self.capsule())
|
|
if refusal is not None:
|
|
return refusal
|
|
refusal = self._gate_intent(intent, payload, request)
|
|
if refusal is not None:
|
|
return refusal
|
|
refusal = self._gate_policy(intent, key_name, request)
|
|
if refusal is not None:
|
|
return refusal
|
|
resolved = self._resolve_signer(signer, key_name, request)
|
|
if isinstance(resolved, Refusal):
|
|
return resolved
|
|
signer, key, pub = resolved
|
|
signature = self._obtain_signature(signer, payload, key, intent, request_id, request)
|
|
if isinstance(signature, Refusal):
|
|
return signature
|
|
return self._run_firewall(
|
|
payload, signature, pub, intent, request, state_dir,
|
|
key_name=key_name, signer_name=getattr(signer, "name", "unknown"),
|
|
)
|
|
|
|
# -- outbound gates, one method each ---------------------------------------
|
|
|
|
def _gate_latch(self, request: dict[str, Any]) -> Refusal | None:
|
|
latch = self.latch_state()
|
|
if not latch.get("latched"):
|
|
return None
|
|
return self._refuse(
|
|
"CUSTODY_LATCHED",
|
|
f"custody latch engaged: {latch.get('reason')}",
|
|
[f"operator unlatch with written note (incident {latch.get('incident')})"],
|
|
"resolve the incident, then `pacta wallet unlatch --note <why>`",
|
|
request,
|
|
)
|
|
|
|
def _gate_intent(self, intent: dict[str, Any], payload: bytes, request: dict[str, Any]) -> Refusal | None:
|
|
problem = self._validate_intent(intent, payload)
|
|
if problem is None:
|
|
return None
|
|
return self._refuse(
|
|
"MALFORMED_INTENT",
|
|
problem,
|
|
["intent.purpose (non-empty string)", "intent.payload_sha256 matching the payload"],
|
|
"resend with a well-formed intent envelope; see WALLET.md#intent",
|
|
request,
|
|
)
|
|
|
|
def _gate_policy(self, intent: dict[str, Any], key_name: str, request: dict[str, Any]) -> Refusal | None:
|
|
"""The lightweight spending-policy engine (POLICY_DENIED consequences).
|
|
|
|
Rules live in ``policy.json`` in the wallet directory - the rules you
|
|
would give a teenager with a debit card: per-request and per-day
|
|
amount ceilings and counterparty allow/deny lists, with per-identity
|
|
overrides. No policy file means no restrictions (and the posture
|
|
reports as much). Amount rules bind on ``intent.amount``; list rules
|
|
bind on ``intent.counterparty`` - policy makes those intent fields
|
|
mandatory, so a request that omits them is refused, not waved past.
|
|
"""
|
|
rules = self._policy_rules(key_name)
|
|
if not rules:
|
|
return None
|
|
|
|
def deny(reason: str, missing: list[str], remediation: str) -> Refusal:
|
|
return self._refuse("POLICY_DENIED", reason, missing, remediation, request)
|
|
|
|
allow = rules.get("counterparty_allowlist")
|
|
denylist = rules.get("counterparty_denylist") or []
|
|
counterparty = intent.get("counterparty")
|
|
if (allow is not None or denylist) and not isinstance(counterparty, str):
|
|
return deny(
|
|
"policy has counterparty rules but the intent names no counterparty",
|
|
["intent.counterparty"],
|
|
"resend with intent.counterparty set",
|
|
)
|
|
if denylist and counterparty in denylist:
|
|
return deny(
|
|
f"counterparty {counterparty!r} is on the denylist",
|
|
[],
|
|
"this counterparty is blocked by wallet policy; change the policy deliberately if wrong",
|
|
)
|
|
if allow is not None and counterparty not in allow:
|
|
return deny(
|
|
f"counterparty {counterparty!r} is not on the allowlist",
|
|
[],
|
|
"add the counterparty to policy.json outbound.counterparty_allowlist if intended",
|
|
)
|
|
max_request = rules.get("max_amount_per_request")
|
|
max_day = rules.get("max_amount_per_day")
|
|
if max_request is not None or max_day is not None:
|
|
amount = intent.get("amount")
|
|
if not isinstance(amount, (int, float)) or amount <= 0:
|
|
return deny(
|
|
"policy has amount ceilings but the intent carries no positive intent.amount",
|
|
["intent.amount (positive number)"],
|
|
"resend with intent.amount set; amounts are policy units, recorded in the ledger",
|
|
)
|
|
if max_request is not None and amount > float(max_request):
|
|
return deny(
|
|
f"amount {amount} exceeds the per-request ceiling {max_request}",
|
|
[],
|
|
"split the request or raise the ceiling deliberately in policy.json",
|
|
)
|
|
if max_day is not None:
|
|
spent = self._spent_last_24h(key_name)
|
|
if spent + amount > float(max_day):
|
|
return deny(
|
|
f"amount {amount} would exceed the daily ceiling {max_day} "
|
|
f"(already released in the last 24h: {spent})",
|
|
[],
|
|
"wait for the window to roll, or raise the ceiling deliberately in policy.json",
|
|
)
|
|
return None
|
|
|
|
def policy(self) -> dict[str, Any]:
|
|
path = self.dir / "policy.json"
|
|
if not path.exists():
|
|
return {}
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
def _policy_rules(self, key_name: str) -> dict[str, Any]:
|
|
policy = self.policy()
|
|
rules = dict(policy.get("outbound") or {})
|
|
rules.update((policy.get("identities") or {}).get(key_name) or {})
|
|
return rules
|
|
|
|
def _spent_last_24h(self, key_name: str) -> float:
|
|
cutoff = datetime.now(timezone.utc).timestamp() - 86400
|
|
spent = 0.0
|
|
for entry in self._ledger_entries():
|
|
if entry.get("entry_type") != "outbound-sign":
|
|
continue
|
|
body = entry.get("body") or {}
|
|
if body.get("identity") != key_name:
|
|
continue
|
|
try:
|
|
stamp = datetime.fromisoformat(str(entry.get("timestamp", "")).replace("Z", "+00:00")).timestamp()
|
|
except ValueError:
|
|
continue
|
|
if stamp < cutoff:
|
|
continue
|
|
amount = (body.get("intent") or {}).get("amount")
|
|
if isinstance(amount, (int, float)):
|
|
spent += float(amount)
|
|
return spent
|
|
|
|
def _resolve_signer(
|
|
self, signer: Any, key_name: str, request: dict[str, Any]
|
|
) -> tuple[Any, Path, Path] | Refusal:
|
|
key = self.keys_dir / f"{key_name}.key.pem"
|
|
pub = self.keys_dir / f"{key_name}.pub.pem"
|
|
if signer is None:
|
|
signer = LocalDogfoodSigner()
|
|
if not pub.exists():
|
|
return self._refuse(
|
|
"SIGNER_UNAVAILABLE",
|
|
f"no identity {key_name} in this wallet",
|
|
[f"keys/{key_name}.pub.pem"],
|
|
"create the identity or name an existing one",
|
|
request,
|
|
)
|
|
if isinstance(signer, LocalDogfoodSigner) and not key.exists():
|
|
return self._refuse(
|
|
"SIGNER_UNAVAILABLE",
|
|
f"identity {key_name} has no local private key (airgap-only identity?)",
|
|
[f"keys/{key_name}.key.pem"],
|
|
"use the airgap signer for this identity",
|
|
request,
|
|
)
|
|
return signer, key, pub
|
|
|
|
def _obtain_signature(
|
|
self,
|
|
signer: Any,
|
|
payload: bytes,
|
|
key: Path,
|
|
intent: dict[str, Any],
|
|
request_id: str | None,
|
|
request: dict[str, Any],
|
|
) -> bytes | Refusal:
|
|
try:
|
|
if isinstance(signer, AirgapSigner):
|
|
return signer.sign(payload, key, request_id=request_id, intent=intent)
|
|
return signer.sign(payload, key)
|
|
except PendingAirgap as pending:
|
|
return self._refuse(
|
|
"PENDING_AIRGAP",
|
|
f"request {pending.request_id} parked in the airgap outbox",
|
|
[f"airgap/inbox/{pending.request_id}.response.json"],
|
|
"sign on the device, drop the response file, resend with the same request_id",
|
|
request,
|
|
sign=False,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return self._refuse(
|
|
"SIGNER_UNAVAILABLE", f"signer failed: {exc}", [], "check the signer backend", request
|
|
)
|
|
|
|
def _run_firewall(
|
|
self,
|
|
payload: bytes,
|
|
signature: bytes,
|
|
pub: Path,
|
|
intent: dict[str, Any],
|
|
request: dict[str, Any],
|
|
state_dir: str | Path | None,
|
|
key_name: str,
|
|
signer_name: str,
|
|
) -> dict[str, Any] | Refusal:
|
|
"""The fresh signature faces the full quorum; only unanimity releases."""
|
|
public_key = pem_public_key_to_raw(pub)
|
|
result = self.quorum(state_dir).verify(payload, signature, public_key)
|
|
if not result.accepted:
|
|
quarantine_ref = self._quarantine(payload, signature, public_key, intent, result)
|
|
incident_ref = self._write_incident(result.incident) if result.incident else None
|
|
self._latch(
|
|
"outbound firewall rejected a signature this wallet just produced "
|
|
f"(quarantine {quarantine_ref})",
|
|
incident_ref,
|
|
)
|
|
return self._refuse(
|
|
"FIREWALL_QUARANTINE",
|
|
"the quorum refused a signature produced by this wallet's own signer - "
|
|
f"classification {result.classification}; the signature was quarantined, "
|
|
"never released, and custody is latched",
|
|
[],
|
|
"treat as a fault/tamper event; inspect quarantine and incidents, then unlatch deliberately",
|
|
request,
|
|
sign=False,
|
|
)
|
|
release = {
|
|
"type": "pacta.wallet.release.v1",
|
|
"signature_hex": signature.hex(),
|
|
"public_key_hex": public_key.hex(),
|
|
"identity": key_name,
|
|
"signer": signer_name,
|
|
"intent": intent,
|
|
"payload_sha256": _sha256(payload),
|
|
"firewall": result.to_dict(),
|
|
"issued_at": _now(),
|
|
}
|
|
self._append_ledger("outbound-sign", release)
|
|
return release
|
|
|
|
def _check_freshness(self, capsule: dict[str, Any]) -> Refusal | None:
|
|
max_age_days = int(capsule["policy"].get("freshness_max_age_days", 0) or 0)
|
|
if max_age_days <= 0:
|
|
return None
|
|
newest: dict[str, Any] | None = None
|
|
for member in capsule["members"]:
|
|
sth = (member.get("evidence") or {}).get("sth") or {}
|
|
if not newest or str(sth.get("timestamp", "")) > str(newest.get("timestamp", "")):
|
|
newest = sth
|
|
if newest is None:
|
|
return None
|
|
fresh, why = check_sth_freshness(newest, max_age_days * 86400)
|
|
if fresh:
|
|
return None
|
|
return self._refuse(
|
|
"EVIDENCE_STALE",
|
|
f"the wallet's newest pinned tree head fails the freshness policy: {why}",
|
|
["a fresh signed tree head from the transparency log"],
|
|
"run `pacta sth-refresh` against the log, then re-init or update the capsule; "
|
|
"a wallet should not outlive its evidence",
|
|
None,
|
|
)
|
|
|
|
@staticmethod
|
|
def _validate_intent(intent: dict[str, Any], payload: bytes) -> str | None:
|
|
if not isinstance(intent, dict):
|
|
return "intent must be an object"
|
|
purpose = intent.get("purpose")
|
|
if not isinstance(purpose, str) or not purpose.strip():
|
|
return "intent.purpose is required (non-empty string): the ledger records why, not just what"
|
|
digest = intent.get("payload_sha256")
|
|
if digest != _sha256(payload):
|
|
return "intent.payload_sha256 must match the payload (binds the intent to these exact bytes)"
|
|
return None
|
|
|
|
def _quarantine(
|
|
self,
|
|
payload: bytes,
|
|
signature: bytes,
|
|
public_key: bytes,
|
|
intent: dict[str, Any],
|
|
result: QuorumResult,
|
|
) -> str:
|
|
existing = sorted(self.quarantine_dir.glob("*.json"))
|
|
ref = f"{len(existing):04d}"
|
|
(self.quarantine_dir / f"{ref}.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"type": "pacta.wallet.quarantined_signature.v1",
|
|
"quarantine_ref": ref,
|
|
"payload_sha256": _sha256(payload),
|
|
"signature_hex": signature.hex(),
|
|
"public_key_hex": public_key.hex(),
|
|
"intent": intent,
|
|
"firewall": result.to_dict(),
|
|
"recorded_at": _now(),
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return ref
|
|
|
|
# -- posture -------------------------------------------------------------
|
|
|
|
def posture(self) -> dict[str, Any]:
|
|
capsule = self.capsule()
|
|
ok_ledger, ledger_problems = self.verify_ledger()
|
|
incidents = sorted(self.incidents_dir.glob("*.json"))
|
|
receipts = sorted(self.receipts_dir.glob("*.json"))
|
|
return {
|
|
"type": "pacta.wallet.posture.v1",
|
|
"capsule_sha256": _sha256(_canonical(capsule)),
|
|
"members": [
|
|
{
|
|
"backend": m["backend"],
|
|
"component": m["component"],
|
|
"risk_tier": m["risk_tier"],
|
|
"source_commit": m["source_commit"],
|
|
"binary_sha256": m["binary_sha256"],
|
|
}
|
|
for m in capsule["members"]
|
|
],
|
|
"policy": capsule["policy"],
|
|
"spending_policy": self.policy() or {"note": "no policy.json - outbound is unrestricted"},
|
|
"latch": self.latch_state(),
|
|
"ledger": {
|
|
"entries": len(self._ledger_entries()),
|
|
"head": self.ledger_head(),
|
|
"chain_ok": ok_ledger,
|
|
"problems": ledger_problems,
|
|
},
|
|
"incidents": len(incidents),
|
|
"refusal_receipts": len(receipts),
|
|
"generated_at": _now(),
|
|
}
|