ProofedMesh/docs/adr/0005-signing-service-and-transparency-log-api.md

172 lines
8.9 KiB
Markdown
Raw Normal View History

# 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