From f2d7dc319d6f959ae7d4b599cfa8a103e8d7f6a9 Mon Sep 17 00:00:00 2001 From: desiorac Date: Thu, 24 Sep 2026 11:43:12 +0200 Subject: [PATCH 1/4] =?UTF-8?q?P5a=20:=20tl-signer,=20mode=20signeur,=20hi?= =?UTF-8?q?storique=20des=20cl=C3=A9s=20(1.12.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signer/tl_signer.py : service à part qui détient les clés et signe par un socket Unix. Opérations fermées (chain hash, réputation, JWS construit par le signeur, artefact Rekor), entrées bornées, aucune opération ne rend une clé privée. - trust_layer/signing.py : couture unique vers les clés. Mode hérité (.pem) par défaut ; TL_SIGNER_SOCKET allume le mode signeur, qui ne lit ni ne crée aucun fichier de clé et refuse de démarrer si sa clé n'est pas dans l'historique. - trust_layer/published_keys.json : historique publié (key-1, rekor-1). /v1/pubkey ajoute kid, rekor_kid, keys, rekor_keys ; did.json liste toutes les clés, celle du nœud en tête. - Preuves : arkforge_kid ; attestations et réputation : signature_kid. - verify_proof.py : clé choisie par kid (ou par la clé portée pour les anciennes preuves), refus d'une clé retirée avant la date de la preuve ; attribution Rekor sur tout l'historique. Testé sur une preuve de juillet et sur la preuve du gel PROVE IT du 23/09. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++ scripts/verify_proof.py | 79 +++++-- signer/__init__.py | 0 signer/tl_signer.py | 193 ++++++++++++++++++ tests/conftest.py | 2 - .../fixtures/verify_proof/proof_2026_07.json | 1 + .../verify_proof/proof_proveit_gel.json | 1 + tests/test_credit_alerts.py | 3 +- tests/test_signer.py | 116 +++++++++++ tests/test_signer_mode.py | 101 +++++++++ tests/test_verdict.py | 7 +- tests/test_verify_proof_keys.py | 155 ++++++++++++++ trust_layer/__init__.py | 2 +- trust_layer/app.py | 79 ++++--- trust_layer/attestation.py | 9 +- trust_layer/config.py | 53 +++-- trust_layer/crypto.py | 4 + trust_layer/ctef.py | 16 +- trust_layer/demo.py | 13 +- trust_layer/proxy.py | 14 +- trust_layer/published_keys.json | 23 +++ trust_layer/rekor.py | 34 +-- trust_layer/reputation.py | 9 +- trust_layer/routers/verdict.py | 10 +- trust_layer/signing.py | 146 +++++++++++++ 25 files changed, 967 insertions(+), 123 deletions(-) create mode 100644 signer/__init__.py create mode 100644 signer/tl_signer.py create mode 100644 tests/fixtures/verify_proof/proof_2026_07.json create mode 100644 tests/fixtures/verify_proof/proof_proveit_gel.json create mode 100644 tests/test_signer.py create mode 100644 tests/test_signer_mode.py create mode 100644 tests/test_verify_proof_keys.py create mode 100644 trust_layer/published_keys.json create mode 100644 trust_layer/signing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d2954..f6c0834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ Versions follow [Semantic Versioning](https://semver.org/). --- +## [1.12.0] — 2026-09-24 + +### Added +- `tl-signer` (`signer/tl_signer.py`): a separate service that holds the private keys and signs over a Unix + socket. Closed operations only (chain hash, reputation statement, JWS built by the signer, Rekor artifact), + bounded inputs, no operation returns key material. Keys are born in the signer, one per node. +- Signer mode: with `TL_SIGNER_SOCKET` set, the Trust Layer reads and creates no key file, takes its public keys + from the socket at startup, and refuses to start if its key is missing from the published history. Unset, the + legacy `.pem` keys are used as before. +- Key history (`trust_layer/published_keys.json`): `/v1/pubkey` adds `kid`, `rekor_kid`, `keys` and `rekor_keys`; + `/.well-known/did.json` lists every key, the node key first, and only keys not retired may assert. +- Proofs carry `arkforge_kid`, attestations and reputation scores `signature_kid`. The kid is added after the + chain hash, like `arkforge_pubkey`. +- `scripts/verify_proof.py` picks the key a proof names (or, for older proofs, the key it carries) from the + history, and refuses a key retired before the proof's date. Rekor entries are attributed to any published + Rekor key of the history. + +### Changed +- CTEF verdicts: the JWS header (`alg`, `kid`) is set by the signer, no longer a hardcoded `#key-1`. + ## [1.11.4] — 2026-09-21 ### Fixed diff --git a/scripts/verify_proof.py b/scripts/verify_proof.py index 087fbb1..f90e38e 100755 --- a/scripts/verify_proof.py +++ b/scripts/verify_proof.py @@ -16,7 +16,8 @@ internal consistency. On its own it is NOT evidence: whoever fabricates a proof produces coherent hashes. 2. Ed25519 signature — ArkForge's own signature over the chain hash, checked - against the key published at /.well-known/did.json. + against ArkForge's published key history (/v1/pubkey): + the key the proof names, not retired at the proof's date. Proves ArkForge issued it. Still not independent. 3. Batch anchor — the chain hash is a leaf of the batch Merkle tree whose root was anchored. Self-consistency again, but it is what @@ -57,6 +58,7 @@ import sys import tempfile import urllib.request +from datetime import datetime from pathlib import Path # Overridable so the procedure can be run verbatim against another instance — @@ -410,25 +412,64 @@ def _b64url_decode(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) +def _utc(ts): + """Seconds precision is enough to order a proof and a key retirement (UTC).""" + return datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S") + + +def published_ed25519_keys(): + """ArkForge's key history (/v1/pubkey `keys`). A server that predates key + rotation publishes a single key, the first verification method of did.json.""" + pub = json.loads(fetch(f"{TRUST_LAYER_BASE}/v1/pubkey")) + if pub.get("keys"): + return pub["keys"] + did = json.loads(fetch(f"{TRUST_LAYER_BASE}/.well-known/did.json")) + x = did["verificationMethod"][0]["publicKeyJwk"]["x"] + return [{"kid": "key-1", "public": "ed25519:" + x, "retired_at": None}] + + +def _published_key_for(proof, keys): + """(public key, None) for the proof, or (None, reason). The proof names its key + by `arkforge_kid`; proofs issued before rotation carry only `arkforge_pubkey`. + A key is valid for proofs dated before its retirement, never after.""" + kid = proof.get("arkforge_kid") + embedded = proof.get("arkforge_pubkey") or "" + if kid: + entry = next((k for k in keys if k.get("kid") == kid), None) + if entry is None: + return None, f"key {kid} named by the proof is not published" + else: + entry = next((k for k in keys if k.get("public") == embedded), None) + if entry is None: + return None, "key in proof does not match any key published (/v1/pubkey, did.json)" + if embedded and embedded != entry.get("public"): + return None, f"key in proof is not the key published as {entry.get('kid')}" + retired = entry.get("retired_at") + if retired: + ts = proof.get("timestamp") + if not ts or _utc(ts) >= _utc(retired): + return None, f"key {entry.get('kid')} was retired at {retired}, proof dated {ts}" + return entry["public"], None + + def check_ed25519(proof, chain_hex, rep, offline): sig_str = proof.get("arkforge_signature") if not sig_str: rep.add("Ed25519 (ArkForge)", SKIP, "proof carries no signature") return - published = None - if not offline: + if offline: + pub = proof.get("arkforge_pubkey") or "" + else: try: - did = json.loads(fetch(f"{TRUST_LAYER_BASE}/.well-known/did.json")) - published = did["verificationMethod"][0]["publicKeyJwk"]["x"] + keys = published_ed25519_keys() except Exception as e: - rep.add("Ed25519 (ArkForge)", FAIL, f"cannot fetch published key: {e}") + rep.add("Ed25519 (ArkForge)", FAIL, f"cannot fetch published keys: {e}") return - embedded = (proof.get("arkforge_pubkey") or "").replace("ed25519:", "") - if published and embedded and published != embedded: - rep.add("Ed25519 (ArkForge)", FAIL, - "key in proof does not match the key published at did.json") - return - pub_b64 = published or embedded + pub, reason = _published_key_for(proof, keys) + if reason: + rep.add("Ed25519 (ArkForge)", FAIL, reason) + return + pub_b64 = pub.replace("ed25519:", "") if not pub_b64: rep.add("Ed25519 (ArkForge)", SKIP, "no public key available") return @@ -712,13 +753,19 @@ def _check_rekor(proof, chain_hex, rep, offline): rep.add("Sigstore Rekor", FAIL, "entry signature does not verify") return - # 4c. Attribution: is the submitting key the one ArkForge publishes? + # 4c. Attribution: is the submitting key one ArkForge publishes? Any Rekor key of + # the history counts, provided it was not retired before the proof's date. attributed = None try: published = json.loads(fetch(f"{TRUST_LAYER_BASE}/v1/pubkey")) - pub_rekor = published.get("rekor_pubkey") - if pub_rekor: - attributed = _normalise_pem(pub_rekor) == _normalise_pem(submitter_pem.decode()) + ts = proof.get("timestamp") + candidates = [published.get("rekor_pubkey")] + [ + k.get("public_pem") for k in published.get("rekor_keys") or [] + if not k.get("retired_at") or (ts and _utc(ts) < _utc(k["retired_at"])) + ] + candidates = [_normalise_pem(c) for c in candidates if c] + if candidates: + attributed = _normalise_pem(submitter_pem.decode()) in candidates except Exception: attributed = None diff --git a/signer/__init__.py b/signer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/signer/tl_signer.py b/signer/tl_signer.py new file mode 100644 index 0000000..7c9b1fd --- /dev/null +++ b/signer/tl_signer.py @@ -0,0 +1,193 @@ +"""tl-signer: holds the Trust Layer private keys and signs through a Unix socket. + +Runs as its own user, with no network, from a root-owned file (arkforge-infra, role +tl_signer). The Trust Layer reaches it through /run/tl-signer/sign.sock; whoever can +open the socket can sign, nobody can read a key. Keys are born here on first start, +one per node, and never leave the state directory: a lost key is replaced by a new +one, published next to the old ones (proof-spec, key history). + +Protocol: one JSON object per line in, one JSON object per line out. Closed set of +operations, each with a bounded, validated input. No operation returns private key +material. + +Depends on the standard library and `cryptography` only (python3-cryptography from +the distribution), so the host does not need a venv. +""" + +import base64 +import json +import logging +import os +import re +import socket +import socketserver +import sys +import threading +from pathlib import Path + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +MAX_REQUEST = 16 * 1024 +MAX_JWS_PAYLOAD = 8 * 1024 +CHAIN_HASH = re.compile(r"[0-9a-f]{64}") +# reputation.py signs "{agent_id}:{score}:{computed_at}" (D44). +REPUTATION = re.compile( + r"sha256:[0-9a-f]{64}:\d{1,3}:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(\+00:00|Z)" +) + +log = logging.getLogger("tl-signer") + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _load_or_create(path: Path, generate): + """Load a PKCS8 key, or create it once. The file never leaves this process.""" + if path.exists(): + return serialization.load_pem_private_key(path.read_bytes(), password=None) + key = generate() + pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(pem) + log.info("generated %s", path.name) + return key + + +class Signer: + def __init__(self, state_dir: Path, kid: str, rekor_kid: str, did: str): + state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + self.kid, self.rekor_kid, self.did = kid, rekor_kid, did + self._ed = _load_or_create(state_dir / "ed25519.pem", Ed25519PrivateKey.generate) + self._rekor = _load_or_create( + state_dir / "rekor.pem", lambda: ec.generate_private_key(ec.SECP256R1()) + ) + self._lock = threading.Lock() + self.counts = {} + raw = self._ed.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + self.ed_public = f"ed25519:{_b64url(raw)}" + self.rekor_public_pem = self._rekor.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ).decode("ascii") + + def _ed_sign(self, message: str) -> str: + return f"ed25519:{_b64url(self._ed.sign(message.encode('utf-8')))}" + + def handle(self, req: dict) -> dict: + op = req.get("op") + if op == "pubkeys": + return { + "ed25519": {"kid": self.kid, "public": self.ed_public}, + "rekor": {"kid": self.rekor_kid, "public_pem": self.rekor_public_pem}, + "did": self.did, + } + if op == "sign_chain_hash": + chain_hash = req.get("chain_hash") + if not isinstance(chain_hash, str) or not CHAIN_HASH.fullmatch(chain_hash): + raise ValueError("chain_hash must be 64 lowercase hex characters") + return {"kid": self.kid, "signature": self._ed_sign(chain_hash)} + if op == "sign_reputation": + payload = req.get("payload") + if not isinstance(payload, str) or not REPUTATION.fullmatch(payload): + raise ValueError("payload is not a reputation statement") + return {"kid": self.kid, "signature": self._ed_sign(payload)} + if op == "sign_jws": + # The header is ours, never the caller's (D45). + payload = req.get("payload") + if not isinstance(payload, dict): + raise ValueError("payload must be a JSON object") + p = json.dumps(payload, separators=(",", ":")).encode("utf-8") + if len(p) > MAX_JWS_PAYLOAD: + raise ValueError("payload too large") + h = json.dumps({"alg": "EdDSA", "kid": f"{self.did}#{self.kid}"}, + separators=(",", ":")).encode("utf-8") + signing_input = f"{_b64url(h)}.{_b64url(p)}" + sig = self._ed.sign(signing_input.encode("ascii")) + return {"kid": self.kid, "jws": f"{signing_input}.{_b64url(sig)}"} + if op == "sign_rekor": + # hashedrekord artifact = the chain hash itself (rekor.py). + chain_hash = req.get("chain_hash") + if not isinstance(chain_hash, str) or not CHAIN_HASH.fullmatch(chain_hash): + raise ValueError("chain_hash must be 64 lowercase hex characters") + der = self._rekor.sign(chain_hash.encode("utf-8"), ec.ECDSA(hashes.SHA256())) + return {"kid": self.rekor_kid, "signature": base64.b64encode(der).decode("ascii")} + raise ValueError("unknown operation") + + def count(self, op: str) -> int: + with self._lock: + self.counts[op] = self.counts.get(op, 0) + 1 + return self.counts[op] + + +class _Handler(socketserver.StreamRequestHandler): + def handle(self): + line = self.rfile.readline(MAX_REQUEST + 1) + signer: Signer = self.server.signer + try: + if len(line) > MAX_REQUEST or not line.endswith(b"\n"): + raise ValueError("request too long or not terminated") + req = json.loads(line) + if not isinstance(req, dict): + raise ValueError("request must be a JSON object") + resp = signer.handle(req) + n = signer.count(req["op"]) + log.info("op=%s n=%d peer_uid=%s", req["op"], n, _peer_uid(self.request)) + except (ValueError, json.JSONDecodeError) as e: + resp = {"error": str(e)} + log.warning("refused: %s peer_uid=%s", e, _peer_uid(self.request)) + self.wfile.write((json.dumps(resp, separators=(",", ":")) + "\n").encode()) + + +def _peer_uid(sock) -> str: + try: + creds = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12) + return str(int.from_bytes(creds[4:8], sys.byteorder)) + except OSError: + return "?" + + +class _Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + + +def build_server(state_dir: Path, sock_path: Path, kid: str, rekor_kid: str, did: str, + listen_fd: int | None = None) -> _Server: + """Server on sock_path, or on an inherited socket (systemd socket activation).""" + if listen_fd is None: + server = _Server(str(sock_path), _Handler) + else: + server = _Server(str(sock_path), _Handler, bind_and_activate=False) + server.socket = socket.socket(fileno=listen_fd) + server.signer = Signer(Path(state_dir), kid, rekor_kid, did) + return server + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + env = os.environ + listen_fd = 3 if env.get("LISTEN_FDS") == "1" and env.get("LISTEN_PID") == str(os.getpid()) else None + server = build_server( + state_dir=Path(env["STATE_DIRECTORY"]), + sock_path=Path(env.get("TL_SIGNER_SOCKET", "/run/tl-signer/sign.sock")), + kid=env["TL_SIGNER_KID"], + rekor_kid=env["TL_SIGNER_REKOR_KID"], + did=env["TL_SIGNER_DID"], + listen_fd=listen_fd, + ) + log.info("serving kid=%s rekor_kid=%s public=%s", server.signer.kid, + server.signer.rekor_kid, server.signer.ed_public) + server.serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index f682576..20faa40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,6 @@ def _isolate_data(tmp_path, monkeypatch): monkeypatch.setattr(proxy_mod, "AGENTS_DIR", tmp_path / "data" / "agents") monkeypatch.setattr(proxy_mod, "SERVICES_DIR", tmp_path / "data" / "services") monkeypatch.setattr(proxy_mod, "TRUST_LAYER_BASE_URL", "https://test.arkforge.fr") - monkeypatch.setattr(proxy_mod, "ARKFORGE_PUBLIC_KEY", test_pubkey) monkeypatch.setattr(proxy_mod, "BACKGROUND_TASKS_LOG", tmp_path / "data" / "background_tasks_log.jsonl") # Batch anchoring — isolate the pending batch state and the batch records @@ -71,7 +70,6 @@ def _isolate_data(tmp_path, monkeypatch): import trust_layer.app as app_mod monkeypatch.setattr(app_mod, "TRUST_LAYER_BASE_URL", "https://test.arkforge.fr") - monkeypatch.setattr(app_mod, "ARKFORGE_PUBLIC_KEY", test_pubkey) monkeypatch.setattr(app_mod, "PROOF_ACCESS_LOG", tmp_path / "data" / "proof_access_log.jsonl") monkeypatch.setattr(app_mod, "WEBHOOK_IDEMPOTENCY_FILE", tmp_path / "data" / "webhook_idempotency.jsonl") monkeypatch.setattr(app_mod, "FUNNEL_EVENTS_LOG", tmp_path / "data" / "funnel_events.jsonl") diff --git a/tests/fixtures/verify_proof/proof_2026_07.json b/tests/fixtures/verify_proof/proof_2026_07.json new file mode 100644 index 0000000..39169b0 --- /dev/null +++ b/tests/fixtures/verify_proof/proof_2026_07.json @@ -0,0 +1 @@ +{"proof_id":"prf_20260723_055735_502cad","is_demo":false,"spec_version":"1.2","hashes":{"request":"sha256:0bb687a6db1b27ecfae8445f2bec9d5d41c9353b7e773c7bf42eb060e351d36c","response":"sha256:4cedb7ff94f9c77a59ce4e8e7833eaa4193bfbfd9ef7e4a2b0388167d478fcd6","chain":"sha256:3f9f344d45f3ab1444a7d4d268e917a8f8c93e5c0b218820a948f1b1743d28fc"},"commitments":null,"batch_anchor":null,"timestamp_authority":{"status":"verified","provider":"freetsa.org","tsr_url":"https://trust.arkforge.tech/v1/proof/prf_20260723_055735_502cad/tsr","tsr_base64":"MIIDqzADAgEAMIIDogYJKoZIhvcNAQcCoIIDkzCCA48CAQMxDzANBglghkgBZQMEAgMFADCCAYYGCyqGSIb3DQEJEAEEoIIBdQSCAXEwggFtAgEBBgQqAwQBMDEwDQYJYIZIAWUDBAIBBQAEIJxcDcEqhwL9i1yyTes+I8JLLqZ9nIOQjD5gmq1pMHaZAgQGcF8NGA8yMDI2MDcyMzA1NTczNloBAf+gggETpIIBDzCCAQsxETAPBgNVBAoMCEZyZWUgVFNBMQwwCgYDVQQLDANUU0ExdjB0BgNVBA0MbVRoaXMgY2VydGlmaWNhdGUgZGlnaXRhbGx5IHNpZ25zIGRvY3VtZW50cyBhbmQgdGltZSBzdGFtcCByZXF1ZXN0cyBtYWRlIHVzaW5nIHRoZSBmcmVldHNhLm9yZyBvbmxpbmUgc2VydmljZXMxGDAWBgNVBAMMD3d3dy5mcmVldHNhLm9yZzEkMCIGCSqGSIb3DQEJARYVYnVzaWxlemFzQG1haWxib3gub3JnMRIwEAYDVQQHDAlXdWVyemJ1cmcxCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCYXllcm4xggHtMIIB6QIBATCBozCBlTERMA8GA1UEChMIRnJlZSBUU0ExEDAOBgNVBAsTB1Jvb3QgQ0ExGDAWBgNVBAMTD3d3dy5mcmVldHNhLm9yZzEiMCAGCSqGSIb3DQEJARYTYnVzaWxlemFzQGdtYWlsLmNvbTESMBAGA1UEBxMJV3VlcnpidXJnMQ8wDQYDVQQIEwZCYXllcm4xCzAJBgNVBAYTAkRFAgkAwumGFg2o6c0wDQYJYIZIAWUDBAIDBQCggbgwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjA3MjMwNTU3MzZaMCsGCyqGSIb3DQEJEAIMMRwwGjAYMBYEFEgf1TxTTThBgMAoZRmgNvmIVEdmME8GCSqGSIb3DQEJBDFCBEAhbjfNfALM0OH+RvbJC7jkbrD5M2wRdBEYkzOhkW1KHZwTfXGKNOO482LPJNPfL8VL6weP1MyPffqQYD5FJZuOMAoGCCqGSM49BAMEBGgwZgIxAPG4aVTpF00X2rMmfWm3merXKjsBt82oRb9qPaPWkhQ8hUmCEgzYadeEgU2WEn5OzgIxANO5JX1dKpR5xKAQgN74Pxm5XAX6B/tVK1eLAO0+8Lm0KAOjCj1CLnPovICu79a+Yw=="},"timestamp":"2026-07-23T05:57:35Z","upstream_timestamp":"Thu, 23 Jul 2026 05:57:35 GMT","verification_algorithm":"https://github.com/ark-forge/proof-spec/blob/main/SPEC.md#2-chain-hash-algorithm","arkforge_signature":"ed25519:6K5BwsGu2MGQNxTEREC1lGKxpa7ORbaKQivaLdXwstkvKd1xQdmS54E03Kg4er1VNaiXvtzk0htexDoriyhhDg","arkforge_pubkey":"ed25519:ZLlGE0eN0eTNUE9vaK1tStf6AuoFUWqJBvqx7QgxfEY","identity_consistent":true,"views_count":1,"transaction_success":true,"upstream_status_code":200,"disputed":null,"dispute_id":null,"transparency_log":{"provider":"sigstore-rekor","status":"verified","uuid":"108e9186e8c5677a23ac0750147650db666187587b58ed2bcb933864563439b258cb47f2864442e4","log_index":2223075408,"integrated_time":1784786259,"log_url":"https://rekor.sigstore.dev/api/v1/log/entries/108e9186e8c5677a23ac0750147650db666187587b58ed2bcb933864563439b258cb47f2864442e4","verify_url":"https://search.sigstore.dev/?logIndex=2223075408"},"agent_identity":"did:web:trust.arkforge.tech","agent_identity_verified":true,"did_resolution_status":"bound","seller":"trust.arkforge.tech","provider_payment":null,"integrity_verified":true} \ No newline at end of file diff --git a/tests/fixtures/verify_proof/proof_proveit_gel.json b/tests/fixtures/verify_proof/proof_proveit_gel.json new file mode 100644 index 0000000..af9cf91 --- /dev/null +++ b/tests/fixtures/verify_proof/proof_proveit_gel.json @@ -0,0 +1 @@ +{"proof_id":"prf_20260923_072144_2ad720","is_demo":false,"spec_version":"3.1","hashes":{"request":"sha256:fcf90245abf92197d0aa081849aab238307b12e0461e1ff23a719355ca49041d","response":"sha256:5670a9ff344b0881f6ed3fc77340238a9f210696bad5f9026efddf23ced6773f","chain":"sha256:39384292e666534cd3111304449f40b7ebdf298e86f3c0ce2e9698eb6e6dd8d9"},"commitments":{"request_hash":"sha256:184a9a40ffea774f7a58ec5302c5bc686ca31912943e94190682ad609f4bbf08","response_hash":"sha256:7719d3ceeb2714c0889317d59919707179d83540e9b59da50520ba4f5bed92e4","transaction_id":"sha256:e0f2d6b72415d1d2c75f9b88fe7bd771d6defe3bcf9612bff99f86b5258721f8","timestamp":"sha256:2ac160c48c20a0043370fbe8c808b2af40e53e4ba9752351fe90ad8c0556d60e","buyer_fingerprint":"sha256:914026ada8b5c796c9eb81149401e1a3fd799c568c3b1ebd21f07b4a8a79dd16","seller":"sha256:f24af5f5f40acb8d774e7a28caec3af411751129105a5fc474e598c1d3e8bc36","upstream_timestamp":"sha256:8979e51cea2e0707111b6db4d0acd8134ef7f6f492403bbebf3934eda142f17c","agent_identity":"sha256:0b0f6229adaee4094c9400d0b2bf217bc9cc5e4be8e94474ec282a98951ccd28","agent_identity_verified":"sha256:579965f1144c38bee8c36954db34af7bfa451510705662a5640dbdd0801a2f39","did_resolution_status":"sha256:e1321c04cee8158929dfede68cd809088c0e9f13214c2d8cc96749c838f95856","identity_consistent":"sha256:b1fca3efc902f5ced3ff1e234b6ce5a69a7ae2fc14c488b4816a7e406597fc15"},"batch_anchor":{"status":"anchored","batch_id":"batch_20260923_072145_099","leaf_index":0,"tree_size":11,"audit_path":["aa84080e4de242139179f39c038578ce2ad0576c0b2e5ad2ba5398df90ef4292","409c92e30065182b603422611c5d3e8320e231c3e50b94caa561879d042ee0fe","838b7671518f9a82dcf4f4d33a947f727887691368b5ed5b52b961e5f1f69903","5d97513d6ad57e37f47fdb8f826d6a684e19c89832353770fc35e0b1dfc52c87"],"root":"sha256:01a5b72c92007b3ff0be9696c325dd1828738173d8a76780c81b3e56e35823b6"},"timestamp_authority":{"status":"verified","provider":"freetsa.org","tsr_base64":"MIIDqTADAgEAMIIDoAYJKoZIhvcNAQcCoIIDkTCCA40CAQMxDzANBglghkgBZQMEAgMFADCCAYYGCyqGSIb3DQEJEAEEoIIBdQSCAXEwggFtAgEBBgQqAwQBMDEwDQYJYIZIAWUDBAIBBQAEIGCqhAcL85NYrN9OPEDiBCTG9xSiEpGVW7grmJsWOADRAgQIVLhTGA8yMDI2MDkyMzA3MzE0OFoBAf+gggETpIIBDzCCAQsxETAPBgNVBAoMCEZyZWUgVFNBMQwwCgYDVQQLDANUU0ExdjB0BgNVBA0MbVRoaXMgY2VydGlmaWNhdGUgZGlnaXRhbGx5IHNpZ25zIGRvY3VtZW50cyBhbmQgdGltZSBzdGFtcCByZXF1ZXN0cyBtYWRlIHVzaW5nIHRoZSBmcmVldHNhLm9yZyBvbmxpbmUgc2VydmljZXMxGDAWBgNVBAMMD3d3dy5mcmVldHNhLm9yZzEkMCIGCSqGSIb3DQEJARYVYnVzaWxlemFzQG1haWxib3gub3JnMRIwEAYDVQQHDAlXdWVyemJ1cmcxCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCYXllcm4xggHrMIIB5wIBATCBozCBlTERMA8GA1UEChMIRnJlZSBUU0ExEDAOBgNVBAsTB1Jvb3QgQ0ExGDAWBgNVBAMTD3d3dy5mcmVldHNhLm9yZzEiMCAGCSqGSIb3DQEJARYTYnVzaWxlemFzQGdtYWlsLmNvbTESMBAGA1UEBxMJV3VlcnpidXJnMQ8wDQYDVQQIEwZCYXllcm4xCzAJBgNVBAYTAkRFAgkAwumGFg2o6c0wDQYJYIZIAWUDBAIDBQCggbgwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjA5MjMwNzMxNDhaMCsGCyqGSIb3DQEJEAIMMRwwGjAYMBYEFEgf1TxTTThBgMAoZRmgNvmIVEdmME8GCSqGSIb3DQEJBDFCBEDn88wzafB1b04DUlNpTTIH8Y/+BZH2x2ZWfriujQbxICRfDNpWxGAhUrDJHfZCvbd/hf0akNKS3u1+dkPFqNVQMAoGCCqGSM49BAMEBGYwZAIwZwZ/bIrKUDB7PRYzGl/0kSvOoM64t7uU1LSKjPj+3iOP8/hLitRCRNr0tKYgympeAjAv9uFS1U2KuK3QJQct0OuBwvEAKj9eCrid2+RMuBkrF+061HFa53QAFo9XZPDbtzk=","anchored":"batch_root"},"timestamp":"2026-09-23T07:21:44Z","upstream_timestamp":"Wed, 23 Sep 2026 07:21:45 GMT","verification_algorithm":"https://github.com/ark-forge/proof-spec/blob/main/SPEC.md#2-chain-hash-algorithm","arkforge_signature":"ed25519:TERiTm-jncs3nEVP7I3HbBrwt4fP1CD3HVDsd_59PUSIJGt_Ve_YtEyQPcQex7upDgCtAkaX3CUOatwKDIgkBA","arkforge_pubkey":"ed25519:ZLlGE0eN0eTNUE9vaK1tStf6AuoFUWqJBvqx7QgxfEY","identity_consistent":true,"views_count":29,"transaction_success":true,"upstream_status_code":200,"disputed":null,"dispute_id":null,"transparency_log":{"provider":"sigstore-rekor","status":"verified","uuid":"108e9186e8c5677ad7fdd0e1b86b5fc6d178f40d929314428c77f5b35847ef4c10045def8277e795","log_index":2916869350,"integrated_time":1790148708,"log_url":"https://rekor.sigstore.dev/api/v1/log/entries/108e9186e8c5677ad7fdd0e1b86b5fc6d178f40d929314428c77f5b35847ef4c10045def8277e795","verify_url":"https://search.sigstore.dev/?logIndex=2916869350","anchored":"batch_root"},"agent_identity":"did:key:z6MktKdvc6Pb3e7iabTpEinZuX8CE8MnGMGGQsdEgm9BVqWq","agent_identity_verified":true,"did_resolution_status":"bound","seller":"proveit.arkforge.tech","disclosed":{"agent_identity":{"nonce":"c00e4b5a932d4bc9d9944ef84a037635b1d3947a67e2c8138cab99f215164c1d","value":"did:key:z6MktKdvc6Pb3e7iabTpEinZuX8CE8MnGMGGQsdEgm9BVqWq"},"agent_identity_verified":{"nonce":"ec5da6b86b80f37e73ab014d8a35d1892774aa6612547ad80c51ebedbbd50332","value":true},"did_resolution_status":{"nonce":"ab7a6365a6e7dea8169ffbeec2c811d3d0089542289834230e315596cf977683","value":"bound"},"identity_consistent":{"nonce":"a7e39945c20c39bfb79a89b6d44f1d7fa229b05db0069b7dbcd9e61c4d557c4c","value":true}},"provider_payment":null,"integrity_verified":true} \ No newline at end of file diff --git a/tests/test_credit_alerts.py b/tests/test_credit_alerts.py index 15806d2..8dab8d2 100644 --- a/tests/test_credit_alerts.py +++ b/tests/test_credit_alerts.py @@ -208,8 +208,7 @@ async def test_proxy_triggers_low_credits_after_debit(real_pro_key): upatch("trust_layer.proxy.add_proof_to_batch"), \ upatch("trust_layer.proxy.send_proof_email"), \ upatch("trust_layer.proxy._update_agent_profile"), \ - upatch("trust_layer.proxy._update_service_profile"), \ - upatch("trust_layer.proxy.sign_proof", return_value="ed25519:testsig"): + upatch("trust_layer.proxy._update_service_profile"): await execute_proxy( target="https://httpbin.org/get", method="GET", diff --git a/tests/test_signer.py b/tests/test_signer.py new file mode 100644 index 0000000..0bec7b8 --- /dev/null +++ b/tests/test_signer.py @@ -0,0 +1,116 @@ +"""tl-signer socket protocol (P5a): the only seam the Trust Layer has to its keys.""" + +import json +import socket +import threading + +import pytest + +from signer import tl_signer +from trust_layer.crypto import verify_proof_signature + +CHAIN = "a" * 64 + + +@pytest.fixture +def signer(tmp_path): + sock = tmp_path / "sign.sock" + server = tl_signer.build_server( + state_dir=tmp_path / "state", sock_path=sock, + kid="key-9", rekor_kid="rekor-9", did="did:web:test.arkforge.fr", + ) + t = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True) + t.start() + yield sock, tmp_path / "state" + server.shutdown() + server.server_close() + + +def call(sock, request): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(str(sock)) + s.sendall((json.dumps(request) + "\n").encode()) + return json.loads(s.makefile().readline()) + + +def test_signs_a_chain_hash_with_the_key_it_publishes(signer): + sock, _ = signer + keys = call(sock, {"op": "pubkeys"}) + signed = call(sock, {"op": "sign_chain_hash", "chain_hash": CHAIN}) + assert signed["kid"] == "key-9" + assert verify_proof_signature(keys["ed25519"]["public"], CHAIN, signed["signature"]) + + +@pytest.mark.parametrize("request_", [ + {"op": "sign_chain_hash", "chain_hash": "A" * 64}, + {"op": "sign_chain_hash", "chain_hash": "a" * 63}, + {"op": "sign_chain_hash", "chain_hash": "a" * 64 + "\n"}, + {"op": "sign_chain_hash", "chain_hash": 42}, + {"op": "sign_reputation", "payload": "anything:else"}, + {"op": "export_key"}, + {"op": "sign", "data": "raw bytes please"}, +]) +def test_refuses_anything_outside_the_closed_operations(signer, request_): + sock, _ = signer + assert "error" in call(sock, request_) + + +def test_refuses_an_oversized_request(signer): + sock, _ = signer + resp = call(sock, {"op": "sign_chain_hash", "chain_hash": CHAIN, "pad": "x" * 20000}) + assert "error" in resp + + +def test_no_response_ever_carries_private_material(signer): + sock, _ = signer + for req in ({"op": "pubkeys"}, {"op": "sign_chain_hash", "chain_hash": CHAIN}): + assert "PRIVATE" not in json.dumps(call(sock, req)) + + +def test_keys_are_born_once_and_kept_private(signer, tmp_path): + sock, state = signer + first = call(sock, {"op": "pubkeys"}) + assert oct(state.stat().st_mode & 0o777) == "0o700" + for f in state.iterdir(): + assert oct(f.stat().st_mode & 0o777) == "0o600" + again = tl_signer.Signer(state, "key-9", "rekor-9", "did:web:test.arkforge.fr") + assert again.ed_public == first["ed25519"]["public"] + assert again.rekor_public_pem == first["rekor"]["public_pem"] + + +def test_signs_the_reputation_statement_format(signer): + sock, _ = signer + statement = "sha256:" + "b" * 64 + ":87:2026-09-24T10:00:00.123456+00:00" + keys = call(sock, {"op": "pubkeys"}) + signed = call(sock, {"op": "sign_reputation", "payload": statement}) + assert verify_proof_signature(keys["ed25519"]["public"], statement, signed["signature"]) + + +def test_builds_the_jws_itself_with_its_own_kid(signer): + from trust_layer.crypto import verify_jws, _b64url_decode + sock, _ = signer + keys = call(sock, {"op": "pubkeys"}) + signed = call(sock, {"op": "sign_jws", "payload": {"certified": True}}) + header = json.loads(_b64url_decode(signed["jws"].split(".")[0])) + assert header == {"alg": "EdDSA", "kid": "did:web:test.arkforge.fr#key-9"} + pub = keys["ed25519"]["public"].split(":", 1)[1] + assert verify_jws(pub, signed["jws"]) == {"certified": True} + + +def test_refuses_a_jws_header_or_an_oversized_payload(signer): + sock, _ = signer + assert "error" in call(sock, {"op": "sign_jws", "payload": "h.p"}) + assert "error" in call(sock, {"op": "sign_jws", "payload": {"x": "y" * 9000}}) + + +def test_signs_a_rekor_artifact_with_the_published_rekor_key(signer): + import base64 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + sock, _ = signer + keys = call(sock, {"op": "pubkeys"}) + signed = call(sock, {"op": "sign_rekor", "chain_hash": CHAIN}) + assert signed["kid"] == "rekor-9" + pub = serialization.load_pem_public_key(keys["rekor"]["public_pem"].encode()) + pub.verify(base64.b64decode(signed["signature"]), CHAIN.encode(), ec.ECDSA(hashes.SHA256())) + assert "error" in call(sock, {"op": "sign_rekor", "chain_hash": "zz"}) diff --git a/tests/test_signer_mode.py b/tests/test_signer_mode.py new file mode 100644 index 0000000..8315521 --- /dev/null +++ b/tests/test_signer_mode.py @@ -0,0 +1,101 @@ +"""Trust Layer in signer mode (P5a): keys live in tl-signer, the API only sees a socket.""" + +import json +import threading +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from signer import tl_signer +from trust_layer.crypto import verify_proof_signature + +DEMO = {"target": "https://api.openai.com/v1/chat/completions", "payload": {"model": "gpt-4"}} +KEY_1 = "ed25519:ZLlGE0eN0eTNUE9vaK1tStf6AuoFUWqJBvqx7QgxfEY" + + +@pytest.fixture +def signer_mode(tmp_path, monkeypatch): + import trust_layer.config as cfg + from trust_layer.signing import SocketSigner + + sock = tmp_path / "sign.sock" + server = tl_signer.build_server(tmp_path / "state", sock, "key-9", "rekor-9", + "did:web:test.arkforge.fr") + threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, + daemon=True).start() + node = SocketSigner(sock) + registry = tmp_path / "published_keys.json" + registry.write_text(json.dumps({"keys": [ + {"kid": "key-1", "type": "Ed25519", "public": KEY_1, "node": "vps1", + "valid_from": "2026-02-26", "retired_at": "2026-10-01"}, + {"kid": "key-9", "type": "Ed25519", "public": node.public, "node": "test", + "valid_from": "2026-10-01", "retired_at": None}, + ]})) + monkeypatch.setattr(cfg, "PUBLISHED_KEYS_FILE", registry) + monkeypatch.setattr(cfg, "SIGNING_KEY_PATH", tmp_path / "must-not-exist.pem") + monkeypatch.setattr(cfg, "REKOR_EC_KEY_PATH", tmp_path / "must-not-exist-rekor.pem") + monkeypatch.setattr(cfg, "_SIGNING_KEY", None) + monkeypatch.setattr(cfg, "_SIGNER", node) + yield node, tmp_path + server.shutdown() + server.server_close() + + +@pytest.fixture +def client(): + from trust_layer.app import app + return TestClient(app) + + +def _demo_proof(client): + with patch("trust_layer.app._post_proof_background", new=AsyncMock()), \ + patch("trust_layer.app._track_task"): + r = client.post("/v1/demo", json=DEMO) + assert r.status_code == 200 + proof = client.get(f"/v1/proof/{r.json()['proof_id']}").json() + return r.json(), proof + + +def test_a_proof_is_signed_by_the_node_key_and_names_it(signer_mode, client): + demo, proof = _demo_proof(client) + published = client.get("/v1/pubkey").json() + assert demo["kid"] == "key-9" + assert published["kid"] == "key-9" + chain = proof["hashes"]["chain"].replace("sha256:", "") + assert verify_proof_signature(published["pubkey"], chain, demo["signature"]) + + +def test_pubkey_and_did_document_publish_the_whole_history(signer_mode, client): + published = client.get("/v1/pubkey").json() + assert [k["kid"] for k in published["keys"]] == ["key-1", "key-9"] + assert published["rekor_kid"] == "rekor-9" + did = client.get("/.well-known/did.json").json() + ids = [m["id"].rsplit("#", 1)[1] for m in did["verificationMethod"]] + assert ids[0] == "key-9" and "key-1" in ids + assert [a.rsplit("#", 1)[1] for a in did["assertionMethod"]] == ["key-9"] + + +def test_signer_mode_never_creates_a_private_key_file(signer_mode, client): + _, tmp = signer_mode + _demo_proof(client) + client.get("/v1/pubkey") + from trust_layer.rekor import _build_entry + entry = _build_entry("c" * 64) + assert entry["spec"]["signature"]["content"] + assert not list(tmp.glob("must-not-exist*")) + + +def test_a_node_key_missing_from_the_registry_refuses_to_start(signer_mode, tmp_path): + from trust_layer.signing import SignerError, check_registered + node, _ = signer_mode + other = tmp_path / "other.json" + other.write_text(json.dumps({"keys": [{"kid": "key-9", "public": KEY_1}]})) + with pytest.raises(SignerError): + check_registered(node, other) + + +def test_an_absent_socket_refuses_to_start(tmp_path): + from trust_layer.signing import SignerError, SocketSigner + with pytest.raises(SignerError): + SocketSigner(tmp_path / "nowhere.sock") diff --git a/tests/test_verdict.py b/tests/test_verdict.py index e6006a2..0551554 100644 --- a/tests/test_verdict.py +++ b/tests/test_verdict.py @@ -7,7 +7,8 @@ import pytest from trust_layer.crypto import generate_keypair, load_signing_key, verify_jws -from trust_layer.ctef import build_tier_upgrade_verdict, GATEWAY_DID, KEY_ID +from trust_layer.ctef import build_tier_upgrade_verdict, GATEWAY_DID +from trust_layer.signing import LocalSigner # --------------------------------------------------------------------------- @@ -39,7 +40,7 @@ def _make_verdict(key, **kwargs): policy_ref="sha256:" + "a" * 64, ) defaults.update(kwargs) - return build_tier_upgrade_verdict(private_key=key, **defaults) + return build_tier_upgrade_verdict(signer=LocalSigner(key), **defaults) def test_build_verdict_keys(test_key): @@ -85,7 +86,7 @@ def test_kid_binding(test_key): pad = 4 - len(h_b64) % 4 header = json.loads(base64.urlsafe_b64decode(h_b64 + ("=" * pad if pad != 4 else ""))) assert header["alg"] == "EdDSA" - assert header["kid"] == KEY_ID + assert header["kid"] == f"{GATEWAY_DID}#key-1" assert header["kid"].startswith(GATEWAY_DID) diff --git a/tests/test_verify_proof_keys.py b/tests/test_verify_proof_keys.py new file mode 100644 index 0000000..d320f5e --- /dev/null +++ b/tests/test_verify_proof_keys.py @@ -0,0 +1,155 @@ +"""verify_proof.py against a key history (P5a rotation, D31/D33). + +Real proofs signed by key-1 (July 2026, and the PROVE IT corpus freeze of +2026-09-23) must keep verifying once key-1 is retired and key-2 signs. A key +is accepted only for proofs dated before its retirement. +""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from trust_layer.signing import LocalSigner + +FIXTURES = Path(__file__).parent / "fixtures" / "verify_proof" +SCRIPT = Path(__file__).parent.parent / "scripts" / "verify_proof.py" +_spec = importlib.util.spec_from_file_location("verify_proof_keys", SCRIPT) +vp = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(vp) + +KEY_1 = "ed25519:ZLlGE0eN0eTNUE9vaK1tStf6AuoFUWqJBvqx7QgxfEY" +RETIRED = "2026-10-01T08:00:00Z" +NODE_2 = LocalSigner(Ed25519PrivateKey.generate(), kid="key-2") + + +@pytest.fixture(autouse=True) +def history(monkeypatch): + pubkey = { + "pubkey": NODE_2.public, "algorithm": "Ed25519", "kid": "key-2", + "keys": [ + {"kid": "key-1", "type": "Ed25519", "public": KEY_1, + "valid_from": "2026-02-26T00:00:00Z", "retired_at": RETIRED}, + {"kid": "key-2", "type": "Ed25519", "public": NODE_2.public, + "valid_from": RETIRED, "retired_at": None}, + ], + } + did = {"verificationMethod": [ + {"id": "did:web:trust.arkforge.tech#key-2", + "publicKeyJwk": {"x": NODE_2.public.split(":", 1)[1]}}, + {"id": "did:web:trust.arkforge.tech#key-1", "publicKeyJwk": {"x": KEY_1.split(":", 1)[1]}}, + ]} + routes = {"/v1/pubkey": json.dumps(pubkey), "/.well-known/did.json": json.dumps(did)} + + def fake_fetch(url, binary=False, timeout=30): + for suffix, data in routes.items(): + if url.endswith(suffix): + return data.encode() if binary else data + raise AssertionError(f"unexpected fetch: {url}") + + monkeypatch.setattr(vp, "fetch", fake_fetch) + + +def _ed25519(proof): + rep = vp.Report() + vp.check_ed25519(proof, proof["hashes"]["chain"].replace("sha256:", ""), rep, offline=False) + return next((s, d) for w, s, d, _ in rep.rows if w == "Ed25519 (ArkForge)") + + +@pytest.mark.parametrize("name", ["proof_2026_07.json", "proof_proveit_gel.json", "proof_rekor.json"]) +def test_proofs_signed_before_the_rotation_still_verify(name): + status, detail = _ed25519(json.loads((FIXTURES / name).read_text())) + assert status == vp.OK, detail + + +def _new_proof(signer, timestamp="2026-10-02T09:00:00Z", kid=None): + chain = "d" * 64 + return {"hashes": {"chain": f"sha256:{chain}"}, "timestamp": timestamp, + "arkforge_signature": signer.sign_chain_hash(chain), + "arkforge_pubkey": signer.public, "arkforge_kid": kid or signer.kid} + + +def test_a_proof_signed_by_the_new_node_key_verifies(): + status, detail = _ed25519(_new_proof(NODE_2)) + assert status == vp.OK, detail + + +def test_a_retired_key_is_refused_after_its_retirement(): + rogue = json.loads((FIXTURES / "proof_2026_07.json").read_text()) + rogue["timestamp"] = "2026-10-05T00:00:00Z" + status, detail = _ed25519(rogue) + assert status == vp.FAIL + assert "retired" in detail + + +def test_a_kid_naming_another_key_is_refused(): + impostor = LocalSigner(Ed25519PrivateKey.generate(), kid="key-2") + status, _ = _ed25519(_new_proof(impostor)) + assert status == vp.FAIL + + +def test_an_unknown_kid_is_refused(): + status, _ = _ed25519(_new_proof(NODE_2, kid="key-7")) + assert status == vp.FAIL + + +# --- Rekor attribution across the rotation ------------------------------------ + +def _rekor_submitter_pem(): + import base64 + entry = json.loads((FIXTURES / "rekor_entry.json").read_text()) + body = next(iter(entry.values()))["body"] + spec = json.loads(base64.b64decode(body))["spec"] + return base64.b64decode(spec["signature"]["publicKey"]["content"]).decode() + + +def _rekor_status(monkeypatch, pubkey): + routes = { + "/v1/pubkey": json.dumps(pubkey).encode(), + "/api/v1/log/publicKey": (FIXTURES / "rekor_log_pubkey.pem").read_bytes(), + } + + def fake_fetch(url, binary=False, timeout=30): + if "/api/v1/log/entries/" in url: + data = (FIXTURES / "rekor_entry.json").read_bytes() + else: + data = next(v for k, v in routes.items() if url.endswith(k)) + return data if binary else data.decode() + + monkeypatch.setattr(vp, "fetch", fake_fetch) + proof = json.loads((FIXTURES / "proof_rekor.json").read_text()) + rep = vp.Report() + chain = vp.check_chain_hash(proof, rep) + anchored = vp.check_batch_anchor(proof, chain, rep) + vp.check_rekor(proof, anchored, rep, offline=False) + return next((s, d) for w, s, d, _ in rep.rows if w == "Sigstore Rekor") + + +def _throwaway_rekor_pem(): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + return ec.generate_private_key(ec.SECP256R1()).public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode() + + +NEW_REKOR_PEM = _throwaway_rekor_pem() + + +def test_an_entry_submitted_by_a_retired_rekor_key_is_still_attributed(monkeypatch): + status, detail = _rekor_status(monkeypatch, { + "rekor_pubkey": NEW_REKOR_PEM, "rekor_kid": "rekor-2", + "rekor_keys": [ + {"kid": "rekor-1", "public_pem": _rekor_submitter_pem(), "retired_at": RETIRED}, + {"kid": "rekor-2", "public_pem": NEW_REKOR_PEM, "retired_at": None}, + ]}) + assert status == vp.OK + assert "published key" in detail + + +def test_an_entry_from_an_unpublished_rekor_key_is_refused(monkeypatch): + status, _ = _rekor_status(monkeypatch, { + "rekor_pubkey": NEW_REKOR_PEM, + "rekor_keys": [{"kid": "rekor-2", "public_pem": NEW_REKOR_PEM, "retired_at": None}]}) + assert status == vp.FAIL diff --git a/trust_layer/__init__.py b/trust_layer/__init__.py index cd264c4..e6f35dc 100644 --- a/trust_layer/__init__.py +++ b/trust_layer/__init__.py @@ -1,3 +1,3 @@ """ArkForge Trust Layer — Certifying proxy for agent-to-agent payments.""" -__version__ = "1.11.4" +__version__ = "1.12.0" diff --git a/trust_layer/app.py b/trust_layer/app.py index 4fcdf11..80ce397 100644 --- a/trust_layer/app.py +++ b/trust_layer/app.py @@ -176,7 +176,6 @@ async def dispatch(self, request: Request, call_next): PLATFORM_OVERAGE_PRICE, PROOF_ACCESS_LOG, PROVEIT_PROOF_SELLERS, - ARKFORGE_PUBLIC_KEY, WEBHOOK_IDEMPOTENCY_FILE, CONVERSION_EVENTS_LOG, FUNNEL_EVENTS_LOG, @@ -830,6 +829,7 @@ async def demo_endpoint(request: Request): "hashes": proof_record["hashes"], "signature": proof_record.get("arkforge_signature"), "pubkey": proof_record.get("arkforge_pubkey"), + "kid": proof_record.get("arkforge_kid"), "signed_at": proof_record["timestamp"], "tsa_status": "pending", "rekor_status": "pending", @@ -3242,12 +3242,31 @@ async def track_event(request: Request): # --- GET /v1/pubkey --- +def _published_keys(): + """(node signer, Ed25519 history, Rekor history), or None if signing is not configured.""" + from . import config as _cfg + from .signing import key_history + signer = _cfg.get_signer() + if signer is None: + return None + ed, rekor = key_history(signer, _cfg.PUBLISHED_KEYS_FILE) + return signer, ed, rekor + + @app.get("/v1/pubkey") async def get_pubkey(): - """Return ArkForge's Ed25519 public key for proof signature verification.""" - if not ARKFORGE_PUBLIC_KEY: + """Return the node's public keys for proof verification, plus the key history. + + Top-level fields describe the key of the node answering (unchanged since v1). + `keys` / `rekor_keys` list every key ever published: a proof names its key by + `arkforge_kid`, and a verifier accepts a key that was not retired at the proof's + date (proof-spec, key history). + """ + published = _published_keys() + if published is None: return _error_response("not_configured", "Signing key not configured", 503) - body = {"pubkey": ARKFORGE_PUBLIC_KEY, "algorithm": "Ed25519"} + signer, ed, rekor = published + body = {"pubkey": signer.public, "algorithm": "Ed25519", "kid": signer.kid} # The Rekor submission key is a different key with a different job: it attributes # a transparency-log entry to ArkForge. Unpublished, an entry is unattributable. from .rekor import get_rekor_public_key_pem @@ -3255,6 +3274,9 @@ async def get_pubkey(): if rekor_pem: body["rekor_pubkey"] = rekor_pem body["rekor_algorithm"] = "ECDSA-P256-SHA256" + body["rekor_kid"] = signer.rekor_kid + body["keys"] = ed + body["rekor_keys"] = rekor return body @@ -3262,37 +3284,39 @@ async def get_pubkey(): @app.get("/.well-known/did.json") async def get_did_document(): - """W3C DID Document for did:web:trust.arkforge.tech.""" - if not TRUST_LAYER_BASE_URL or not ARKFORGE_PUBLIC_KEY: + """W3C DID Document for did:web:trust.arkforge.tech. + + Every published key is a verification method, the node key first (older + verifiers read verificationMethod[0]); only keys not retired may assert. + """ + published = _published_keys() + if not TRUST_LAYER_BASE_URL or published is None: return _error_response("not_configured", "Trust layer not fully configured", 503) + signer, ed, _ = published # did:web strips the https:// scheme did = "did:web:" + TRUST_LAYER_BASE_URL.removeprefix("https://").removeprefix("http://") - key_id = f"{did}#key-1" + ordered = sorted(ed, key=lambda k: k.get("public") != signer.public) - # ARKFORGE_PUBLIC_KEY format: "ed25519:" - pubkey_b64url = ARKFORGE_PUBLIC_KEY.split(":", 1)[1] if ":" in ARKFORGE_PUBLIC_KEY else ARKFORGE_PUBLIC_KEY + def method(k): + return { + "id": f"{did}#{k['kid']}", + "type": "Ed25519VerificationKey2020", + "controller": did, + # "ed25519:" -> JWK x + "publicKeyJwk": {"kty": "OKP", "crv": "Ed25519", "x": k["public"].split(":", 1)[-1]}, + } + active = [f"{did}#{k['kid']}" for k in ordered if not k.get("retired_at")] return { "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1", ], "id": did, - "verificationMethod": [ - { - "id": key_id, - "type": "Ed25519VerificationKey2020", - "controller": did, - "publicKeyJwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": pubkey_b64url, - }, - } - ], - "authentication": [key_id], - "assertionMethod": [key_id], + "verificationMethod": [method(k) for k in ordered], + "authentication": active, + "assertionMethod": active, } @@ -3301,12 +3325,13 @@ async def get_did_document(): @app.get("/.well-known/agent.json") async def get_agent_json(): """agent.json v1.4 capability manifest for trust.arkforge.tech.""" - if not TRUST_LAYER_BASE_URL or not ARKFORGE_PUBLIC_KEY: + published = _published_keys() + if not TRUST_LAYER_BASE_URL or published is None: return _error_response("not_configured", "Trust layer not fully configured", 503) origin = TRUST_LAYER_BASE_URL.removeprefix("https://").removeprefix("http://") did = "did:web:" + origin - pubkey_b64url = ARKFORGE_PUBLIC_KEY.split(":", 1)[1] if ":" in ARKFORGE_PUBLIC_KEY else ARKFORGE_PUBLIC_KEY + pubkey_b64url = published[0].public.split(":", 1)[-1] base = f"https://{origin}" return { @@ -3540,14 +3565,14 @@ async def attest_endpoint( return JSONResponse(status_code=200, content=attestation_to_encina_response(existing)) try: - from .config import get_signing_key + from .config import get_signer attestation = build_attestation( record_id=record_id, record_type=record_type, occurred_at_utc=occurred_at_utc, content_hash=content_hash, attester_fingerprint=f"sha256:{attester_fingerprint}", - signing_key=get_signing_key(), + signer=get_signer(), ) except ValueError as e: return _error_response("invalid_request", str(e), 400) diff --git a/trust_layer/attestation.py b/trust_layer/attestation.py index 961593c..c9ff73f 100644 --- a/trust_layer/attestation.py +++ b/trust_layer/attestation.py @@ -15,10 +15,7 @@ from pathlib import Path from typing import Optional -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - from .config import ATTESTATIONS_DIR -from .crypto import sign_proof from .persistence import save_json, load_json from .proofs import canonical_json, sha256_hex @@ -39,7 +36,7 @@ def build_attestation( occurred_at_utc: str, content_hash: str, attester_fingerprint: str, - signing_key: Ed25519PrivateKey, + signer, ) -> dict: """Build and sign an attestation record. @@ -74,7 +71,7 @@ def build_attestation( "record_type": record_type, } chain_hash = sha256_hex(canonical_json(chain_data)) - signature = sign_proof(signing_key, chain_hash) + signature = signer.sign_chain_hash(chain_hash) return { "attestation_id": attestation_id, @@ -89,6 +86,7 @@ def build_attestation( "chain": f"sha256:{chain_hash}", }, "signature": signature, + "signature_kid": signer.kid, } @@ -137,5 +135,6 @@ def attestation_to_encina_response(attestation: dict) -> dict: "specVersion": attestation["spec_version"], "chainHash": chain_hash, "attesterFingerprint": attestation["attester_fingerprint"], + "signatureKid": attestation.get("signature_kid"), }, } diff --git a/trust_layer/config.py b/trust_layer/config.py index 5a7407b..5c35de2 100644 --- a/trust_layer/config.py +++ b/trust_layer/config.py @@ -380,19 +380,40 @@ def _find_system_ca_file(): str(BASE_DIR / "trust_layer" / ".signing_key.pem"), )) -# Fail-fast: load signing key at import time. -# If absent, the server refuses to start — unsigned proofs are not allowed. -try: - from .crypto import load_signing_key, get_public_key_b64url - _SIGNING_KEY = load_signing_key(SIGNING_KEY_PATH) - ARKFORGE_PUBLIC_KEY = get_public_key_b64url(_SIGNING_KEY) -except Exception as _e: - raise RuntimeError( - f"Signing key unavailable at {SIGNING_KEY_PATH}: {_e}. " - "Generate it with: python3 -m trust_layer.crypto" - ) from _e - - -def get_signing_key(): - """Return the Ed25519 private key, or None if not configured.""" - return _SIGNING_KEY +# Published key history (kid, public key, node, validity), identical on every node. +PUBLISHED_KEYS_FILE = BASE_DIR / "trust_layer" / "published_keys.json" + +# Signer mode (P5a): TL_SIGNER_SOCKET names the tl-signer socket. The private keys +# then live in tl-signer only; nothing here reads or creates a key file. Unset, the +# legacy .pem next to the package is used (default until the switch). +SIGNER_SOCKET = os.environ.get("TL_SIGNER_SOCKET", "") + +# Fail-fast: the server refuses to start without a way to sign (unsigned proofs are +# not allowed) and, in signer mode, with a key missing from the published history. +if SIGNER_SOCKET: + from .signing import SocketSigner, check_registered + _SIGNING_KEY = None + _SIGNER = SocketSigner(SIGNER_SOCKET) + check_registered(_SIGNER, PUBLISHED_KEYS_FILE) + ARKFORGE_PUBLIC_KEY = _SIGNER.public +else: + _SIGNER = None + try: + from .crypto import load_signing_key, get_public_key_b64url + _SIGNING_KEY = load_signing_key(SIGNING_KEY_PATH) + ARKFORGE_PUBLIC_KEY = get_public_key_b64url(_SIGNING_KEY) + except Exception as _e: + raise RuntimeError( + f"Signing key unavailable at {SIGNING_KEY_PATH}: {_e}. " + "Generate it with: python3 -m trust_layer.crypto" + ) from _e + + +def get_signer(): + """The node's signer (trust_layer.signing), or None if not configured.""" + if _SIGNER is not None: + return _SIGNER + if _SIGNING_KEY is None: + return None + from .signing import LocalSigner + return LocalSigner(_SIGNING_KEY) diff --git a/trust_layer/crypto.py b/trust_layer/crypto.py index 79348aa..e88c1e8 100644 --- a/trust_layer/crypto.py +++ b/trust_layer/crypto.py @@ -96,7 +96,11 @@ def get_public_key_b64url(private_key: Ed25519PrivateKey) -> str: if __name__ == "__main__": + import os import sys + if os.environ.get("TL_SIGNER_SOCKET"): + # Signer mode: keys are born in tl-signer, never next to the package (P5a). + sys.exit("TL_SIGNER_SOCKET is set: keys live in tl-signer, refusing to write one here") key_path = Path(__file__).parent / ".signing_key.pem" if "--force" in sys.argv and key_path.exists(): key_path.unlink() diff --git a/trust_layer/ctef.py b/trust_layer/ctef.py index f878ccf..918d523 100644 --- a/trust_layer/ctef.py +++ b/trust_layer/ctef.py @@ -4,12 +4,7 @@ import json from datetime import datetime, timezone, timedelta -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from .crypto import sign_jws - GATEWAY_DID = "did:web:trust.arkforge.tech" -KEY_ID = f"{GATEWAY_DID}#key-1" def _jcs(obj: dict) -> bytes: @@ -17,7 +12,7 @@ def _jcs(obj: dict) -> bytes: def build_tier_upgrade_verdict( - private_key: Ed25519PrivateKey, + signer, requester_did: str, current_tier: str, requested_tier: str, @@ -31,7 +26,7 @@ def build_tier_upgrade_verdict( """Build and sign a CTEF tier_upgrade_proof envelope. Returns: ctef_envelope, envelope_sha256, envelope_jcs_bytes, verdict_jws. - The verdict_jws is a compact EdDSA/Ed25519 JWS verifiable against GATEWAY_DID#key-1. + The verdict_jws is a compact EdDSA/Ed25519 JWS verifiable against the key its header names (GATEWAY_DID#). """ now = datetime.now(timezone.utc) issued_at = now.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -52,11 +47,8 @@ def build_tier_upgrade_verdict( "policy_ref": policy_ref, } - verdict_jws = sign_jws( - private_key, - {"alg": "EdDSA", "kid": KEY_ID}, - jws_payload, - ) + # The signer sets the header itself, kid included (D45). + verdict_jws = signer.sign_jws(jws_payload) ctef_envelope = { "claim_type": "authority", diff --git a/trust_layer/demo.py b/trust_layer/demo.py index f39d756..0ff47e0 100644 --- a/trust_layer/demo.py +++ b/trust_layer/demo.py @@ -9,8 +9,7 @@ from datetime import datetime, timezone from .proofs import generate_proof_id, generate_proof, store_proof -from .crypto import sign_proof -from .config import get_signing_key, ARKFORGE_PUBLIC_KEY, TRUST_LAYER_BASE_URL +from .config import get_signer, TRUST_LAYER_BASE_URL _DEMO_RATE_LIMIT = 10 # max demos per IP per window _DEMO_WINDOW_S = 3600 # 1-hour fixed window @@ -122,10 +121,12 @@ def build_demo_proof(target: str, payload: dict) -> dict: "_raw_chain_hash": chain_hash, } - signing_key = get_signing_key() - if signing_key: - proof_record["arkforge_signature"] = sign_proof(signing_key, chain_hash) - proof_record["arkforge_pubkey"] = ARKFORGE_PUBLIC_KEY + signer = get_signer() + if signer: + # Added after the chain hash: the kid, like the pubkey, is not hashed (D46). + proof_record["arkforge_signature"] = signer.sign_chain_hash(chain_hash) + proof_record["arkforge_pubkey"] = signer.public + proof_record["arkforge_kid"] = signer.kid store_proof(proof_id, proof_record) # Full record on purpose: the caller needs _raw_chain_hash to queue the anchor. diff --git a/trust_layer/proxy.py b/trust_layer/proxy.py index 524ddd7..c6b366f 100644 --- a/trust_layer/proxy.py +++ b/trust_layer/proxy.py @@ -26,14 +26,13 @@ AGENTS_DIR, SERVICES_DIR, BACKGROUND_TASKS_LOG, - ARKFORGE_PUBLIC_KEY, INTERNAL_SECRET, TRUSTED_INTERNAL_HOSTS, CHALLENGE_SECRET, CHALLENGE_HOSTS, CHALLENGE_OPEN, CHALLENGE_KEYS, - get_signing_key, + get_signer, ) from .keys import validate_api_key, get_key_plan, _KEYS_LOCK from .payments.base import ChargeResult @@ -45,7 +44,6 @@ from .rate_limit import check_rate_limit from .batch_anchor import add_proof as add_proof_to_batch from .email_notify import send_proof_email, send_low_credits_email, send_credits_exhausted_email -from .crypto import sign_proof logger = logging.getLogger("trust_layer.proxy") @@ -847,10 +845,12 @@ async def execute_proxy( # Ed25519 signature: sign the chain hash to prove ArkForge origin chain_hash = proof["_raw_chain_hash"] - signing_key = get_signing_key() - if signing_key: - proof_record["arkforge_signature"] = sign_proof(signing_key, chain_hash) - proof_record["arkforge_pubkey"] = ARKFORGE_PUBLIC_KEY + signer = get_signer() + if signer: + # Added after the chain hash: the kid, like the pubkey, is not hashed (D46). + proof_record["arkforge_signature"] = signer.sign_chain_hash(chain_hash) + proof_record["arkforge_pubkey"] = signer.public + proof_record["arkforge_kid"] = signer.kid # 10. Store proof store_proof(proof_id, proof_record) diff --git a/trust_layer/published_keys.json b/trust_layer/published_keys.json new file mode 100644 index 0000000..98eabda --- /dev/null +++ b/trust_layer/published_keys.json @@ -0,0 +1,23 @@ +{ + "_doc": "Published key history (proof-spec, key history). Public keys only. One Ed25519 key and one Rekor key per node, born in tl-signer (P5a). A key is valid for proofs dated before retired_at. Append only: a retired key stays listed forever.", + "keys": [ + { + "kid": "key-1", + "type": "Ed25519", + "public": "ed25519:ZLlGE0eN0eTNUE9vaK1tStf6AuoFUWqJBvqx7QgxfEY", + "node": "vps1, vps2 (shared, before P5a)", + "valid_from": "2026-02-26T00:00:00Z", + "retired_at": null + } + ], + "rekor_keys": [ + { + "kid": "rekor-1", + "type": "ECDSA-P256-SHA256", + "public_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZCQKftNdG+Yb8NRMldmdsJELOGAD\n75BNH9+IhSEB4tT69sXaUYkDIUncGyjxY0ZdrVB/el5y3UW5xVM8Cmaq9A==\n-----END PUBLIC KEY-----\n", + "node": "vps1, vps2 (shared, before P5a)", + "valid_from": "2026-03-03T00:00:00Z", + "retired_at": null + } + ] +} diff --git a/trust_layer/rekor.py b/trust_layer/rekor.py index f4abf8a..c0da2c5 100644 --- a/trust_layer/rekor.py +++ b/trust_layer/rekor.py @@ -1,9 +1,9 @@ """Sigstore Rekor transparency log — submit proof chain hash for public auditability. Uses ECDSA P-256 + SHA-256 (hashedrekord v0.0.1), the format natively supported -by Rekor without Sigstore/Fulcio certificates. A dedicated EC key is generated -once and stored at REKOR_EC_KEY_PATH; it is separate from the Ed25519 signing key -used for arkforge_signature. +by Rekor without Sigstore/Fulcio certificates. The EC key is separate from the +Ed25519 signing key used for arkforge_signature. It lives in tl-signer in signer +mode (P5a); in legacy mode it is generated once and stored at REKOR_EC_KEY_PATH. """ import base64 @@ -69,12 +69,9 @@ def get_rekor_public_key_pem() -> Optional[str]: log proves that *some* key attested a hash at time T, never that ArkForge did — which is precisely the witness the anchoring is supposed to provide. """ + from .config import get_signer try: - key = _get_or_create_rekor_ec_key() - return key.public_key().public_bytes( - serialization.Encoding.PEM, - serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode("ascii") + return get_signer().rekor_public_pem except Exception as e: logger.warning("Rekor public key unavailable: %s", e) return None @@ -92,21 +89,24 @@ def _build_entry(chain_hash_hex: str, ec_key=None) -> dict: chain_hash_hex: SHA-256 chain hash as hex string (our proof integrity anchor). ec_key: ECDSA P-256 private key (optional — uses managed key if None). """ - if ec_key is None: - ec_key = _get_or_create_rekor_ec_key() - artifact_bytes = chain_hash_hex.encode("utf-8") sha256_hex = hashlib.sha256(artifact_bytes).hexdigest() # ECDSA signature over the artifact (SHA-256 hashing done internally by ECDSA) - sig_der = ec_key.sign(artifact_bytes, ec.ECDSA(hashes.SHA256())) - sig_b64 = base64.b64encode(sig_der).decode("ascii") + if ec_key is None: + from .config import get_signer + signer = get_signer() + sig_b64 = signer.sign_rekor(chain_hash_hex) + pub_pem = signer.rekor_public_pem.encode("ascii") + else: + sig_der = ec_key.sign(artifact_bytes, ec.ECDSA(hashes.SHA256())) + sig_b64 = base64.b64encode(sig_der).decode("ascii") + pub_pem = ec_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) # Public key as base64-encoded PEM SPKI - pub_pem = ec_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) pub_b64 = base64.b64encode(pub_pem).decode("ascii") return { diff --git a/trust_layer/reputation.py b/trust_layer/reputation.py index 6758354..1380a98 100644 --- a/trust_layer/reputation.py +++ b/trust_layer/reputation.py @@ -14,8 +14,7 @@ from datetime import datetime, timezone from pathlib import Path -from .config import AGENTS_DIR, get_signing_key, ARKFORGE_PUBLIC_KEY -from .crypto import sign_proof +from .config import AGENTS_DIR, get_signer from .persistence import load_json, save_json REPUTATION_CONFIG = { @@ -124,8 +123,8 @@ def compute_reputation(agent_id: str, profile: dict) -> dict: # Sign: "{agent_id}:{score}:{computed_at}" with Ed25519 canonical_id = agent_id if agent_id.startswith("sha256:") else f"sha256:{agent_id}" sign_payload = f"{canonical_id}:{score}:{computed_at}" - signing_key = get_signing_key() - signature = sign_proof(signing_key, sign_payload) if signing_key else None + signer = get_signer() + signature = signer.sign_reputation(sign_payload) if signer else None return { "agent_id": canonical_id, @@ -145,6 +144,7 @@ def compute_reputation(agent_id: str, profile: dict) -> dict: }, "reputation_score": score, "signature": signature, + "signature_kid": signer.kid if signer else None, "computed_at": computed_at, } @@ -210,5 +210,6 @@ def get_public_reputation(rep: dict) -> dict: "last_proof_at": rep.get("last_proof_at"), "unique_services_count": len(rep.get("unique_services", [])), "signature": rep.get("signature"), + "signature_kid": rep.get("signature_kid"), "computed_at": rep["computed_at"], } diff --git a/trust_layer/routers/verdict.py b/trust_layer/routers/verdict.py index e9ea123..6fa4bcb 100644 --- a/trust_layer/routers/verdict.py +++ b/trust_layer/routers/verdict.py @@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field -from ..config import get_signing_key +from ..config import get_signer from ..keys import validate_api_key from ..ctef import build_tier_upgrade_verdict, GATEWAY_DID @@ -54,7 +54,7 @@ async def tier_upgrade_verdict( authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None), ) -> JSONResponse: - """Issue a CTEF tier_upgrade_proof verdict signed by did:web:trust.arkforge.tech#key-1.""" + """Issue a CTEF tier_upgrade_proof verdict signed by the node key (did:web:trust.arkforge.tech#).""" api_key = _get_api_key(authorization, x_api_key) if not api_key: return _error("invalid_api_key", "API key required.", 401) @@ -71,8 +71,8 @@ async def tier_upgrade_verdict( if body.actual > body.limit: return _error("constraint_violation", "actual must not exceed limit.", 422) - signing_key = get_signing_key() - if signing_key is None: + signer = get_signer() + if signer is None: logger.error("Signing key unavailable for verdict request") return _error("signing_unavailable", "Signing key not configured.", 503) @@ -84,7 +84,7 @@ async def tier_upgrade_verdict( try: result = build_tier_upgrade_verdict( - private_key=signing_key, + signer=signer, requester_did=body.requester_did, current_tier=body.current_tier, requested_tier=body.requested_tier, diff --git a/trust_layer/signing.py b/trust_layer/signing.py new file mode 100644 index 0000000..f0ae87c --- /dev/null +++ b/trust_layer/signing.py @@ -0,0 +1,146 @@ +"""Signing seam: the in-process legacy key, or the tl-signer socket (P5a). + +Callers never touch a private key. They ask `config.get_signer()` for a signer and +use its closed operations, the same ones tl-signer serves (signer/tl_signer.py): +chain hash, reputation statement, JWS built by the signer, Rekor artifact. + +Legacy mode (default until the switch): the Ed25519 key is the .pem next to the +package, kid `key-1`; the Rekor key is `rekor-1`. Signer mode (TL_SIGNER_SOCKET set): +no key file is read or created, the public keys come from the socket at startup. +""" + +import base64 +import json +import socket +from pathlib import Path + +from .crypto import get_public_key_b64url, sign_jws, sign_proof + +LEGACY_KID = "key-1" +LEGACY_REKOR_KID = "rekor-1" +GATEWAY_DID = "did:web:trust.arkforge.tech" +SOCKET_TIMEOUT = 5.0 + + +class SignerError(RuntimeError): + """The signer is unreachable, refused the request, or is not published.""" + + +class LocalSigner: + """Legacy: the private keys live in this process (.pem files).""" + + def __init__(self, ed_key, kid: str = LEGACY_KID, rekor_kid: str = LEGACY_REKOR_KID, + did: str = GATEWAY_DID): + self._ed = ed_key + self.kid, self.rekor_kid, self.did = kid, rekor_kid, did + self.public = get_public_key_b64url(ed_key) + + @property + def rekor_public_pem(self) -> str: + from cryptography.hazmat.primitives import serialization + from .rekor import _get_or_create_rekor_ec_key + return _get_or_create_rekor_ec_key().public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + + def sign_chain_hash(self, chain_hash: str) -> str: + return sign_proof(self._ed, chain_hash) + + def sign_reputation(self, statement: str) -> str: + return sign_proof(self._ed, statement) + + def sign_jws(self, payload: dict) -> str: + return sign_jws(self._ed, {"alg": "EdDSA", "kid": f"{self.did}#{self.kid}"}, payload) + + def sign_rekor(self, chain_hash: str) -> str: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + from .rekor import _get_or_create_rekor_ec_key + der = _get_or_create_rekor_ec_key().sign(chain_hash.encode("utf-8"), + ec.ECDSA(hashes.SHA256())) + return base64.b64encode(der).decode("ascii") + + +class SocketSigner: + """Signer mode: every signature is a request to tl-signer. Fails fast at startup.""" + + def __init__(self, sock_path): + self.sock_path = Path(sock_path) + keys = self._call({"op": "pubkeys"}) + self.kid = keys["ed25519"]["kid"] + self.public = keys["ed25519"]["public"] + self.rekor_kid = keys["rekor"]["kid"] + self.rekor_public_pem = keys["rekor"]["public_pem"] + self.did = keys["did"] + + def _call(self, request: dict) -> dict: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.settimeout(SOCKET_TIMEOUT) + s.connect(str(self.sock_path)) + s.sendall((json.dumps(request, separators=(",", ":")) + "\n").encode()) + line = s.makefile("rb").readline() + except OSError as e: + raise SignerError(f"tl-signer unreachable at {self.sock_path}: {e}") from e + try: + resp = json.loads(line) + except ValueError as e: + raise SignerError("tl-signer sent no valid answer") from e + if "error" in resp: + raise SignerError(f"tl-signer refused {request.get('op')}: {resp['error']}") + return resp + + def _signed(self, request: dict, field: str = "signature") -> str: + resp = self._call(request) + if resp.get("kid") not in (self.kid, self.rekor_kid): + # The signer restarted with another key: this process publishes a stale one. + raise SignerError("tl-signer key changed since startup, restart the Trust Layer") + return resp[field] + + def sign_chain_hash(self, chain_hash: str) -> str: + return self._signed({"op": "sign_chain_hash", "chain_hash": chain_hash}) + + def sign_reputation(self, statement: str) -> str: + return self._signed({"op": "sign_reputation", "payload": statement}) + + def sign_jws(self, payload: dict) -> str: + return self._signed({"op": "sign_jws", "payload": payload}, field="jws") + + def sign_rekor(self, chain_hash: str) -> str: + return self._signed({"op": "sign_rekor", "chain_hash": chain_hash}) + + +def load_registry(path) -> list: + """Published key history (trust_layer/published_keys.json), oldest first.""" + return json.loads(Path(path).read_text())["keys"] + + +def key_history(signer, registry_path) -> tuple[list, list]: + """(Ed25519 keys, Rekor keys) to publish: the registry, plus the node keys if absent. + + Absent only in legacy mode or tests (signer mode refuses to start without them). + """ + path = Path(registry_path) + data = json.loads(path.read_text()) if path.exists() else {} + ed = list(data.get("keys", [])) + rekor = list(data.get("rekor_keys", [])) + if signer.public not in [k.get("public") for k in ed]: + ed.append({"kid": signer.kid, "type": "Ed25519", "public": signer.public, + "node": None, "valid_from": None, "retired_at": None}) + pem = signer.rekor_public_pem + if pem and pem not in [k.get("public_pem") for k in rekor]: + rekor.append({"kid": signer.rekor_kid, "type": "ECDSA-P256-SHA256", "public_pem": pem, + "node": None, "valid_from": None, "retired_at": None}) + return ed, rekor + + +def check_registered(signer, registry_path) -> None: + """Signer mode refuses to start with a key nobody can find in the history (D43).""" + for entry in load_registry(registry_path): + if entry.get("kid") == signer.kid: + if entry.get("public") != signer.public: + raise SignerError(f"{signer.kid} in the registry is not the key tl-signer holds") + if entry.get("retired_at"): + raise SignerError(f"{signer.kid} is retired in the registry") + return + raise SignerError(f"{signer.kid} is not in the published key registry") From 93f561bf0b70d4e43411a0b7b99ce7f5b4a66f3b Mon Sep 17 00:00:00 2001 From: desiorac Date: Thu, 24 Sep 2026 11:46:44 +0200 Subject: [PATCH 2/4] =?UTF-8?q?P5a=20:=20coffre=20CEO=20lu=20par=20LoadCre?= =?UTF-8?q?dential=20sous=20un=20utilisateur=20d=C3=A9di=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ tests/test_config_loadcred.py | 44 +++++++++++++++++++++++++++++++++++ trust_layer/config.py | 22 ++++++++++++++---- 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/test_config_loadcred.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c0834..3269dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ Versions follow [Semantic Versioning](https://semver.org/). history, and refuses a key retired before the proof's date. Rekor entries are attributed to any published Rekor key of the history. +- Under its own user, the service reads the CEO vault from systemd credentials (`LoadCredential=vault.json.enc`, + `vault_key`); the master key is only in the environment while the vault loads. + ### Changed - CTEF verdicts: the JWS header (`alg`, `kid`) is set by the signer, no longer a hardcoded `#key-1`. diff --git a/tests/test_config_loadcred.py b/tests/test_config_loadcred.py new file mode 100644 index 0000000..48639e0 --- /dev/null +++ b/tests/test_config_loadcred.py @@ -0,0 +1,44 @@ +"""Secrets under the Trust Layer's own user (P5a): the CEO vault arrives as systemd +credentials (LoadCredential=), since the service can no longer read ubuntu's files.""" + +import json +import os +import sys + +FAKE_VAULT = ''' +import json, os +from pathlib import Path +VAULT_FILE = Path("/nonexistent/vault.json.enc") +KEY_FILE = Path("/nonexistent/.vault_key") +class _Vault: + def get_section(self, name): + if os.environ.get("VAULT_MASTER_KEY") != "mk-test": + raise PermissionError("no master key") + return json.loads(VAULT_FILE.read_text()).get(name, {}) +vault = _Vault() +''' + + +def test_vault_is_read_from_credentials_and_master_key_not_left_in_env(tmp_path, monkeypatch): + import trust_layer.config as cfg + ceo = tmp_path / "ceo" / "automation" + ceo.mkdir(parents=True) + (ceo / "__init__.py").write_text("") + (ceo / "vault.py").write_text(FAKE_VAULT) + creds = tmp_path / "creds" + creds.mkdir() + (creds / "vault.json.enc").write_text(json.dumps({"proveit": {"challenge_hosts": "corpus.example"}})) + (creds / "vault_key").write_text("mk-test\n") + + monkeypatch.setenv("VAULT_PATH", str(tmp_path / "ceo")) + monkeypatch.setenv("CREDENTIALS_DIRECTORY", str(creds)) + monkeypatch.delenv("VAULT_MASTER_KEY", raising=False) + monkeypatch.delenv("TRUST_LAYER_CHALLENGE_HOSTS", raising=False) + for m in [m for m in sys.modules if m == "automation" or m.startswith("automation.")]: + monkeypatch.delitem(sys.modules, m) + monkeypatch.setattr(sys, "path", list(sys.path)) + + cfg._load_secrets() + + assert os.environ["TRUST_LAYER_CHALLENGE_HOSTS"] == "corpus.example" + assert "VAULT_MASTER_KEY" not in os.environ diff --git a/trust_layer/config.py b/trust_layer/config.py index 5c35de2..82d4c02 100644 --- a/trust_layer/config.py +++ b/trust_layer/config.py @@ -62,10 +62,24 @@ def _load_secrets() -> None: _vault_path = os.environ.get("VAULT_PATH", "/opt/claude-ceo") if _vault_path not in _sys.path: _sys.path.insert(0, _vault_path) - from automation.vault import vault as _vault # type: ignore[import] - _stripe = _vault.get_section("stripe") or {} - _smtp = _vault.get_section("smtp") or {} - _proveit = _vault.get_section("proveit") or {} + from automation import vault as _vault_mod # type: ignore[import] + _vault = _vault_mod.vault + # Under its own user (P5a) the service cannot read ubuntu's vault files: + # systemd hands them over (LoadCredential=vault.json.enc, vault_key). The + # master key goes through VAULT_MASTER_KEY, the vault's own interface, only + # while the sections load: openssl subprocesses must not inherit it. + _creds = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/nonexistent")) + _from_creds = (_creds / "vault.json.enc").exists() and (_creds / "vault_key").exists() + if _from_creds: + _vault_mod.VAULT_FILE = _creds / "vault.json.enc" + os.environ["VAULT_MASTER_KEY"] = (_creds / "vault_key").read_text().strip() + try: + _stripe = _vault.get_section("stripe") or {} + _smtp = _vault.get_section("smtp") or {} + _proveit = _vault.get_section("proveit") or {} + finally: + if _from_creds: + os.environ.pop("VAULT_MASTER_KEY", None) _mapping = { "STRIPE_LIVE_SECRET_KEY": _stripe.get("live_secret_key", ""), "STRIPE_TEST_SECRET_KEY": _stripe.get("test_secret_key", ""), From 64efe121180a57b41855fb80d48ac12508e4917f Mon Sep 17 00:00:00 2001 From: desiorac Date: Thu, 24 Sep 2026 11:52:33 +0200 Subject: [PATCH 3/4] =?UTF-8?q?P5a=20:=20auto-test=20de=20signature=20au?= =?UTF-8?q?=20d=C3=A9marrage,=20/v1/health.signing,=20canari=20du=20d?= =?UTF-8?q?=C3=A9ploiement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ scripts/deploy_trust_layer_prod.sh | 21 ++++++++++++++++++++- tests/test_signer_mode.py | 10 ++++++++++ trust_layer/app.py | 3 +++ trust_layer/signing.py | 18 +++++++++++++++++- 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3269dee..e53eeb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ Versions follow [Semantic Versioning](https://semver.org/). - Under its own user, the service reads the CEO vault from systemd credentials (`LoadCredential=vault.json.enc`, `vault_key`); the master key is only in the environment while the vault loads. +- `/v1/health` adds `signing` (`mode`, `kid`, `self_test`). In signer mode the Trust Layer signs and verifies a + fixed hash at startup and refuses to start if it fails. +- Deploy script: the standby and primary canaries require `signing.self_test == ok` and log the signing kid. ### Changed - CTEF verdicts: the JWS header (`alg`, `kid`) is set by the signer, no longer a hardcoded `#key-1`. diff --git a/scripts/deploy_trust_layer_prod.sh b/scripts/deploy_trust_layer_prod.sh index 32796b6..a096a42 100755 --- a/scripts/deploy_trust_layer_prod.sh +++ b/scripts/deploy_trust_layer_prod.sh @@ -107,6 +107,15 @@ except Exception: print('') } # --- Health of a node: "status version role" --- +# Signing on a node, from /v1/health (1.12.0+): "self_test kid mode". +signing_of() { + python3 -c " +import sys, json +try: + s = json.load(sys.stdin).get('signing') or {} + print(s.get('self_test', ''), s.get('kid', ''), s.get('mode', '')) +except Exception: print('')" +} local_health() { curl -s --max-time 5 "$LOCAL_URL/v1/health" 2>/dev/null || true; } standby_health() { $SSH "$STANDBY_HOST" "curl -s --max-time 5 $LOCAL_URL/v1/health" 2>/dev/null || true; } standby_http_code() { $SSH "$STANDBY_HOST" "curl -s -o /dev/null -w '%{http_code}' --max-time 5 $LOCAL_URL$1" 2>/dev/null || echo "000"; } @@ -325,6 +334,14 @@ if [ "$STANDBY_OK" = true ]; then done fi +# The standby signs with its own key after a failover (P5a, D33): its startup +# self-test must have signed and verified with the key it publishes. +if [ "$STANDBY_OK" = true ]; then + SIG=$(standby_health | signing_of) + log "Phase 2a canary: standby signing = $SIG" + case "$SIG" in ok\ *) ;; *) STANDBY_OK=false ;; esac +fi + if [ "$STANDBY_OK" = false ]; then rollback_standby rollback_local_tree @@ -350,7 +367,9 @@ for i in $(seq 1 6); do H=$(local_health) log "Phase 2b attempt $i/6: status=$(echo "$H" | json_field status) version=$(echo "$H" | json_field version) role=$(echo "$H" | json_field role)" if [ "$(echo "$H" | json_field status)" = "ok" ] && [ "$(echo "$H" | json_field version)" = "$NEW_VERSION" ]; then - PRIMARY_OK=true + SIG=$(echo "$H" | signing_of) + log "Phase 2b: primary signing = $SIG" + case "$SIG" in ok\ *) PRIMARY_OK=true ;; esac break fi done diff --git a/tests/test_signer_mode.py b/tests/test_signer_mode.py index 8315521..84d1f39 100644 --- a/tests/test_signer_mode.py +++ b/tests/test_signer_mode.py @@ -99,3 +99,13 @@ def test_an_absent_socket_refuses_to_start(tmp_path): from trust_layer.signing import SignerError, SocketSigner with pytest.raises(SignerError): SocketSigner(tmp_path / "nowhere.sock") + + +def test_health_shows_which_key_signs_on_this_node(signer_mode, client): + signing = client.get("/v1/health").json()["signing"] + assert signing == {"mode": "signer", "kid": "key-9", "self_test": "ok"} + + +def test_health_in_legacy_mode_names_key_1(client): + signing = client.get("/v1/health").json()["signing"] + assert signing["mode"] == "legacy" and signing["kid"] == "key-1" diff --git a/trust_layer/app.py b/trust_layer/app.py index 80ce397..b255a11 100644 --- a/trust_layer/app.py +++ b/trust_layer/app.py @@ -2936,6 +2936,9 @@ async def health(): resp["mode"] = "failover" if (blocked or role == "standby") else "primary" resp["role"] = role resp["write_enabled"] = not blocked + from . import config as _cfg + from .signing import signing_status + resp["signing"] = signing_status(_cfg.get_signer()) from .email_notify import _email_failure_count, _email_success_count, _last_failure_time resp["email"] = { "consecutive_failures": _email_failure_count, diff --git a/trust_layer/signing.py b/trust_layer/signing.py index f0ae87c..fa44183 100644 --- a/trust_layer/signing.py +++ b/trust_layer/signing.py @@ -14,12 +14,14 @@ import socket from pathlib import Path -from .crypto import get_public_key_b64url, sign_jws, sign_proof +from .crypto import get_public_key_b64url, sign_jws, sign_proof, verify_proof_signature LEGACY_KID = "key-1" LEGACY_REKOR_KID = "rekor-1" GATEWAY_DID = "did:web:trust.arkforge.tech" SOCKET_TIMEOUT = 5.0 +# sha256("tl-signer self-test"): signed at startup, verified with the published key. +SELF_TEST_HASH = "0c81932a6a92f6612e9b61f221c1af652d972f23736b6b5f8f63b233ed710234" class SignerError(RuntimeError): @@ -72,6 +74,10 @@ def __init__(self, sock_path): self.rekor_kid = keys["rekor"]["kid"] self.rekor_public_pem = keys["rekor"]["public_pem"] self.did = keys["did"] + # Proves at startup that this node signs with the key it will publish (the + # standby canary of the deploy reads it in /v1/health). + if not verify_proof_signature(self.public, SELF_TEST_HASH, self.sign_chain_hash(SELF_TEST_HASH)): + raise SignerError("tl-signer signature does not verify with its own public key") def _call(self, request: dict) -> dict: try: @@ -110,6 +116,16 @@ def sign_rekor(self, chain_hash: str) -> str: return self._signed({"op": "sign_rekor", "chain_hash": chain_hash}) +def signing_status(signer) -> dict: + """What /v1/health says about signing on this node.""" + if signer is None: + return {"mode": "none", "kid": None, "self_test": "failed"} + if isinstance(signer, SocketSigner): + return {"mode": "signer", "kid": signer.kid, "self_test": "ok"} # checked at startup + ok = verify_proof_signature(signer.public, SELF_TEST_HASH, signer.sign_chain_hash(SELF_TEST_HASH)) + return {"mode": "legacy", "kid": signer.kid, "self_test": "ok" if ok else "failed"} + + def load_registry(path) -> list: """Published key history (trust_layer/published_keys.json), oldest first.""" return json.loads(Path(path).read_text())["keys"] From 915afc62a004a60d3e770b2c4f64a708b814c780 Mon Sep 17 00:00:00 2001 From: desiorac Date: Thu, 24 Sep 2026 12:00:49 +0200 Subject: [PATCH 4/4] =?UTF-8?q?P5a=20:=20signeur=20v=C3=A9rifi=C3=A9=20ava?= =?UTF-8?q?nt=20facturation,=20remboursement=20s=20il=20tombe=20apr=C3=A8s?= =?UTF-8?q?=20;=20r=C3=A9f=C3=A9rences=20internes=20retir=C3=A9es=20du=20c?= =?UTF-8?q?ode=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ scripts/deploy_trust_layer_prod.sh | 2 +- signer/tl_signer.py | 4 +- tests/test_config_loadcred.py | 2 +- tests/test_signer.py | 2 +- tests/test_signer_mode.py | 72 ++++++++++++++++++++++++++++-- tests/test_verify_proof_keys.py | 2 +- trust_layer/config.py | 4 +- trust_layer/credits.py | 25 +++++++++++ trust_layer/crypto.py | 2 +- trust_layer/ctef.py | 2 +- trust_layer/demo.py | 2 +- trust_layer/proxy.py | 29 ++++++++++-- trust_layer/published_keys.json | 6 +-- trust_layer/rekor.py | 2 +- trust_layer/signing.py | 12 ++++- 16 files changed, 146 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53eeb2..8e89ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Versions follow [Semantic Versioning](https://semver.org/). - `/v1/health` adds `signing` (`mode`, `kid`, `self_test`). In signer mode the Trust Layer signs and verifies a fixed hash at startup and refuses to start if it fails. - Deploy script: the standby and primary canaries require `signing.self_test == ok` and log the signing kid. +- A paid request never loses its money to an unavailable signer: `/v1/proxy` checks the signer before counting or + charging (503 `signing_unavailable`, nothing charged), and refunds the debit if the signer is lost between the + charge and the signature (`refund` credit transaction). ### Changed - CTEF verdicts: the JWS header (`alg`, `kid`) is set by the signer, no longer a hardcoded `#key-1`. diff --git a/scripts/deploy_trust_layer_prod.sh b/scripts/deploy_trust_layer_prod.sh index a096a42..c650587 100755 --- a/scripts/deploy_trust_layer_prod.sh +++ b/scripts/deploy_trust_layer_prod.sh @@ -334,7 +334,7 @@ if [ "$STANDBY_OK" = true ]; then done fi -# The standby signs with its own key after a failover (P5a, D33): its startup +# The standby signs with its own key after a failover: its startup # self-test must have signed and verified with the key it publishes. if [ "$STANDBY_OK" = true ]; then SIG=$(standby_health | signing_of) diff --git a/signer/tl_signer.py b/signer/tl_signer.py index 7c9b1fd..8e5caad 100644 --- a/signer/tl_signer.py +++ b/signer/tl_signer.py @@ -32,7 +32,7 @@ MAX_REQUEST = 16 * 1024 MAX_JWS_PAYLOAD = 8 * 1024 CHAIN_HASH = re.compile(r"[0-9a-f]{64}") -# reputation.py signs "{agent_id}:{score}:{computed_at}" (D44). +# reputation.py signs "{agent_id}:{score}:{computed_at}". REPUTATION = re.compile( r"sha256:[0-9a-f]{64}:\d{1,3}:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(\+00:00|Z)" ) @@ -101,7 +101,7 @@ def handle(self, req: dict) -> dict: raise ValueError("payload is not a reputation statement") return {"kid": self.kid, "signature": self._ed_sign(payload)} if op == "sign_jws": - # The header is ours, never the caller's (D45). + # The header is ours, never the caller's. payload = req.get("payload") if not isinstance(payload, dict): raise ValueError("payload must be a JSON object") diff --git a/tests/test_config_loadcred.py b/tests/test_config_loadcred.py index 48639e0..d9d7fd8 100644 --- a/tests/test_config_loadcred.py +++ b/tests/test_config_loadcred.py @@ -1,4 +1,4 @@ -"""Secrets under the Trust Layer's own user (P5a): the CEO vault arrives as systemd +"""Secrets under the Trust Layer's own user: the CEO vault arrives as systemd credentials (LoadCredential=), since the service can no longer read ubuntu's files.""" import json diff --git a/tests/test_signer.py b/tests/test_signer.py index 0bec7b8..9a571c1 100644 --- a/tests/test_signer.py +++ b/tests/test_signer.py @@ -1,4 +1,4 @@ -"""tl-signer socket protocol (P5a): the only seam the Trust Layer has to its keys.""" +"""tl-signer socket protocol: the only seam the Trust Layer has to its keys.""" import json import socket diff --git a/tests/test_signer_mode.py b/tests/test_signer_mode.py index 84d1f39..e0ef60d 100644 --- a/tests/test_signer_mode.py +++ b/tests/test_signer_mode.py @@ -1,4 +1,4 @@ -"""Trust Layer in signer mode (P5a): keys live in tl-signer, the API only sees a socket.""" +"""Trust Layer in signer mode: keys live in tl-signer, the API only sees a socket.""" import json import threading @@ -37,7 +37,7 @@ def signer_mode(tmp_path, monkeypatch): monkeypatch.setattr(cfg, "REKOR_EC_KEY_PATH", tmp_path / "must-not-exist-rekor.pem") monkeypatch.setattr(cfg, "_SIGNING_KEY", None) monkeypatch.setattr(cfg, "_SIGNER", node) - yield node, tmp_path + yield node, tmp_path, server server.shutdown() server.server_close() @@ -77,7 +77,7 @@ def test_pubkey_and_did_document_publish_the_whole_history(signer_mode, client): def test_signer_mode_never_creates_a_private_key_file(signer_mode, client): - _, tmp = signer_mode + _, tmp, _ = signer_mode _demo_proof(client) client.get("/v1/pubkey") from trust_layer.rekor import _build_entry @@ -88,7 +88,7 @@ def test_signer_mode_never_creates_a_private_key_file(signer_mode, client): def test_a_node_key_missing_from_the_registry_refuses_to_start(signer_mode, tmp_path): from trust_layer.signing import SignerError, check_registered - node, _ = signer_mode + node, _, _ = signer_mode other = tmp_path / "other.json" other.write_text(json.dumps({"keys": [{"kid": "key-9", "public": KEY_1}]})) with pytest.raises(SignerError): @@ -109,3 +109,67 @@ def test_health_shows_which_key_signs_on_this_node(signer_mode, client): def test_health_in_legacy_mode_names_key_1(client): signing = client.get("/v1/health").json()["signing"] assert signing["mode"] == "legacy" and signing["kid"] == "key-1" + + +# --- a paid request never loses its money to an unavailable signer --------------- + +def _paid_key(): + from trust_layer.config import PRO_OVERAGE_PRICE, PROOF_PRICE + from trust_layer.credits import add_credits + from trust_layer.keys import create_api_key, update_overage_settings + key = create_api_key("cus_sig", "ref_sig", "sig@test.com", test_mode=False, plan="pro") + update_overage_settings(key, enabled=True, cap_eur=10.0, overage_rate=PRO_OVERAGE_PRICE) + add_credits(key, round(PROOF_PRICE * 5, 2), "pi_sig_test") + return key + + +async def _proxy(key, on_upstream=None): + from unittest.mock import MagicMock, patch as upatch + from trust_layer.proxy import execute_proxy + resp = MagicMock(status_code=200, headers={"Date": "Mon, 02 Mar 2026 13:00:00 GMT"}) + resp.json.return_value = {"result": "ok"} + + async def upstream(*a, **k): + if on_upstream: + on_upstream() + return resp + client = AsyncMock() + client.__aenter__.return_value = client + client.__aexit__.return_value = None + client.get.side_effect = upstream + with upatch("trust_layer.proxy.check_rate_limit", return_value=(True, 0, True, "")), \ + upatch("trust_layer.proxy.httpx.AsyncClient", return_value=client), \ + upatch("trust_layer.proxy.add_proof_to_batch"), \ + upatch("trust_layer.proxy.send_proof_email"): + return await execute_proxy(target="https://httpbin.org/get", method="GET", payload={}, + amount=0.0, currency="eur", api_key=key) + + +def _stop(server): + server.shutdown() + server.server_close() + + +@pytest.mark.asyncio +async def test_signer_down_before_the_charge_costs_nothing(signer_mode): + from trust_layer.credits import get_balance + from trust_layer.proxy import ProxyError + key = _paid_key() + before = get_balance(key) + _stop(signer_mode[2]) + with pytest.raises(ProxyError) as e: + await _proxy(key) + assert e.value.status == 503 and e.value.code == "signing_unavailable" + assert get_balance(key) == pytest.approx(before) + + +@pytest.mark.asyncio +async def test_signer_lost_after_the_charge_refunds_it(signer_mode): + from trust_layer.credits import get_balance + from trust_layer.proxy import ProxyError + key = _paid_key() + before = get_balance(key) + with pytest.raises(ProxyError) as e: + await _proxy(key, on_upstream=lambda: _stop(signer_mode[2])) + assert e.value.status == 503 and e.value.code == "signing_unavailable" + assert get_balance(key) == pytest.approx(before) diff --git a/tests/test_verify_proof_keys.py b/tests/test_verify_proof_keys.py index d320f5e..ea9951b 100644 --- a/tests/test_verify_proof_keys.py +++ b/tests/test_verify_proof_keys.py @@ -1,4 +1,4 @@ -"""verify_proof.py against a key history (P5a rotation, D31/D33). +"""verify_proof.py against a key history. Real proofs signed by key-1 (July 2026, and the PROVE IT corpus freeze of 2026-09-23) must keep verifying once key-1 is retired and key-2 signs. A key diff --git a/trust_layer/config.py b/trust_layer/config.py index 82d4c02..6cc3e99 100644 --- a/trust_layer/config.py +++ b/trust_layer/config.py @@ -64,7 +64,7 @@ def _load_secrets() -> None: _sys.path.insert(0, _vault_path) from automation import vault as _vault_mod # type: ignore[import] _vault = _vault_mod.vault - # Under its own user (P5a) the service cannot read ubuntu's vault files: + # Under its own user, the service cannot read ubuntu's vault files: # systemd hands them over (LoadCredential=vault.json.enc, vault_key). The # master key goes through VAULT_MASTER_KEY, the vault's own interface, only # while the sections load: openssl subprocesses must not inherit it. @@ -397,7 +397,7 @@ def _find_system_ca_file(): # Published key history (kid, public key, node, validity), identical on every node. PUBLISHED_KEYS_FILE = BASE_DIR / "trust_layer" / "published_keys.json" -# Signer mode (P5a): TL_SIGNER_SOCKET names the tl-signer socket. The private keys +# Signer mode: TL_SIGNER_SOCKET names the tl-signer socket. The private keys # then live in tl-signer only; nothing here reads or creates a key file. Unset, the # legacy .pem next to the package is used (default until the switch). SIGNER_SOCKET = os.environ.get("TL_SIGNER_SOCKET", "") diff --git a/trust_layer/credits.py b/trust_layer/credits.py index 97e536f..525f756 100644 --- a/trust_layer/credits.py +++ b/trust_layer/credits.py @@ -118,6 +118,31 @@ def add_credits(api_key: str, amount: float, stripe_pi: str) -> float: return new_balance +def refund_credits(api_key: str, amount: float, debit_id: str, proof_id: str) -> float: + """Give back a debit that produced no proof. Returns the new balance.""" + with _key_lock(api_key): + keys = load_api_keys() + info = keys.get(api_key) + if not info: + raise ValueError("API key not found") + new_balance = round(float(info.get("credit_balance", 0.0)) + amount, 2) + info["credit_balance"] = new_balance + save_api_keys(keys) + + log_transaction({ + "id": _generate_credit_id(), + "type": "refund", + "api_key_prefix": api_key[:8], + "amount": amount, + "debit_id": debit_id, + "proof_id": proof_id, + "balance_after": new_balance, + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + logger.warning("Credit refund %.2f EUR (debit=%s, proof=%s)", amount, debit_id, proof_id) + return new_balance + + def log_transaction(entry: dict): """Append a credit transaction to the JSONL log.""" try: diff --git a/trust_layer/crypto.py b/trust_layer/crypto.py index e88c1e8..6ad0157 100644 --- a/trust_layer/crypto.py +++ b/trust_layer/crypto.py @@ -99,7 +99,7 @@ def get_public_key_b64url(private_key: Ed25519PrivateKey) -> str: import os import sys if os.environ.get("TL_SIGNER_SOCKET"): - # Signer mode: keys are born in tl-signer, never next to the package (P5a). + # Signer mode: keys are born in tl-signer, never next to the package. sys.exit("TL_SIGNER_SOCKET is set: keys live in tl-signer, refusing to write one here") key_path = Path(__file__).parent / ".signing_key.pem" if "--force" in sys.argv and key_path.exists(): diff --git a/trust_layer/ctef.py b/trust_layer/ctef.py index 918d523..ee8b224 100644 --- a/trust_layer/ctef.py +++ b/trust_layer/ctef.py @@ -47,7 +47,7 @@ def build_tier_upgrade_verdict( "policy_ref": policy_ref, } - # The signer sets the header itself, kid included (D45). + # The signer sets the header itself, kid included. verdict_jws = signer.sign_jws(jws_payload) ctef_envelope = { diff --git a/trust_layer/demo.py b/trust_layer/demo.py index 0ff47e0..19cb4d1 100644 --- a/trust_layer/demo.py +++ b/trust_layer/demo.py @@ -123,7 +123,7 @@ def build_demo_proof(target: str, payload: dict) -> dict: signer = get_signer() if signer: - # Added after the chain hash: the kid, like the pubkey, is not hashed (D46). + # Added after the chain hash: the kid, like the pubkey, is not hashed. proof_record["arkforge_signature"] = signer.sign_chain_hash(chain_hash) proof_record["arkforge_pubkey"] = signer.public proof_record["arkforge_kid"] = signer.kid diff --git a/trust_layer/proxy.py b/trust_layer/proxy.py index c6b366f..2dc1c5b 100644 --- a/trust_layer/proxy.py +++ b/trust_layer/proxy.py @@ -36,7 +36,8 @@ ) from .keys import validate_api_key, get_key_plan, _KEYS_LOCK from .payments.base import ChargeResult -from .credits import debit_credits, InsufficientCredits +from .credits import debit_credits, refund_credits, InsufficientCredits +from .signing import SignerError from .rate_limit import rollback_overage from .proofs import sha256_hex, generate_proof_id, generate_proof, store_proof, strip_private from .receipt import fetch_receipt @@ -606,6 +607,16 @@ async def execute_proxy( is_free = plan == "free" is_internal = plan == "internal" + # 2c. Signer reachable before anything is counted or charged (signer mode: a + # separate process). A signer lost later in the request is handled at signing. + signer = get_signer() + if signer is not None: + try: + signer.ping() + except SignerError: + logger.error("tl-signer unreachable, request refused before charge") + raise ProxyError("signing_unavailable", "Proof signing is temporarily unavailable. Nothing was charged.", 503) + # 3. Check rate limit (must be before amount calculation: overage status affects price) allowed, remaining, is_overage, block_reason = check_rate_limit(api_key) if not allowed: @@ -656,6 +667,7 @@ async def execute_proxy( target_domain = urlparse(target).hostname or "unknown" proof_id_for_debit = generate_proof_id() + debit_id = None if is_free or is_test or is_internal: charge_result = ChargeResult( @@ -845,10 +857,19 @@ async def execute_proxy( # Ed25519 signature: sign the chain hash to prove ArkForge origin chain_hash = proof["_raw_chain_hash"] - signer = get_signer() if signer: - # Added after the chain hash: the kid, like the pubkey, is not hashed (D46). - proof_record["arkforge_signature"] = signer.sign_chain_hash(chain_hash) + try: + signature = signer.sign_chain_hash(chain_hash) + except SignerError: + # Charged, but no signed proof: give the money back, never keep it. + logger.error("tl-signer lost during request, refunding proof %s", proof_id_for_debit) + if debit_id: + refund_credits(api_key, charge_result.amount, debit_id, proof_id_for_debit) + if is_overage: + rollback_overage(api_key) + raise ProxyError("signing_unavailable", "Proof signing is temporarily unavailable. The charge was refunded.", 503) + # Added after the chain hash: the kid, like the pubkey, is not hashed. + proof_record["arkforge_signature"] = signature proof_record["arkforge_pubkey"] = signer.public proof_record["arkforge_kid"] = signer.kid diff --git a/trust_layer/published_keys.json b/trust_layer/published_keys.json index 98eabda..a5cfb60 100644 --- a/trust_layer/published_keys.json +++ b/trust_layer/published_keys.json @@ -1,11 +1,11 @@ { - "_doc": "Published key history (proof-spec, key history). Public keys only. One Ed25519 key and one Rekor key per node, born in tl-signer (P5a). A key is valid for proofs dated before retired_at. Append only: a retired key stays listed forever.", + "_doc": "Published key history (proof-spec, key history). Public keys only. One Ed25519 key and one Rekor key per node, born in tl-signer. A key is valid for proofs dated before retired_at. Append only: a retired key stays listed forever.", "keys": [ { "kid": "key-1", "type": "Ed25519", "public": "ed25519:ZLlGE0eN0eTNUE9vaK1tStf6AuoFUWqJBvqx7QgxfEY", - "node": "vps1, vps2 (shared, before P5a)", + "node": "vps1, vps2 (shared, before per-node keys)", "valid_from": "2026-02-26T00:00:00Z", "retired_at": null } @@ -15,7 +15,7 @@ "kid": "rekor-1", "type": "ECDSA-P256-SHA256", "public_pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZCQKftNdG+Yb8NRMldmdsJELOGAD\n75BNH9+IhSEB4tT69sXaUYkDIUncGyjxY0ZdrVB/el5y3UW5xVM8Cmaq9A==\n-----END PUBLIC KEY-----\n", - "node": "vps1, vps2 (shared, before P5a)", + "node": "vps1, vps2 (shared, before per-node keys)", "valid_from": "2026-03-03T00:00:00Z", "retired_at": null } diff --git a/trust_layer/rekor.py b/trust_layer/rekor.py index c0da2c5..051029a 100644 --- a/trust_layer/rekor.py +++ b/trust_layer/rekor.py @@ -3,7 +3,7 @@ Uses ECDSA P-256 + SHA-256 (hashedrekord v0.0.1), the format natively supported by Rekor without Sigstore/Fulcio certificates. The EC key is separate from the Ed25519 signing key used for arkforge_signature. It lives in tl-signer in signer -mode (P5a); in legacy mode it is generated once and stored at REKOR_EC_KEY_PATH. +mode; in legacy mode it is generated once and stored at REKOR_EC_KEY_PATH. """ import base64 diff --git a/trust_layer/signing.py b/trust_layer/signing.py index fa44183..95757bc 100644 --- a/trust_layer/signing.py +++ b/trust_layer/signing.py @@ -1,4 +1,4 @@ -"""Signing seam: the in-process legacy key, or the tl-signer socket (P5a). +"""Signing seam: the in-process legacy key, or the tl-signer socket. Callers never touch a private key. They ask `config.get_signer()` for a signer and use its closed operations, the same ones tl-signer serves (signer/tl_signer.py): @@ -45,6 +45,9 @@ def rekor_public_pem(self) -> str: serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ).decode("ascii") + def ping(self) -> None: + """In-process key: always reachable.""" + def sign_chain_hash(self, chain_hash: str) -> str: return sign_proof(self._ed, chain_hash) @@ -103,6 +106,11 @@ def _signed(self, request: dict, field: str = "signature") -> str: raise SignerError("tl-signer key changed since startup, restart the Trust Layer") return resp[field] + def ping(self) -> None: + """Raise SignerError unless tl-signer answers with the key this process publishes.""" + if self._call({"op": "pubkeys"})["ed25519"]["kid"] != self.kid: + raise SignerError("tl-signer key changed since startup, restart the Trust Layer") + def sign_chain_hash(self, chain_hash: str) -> str: return self._signed({"op": "sign_chain_hash", "chain_hash": chain_hash}) @@ -151,7 +159,7 @@ def key_history(signer, registry_path) -> tuple[list, list]: def check_registered(signer, registry_path) -> None: - """Signer mode refuses to start with a key nobody can find in the history (D43).""" + """Signer mode refuses to start with a key nobody can find in the history.""" for entry in load_registry(registry_path): if entry.get("kid") == signer.kid: if entry.get("public") != signer.public: