ProofedMesh/docs/adr/0005-signing-service-and-transparency-log-api.md
CTO Agent cacdf7e286
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / JSON Schema (ajv) (push) Has been cancelled
validate / Go (vet + test) (push) Has been cancelled
attest+tlog: ADR-0005 Signing/TLog API contract, HTTP clients + orchestrator in admin-api (go vet clean, 11 new tests pass)
2026-10-25 17:51:33 +00:00

171 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0005: Signing-Service- und Transparency-Log-API-Vertrag
- **Status:** accepted
- **Datum:** 2026-10-25
- **Entscheider:** CTO
- **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.2 (DSSE), §4.5a (Query Attestation), §8 (Transparency Log), `services/admin-api/internal/attest/`, `services/admin-api/internal/tlog/`
## Kontext und Problemstellung
Jedes Mal, wenn ein ProofedMesh-Dienst eine Attestation signieren will (Approval, Transformation, Query, Rate-Change, Receipt-Ingest, SAP-Buchungs-Nachweis u.a.), muss er dafür einen zentralen Dienst aufrufen und das Ergebnis anschließend in den Transparency Log eintragen. Im Trust-Core (`trust-api`, `signing-service`, `trillian-log`) liegen die kryptografischen Schlüssel und die Append-Only-Historie; die aufrufenden Dienste (admin-api, agent-travel, agent-sink-posting, …) kennen weder Schlüssel noch Log-Interna.
Der Vertrag zwischen "aufrufender Service" und diesen beiden zentralen Komponenten ist genau einmal zu entscheiden und dann für alle Services identisch — sonst entstehen pro Konsument eigene Sprachen und wir verlieren die Austauschbarkeit (Policy: Signing Service kapselt OpenBao-Transit, Transparency Log kapselt Trillian).
## Entscheidung
Zwei kleine, deliberat flach gehaltene REST-APIs. Keine gRPC-Pflicht, kein Protobuf-Zwang. Transportsicherheit per mTLS zwischen Dienst-Namespaces. Authentisierung per **Pocket-ID-Access-Token** (`client_credentials`, Workload-Identität) plus Standard-OIDC-Discovery.
### Signing Service — `POST /api/v1/sign/dsse`
```
POST https://trust.basicstack.de/api/v1/sign/dsse
Authorization: Bearer <pocket-id-access-token>
Content-Type: application/json
```
Request:
```json
{
"key_ref": "trust-api/rate-change-signer-2026-q4",
"statement": {
"_type": "https://in-toto.io/Statement/v1",
"subject": [{"name": "...", "digest": {"sha256": "..."}}],
"predicateType": "https://basicstack.de/attestations/<name>/v1",
"predicate": { ... }
}
}
```
- `key_ref` referenziert einen in OpenBao Transit registrierten Schlüssel. Der Signing Service prüft via OPA, dass der aufrufende `client_id` berechtigt ist, mit diesem `key_ref` zu signieren (siehe `policies/signing/*.rego`, später).
- `statement` ist ein vollständiges in-toto-v1-Statement.
Response `200`:
```json
{
"envelope": {
"payloadType": "application/vnd.in-toto+json",
"payload": "<base64 of canonicalised statement>",
"signatures": [{"keyid": "trust-api/rate-change-signer-2026-q4", "sig": "<base64>"}]
},
"content_hash": "sha256:<64 hex chars>",
"signed_at": "2026-10-25T18:30:00Z"
}
```
- `envelope` ist der DSSE-Payload. Payload wird canonicalised (JSONC canonical bytes nach RFC 8785) und base64url (no padding) encoded; wir legen uns auf `base64url` fest.
- `content_hash` ist der SHA-256 **des gesamten DSSE-Envelopes** (also der JSON-Serialisierung mit stabilen Keys und ohne Whitespace). Dieser Hash ist unsere stabile Attestation-ID überall sonst im System.
Fehler:
- `401` wenn kein gültiges Token.
- `403` wenn OPA die Signatur mit diesem `key_ref` nicht erlaubt (`error.code = "signing_denied"`, `error.message` = OPA-deny_reason).
- `400` wenn das Statement kein valides in-toto-v1 ist.
- `503` wenn OpenBao Transit nicht erreichbar.
### Transparency Log — `POST /api/v1/log/entries`
```
POST https://trust.basicstack.de/api/v1/log/entries
Authorization: Bearer <pocket-id-access-token>
Content-Type: application/json
```
Request:
```json
{
"envelope": { ...DSSE-Envelope aus dem Signing Service... },
"content_hash": "sha256:<64 hex chars>"
}
```
- `content_hash` muss mit dem SHA-256 des serialisierten `envelope` übereinstimmen — der Transparency Log rechnet nach und weist Mismatches ab.
Response `201` (Erstanlage) **oder** `200` (Idempotenz: content_hash war bereits enthalten):
```json
{
"log_index": 42,
"content_hash": "sha256:<...>",
"logged_at": "2026-10-25T18:30:01Z",
"inclusion_proof": {
"tree_size": 43,
"tree_root_hash": "sha256:<...>",
"signed_tree_head_ref": "sha256:<...>",
"hashes": ["sha256:<...>", "sha256:<...>", "..."]
}
}
```
- `log_index` ist ein monoton wachsender Integer, pro Attestation einmalig.
- `inclusion_proof.hashes` ist der Merkle-Pfad zum signierten Tree-Head (Format kompatibel zu RFC 9162 / certificate-transparency).
- `signed_tree_head_ref` referenziert den zuletzt signierten Tree-Head (den der Transparency Log periodisch veröffentlicht). Konsumenten können gegen diesen Head verifizieren.
Idempotenz: Der Submit MUSS idempotent sein. Zweites `POST` mit identischem `content_hash` liefert `200` + die ursprünglichen Index/Proof-Daten.
Fehler:
- `409` wenn `content_hash` nicht zum `envelope` passt.
- `401` / `403` analog zu Signing Service.
- `503` wenn das Trillian-Backend nicht erreichbar ist.
### Beziehung zwischen den beiden Aufrufen
Jede attestierbare Operation sieht so aus:
```
1. Store.Create / Store.Update (lokal, trust-postgres oder expense-postgres)
→ gibt row-id zurück, rate_change_attestation_ref := NULL
2. attest.Signer.SignDSSE(ctx, statement)
→ envelope + content_hash
3. tlog.Submitter.Submit(ctx, envelope, content_hash)
→ log_index + inclusion_proof
4. Store.AttachAttestation(ctx, row-id, content_hash)
→ rate_change_attestation_ref := content_hash
```
Fehlerpfad: Scheitert Schritt 2 oder 3, bleibt die Row ohne `attestation_ref`. Ein nachgelagerter Reconciliation-Worker (eigenes Deployment, später) sucht periodisch nach solchen Rows und wiederholt die Attestation. Idempotenz von `/api/v1/log/entries` macht das sicher.
## Begründung
- **REST + JSON** statt gRPC. Debuggbar mit curl, lesbar in Logs, einfacher mTLS-Setup. gRPC-Overhead wäre bei den erwarteten Call-Raten (einzelne Signaturen pro Workflow-Schritt, nicht Mio/s) nicht gerechtfertigt.
- **DSSE als Envelope-Format**: unverändert zu `docs/architecture/v1.1.md` §4.2.
- **content_hash als einziger stabiler Rückbezug**: steht in Signatur-Envelopes, in `rate_change_attestation_ref`, in `receipt_attestation_ref`, in `computed_from.transform_attestation_ref` — konsistente Semantik, überall nachprüfbar.
- **Idempotenz-Pflicht am Log** entkoppelt Store-Transaktionen vom Netz-Erfolg. Reconciliation ist Standard-Pattern, nicht Ausnahme.
- **Pocket-ID-Token als Auth**: konsistent zum Rest der Architektur (ADR-0002); kein Zweit-IdP für diese beiden Pfade.
- **Klare Trennung** `/api/v1/sign/*` vs. `/api/v1/log/*`: die beiden Dienste leben zwar organisatorisch im `agent-trust`-Namespace, dürfen aber zu unterschiedlichen Zeiten ausfallen (OpenBao vs. Trillian), und Konsumenten müssen das unterscheiden können.
### Alternativen bewusst nicht gewählt
- **gRPC-first mit Protobuf**: zu viel Build-Pipeline-Komplexität für den jetzigen Scope.
- **Signatur direkt in jedem Service** (jeder hat eigenen privaten Ed25519-Schlüssel in-process): widerspricht `docs/architecture/v1.1.md` §9.1 ("Agenten dürfen private Schlüssel nicht lesen"). Zentraler Signing Service bleibt verbindlich.
- **Direkte Trillian-API** vom Konsumenten aus: Trillian's gRPC-API ist komplex, das Mapping von DSSE-Envelope auf Trillian-LogLeaf ist Boilerplate, und wir wollen Trillian austauschen können (z.B. später gegen sigstore rekor). Unsere Wrapper-API lässt das offen.
## Konsequenzen
### Positive
- Konsumenten (admin-api, Agenten) brauchen nur zwei kleine HTTP-Clients mit identischem Shape.
- Idempotenz am Log entkoppelt lokale Transaktion vom Netzwerk-Rundlauf.
- Reconciliation ist entworfen, nicht nachträglich improvisiert.
### Negative / Trade-offs
- Zusätzlicher Hop: Store→Sign→Log→AttachAttestation statt alles in einer Transaktion. Latency-Summe im Normalfall ~50–200 ms pro Attestation; akzeptabel für Admin-Flows und Agent-Operationen.
- Reconciliation-Job ist Pflicht-Komponente, nicht optional. Muss als eigenes Deployment mit Cron oder Queue-Trigger existieren.
- Konsumenten müssen "attestation pending"-UI-Status zeigen können, damit halbfertige Rows nicht wie Fehler wirken.
### Folgearbeiten / Open Points
- **`services/signing-service/`**: eigenes Deployment, implementiert `/api/v1/sign/dsse`, kapselt OpenBao Transit. Kommt als Phase-2-Hauptarbeit.
- **`services/trust-api/` oder `services/transparency-log/`**: wo `/api/v1/log/entries` wirklich implementiert wird — unentschieden, hängt an Trillian-vs-eigene-Lösung-Entscheidung. Ich tendiere zu einem eigenen `transparency-log/` Service, der Trillian wrappt.
- **`services/attest-reconciler/`**: periodischer Worker, der dirty rows nachträglich attestiert. Später.
- **OPA-Policy `policies/signing/*.rego`**: welcher `client_id` darf welche `key_ref` nutzen. Später.
## Links
- `services/admin-api/internal/attest/` — Signing-Client (Erstkonsument, kommt in diesem Heartbeat)
- `services/admin-api/internal/tlog/` — Transparency-Log-Client (dto.)
- `docs/architecture/v1.1.md` §4.2, §4.5a, §8.3
- RFC 9162 (Certificate Transparency v2)
- DSSE Spec: https://github.com/secure-systems-lab/dsse
- in-toto Attestation Framework v1.0: https://in-toto.io