172 lines
8.9 KiB
Markdown
172 lines
8.9 KiB
Markdown
|
|
# 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
|