mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
warden wallet core: R4-gated capsule, hash-chained ledger, quorum inbound, firewalled outbound
- Wallet.init is the R4 gate in executable form: per member, the built binary's source commit must match an attestation whose verdicts are re-derived locally, scored >= R4, and Merkle-bound to a verified STH under the log key (explicit --trusted-provider required, never inferred) - append-only hash-chained ledger; latch semantics (tamper => custody frozen; unlatch is a deliberate operator act recorded with a note) - outbound: intent envelope bound to payload bytes -> signer (local dogfood or airgap/Precursor-style outbox) -> quorum firewall; a rejected self-signature is quarantined, never released, and latches - refusal receipts: signed machine-actionable artifacts (code, missing, remediation); unsigned-by-design while latched - live end-to-end against ltl.zkdefi.org: 4 members R4 (leaves 4-7), release firewall unanimous-accept, inbound unanimous-accept, refusal signed, ledger chain verified Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bbc99a9127
commit
97ac5594fe
1 changed files with 812 additions and 0 deletions
812
src/pacta/wallet.py
Normal file
812
src/pacta/wallet.py
Normal file
|
|
@ -0,0 +1,812 @@
|
||||||
|
"""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 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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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) -> None:
|
||||||
|
self.dir = Path(wallet_dir)
|
||||||
|
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"
|
||||||
|
|
||||||
|
# -- 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()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _append_ledger(self, entry_type: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
entries = self._ledger_entries()
|
||||||
|
prev_hash = entries[-1]["entry_hash"] if entries else "0" * 64
|
||||||
|
entry = {
|
||||||
|
"index": len(entries),
|
||||||
|
"timestamp": _now(),
|
||||||
|
"entry_type": entry_type,
|
||||||
|
"body": body,
|
||||||
|
"prev_hash": prev_hash,
|
||||||
|
}
|
||||||
|
entry["entry_hash"] = _sha256(_canonical(entry))
|
||||||
|
with self.ledger_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def verify_ledger(self) -> tuple[bool, list[str]]:
|
||||||
|
problems: list[str] = []
|
||||||
|
prev = "0" * 64
|
||||||
|
for entry in self._ledger_entries():
|
||||||
|
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:
|
||||||
|
entries = self._ledger_entries()
|
||||||
|
return entries[-1]["entry_hash"] if entries else "0" * 64
|
||||||
|
|
||||||
|
# -- quorum assembly -------------------------------------------------------
|
||||||
|
|
||||||
|
def quorum(self, state_dir: str | Path | None = None) -> QuorumVerifier:
|
||||||
|
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
|
||||||
|
return QuorumVerifier(members, min_members=int(capsule["policy"]["min_members"]))
|
||||||
|
|
||||||
|
# -- 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.
|
||||||
|
|
||||||
|
Order matters and is deliberate: latch, policy freshness, intent
|
||||||
|
binding, signer, 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}
|
||||||
|
latch = self.latch_state()
|
||||||
|
if latch.get("latched"):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
capsule = self.capsule()
|
||||||
|
freshness = self._check_freshness(capsule)
|
||||||
|
if freshness is not None:
|
||||||
|
return freshness if isinstance(freshness, Refusal) else freshness
|
||||||
|
problem = self._validate_intent(intent, payload)
|
||||||
|
if problem:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if isinstance(signer, AirgapSigner):
|
||||||
|
signature = signer.sign(payload, key, request_id=request_id, intent=intent)
|
||||||
|
else:
|
||||||
|
signature = 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
|
||||||
|
)
|
||||||
|
# THE FIREWALL: the fresh signature faces the full quorum.
|
||||||
|
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)
|
||||||
|
if result.incident:
|
||||||
|
incident_ref = self._write_incident(result.incident)
|
||||||
|
else:
|
||||||
|
incident_ref = 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": getattr(signer, "name", "unknown"),
|
||||||
|
"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"],
|
||||||
|
"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(),
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue