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

8.9 KiB
Raw Permalink Blame 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:

{
  "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:

{
  "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:

{
  "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):

{
  "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.
  • 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