Compare commits
3 commits
b99801f0df
...
ba1b88d490
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ba1b88d490 | ||
|
|
30dd2f8b65 | ||
|
|
3a01cc651f |
63 changed files with 7237 additions and 107 deletions
11
db/trust/migrations/0004_approval_sessions.down.sql
Normal file
11
db/trust/migrations/0004_approval_sessions.down.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Rollback for 0004_approval_sessions.
|
||||
-- DESTRUKTIV: entfernt sämtliche Approval-Historie (Sessions, Previews,
|
||||
-- bestätigte Approvals). Die in Trillian abgelegten DSSE-Envelopes bleiben
|
||||
-- erhalten, aber die Vorlagen und Session-Hintergründe sind weg; Audits,
|
||||
-- die auf H_content vs. gespeicherte canonical content zurückgreifen,
|
||||
-- verlieren damit ihren lokalen Referenzpunkt. Vor Anwendung IMMER einen
|
||||
-- Dump der trust-DB ziehen.
|
||||
|
||||
DROP TABLE IF EXISTS approvals;
|
||||
DROP TABLE IF EXISTS approval_previews;
|
||||
DROP TABLE IF EXISTS approval_sessions;
|
||||
102
db/trust/migrations/0004_approval_sessions.up.sql
Normal file
102
db/trust/migrations/0004_approval_sessions.up.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- Migration: 0004_approval_sessions
|
||||
-- Content-gebundene Approvals gemäß docs/architecture/v1.1.md §4.3 und
|
||||
-- Phase-1-Spezifikation [DEV-608](/DEV/issues/DEV-608).
|
||||
--
|
||||
-- Drei Tabellen, strikt in der Reihenfolge der Session → Preview → Confirm
|
||||
-- Pipeline des approval-service:
|
||||
--
|
||||
-- 1. `approval_sessions` — eine durch die Pocket-ID-Session gestützte
|
||||
-- Approval-Session; sie bindet (session_id, user_sub) an ein Ablauf-
|
||||
-- datum und wird vom Approval-UI pro Browser-Login einmal erzeugt.
|
||||
-- 2. `approval_previews` — ein serverseitig gerendertes Freigabe-Objekt;
|
||||
-- Content-Hash (`H_content`), Salt und Challenge-Nonce werden gleich
|
||||
-- bei der Vorschau festgenagelt, damit das Confirm-Step eine *andere*
|
||||
-- Payload nicht nachträglich unterschieben kann.
|
||||
-- 3. `approvals` — die bestätigte, DSSE-signierte Approval. `tlog_status`
|
||||
-- spiegelt den TLog-Einwurf; `attestation-pending` bedeutet die Row
|
||||
-- ist persistiert, der TLog-Submit aber noch nicht quittiert und wird
|
||||
-- vom attest-reconciler bzw. einem späteren Worker nachgezogen.
|
||||
--
|
||||
-- Design-Entscheidungen:
|
||||
-- * Alle `*_id`-Spalten sind TEXT (opaque IDs; Service-generiert, nicht
|
||||
-- UUID gezwungen) — bleibt kompatibel mit dem bestehenden Stil der
|
||||
-- Trust-DB.
|
||||
-- * `content_canonical` wird in Preview mitgespeichert, damit Confirm
|
||||
-- den Hash deterministisch nachrechnen und das signierte Statement
|
||||
-- ohne erneutes Rendern aufbauen kann.
|
||||
-- * `tlog_status` ist ein Enum via CHECK statt pg-native ENUM, um die
|
||||
-- Migration ohne CREATE TYPE drop-safe zu halten.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_sessions (
|
||||
session_id TEXT PRIMARY KEY
|
||||
CHECK (length(session_id) BETWEEN 16 AND 128),
|
||||
user_sub TEXT NOT NULL
|
||||
CHECK (length(user_sub) BETWEEN 1 AND 256),
|
||||
active_until TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_sessions_user
|
||||
ON approval_sessions (user_sub);
|
||||
|
||||
CREATE INDEX idx_approval_sessions_active_until
|
||||
ON approval_sessions (active_until);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval_previews (
|
||||
preview_id TEXT PRIMARY KEY
|
||||
CHECK (length(preview_id) BETWEEN 16 AND 128),
|
||||
session_id TEXT NOT NULL
|
||||
REFERENCES approval_sessions(session_id)
|
||||
ON DELETE CASCADE,
|
||||
content_hash TEXT NOT NULL
|
||||
CHECK (content_hash ~ '^sha256:[0-9a-f]{64}$'),
|
||||
content_canonical BYTEA NOT NULL,
|
||||
salt BYTEA NOT NULL
|
||||
CHECK (length(salt) BETWEEN 16 AND 64),
|
||||
challenge_nonce BYTEA NOT NULL
|
||||
CHECK (length(challenge_nonce) BETWEEN 16 AND 64),
|
||||
verification_digits TEXT NOT NULL
|
||||
CHECK (verification_digits ~ '^[0-9]{4,12}$'),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_previews_session
|
||||
ON approval_previews (session_id);
|
||||
|
||||
CREATE INDEX idx_approval_previews_expires
|
||||
ON approval_previews (expires_at)
|
||||
WHERE consumed_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approvals (
|
||||
approval_id TEXT PRIMARY KEY
|
||||
CHECK (length(approval_id) BETWEEN 16 AND 128),
|
||||
preview_id TEXT NOT NULL
|
||||
REFERENCES approval_previews(preview_id),
|
||||
session_id TEXT NOT NULL
|
||||
REFERENCES approval_sessions(session_id),
|
||||
user_sub TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL
|
||||
CHECK (content_hash ~ '^sha256:[0-9a-f]{64}$'),
|
||||
envelope_json JSONB NOT NULL,
|
||||
attestation_ref TEXT NOT NULL
|
||||
CHECK (attestation_ref ~ '^sha256:[0-9a-f]{64}$'),
|
||||
tlog_status TEXT NOT NULL DEFAULT 'attestation-pending'
|
||||
CHECK (tlog_status IN ('attestation-pending',
|
||||
'logged',
|
||||
'failed')),
|
||||
log_index BIGINT,
|
||||
tlog_error TEXT,
|
||||
confirmed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
logged_at TIMESTAMPTZ,
|
||||
CONSTRAINT approvals_log_consistency
|
||||
CHECK ((tlog_status = 'logged') = (log_index IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approvals_tlog_pending
|
||||
ON approvals (confirmed_at)
|
||||
WHERE tlog_status = 'attestation-pending';
|
||||
|
||||
CREATE INDEX idx_approvals_user
|
||||
ON approvals (user_sub, confirmed_at);
|
||||
|
|
@ -7,4 +7,7 @@ metadata:
|
|||
app.kubernetes.io/name: signing-service
|
||||
app.kubernetes.io/part-of: proofedmesh
|
||||
app.kubernetes.io/component: trust-core
|
||||
automountServiceAccountToken: true
|
||||
# The signing-service pod uses an EXPLICIT projected SA token
|
||||
# (deploy/signing-service/20-deployment.yaml) rather than the default
|
||||
# auto-mount, so this SA opts out of auto-mounting.
|
||||
automountServiceAccountToken: false
|
||||
|
|
|
|||
40
deploy/signing-service/01-rbac.yaml
Normal file
40
deploy/signing-service/01-rbac.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# RBAC for the signing-service's K8s TokenReview path (DEV-609).
|
||||
#
|
||||
# The signing-service verifies the caller's workload identity by handing the
|
||||
# `X-Workload-Token` header to the Kubernetes API server as a TokenReview.
|
||||
# That requires create on `tokenreviews.authentication.k8s.io`, which is
|
||||
# NOT part of any default role, so we grant it via a dedicated ClusterRole.
|
||||
#
|
||||
# The ClusterRole is intentionally narrow: `tokenreviews` + `subjectaccessreviews`
|
||||
# so the service can also diagnose "the token authenticated as X, is X allowed
|
||||
# Y?" patterns in Phase 2 without a second RBAC update.
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: signing-service-tokenreview
|
||||
labels:
|
||||
app.kubernetes.io/name: signing-service
|
||||
app.kubernetes.io/part-of: proofedmesh
|
||||
app.kubernetes.io/component: trust-core
|
||||
rules:
|
||||
- apiGroups: ["authentication.k8s.io"]
|
||||
resources: ["tokenreviews"]
|
||||
verbs: ["create"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: signing-service-tokenreview
|
||||
labels:
|
||||
app.kubernetes.io/name: signing-service
|
||||
app.kubernetes.io/part-of: proofedmesh
|
||||
app.kubernetes.io/component: trust-core
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: signing-service-tokenreview
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: signing-service
|
||||
namespace: agent-trust
|
||||
|
|
@ -26,7 +26,12 @@ spec:
|
|||
app.kubernetes.io/component: trust-core
|
||||
spec:
|
||||
serviceAccountName: signing-service
|
||||
automountServiceAccountToken: true
|
||||
# The signing-service MUST NOT get the default auto-mounted token
|
||||
# because we need a projected token with an explicit `trust-api`
|
||||
# audience so the pod SA token can be used to call the API server
|
||||
# for TokenReview (the audience on the pod's own SA token is what
|
||||
# the K8s API server will accept as Bearer credential).
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
|
|
@ -41,22 +46,42 @@ spec:
|
|||
args:
|
||||
- "-listen=:8081"
|
||||
- "-backend=memory"
|
||||
- "-keys=trust-api/rate-change-signer-2026-q4"
|
||||
- "-keys=trust-api/approval-signer-2026-q4,trust-api/agent-testagent-transform-2026-q4"
|
||||
- "-keys-from-env"
|
||||
- "-bearers=$(SIGNING_BEARERS)"
|
||||
- "-pocket-id-issuer=$(POCKET_ID_ISSUER)"
|
||||
- "-pocket-id-audience=$(POCKET_ID_AUDIENCE)"
|
||||
- "-k8s-tokenreview=on"
|
||||
- "-registry-dsn=$(TRUST_PG_DSN)"
|
||||
- "-policy-bundle=/etc/signing-service/policies/signing_service.rego"
|
||||
env:
|
||||
# Pulled from the dev Secret below; see deploy/signing-service/README.md
|
||||
# for the full secret shape and the SealedSecret recipe.
|
||||
- name: SIGNING_BEARERS
|
||||
# ---- Pocket-ID OIDC discovery (DEV-609) ----
|
||||
- name: POCKET_ID_ISSUER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: signing-service-dev
|
||||
key: SIGNING_BEARERS
|
||||
- name: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
|
||||
name: signing-service-prod
|
||||
key: POCKET_ID_ISSUER
|
||||
- name: POCKET_ID_AUDIENCE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: signing-service-dev
|
||||
key: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
|
||||
name: signing-service-prod
|
||||
key: POCKET_ID_AUDIENCE
|
||||
# ---- trust-api agent registry DSN (DEV-609) ----
|
||||
- name: TRUST_PG_DSN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: signing-service-prod
|
||||
key: TRUST_PG_DSN
|
||||
# ---- Signing keys (same shape as dev; swapped to prod Secret) ----
|
||||
- name: KEY_TRUST_API_APPROVAL_SIGNER_2026_Q4
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: signing-service-prod
|
||||
key: KEY_TRUST_API_APPROVAL_SIGNER_2026_Q4
|
||||
- name: KEY_TRUST_API_AGENT_TESTAGENT_TRANSFORM_2026_Q4
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: signing-service-prod
|
||||
key: KEY_TRUST_API_AGENT_TESTAGENT_TRANSFORM_2026_Q4
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8081
|
||||
|
|
@ -77,17 +102,58 @@ spec:
|
|||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
memory: "64Mi"
|
||||
cpu: "100m"
|
||||
memory: "128Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "256Mi"
|
||||
cpu: "1000m"
|
||||
memory: "512Mi"
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumeMounts:
|
||||
- name: kube-api-token
|
||||
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
|
||||
readOnly: true
|
||||
- name: opa-policy
|
||||
mountPath: /etc/signing-service/policies
|
||||
readOnly: true
|
||||
volumes:
|
||||
# Projected SA token the signing-service presents to the
|
||||
# Kubernetes API server to authorize TokenReview calls. The
|
||||
# projected volume also carries the API server CA, which the
|
||||
# in-cluster TokenReviewer loads from
|
||||
# /var/run/secrets/kubernetes.io/serviceaccount/ca.crt.
|
||||
- name: kube-api-token
|
||||
projected:
|
||||
sources:
|
||||
- serviceAccountToken:
|
||||
path: token
|
||||
# The API server audience — pod-SA tokens addressed to
|
||||
# the api server are what RBAC checks against.
|
||||
audience: "https://kubernetes.default.svc"
|
||||
expirationSeconds: 3600
|
||||
- configMap:
|
||||
name: kube-root-ca.crt
|
||||
items:
|
||||
- key: ca.crt
|
||||
path: ca.crt
|
||||
- downwardAPI:
|
||||
items:
|
||||
- path: namespace
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
# OPA Rego policy source, mounted read-only. The ConfigMap is
|
||||
# created from policies/sinks/signing_service.rego by GitOps
|
||||
# (see deploy/signing-service/README.md).
|
||||
- name: opa-policy
|
||||
configMap:
|
||||
name: signing-service-policy
|
||||
items:
|
||||
- key: signing_service.rego
|
||||
path: signing_service.rego
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ directory assumes that file (or an equivalent) is already applied.
|
|||
|
||||
```
|
||||
deploy/signing-service/
|
||||
├── 00-serviceaccount.yaml — ServiceAccount with projected SA token
|
||||
├── 00-serviceaccount.yaml — ServiceAccount (opts out of auto-mount; a projected volume is used)
|
||||
├── 01-rbac.yaml — ClusterRole + Binding for TokenReview (DEV-609)
|
||||
├── 10-secret-dev.example.yaml — TEMPLATE for the dev-mode Secret
|
||||
├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless)
|
||||
├── 30-service.yaml — ClusterIP Service on :8081
|
||||
|
|
@ -17,6 +18,27 @@ deploy/signing-service/
|
|||
└── README.md — this file
|
||||
```
|
||||
|
||||
## Prod wiring (DEV-609)
|
||||
|
||||
The Deployment reads the following prod flags via env:
|
||||
|
||||
| Flag | Env | Purpose |
|
||||
| ---------------------- | ------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `-pocket-id-issuer` | `POCKET_ID_ISSUER` | Pocket-ID OIDC issuer URL — enables JWKS validator (replaces static bearer table). |
|
||||
| `-pocket-id-audience` | `POCKET_ID_AUDIENCE` | Expected `aud` claim on Pocket-ID tokens. |
|
||||
| `-k8s-tokenreview=on` | (flag) | Enables K8s TokenReview on `X-Workload-Token` using the pod's projected SA token. |
|
||||
| `-registry-dsn` | `TRUST_PG_DSN` | Postgres DSN for the trust-api `agents` table (DEV-607). |
|
||||
| `-policy-bundle` | (flag, file path) | Rego file mounted at `/etc/signing-service/policies/signing_service.rego`. |
|
||||
|
||||
The OPA policy source lives at `policies/sinks/signing_service.rego`. Create
|
||||
the ConfigMap it is mounted from before applying the Deployment:
|
||||
|
||||
```bash
|
||||
kubectl -n agent-trust create configmap signing-service-policy \
|
||||
--from-file=signing_service.rego=policies/sinks/signing_service.rego \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
## Apply order
|
||||
|
||||
```bash
|
||||
|
|
|
|||
74
policies/sinks/signing_service.rego
Normal file
74
policies/sinks/signing_service.rego
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# METADATA
|
||||
# title: Signing-service policy (ProofedMesh Phase 1)
|
||||
# description: |
|
||||
# Decides whether an authenticated client may obtain a DSSE signature for a
|
||||
# given key_ref. Enforces two independent conditions:
|
||||
#
|
||||
# 1. The Pocket-ID client identity (`sub` claim) and the Kubernetes
|
||||
# ServiceAccount identity (`<ns>/<sa>` from TokenReview) resolve to the
|
||||
# same agent row in the trust-api `agents` table.
|
||||
# 2. The requested `key_ref` is in the set of key_refs this agent's role
|
||||
# is permitted to sign with.
|
||||
#
|
||||
# Both lookups are passed in as `input.registry.by_pocket_id` and
|
||||
# `input.registry.by_k8s`. The signing-service MUST perform both lookups
|
||||
# from the DB and hand the raw rows (or `null` on no match) to OPA — the
|
||||
# policy does not reach out to the DB on its own.
|
||||
# authors:
|
||||
# - CTO Agent <cto@basicstack.de>
|
||||
# related_resources:
|
||||
# - ref: docs/architecture/v1.1.md#user-content-44-identitaet-und-schluessel
|
||||
# - ref: db/trust/migrations/0002_agents.up.sql
|
||||
package proofedmesh.sinks.signing_service
|
||||
|
||||
import rego.v1
|
||||
|
||||
default allow := false
|
||||
|
||||
# Map agent_id → set of allowed key_ref prefixes. Prefixes end at the
|
||||
# quarter boundary so we can rotate the suffix (`-2026-q4`, `-2027-q1`, …)
|
||||
# without changing the policy. Keep this list in sync with the trust-api
|
||||
# agent registry seeds (db/trust/seeds/0001_phase1_agents.sql).
|
||||
agent_key_prefixes := {
|
||||
"agent-approval-service": {"trust-api/approval-signer-"},
|
||||
"agent-testagent-transform": {"trust-api/agent-testagent-transform-"},
|
||||
"agent-dummy-sink": {"trust-api/dummy-sink-signer-"},
|
||||
}
|
||||
|
||||
# Both registry lookups must have returned the SAME agent row and that row
|
||||
# must be internally consistent with the identities actually presented by
|
||||
# the caller (Pocket-ID `sub` and the K8s ServiceAccount reported by
|
||||
# TokenReview). Any null, mismatch, or absent field denies.
|
||||
identities_agree if {
|
||||
input.registry.by_pocket_id.agent_id == input.registry.by_k8s.agent_id
|
||||
input.registry.by_pocket_id.pocket_id_client == input.pocket_id_client
|
||||
input.registry.by_pocket_id.k8s_namespace == input.k8s.namespace
|
||||
input.registry.by_pocket_id.k8s_service_account == input.k8s.service_account
|
||||
input.registry.by_k8s.pocket_id_client == input.pocket_id_client
|
||||
input.registry.by_k8s.k8s_namespace == input.k8s.namespace
|
||||
input.registry.by_k8s.k8s_service_account == input.k8s.service_account
|
||||
}
|
||||
|
||||
key_ref_matches_role if {
|
||||
some prefix in agent_key_prefixes[input.registry.by_pocket_id.agent_id]
|
||||
startswith(input.key_ref, prefix)
|
||||
}
|
||||
|
||||
allow if {
|
||||
identities_agree
|
||||
key_ref_matches_role
|
||||
}
|
||||
|
||||
deny_reason contains msg if {
|
||||
not identities_agree
|
||||
msg := "pocket-id and k8s identities do not resolve to the same registry agent"
|
||||
}
|
||||
|
||||
deny_reason contains msg if {
|
||||
identities_agree
|
||||
not key_ref_matches_role
|
||||
msg := sprintf(
|
||||
"agent %q is not permitted to sign with key_ref %q",
|
||||
[input.registry.by_pocket_id.agent_id, input.key_ref],
|
||||
)
|
||||
}
|
||||
144
services/approval-service/README.md
Normal file
144
services/approval-service/README.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# approval-service
|
||||
|
||||
Phase 1 der ProofedMesh content-gebundenen Approvals ([DEV-608](/DEV/issues/DEV-608),
|
||||
siehe `docs/architecture/v1.1.md §4.3`). Der Dienst nimmt menschliche
|
||||
Freigaben an, bindet sie kryptographisch an einen deterministisch gerenderten
|
||||
JSON-Inhalt (`H_content = sha256(canonical_json)`), verifiziert eine WebAuthn-
|
||||
Assertion gegen die Challenge `nonce || H_content` und erzeugt daraus eine
|
||||
DSSE-signierte Approval-Attestation, die ins Transparency-Log eingetragen
|
||||
wird.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Alle Routen sind JSON-API, Pocket-ID-Session-Cookie-geschützt.
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
| ------- | --------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `POST` | `/api/v1/sessions` | Startet eine Approval-Session. Antwort: `{session_id, active_until}`. |
|
||||
| `POST` | `/api/v1/approvals/preview` | Rendert Freigabe-Inhalt serverseitig, berechnet `H_content`, legt Challenge und Prüfziffer ab. |
|
||||
| `POST` | `/api/v1/approvals/confirm` | Nimmt die WebAuthn-Assertion entgegen, signiert und loggt die Approval. |
|
||||
| `GET` | `/healthz` | Liveness. |
|
||||
|
||||
### `POST /api/v1/sessions`
|
||||
|
||||
Erzeugt eine Approval-Session an die aktuelle Pocket-ID-`sub`. `active_until`
|
||||
ist in Phase 1 fest auf `SessionTTL` (Default 15 min).
|
||||
|
||||
### `POST /api/v1/approvals/preview`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_…",
|
||||
"content": { "...application-defined..." }
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"preview_id": "pv_…",
|
||||
"content_hash": "sha256:<hex>",
|
||||
"verification_digits": "734219",
|
||||
"webauthn_challenge": "<b64url>",
|
||||
"expires_at": "2026-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`verification_digits` ist eine 6-stellige Prüfziffer aus `H_content`, die der
|
||||
Mensch im UI vor der Freigabe mit der Signaturaufforderung abgleichen MUSS.
|
||||
`webauthn_challenge` ist bereits `nonce || H_content` (b64url), damit der
|
||||
Browser direkt eine WebAuthn-Assertion anfordern kann.
|
||||
|
||||
### `POST /api/v1/approvals/confirm`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"preview_id": "pv_…",
|
||||
"assertion": {
|
||||
"credential_id": "<b64url>",
|
||||
"client_data_json": "<b64url>",
|
||||
"authenticator_data": "<b64url>",
|
||||
"signature": "<b64url>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_id": "ap_…",
|
||||
"attestation_ref": "sha256:<hex>",
|
||||
"log_index": 42,
|
||||
"tlog_status": "logged"
|
||||
}
|
||||
```
|
||||
|
||||
Wenn der TLog-Submit scheitert, bleibt die Row `tlog_status =
|
||||
"attestation-pending"`; die Antwort meldet dann `log_index: null,
|
||||
tlog_status: "attestation-pending"`. Ein späterer Reconciler zieht den
|
||||
Submit nach (ADR-0005, idempotent per `content_hash`).
|
||||
|
||||
## Fehler
|
||||
|
||||
| Zustand | Code | Body `error.code` |
|
||||
| -------------------------------------------- | ---- | ------------------------- |
|
||||
| Session abgelaufen / unbekannt | 403 | `session_expired` |
|
||||
| `H_content`-Mismatch (Assertion vs. Preview) | 400 | `content_hash_mismatch` |
|
||||
| Challenge abgelaufen | 403 | `challenge_expired` |
|
||||
| WebAuthn-Assertion ungültig | 401 | `assertion_invalid` |
|
||||
| Signing-Service nicht erreichbar | 502 | `signer_unavailable` |
|
||||
|
||||
## Phase 1 Scope
|
||||
|
||||
- Store ist in-memory (`internal/store/memory.go`); Postgres-Impl ist in
|
||||
`internal/store/postgres.go` als Skelett angelegt (Pattern aus
|
||||
`admin-api/internal/store/postgres.go`). Die SQL-Migration liegt bereits
|
||||
in `db/trust/migrations/0004_approval_sessions.*.sql`.
|
||||
- WebAuthn-Verifikation ist eine stdlib-only Shim im Stil von
|
||||
`admin-api/internal/auth/pocket_id.go`: In-Memory-`Credential`-Store,
|
||||
Ed25519-Assertion über `authenticator_data || sha256(client_data_json)`,
|
||||
Challenge-Vergleich konstantzeitig. Die Datenformate sind
|
||||
WebAuthn-kompatibel (b64url-encoded `client_data_json` mit `challenge`,
|
||||
`type`, `origin`), damit das Phase-2-Umstieg auf
|
||||
`github.com/go-webauthn/webauthn` ohne API-Bruch möglich bleibt.
|
||||
- Keine HTML/UI; nur JSON. Browser-Integration landet in einem Folge-Task.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle über Flags oder Env (`APPROVAL_*`):
|
||||
|
||||
| Env / Flag | Default | Zweck |
|
||||
| ------------------------------- | --------------------------- | --------------------------------------------------------- |
|
||||
| `APPROVAL_LISTEN` | `:8080` | HTTP-Listen-Adresse. |
|
||||
| `APPROVAL_SIGNING_URL` | *(required)* | Base URL des signing-service (`/api/v1/sign/dsse`). |
|
||||
| `APPROVAL_SIGNING_KEY_REF` | `trust-api/approval-signer-2026-q4` | DSSE-Signaturschlüssel. |
|
||||
| `APPROVAL_SIGNING_TOKEN` | *(optional)* | Bearer-Token für signing-service (Phase 1 Static). |
|
||||
| `APPROVAL_TLOG_URL` | *(required)* | Base URL des transparency-log. |
|
||||
| `APPROVAL_SESSION_TTL` | `15m` | Lebensdauer einer Approval-Session. |
|
||||
| `APPROVAL_CHALLENGE_TTL` | `2m` | Max. Zeit zwischen Preview und Confirm. |
|
||||
| `APPROVAL_WEBAUTHN_RP_ID` | `trust.basicstack.de` | WebAuthn relying party ID. |
|
||||
| `APPROVAL_WEBAUTHN_RP_ORIGIN` | `https://trust.basicstack.de` | Erlaubter Origin in `client_data_json`. |
|
||||
| `APPROVAL_POCKET_ID_ISSUER` | *(required in prod)* | Pocket-ID OIDC-Issuer. |
|
||||
| `APPROVAL_POCKET_ID_CLIENT` | *(required in prod)* | Client-ID des approval-service. |
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
cd services/approval-service
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Deckt ab:
|
||||
|
||||
- Happy Path (Session → Preview → Confirm → DSSE verifiziert und ins TLog
|
||||
eingetragen).
|
||||
- Tampered `H_content` in der Assertion → 400.
|
||||
- Abgelaufene Challenge → 403.
|
||||
- Signing-Service antwortet nicht (connection refused) → 502.
|
||||
- TLog-Submit scheitert → Approval bleibt `attestation-pending`.
|
||||
149
services/approval-service/cmd/approval-service/main.go
Normal file
149
services/approval-service/cmd/approval-service/main.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Command approval-service is the Phase-1 ProofedMesh content-binding
|
||||
// approval HTTP server. See services/approval-service/README.md for the
|
||||
// endpoint contract and configuration.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/httpapi"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/service"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/tlog"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := struct {
|
||||
Listen string
|
||||
SigningURL string
|
||||
SigningKeyRef string
|
||||
SigningToken string
|
||||
TLogURL string
|
||||
SessionTTL time.Duration
|
||||
ChallengeTTL time.Duration
|
||||
WebauthnRPID string
|
||||
WebauthnOrigins string
|
||||
AttestationIss string
|
||||
}{}
|
||||
flag.StringVar(&cfg.Listen, "listen", envOr("APPROVAL_LISTEN", ":8080"), "HTTP listen address")
|
||||
flag.StringVar(&cfg.SigningURL, "signing-url", os.Getenv("APPROVAL_SIGNING_URL"), "signing-service base URL")
|
||||
flag.StringVar(&cfg.SigningKeyRef, "signing-key-ref", envOr("APPROVAL_SIGNING_KEY_REF", "trust-api/approval-signer-2026-q4"), "DSSE signing key reference")
|
||||
flag.StringVar(&cfg.SigningToken, "signing-token", os.Getenv("APPROVAL_SIGNING_TOKEN"), "Bearer token for signing-service (Phase 1 static)")
|
||||
flag.StringVar(&cfg.TLogURL, "tlog-url", os.Getenv("APPROVAL_TLOG_URL"), "transparency-log base URL")
|
||||
flag.DurationVar(&cfg.SessionTTL, "session-ttl", envDur("APPROVAL_SESSION_TTL", 15*time.Minute), "approval session lifetime")
|
||||
flag.DurationVar(&cfg.ChallengeTTL, "challenge-ttl", envDur("APPROVAL_CHALLENGE_TTL", 2*time.Minute), "challenge lifetime between preview and confirm")
|
||||
flag.StringVar(&cfg.WebauthnRPID, "webauthn-rp-id", envOr("APPROVAL_WEBAUTHN_RP_ID", "trust.basicstack.de"), "WebAuthn relying party ID")
|
||||
flag.StringVar(&cfg.WebauthnOrigins, "webauthn-origins", envOr("APPROVAL_WEBAUTHN_RP_ORIGIN", "https://trust.basicstack.de"), "comma-separated allowed WebAuthn origins")
|
||||
flag.StringVar(&cfg.AttestationIss, "attestation-issuer", envOr("APPROVAL_POCKET_ID_ISSUER", "https://auth.basicstack.de"), "issuer embedded in attestations")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
if cfg.SigningURL == "" {
|
||||
fatal(logger, errors.New("APPROVAL_SIGNING_URL (or -signing-url) is required"))
|
||||
}
|
||||
if cfg.TLogURL == "" {
|
||||
fatal(logger, errors.New("APPROVAL_TLOG_URL (or -tlog-url) is required"))
|
||||
}
|
||||
|
||||
st := store.NewMemory()
|
||||
mgr := sessions.New(st)
|
||||
mgr.SessionTTL = cfg.SessionTTL
|
||||
mgr.ChallengeTTL = cfg.ChallengeTTL
|
||||
|
||||
signClient := signer.Client{
|
||||
BaseURL: cfg.SigningURL,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
TokenSource: signer.StaticToken(cfg.SigningToken),
|
||||
}
|
||||
tlogClient := tlog.Client{
|
||||
BaseURL: cfg.TLogURL,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
orch := &service.Orchestrator{
|
||||
Store: st,
|
||||
Sessions: mgr,
|
||||
Webauthn: webauthn.NewStore(),
|
||||
WebauthnCfg: webauthn.VerifyConfig{
|
||||
RPID: cfg.WebauthnRPID,
|
||||
AllowedOrigins: splitAndTrim(cfg.WebauthnOrigins, ","),
|
||||
},
|
||||
Signer: signClient,
|
||||
Submitter: tlogClient,
|
||||
SigningKeyRef: cfg.SigningKeyRef,
|
||||
AttestationIssuer: cfg.AttestationIss,
|
||||
}
|
||||
|
||||
server := &httpapi.Server{
|
||||
// Phase 1 wires an "unauthenticated" dev resolver — the Pocket-ID
|
||||
// cookie middleware (shared with admin-api) attaches in deployment.
|
||||
// This default is deliberately noisy so misconfigured deployments
|
||||
// fail closed rather than silently accepting anonymous callers.
|
||||
Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
||||
return auth.Principal{}, errors.New("approval-service: no auth middleware mounted; wire Pocket-ID before serving traffic")
|
||||
}),
|
||||
Sessions: mgr,
|
||||
Orchestrator: orch,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server.Mount(mux)
|
||||
|
||||
logger.Info("approval-service starting",
|
||||
"listen", cfg.Listen,
|
||||
"signing_url", cfg.SigningURL,
|
||||
"tlog_url", cfg.TLogURL,
|
||||
"webauthn_rp_id", cfg.WebauthnRPID,
|
||||
)
|
||||
if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
|
||||
fatal(logger, err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDur(key string, fallback time.Duration) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func splitAndTrim(s, sep string) []string {
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fatal(logger *slog.Logger, err error) {
|
||||
logger.Error("approval-service: fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
3
services/approval-service/go.mod
Normal file
3
services/approval-service/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module basicstack.de/proofedmesh/services/approval-service
|
||||
|
||||
go 1.23
|
||||
62
services/approval-service/internal/auth/context.go
Normal file
62
services/approval-service/internal/auth/context.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Package auth carries the authenticated principal across the HTTP stack
|
||||
// so handlers do not re-decode the Pocket-ID session cookie on each call.
|
||||
// The Pocket-ID verification lives in services/admin-api/internal/auth; the
|
||||
// approval-service accepts a Principal already stamped by the gateway or
|
||||
// its own future middleware wrap (Phase 2).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
principalKey ctxKey = iota
|
||||
sessionKey
|
||||
)
|
||||
|
||||
// Principal is the subject identifier (Pocket-ID `sub`) of the browser user
|
||||
// driving the approval. Attestations will carry it as `performed_by.sub`.
|
||||
type Principal struct {
|
||||
Sub string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// ErrNoPrincipal is returned by MustPrincipal when the request context did
|
||||
// not carry an authenticated principal.
|
||||
var ErrNoPrincipal = errors.New("auth: request is not authenticated")
|
||||
|
||||
// WithPrincipal returns a copy of ctx carrying p.
|
||||
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
||||
return context.WithValue(ctx, principalKey, p)
|
||||
}
|
||||
|
||||
// FromContext returns the Principal stamped by WithPrincipal.
|
||||
func FromContext(ctx context.Context) (Principal, bool) {
|
||||
p, ok := ctx.Value(principalKey).(Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// MustFromContext is a convenience that returns ErrNoPrincipal when no
|
||||
// principal is present.
|
||||
func MustFromContext(ctx context.Context) (Principal, error) {
|
||||
p, ok := FromContext(ctx)
|
||||
if !ok || p.Sub == "" {
|
||||
return Principal{}, ErrNoPrincipal
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// WithSession binds the Pocket-ID `sid` claim to ctx. Approval-sessions
|
||||
// scope themselves to the browser login via this value.
|
||||
func WithSession(ctx context.Context, sid string) context.Context {
|
||||
return context.WithValue(ctx, sessionKey, sid)
|
||||
}
|
||||
|
||||
// SessionFromContext returns the browser sid carried in ctx.
|
||||
func SessionFromContext(ctx context.Context) (string, bool) {
|
||||
s, ok := ctx.Value(sessionKey).(string)
|
||||
return s, ok && s != ""
|
||||
}
|
||||
105
services/approval-service/internal/canonical/canonical.go
Normal file
105
services/approval-service/internal/canonical/canonical.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Package canonical implements the stable JSON serialisation the
|
||||
// approval-service uses to compute H_content. It MUST stay byte-for-byte
|
||||
// compatible with services/signing-service/internal/canonical/canonical.go
|
||||
// and services/admin-api/internal/attest/hash.go, otherwise the signing
|
||||
// service's content_hash of the Statement would not match the H_content we
|
||||
// bound into the WebAuthn challenge.
|
||||
//
|
||||
// Not a full RFC 8785 implementation: all inputs we hash are strings,
|
||||
// nulls, bools, numbers (as float64 after a json.Unmarshal round-trip), and
|
||||
// nested arrays/maps of those. encoding/json emits stable output for the
|
||||
// leaves; we only force deterministic object key ordering.
|
||||
package canonical
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SHA256Hex returns the hex-encoded SHA-256 of b.
|
||||
func SHA256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Marshal returns the canonical JSON encoding of v (sorted object keys).
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
first, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var any_ any
|
||||
if err := json.Unmarshal(first, &any_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeSorted(&buf, any_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Hash returns the sha256:<hex> content hash of v's canonical JSON.
|
||||
func Hash(v any) (string, []byte, error) {
|
||||
raw, err := Marshal(v)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "sha256:" + SHA256Hex(raw), raw, nil
|
||||
}
|
||||
|
||||
func writeSorted(buf *bytes.Buffer, v any) error {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
buf.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
buf.WriteString("true")
|
||||
} else {
|
||||
buf.WriteString("false")
|
||||
}
|
||||
case float64:
|
||||
b, _ := json.Marshal(x)
|
||||
buf.Write(b)
|
||||
case string:
|
||||
b, _ := json.Marshal(x)
|
||||
buf.Write(b)
|
||||
case []any:
|
||||
buf.WriteByte('[')
|
||||
for i, el := range x {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
if err := writeSorted(buf, el); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
kb, _ := json.Marshal(k)
|
||||
buf.Write(kb)
|
||||
buf.WriteByte(':')
|
||||
if err := writeSorted(buf, x[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("canonical.writeSorted: unsupported type %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package canonical
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMarshalSortsKeys(t *testing.T) {
|
||||
got, err := Marshal(map[string]any{"b": 1, "a": 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both keys must be present and sorted by name
|
||||
want := `{"a":2,"b":1}`
|
||||
if string(got) != want {
|
||||
t.Errorf("got %q want %q", string(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStableAcrossRuns(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"outer": map[string]any{"z": 1, "a": 2, "m": 3},
|
||||
"arr": []any{"3", "1", "2"},
|
||||
}
|
||||
a, _ := Marshal(input)
|
||||
b, _ := Marshal(input)
|
||||
if string(a) != string(b) {
|
||||
t.Errorf("not stable:\n%s\nvs\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashReturnsPrefix(t *testing.T) {
|
||||
hash, raw, err := Hash(map[string]any{"k": "v"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hash[:7] != "sha256:" {
|
||||
t.Errorf("hash missing sha256: prefix: %q", hash)
|
||||
}
|
||||
if string(raw) != `{"k":"v"}` {
|
||||
t.Errorf("canonical form: %q", raw)
|
||||
}
|
||||
}
|
||||
117
services/approval-service/internal/content/binding.go
Normal file
117
services/approval-service/internal/content/binding.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Package content implements the content-binding primitives of the
|
||||
// approval-service: the H_content hash of a renderable approval payload,
|
||||
// the WebAuthn challenge derivation (nonce || H_content), and the human-
|
||||
// readable verification digits an operator cross-checks against the UI.
|
||||
//
|
||||
// The *content* passed in from the HTTP handler is server-rendered JSON —
|
||||
// not the raw request payload. "Server-rendered" means we have already
|
||||
// resolved template variables, lookups, and defaults, so H_content is a
|
||||
// hash of exactly what the approving human saw. See
|
||||
// docs/architecture/v1.1.md §4.3.
|
||||
package content
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/canonical"
|
||||
)
|
||||
|
||||
// Phase1Rendered is the server-rendered approval payload. For Phase 1 the
|
||||
// approval-service does not own a template language — the caller hands us
|
||||
// the fully resolved object (title, bullets, action, etc.) and we hash it
|
||||
// as-is. Phase 2 will introduce typed template kinds.
|
||||
type Phase1Rendered = map[string]any
|
||||
|
||||
// Binding is everything the preview endpoint persists so the confirm
|
||||
// endpoint can verify the WebAuthn assertion unambiguously.
|
||||
type Binding struct {
|
||||
// ContentCanonical is the canonical JSON bytes of the rendered content.
|
||||
ContentCanonical []byte
|
||||
// ContentHash is "sha256:<hex>" over ContentCanonical.
|
||||
ContentHash string
|
||||
// Salt is 16 random bytes mixed into VerificationDigits so an attacker
|
||||
// who guesses a content payload cannot pre-compute its digits.
|
||||
Salt []byte
|
||||
// Nonce is 32 random bytes forming the first half of the WebAuthn
|
||||
// challenge; the second half is the raw sha256 of ContentCanonical.
|
||||
Nonce []byte
|
||||
// Challenge is Nonce || sha256(ContentCanonical). Base64url-encoded on
|
||||
// the wire inside the WebAuthn ClientDataJSON `challenge` field.
|
||||
Challenge []byte
|
||||
// VerificationDigits is a short 6-digit number derived from the
|
||||
// H_content + Salt, shown to the human for cross-checking.
|
||||
VerificationDigits string
|
||||
}
|
||||
|
||||
// Bind computes the full content-binding for rendered. The caller MUST
|
||||
// pass server-rendered content (NOT raw user input).
|
||||
func Bind(rendered Phase1Rendered) (Binding, error) {
|
||||
if rendered == nil {
|
||||
return Binding{}, errors.New("content: rendered payload is nil")
|
||||
}
|
||||
raw, err := canonical.Marshal(rendered)
|
||||
if err != nil {
|
||||
return Binding{}, fmt.Errorf("canonicalise rendered: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return Binding{}, fmt.Errorf("salt: %w", err)
|
||||
}
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return Binding{}, fmt.Errorf("nonce: %w", err)
|
||||
}
|
||||
challenge := make([]byte, 0, len(nonce)+len(sum))
|
||||
challenge = append(challenge, nonce...)
|
||||
challenge = append(challenge, sum[:]...)
|
||||
return Binding{
|
||||
ContentCanonical: raw,
|
||||
ContentHash: "sha256:" + hex.EncodeToString(sum[:]),
|
||||
Salt: salt,
|
||||
Nonce: nonce,
|
||||
Challenge: challenge,
|
||||
VerificationDigits: digits(sum[:], salt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RehashCanonical takes previously-stored canonical bytes and recomputes
|
||||
// the hash. Used on the confirm path to prove the preview's canonical
|
||||
// bytes still produce the expected ContentHash — defense-in-depth against
|
||||
// store tampering.
|
||||
func RehashCanonical(canonicalJSON []byte) string {
|
||||
sum := sha256.Sum256(canonicalJSON)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ChallengeFor returns the canonical WebAuthn challenge bytes given a
|
||||
// stored (nonce, canonicalJSON) pair. We re-derive the challenge rather
|
||||
// than storing it so a corrupted challenge column cannot smuggle an
|
||||
// attacker-chosen value into the signature path.
|
||||
func ChallengeFor(nonce, canonicalJSON []byte) []byte {
|
||||
sum := sha256.Sum256(canonicalJSON)
|
||||
out := make([]byte, 0, len(nonce)+len(sum))
|
||||
out = append(out, nonce...)
|
||||
out = append(out, sum[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// digits folds H_content and salt into a 6-digit decimal code. The salt
|
||||
// prevents an attacker who knows a candidate rendered payload from pre-
|
||||
// computing the digits an operator will see.
|
||||
func digits(hContent, salt []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("verification-digits/v1\x00"))
|
||||
h.Write(hContent)
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(salt)
|
||||
sum := h.Sum(nil)
|
||||
// take the first 8 bytes as a big-endian uint64, mod 10^6 for 6 digits.
|
||||
n := binary.BigEndian.Uint64(sum[:8]) % 1_000_000
|
||||
return fmt.Sprintf("%06d", n)
|
||||
}
|
||||
89
services/approval-service/internal/content/binding_test.go
Normal file
89
services/approval-service/internal/content/binding_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package content
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBindStableHash(t *testing.T) {
|
||||
payload := Phase1Rendered{
|
||||
"title": "Approve policy change v3",
|
||||
"action": "sign",
|
||||
"detail": map[string]any{"policy_id": "pol-42"},
|
||||
}
|
||||
a, err := Bind(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Bind: %v", err)
|
||||
}
|
||||
b, err := Bind(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Bind again: %v", err)
|
||||
}
|
||||
if a.ContentHash != b.ContentHash {
|
||||
t.Errorf("content_hash not stable: %q vs %q", a.ContentHash, b.ContentHash)
|
||||
}
|
||||
if !bytes.Equal(a.ContentCanonical, b.ContentCanonical) {
|
||||
t.Errorf("canonical bytes not stable")
|
||||
}
|
||||
if bytes.Equal(a.Salt, b.Salt) {
|
||||
t.Errorf("salt should be random (equal across two calls)")
|
||||
}
|
||||
if bytes.Equal(a.Nonce, b.Nonce) {
|
||||
t.Errorf("nonce should be random (equal across two calls)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsNil(t *testing.T) {
|
||||
if _, err := Bind(nil); err == nil {
|
||||
t.Fatalf("Bind(nil) must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDigitsFormat(t *testing.T) {
|
||||
b, err := Bind(Phase1Rendered{"k": "v"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := len(b.VerificationDigits); l != 6 {
|
||||
t.Errorf("want 6 digits, got %d (%q)", l, b.VerificationDigits)
|
||||
}
|
||||
for _, r := range b.VerificationDigits {
|
||||
if r < '0' || r > '9' {
|
||||
t.Errorf("non-digit %q in verification digits %q", r, b.VerificationDigits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeFor_Deterministic(t *testing.T) {
|
||||
b, err := Bind(Phase1Rendered{"k": "v"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ChallengeFor(b.Nonce, b.ContentCanonical)
|
||||
if !bytes.Equal(got, b.Challenge) {
|
||||
t.Errorf("ChallengeFor must reproduce the challenge")
|
||||
}
|
||||
// Flip a byte in canonical content — challenge must change.
|
||||
alt := append([]byte(nil), b.ContentCanonical...)
|
||||
alt[0] ^= 0xff
|
||||
if bytes.Equal(ChallengeFor(b.Nonce, alt), b.Challenge) {
|
||||
t.Errorf("challenge did not change when canonical content was tampered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRehashCanonicalMatchesBind(t *testing.T) {
|
||||
b, err := Bind(Phase1Rendered{"foo": "bar"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := RehashCanonical(b.ContentCanonical); got != b.ContentHash {
|
||||
t.Errorf("RehashCanonical=%q want %q", got, b.ContentHash)
|
||||
}
|
||||
// Sanity: computed independently
|
||||
sum := sha256.Sum256(b.ContentCanonical)
|
||||
if "sha256:"+hex.EncodeToString(sum[:]) != b.ContentHash {
|
||||
t.Errorf("ContentHash does not match computed sha256")
|
||||
}
|
||||
}
|
||||
245
services/approval-service/internal/httpapi/server.go
Normal file
245
services/approval-service/internal/httpapi/server.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// Package httpapi mounts the approval-service HTTP endpoints.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/content"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/service"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||||
pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi"
|
||||
)
|
||||
|
||||
// AuthResolver resolves the authenticated principal from the request.
|
||||
// Prod wires it to the Pocket-ID cookie middleware; tests inject a
|
||||
// fixed principal via a shim.
|
||||
type AuthResolver interface {
|
||||
Resolve(r *http.Request) (auth.Principal, error)
|
||||
}
|
||||
|
||||
// AuthFunc adapts a plain function to AuthResolver.
|
||||
type AuthFunc func(r *http.Request) (auth.Principal, error)
|
||||
|
||||
// Resolve calls f.
|
||||
func (f AuthFunc) Resolve(r *http.Request) (auth.Principal, error) { return f(r) }
|
||||
|
||||
// Server is the HTTP handler for the approval-service.
|
||||
type Server struct {
|
||||
Auth AuthResolver
|
||||
Sessions *sessions.Manager
|
||||
Orchestrator *service.Orchestrator
|
||||
Logger *slog.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Mount registers the three API routes plus /healthz on mux.
|
||||
func (s *Server) Mount(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/sessions", s.handleCreateSession)
|
||||
mux.HandleFunc("POST /api/v1/approvals/preview", s.handlePreview)
|
||||
mux.HandleFunc("POST /api/v1/approvals/confirm", s.handleConfirm)
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.resolve(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
// POST /sessions has no body in Phase 1; the Pocket-ID principal
|
||||
// alone identifies the user.
|
||||
sess, err := s.Sessions.StartSession(r.Context(), p.Sub)
|
||||
if err != nil {
|
||||
s.logError("start session failed", err)
|
||||
writeErr(w, http.StatusInternalServerError, "session_create_failed", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, pkgapi.CreateSessionResponse{
|
||||
SessionID: sess.ID,
|
||||
UserSub: sess.UserSub,
|
||||
ActiveUntil: sess.ActiveUntil,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.resolve(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
var req pkgapi.PreviewRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "session_id is required")
|
||||
return
|
||||
}
|
||||
if len(req.Content) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "content is required")
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := s.Sessions.GetActiveSession(r.Context(), req.SessionID, p.Sub)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeErr(w, http.StatusNotFound, "session_not_found", "session_id unknown")
|
||||
return
|
||||
case errors.Is(err, sessions.ErrSessionExpired):
|
||||
writeErr(w, http.StatusForbidden, "session_expired", "approval session expired")
|
||||
return
|
||||
case err != nil:
|
||||
s.logError("session lookup failed", err)
|
||||
writeErr(w, http.StatusInternalServerError, "session_lookup_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bind, err := content.Bind(req.Content)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "content: "+err.Error())
|
||||
return
|
||||
}
|
||||
previewID, err := s.Orchestrator.NewID("pv")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "id_gen_failed", err.Error())
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
pv := store.Preview{
|
||||
ID: previewID,
|
||||
SessionID: sess.ID,
|
||||
UserSub: p.Sub,
|
||||
ContentHash: bind.ContentHash,
|
||||
ContentCanonical: bind.ContentCanonical,
|
||||
Salt: bind.Salt,
|
||||
Nonce: bind.Nonce,
|
||||
VerificationDigits: bind.VerificationDigits,
|
||||
ExpiresAt: now.Add(s.Sessions.ChallengeTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := s.Orchestrator.Store.SavePreview(r.Context(), pv); err != nil {
|
||||
s.logError("save preview failed", err)
|
||||
writeErr(w, http.StatusInternalServerError, "save_preview_failed", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pkgapi.PreviewResponse{
|
||||
PreviewID: pv.ID,
|
||||
ContentHash: pv.ContentHash,
|
||||
VerificationDigits: pv.VerificationDigits,
|
||||
WebauthnChallenge: base64.RawURLEncoding.EncodeToString(bind.Challenge),
|
||||
ExpiresAt: pv.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.resolve(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
var req pkgapi.ConfirmRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.PreviewID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "bad_request", "preview_id is required")
|
||||
return
|
||||
}
|
||||
assertion := webauthn.Assertion(req.Assertion)
|
||||
res, err := s.Orchestrator.Confirm(r.Context(), req.PreviewID, assertion, p.Sub)
|
||||
if err != nil {
|
||||
s.mapConfirmError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pkgapi.ConfirmResponse{
|
||||
ApprovalID: res.ApprovalID,
|
||||
AttestationRef: res.AttestationRef,
|
||||
LogIndex: res.LogIndex,
|
||||
TLogStatus: string(res.TLogStatus),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mapConfirmError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeErr(w, http.StatusNotFound, "preview_not_found", "preview_id unknown")
|
||||
case errors.Is(err, store.ErrAlreadyConsumed):
|
||||
writeErr(w, http.StatusConflict, "preview_consumed", "preview already consumed")
|
||||
case errors.Is(err, service.ErrChallengeExpired):
|
||||
writeErr(w, http.StatusForbidden, "challenge_expired", "approval challenge expired")
|
||||
case errors.Is(err, service.ErrContentHashMismatch):
|
||||
writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error())
|
||||
case errors.Is(err, webauthn.ErrChallengeMismatch):
|
||||
writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error())
|
||||
case errors.Is(err, webauthn.ErrCredentialUnknown):
|
||||
writeErr(w, http.StatusUnauthorized, "credential_unknown", "credential_id not registered")
|
||||
case errors.Is(err, webauthn.ErrAssertionInvalid):
|
||||
writeErr(w, http.StatusUnauthorized, "assertion_invalid", "webauthn assertion verification failed")
|
||||
case errors.Is(err, signer.ErrSignerUnavailable):
|
||||
writeErr(w, http.StatusBadGateway, "signer_unavailable", err.Error())
|
||||
default:
|
||||
s.logError("confirm failed", err)
|
||||
writeErr(w, http.StatusInternalServerError, "confirm_failed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) resolve(r *http.Request) (auth.Principal, error) {
|
||||
if s.Auth == nil {
|
||||
return auth.Principal{}, errors.New("no auth resolver configured")
|
||||
}
|
||||
return s.Auth.Resolve(r)
|
||||
}
|
||||
|
||||
func (s *Server) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (s *Server) logError(msg string, err error) {
|
||||
if s.Logger != nil {
|
||||
s.Logger.Error("approval-service: "+msg, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, pkgapi.Error{Error: pkgapi.ErrorBody{Code: code, Message: msg}})
|
||||
}
|
||||
|
||||
// Context sanity: ensure we never return a canceled error as a 500 just
|
||||
// because the client disconnected mid-flight.
|
||||
var _ context.Context = context.Background()
|
||||
463
services/approval-service/internal/httpapi/server_test.go
Normal file
463
services/approval-service/internal/httpapi/server_test.go
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/httpapi"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/service"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/tlog"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||||
pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi"
|
||||
)
|
||||
|
||||
// fixture bundles everything a test needs to drive the HTTP server.
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
userSub string
|
||||
credID string
|
||||
credPriv ed25519.PrivateKey
|
||||
rpID string
|
||||
origin string
|
||||
fakeSigner *fakeSigner
|
||||
fakeTLog *fakeTLog
|
||||
store *store.Memory
|
||||
}
|
||||
|
||||
type fakeSigner struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
wantKeyRef string
|
||||
seenStatement signer.Statement
|
||||
}
|
||||
|
||||
func (f *fakeSigner) SignDSSE(_ context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.err != nil {
|
||||
return signer.Envelope{}, "", f.err
|
||||
}
|
||||
if f.wantKeyRef != "" && keyRef != f.wantKeyRef {
|
||||
return signer.Envelope{}, "", errors.New("unexpected keyRef")
|
||||
}
|
||||
f.seenStatement = stmt
|
||||
// Produce a deterministic envelope so tests can assert on it.
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte("payload-bytes"))
|
||||
env := signer.Envelope{
|
||||
PayloadType: "application/vnd.in-toto+json",
|
||||
Payload: payload,
|
||||
Signatures: []signer.EnvelopeSignature{{KeyID: keyRef, Sig: "AA"}},
|
||||
}
|
||||
envBytes, _ := json.Marshal(env)
|
||||
sum := sha256.Sum256(envBytes)
|
||||
return env, "sha256:" + hex(sum[:]), nil
|
||||
}
|
||||
|
||||
type fakeTLog struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
submits int
|
||||
lastHash string
|
||||
}
|
||||
|
||||
func (f *fakeTLog) Submit(_ context.Context, _ signer.Envelope, contentHash string) (tlog.Receipt, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.submits++
|
||||
f.lastHash = contentHash
|
||||
if f.err != nil {
|
||||
return tlog.Receipt{}, f.err
|
||||
}
|
||||
return tlog.Receipt{
|
||||
LogIndex: 42,
|
||||
ContentHash: contentHash,
|
||||
LoggedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newFixture wires a Server backed by an in-memory store + fakes. A single
|
||||
// WebAuthn credential is pre-registered for userSub so tests can confirm
|
||||
// previews without going through a real enrollment ceremony.
|
||||
func newFixture(t *testing.T, opts ...func(*service.Orchestrator)) *fixture {
|
||||
t.Helper()
|
||||
st := store.NewMemory()
|
||||
mgr := sessions.New(st)
|
||||
mgr.SessionTTL = 15 * time.Minute
|
||||
mgr.ChallengeTTL = 2 * time.Minute
|
||||
|
||||
userSub := "user-alice"
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
idBytes := make([]byte, 16)
|
||||
_, _ = rand.Read(idBytes)
|
||||
credID := base64.RawURLEncoding.EncodeToString(idBytes)
|
||||
webStore := webauthn.NewStore()
|
||||
if err := webStore.AddCredential(webauthn.Credential{
|
||||
ID: credID,
|
||||
UserSub: userSub,
|
||||
PublicKey: pub,
|
||||
}); err != nil {
|
||||
t.Fatalf("register credential: %v", err)
|
||||
}
|
||||
|
||||
rpID := "trust.basicstack.de"
|
||||
origin := "https://trust.basicstack.de"
|
||||
|
||||
sig := &fakeSigner{wantKeyRef: "trust-api/approval-signer-2026-q4"}
|
||||
submitter := &fakeTLog{}
|
||||
|
||||
orch := &service.Orchestrator{
|
||||
Store: st,
|
||||
Sessions: mgr,
|
||||
Webauthn: webStore,
|
||||
WebauthnCfg: webauthn.VerifyConfig{
|
||||
RPID: rpID,
|
||||
AllowedOrigins: []string{origin},
|
||||
},
|
||||
Signer: sig,
|
||||
Submitter: submitter,
|
||||
SigningKeyRef: "trust-api/approval-signer-2026-q4",
|
||||
AttestationIssuer: "https://auth.basicstack.de",
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(orch)
|
||||
}
|
||||
|
||||
srv := &httpapi.Server{
|
||||
Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
||||
return auth.Principal{Sub: userSub, Groups: []string{"proofedmesh-admin"}}, nil
|
||||
}),
|
||||
Sessions: mgr,
|
||||
Orchestrator: orch,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
srv.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return &fixture{
|
||||
t: t,
|
||||
server: ts,
|
||||
userSub: userSub,
|
||||
credID: credID,
|
||||
credPriv: priv,
|
||||
rpID: rpID,
|
||||
origin: origin,
|
||||
fakeSigner: sig,
|
||||
fakeTLog: submitter,
|
||||
store: st,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) startSession() pkgapi.CreateSessionResponse {
|
||||
f.t.Helper()
|
||||
var out pkgapi.CreateSessionResponse
|
||||
f.do("POST", "/api/v1/sessions", nil, http.StatusCreated, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fixture) preview(sessionID string, content map[string]any, want int) pkgapi.PreviewResponse {
|
||||
f.t.Helper()
|
||||
var out pkgapi.PreviewResponse
|
||||
f.do("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{
|
||||
SessionID: sessionID,
|
||||
Content: content,
|
||||
}, want, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fixture) confirm(req pkgapi.ConfirmRequest, want int) (*pkgapi.ConfirmResponse, *pkgapi.Error) {
|
||||
f.t.Helper()
|
||||
resp := f.doRaw("POST", "/api/v1/approvals/confirm", req)
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
f.t.Fatalf("confirm: status %d want %d: %s", resp.StatusCode, want, string(body))
|
||||
}
|
||||
if want >= 200 && want < 300 {
|
||||
var out pkgapi.ConfirmResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
f.t.Fatalf("decode confirm: %v (%s)", err, string(body))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
var ebody pkgapi.Error
|
||||
if err := json.Unmarshal(body, &ebody); err != nil {
|
||||
f.t.Fatalf("decode confirm error: %v (%s)", err, string(body))
|
||||
}
|
||||
return nil, &ebody
|
||||
}
|
||||
|
||||
func (f *fixture) do(method, path string, body any, want int, out any) {
|
||||
f.t.Helper()
|
||||
resp := f.doRaw(method, path, body)
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
f.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, string(raw))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
f.t.Fatalf("decode %s: %v (%s)", path, err, string(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) doRaw(method, path string, body any) *http.Response {
|
||||
f.t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
f.t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, f.server.URL+path, r)
|
||||
if err != nil {
|
||||
f.t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := f.server.Client().Do(req)
|
||||
if err != nil {
|
||||
f.t.Fatalf("do: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (f *fixture) signAssertion(challenge []byte) pkgapi.Assertion {
|
||||
f.t.Helper()
|
||||
cd := map[string]string{
|
||||
"type": "webauthn.get",
|
||||
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
|
||||
"origin": f.origin,
|
||||
}
|
||||
cdBytes, _ := json.Marshal(cd)
|
||||
authData := webauthn.MakeAuthenticatorData(f.rpID)
|
||||
cdHash := sha256.Sum256(cdBytes)
|
||||
signed := append(append([]byte(nil), authData...), cdHash[:]...)
|
||||
sig := ed25519.Sign(f.credPriv, signed)
|
||||
return pkgapi.Assertion{
|
||||
CredentialID: f.credID,
|
||||
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes),
|
||||
AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData),
|
||||
Signature: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}
|
||||
}
|
||||
|
||||
func hex(b []byte) string {
|
||||
const hexchars = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, c := range b {
|
||||
out[i*2] = hexchars[c>>4]
|
||||
out[i*2+1] = hexchars[c&0x0f]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// --- actual tests ---------------------------------------------------------
|
||||
|
||||
func TestHappyPath(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sess := f.startSession()
|
||||
if sess.SessionID == "" {
|
||||
t.Fatalf("no session_id returned")
|
||||
}
|
||||
if sess.UserSub != f.userSub {
|
||||
t.Errorf("session.user_sub = %q want %q", sess.UserSub, f.userSub)
|
||||
}
|
||||
|
||||
pv := f.preview(sess.SessionID, map[string]any{
|
||||
"title": "Approve rate change",
|
||||
"action": "sign",
|
||||
}, http.StatusOK)
|
||||
if !strings.HasPrefix(pv.ContentHash, "sha256:") {
|
||||
t.Errorf("preview content_hash: %q", pv.ContentHash)
|
||||
}
|
||||
if pv.VerificationDigits == "" {
|
||||
t.Errorf("empty verification digits")
|
||||
}
|
||||
|
||||
challenge, err := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
||||
if err != nil {
|
||||
t.Fatalf("decode challenge: %v", err)
|
||||
}
|
||||
assertion := f.signAssertion(challenge)
|
||||
ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
|
||||
if ok.TLogStatus != "logged" {
|
||||
t.Errorf("tlog status: %q want logged", ok.TLogStatus)
|
||||
}
|
||||
if ok.LogIndex == nil || *ok.LogIndex != 42 {
|
||||
t.Errorf("log_index = %v want 42", ok.LogIndex)
|
||||
}
|
||||
if ok.AttestationRef == "" || !strings.HasPrefix(ok.AttestationRef, "sha256:") {
|
||||
t.Errorf("attestation_ref: %q", ok.AttestationRef)
|
||||
}
|
||||
|
||||
// Assert the signer saw a Statement whose subject.digest.sha256 is
|
||||
// the H_content of the preview (no "sha256:" prefix per in-toto).
|
||||
f.fakeSigner.mu.Lock()
|
||||
stmt := f.fakeSigner.seenStatement
|
||||
f.fakeSigner.mu.Unlock()
|
||||
subj, _ := stmt["subject"].([]any)
|
||||
if len(subj) != 1 {
|
||||
t.Fatalf("statement subject: %+v", subj)
|
||||
}
|
||||
first, _ := subj[0].(map[string]any)
|
||||
digest, _ := first["digest"].(map[string]any)
|
||||
gotDigest, _ := digest["sha256"].(string)
|
||||
wantDigest := strings.TrimPrefix(pv.ContentHash, "sha256:")
|
||||
if gotDigest != wantDigest {
|
||||
t.Errorf("signed subject digest=%q want %q", gotDigest, wantDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm_TamperedHContent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sess := f.startSession()
|
||||
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
||||
|
||||
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
||||
// Flip the last byte of the challenge (equivalent to signing with a
|
||||
// different H_content), the signature will be over wrong data.
|
||||
tampered := append([]byte(nil), challenge...)
|
||||
tampered[len(tampered)-1] ^= 0xff
|
||||
assertion := f.signAssertion(tampered)
|
||||
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadRequest)
|
||||
if errBody.Error.Code != "content_hash_mismatch" {
|
||||
t.Errorf("error code = %q want content_hash_mismatch", errBody.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm_ExpiredChallenge(t *testing.T) {
|
||||
// Force a short challenge TTL so we can race the clock.
|
||||
f := newFixture(t)
|
||||
sess := f.startSession()
|
||||
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
||||
|
||||
// Walk the preview's ExpiresAt backward directly in the store to
|
||||
// simulate an expired challenge without a sleep.
|
||||
prev, err := f.store.GetPreview(context.Background(), pv.PreviewID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPreview: %v", err)
|
||||
}
|
||||
prev.ExpiresAt = time.Now().Add(-1 * time.Second)
|
||||
if err := f.store.SavePreview(context.Background(), prev); err != nil {
|
||||
t.Fatalf("SavePreview: %v", err)
|
||||
}
|
||||
|
||||
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
||||
assertion := f.signAssertion(challenge)
|
||||
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusForbidden)
|
||||
if errBody.Error.Code != "challenge_expired" {
|
||||
t.Errorf("error code = %q want challenge_expired", errBody.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm_SignerUnavailable(t *testing.T) {
|
||||
f := newFixture(t, func(o *service.Orchestrator) {
|
||||
o.Signer = &fakeSigner{err: signer.ErrSignerUnavailable}
|
||||
})
|
||||
sess := f.startSession()
|
||||
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
||||
|
||||
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
||||
assertion := f.signAssertion(challenge)
|
||||
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadGateway)
|
||||
if errBody.Error.Code != "signer_unavailable" {
|
||||
t.Errorf("error code = %q want signer_unavailable", errBody.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm_TLogFailureStaysPending(t *testing.T) {
|
||||
f := newFixture(t, func(o *service.Orchestrator) {
|
||||
o.Submitter = &fakeTLog{err: tlog.ErrTLogUnavailable}
|
||||
})
|
||||
sess := f.startSession()
|
||||
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
||||
|
||||
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
||||
assertion := f.signAssertion(challenge)
|
||||
ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
|
||||
if ok.TLogStatus != "attestation-pending" {
|
||||
t.Errorf("tlog status = %q want attestation-pending", ok.TLogStatus)
|
||||
}
|
||||
if ok.LogIndex != nil {
|
||||
t.Errorf("log_index = %v want nil", ok.LogIndex)
|
||||
}
|
||||
// Approval must still have been persisted.
|
||||
appr, ok2 := f.store.GetApproval(ok.ApprovalID)
|
||||
if !ok2 {
|
||||
t.Fatalf("approval not persisted after tlog failure")
|
||||
}
|
||||
if appr.TLogStatus != store.TLogPending {
|
||||
t.Errorf("stored tlog status = %q want attestation-pending", appr.TLogStatus)
|
||||
}
|
||||
if appr.TLogError == "" {
|
||||
t.Errorf("expected tlog_error to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreview_SessionExpired(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sess := f.startSession()
|
||||
// Simulate session expiry.
|
||||
raw, err := f.store.GetSession(context.Background(), sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
raw.ActiveUntil = time.Now().Add(-1 * time.Second)
|
||||
if err := f.store.CreateSession(context.Background(), raw); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
resp := f.doRaw("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{
|
||||
SessionID: sess.SessionID,
|
||||
Content: map[string]any{"k": "v"},
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
raw2, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status %d want 403: %s", resp.StatusCode, raw2)
|
||||
}
|
||||
var ebody pkgapi.Error
|
||||
_ = json.Unmarshal(raw2, &ebody)
|
||||
if ebody.Error.Code != "session_expired" {
|
||||
t.Errorf("error code = %q want session_expired", ebody.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm_PreviewConsumedTwice(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sess := f.startSession()
|
||||
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
||||
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
||||
assertion := f.signAssertion(challenge)
|
||||
_, _ = f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
|
||||
// Second confirm must be refused.
|
||||
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusConflict)
|
||||
if errBody.Error.Code != "preview_consumed" {
|
||||
t.Errorf("error code = %q want preview_consumed", errBody.Error.Code)
|
||||
}
|
||||
}
|
||||
236
services/approval-service/internal/service/orchestrator.go
Normal file
236
services/approval-service/internal/service/orchestrator.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// Package service wires the approval pipeline components (sessions,
|
||||
// content binding, webauthn, DSSE signing, Transparency Log) into one
|
||||
// Confirm orchestrator. Keeping it HTTP-free lets unit tests drive the
|
||||
// whole flow without an httptest server.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/content"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/tlog"
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
|
||||
)
|
||||
|
||||
// Signer is the signing-service client the orchestrator depends on.
|
||||
type Signer interface {
|
||||
SignDSSE(ctx context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error)
|
||||
}
|
||||
|
||||
// Submitter is the transparency-log client the orchestrator depends on.
|
||||
type Submitter interface {
|
||||
Submit(ctx context.Context, env signer.Envelope, contentHash string) (tlog.Receipt, error)
|
||||
}
|
||||
|
||||
// Orchestrator holds everything a Confirm needs. Fields are populated
|
||||
// from main.go; the HTTP handler just calls Confirm.
|
||||
type Orchestrator struct {
|
||||
Store store.Store
|
||||
Sessions *sessions.Manager
|
||||
Webauthn *webauthn.Store
|
||||
WebauthnCfg webauthn.VerifyConfig
|
||||
Signer Signer
|
||||
Submitter Submitter
|
||||
SigningKeyRef string
|
||||
// AttestationIssuer is embedded as predicate.performed_by.iss so
|
||||
// verifiers can match the approval against our Pocket-ID issuer.
|
||||
AttestationIssuer string
|
||||
Now func() time.Time
|
||||
GenID sessions.IDGenerator
|
||||
}
|
||||
|
||||
// ErrContentHashMismatch is surfaced by Confirm when the preview's
|
||||
// stored canonical content no longer hashes to its stored content_hash
|
||||
// (store corruption) OR when the webauthn challenge the browser signed
|
||||
// does not match the challenge we expected for this preview. In both
|
||||
// cases the HTTP layer returns 400 per DEV-608.
|
||||
var ErrContentHashMismatch = errors.New("service: content hash mismatch")
|
||||
|
||||
// ErrChallengeExpired is surfaced by Confirm when the preview's
|
||||
// expires_at is in the past. The HTTP layer returns 403.
|
||||
var ErrChallengeExpired = errors.New("service: challenge expired")
|
||||
|
||||
// ConfirmResult is what Confirm returns to the HTTP handler.
|
||||
type ConfirmResult struct {
|
||||
ApprovalID string
|
||||
AttestationRef string
|
||||
LogIndex *int64
|
||||
TLogStatus store.TLogStatus
|
||||
}
|
||||
|
||||
// Confirm runs the full end-of-pipeline sequence:
|
||||
//
|
||||
// 1. Load the preview (must exist, must not be consumed, must still be
|
||||
// within expires_at).
|
||||
// 2. Re-derive the WebAuthn challenge from the stored (nonce,
|
||||
// canonical) and verify the assertion against it.
|
||||
// 3. Build the DSSE Statement with H_content as the subject digest.
|
||||
// 4. Call the signing-service.
|
||||
// 5. Persist the Approval row (status = attestation-pending).
|
||||
// 6. Submit to the Transparency Log. On success, flip status to
|
||||
// "logged" and attach log_index. On TLog failure, leave the row at
|
||||
// attestation-pending so the reconciler can retry; the HTTP response
|
||||
// still returns ApprovalID + attestation_ref so the UI can carry on.
|
||||
//
|
||||
// The order (persist → submit) is deliberate: we never return a
|
||||
// log_index to the caller for an approval that was not durably stored,
|
||||
// and we never lose a signed envelope because we failed to log it.
|
||||
func (o *Orchestrator) Confirm(ctx context.Context, previewID string, assertion webauthn.Assertion, userSub string) (ConfirmResult, error) {
|
||||
if previewID == "" {
|
||||
return ConfirmResult{}, errors.New("service: previewID is empty")
|
||||
}
|
||||
if userSub == "" {
|
||||
return ConfirmResult{}, errors.New("service: userSub is empty")
|
||||
}
|
||||
now := o.now()
|
||||
|
||||
pv, err := o.Store.GetPreview(ctx, previewID)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if pv.UserSub != userSub {
|
||||
return ConfirmResult{}, store.ErrNotFound
|
||||
}
|
||||
if pv.ConsumedAt != nil {
|
||||
return ConfirmResult{}, store.ErrAlreadyConsumed
|
||||
}
|
||||
if !now.Before(pv.ExpiresAt) {
|
||||
return ConfirmResult{}, ErrChallengeExpired
|
||||
}
|
||||
|
||||
// Defense-in-depth: recompute H_content from the stored canonical
|
||||
// content and make sure it still equals the stored content_hash.
|
||||
if content.RehashCanonical(pv.ContentCanonical) != pv.ContentHash {
|
||||
return ConfirmResult{}, fmt.Errorf("%w: stored canonical content does not match stored content_hash", ErrContentHashMismatch)
|
||||
}
|
||||
|
||||
expectedChallenge := content.ChallengeFor(pv.Nonce, pv.ContentCanonical)
|
||||
cred, err := webauthn.Verify(o.WebauthnCfg, o.Webauthn, assertion, expectedChallenge)
|
||||
if err != nil {
|
||||
if errors.Is(err, webauthn.ErrChallengeMismatch) {
|
||||
return ConfirmResult{}, fmt.Errorf("%w: %v", ErrContentHashMismatch, err)
|
||||
}
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if cred.UserSub != userSub {
|
||||
return ConfirmResult{}, webauthn.ErrAssertionInvalid
|
||||
}
|
||||
|
||||
// Atomically mark the preview consumed so a parallel confirm cannot
|
||||
// reuse the same signed envelope. If this fails ErrAlreadyConsumed
|
||||
// we lose the race and must not continue.
|
||||
if _, err := o.Store.ConsumePreview(ctx, pv.ID, now); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
|
||||
stmt := buildStatement(pv, userSub, o.AttestationIssuer, now)
|
||||
env, envHash, err := o.Signer.SignDSSE(ctx, o.SigningKeyRef, stmt)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
|
||||
approvalID, err := o.genID("ap")
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
|
||||
appr := store.Approval{
|
||||
ID: approvalID,
|
||||
PreviewID: pv.ID,
|
||||
SessionID: pv.SessionID,
|
||||
UserSub: userSub,
|
||||
ContentHash: pv.ContentHash,
|
||||
Envelope: env,
|
||||
AttestationRef: envHash,
|
||||
TLogStatus: store.TLogPending,
|
||||
ConfirmedAt: now,
|
||||
}
|
||||
if err := o.Store.SaveApproval(ctx, appr); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
|
||||
receipt, submitErr := o.Submitter.Submit(ctx, env, envHash)
|
||||
if submitErr != nil {
|
||||
// Approval row stays at attestation-pending; record the error
|
||||
// for diagnostics but return success-with-pending to the client.
|
||||
_ = o.Store.MarkApprovalTLogError(ctx, approvalID, submitErr.Error())
|
||||
return ConfirmResult{
|
||||
ApprovalID: approvalID,
|
||||
AttestationRef: envHash,
|
||||
TLogStatus: store.TLogPending,
|
||||
}, nil
|
||||
}
|
||||
loggedAt := receipt.LoggedAt
|
||||
if loggedAt.IsZero() {
|
||||
loggedAt = now
|
||||
}
|
||||
if err := o.Store.MarkApprovalLogged(ctx, approvalID, receipt.LogIndex, loggedAt); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
idx := receipt.LogIndex
|
||||
return ConfirmResult{
|
||||
ApprovalID: approvalID,
|
||||
AttestationRef: envHash,
|
||||
LogIndex: &idx,
|
||||
TLogStatus: store.TLogLogged,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) now() time.Time {
|
||||
if o.Now != nil {
|
||||
return o.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// NewID returns an opaque ID with the given prefix. Exposed so the HTTP
|
||||
// layer can mint preview_ids with the same injectable generator.
|
||||
func (o *Orchestrator) NewID(prefix string) (string, error) {
|
||||
if o.GenID != nil {
|
||||
return o.GenID(prefix)
|
||||
}
|
||||
return sessions.DefaultIDGenerator(prefix)
|
||||
}
|
||||
|
||||
func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(prefix) }
|
||||
|
||||
// buildStatement renders the in-toto v1 Statement for an approval. The
|
||||
// subject.digest IS the H_content of the preview; verifiers rehash the
|
||||
// stored canonical content to prove the signature covers it.
|
||||
func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.Time) signer.Statement {
|
||||
// Strip the "sha256:" prefix for the digest map, per in-toto convention.
|
||||
digest := pv.ContentHash
|
||||
if len(digest) > 7 && digest[:7] == "sha256:" {
|
||||
digest = digest[7:]
|
||||
}
|
||||
predicate := map[string]any{
|
||||
"approval_schema": "basicstack.de/attestations/approval/v1",
|
||||
"preview_id": pv.ID,
|
||||
"session_id": pv.SessionID,
|
||||
"content_hash": pv.ContentHash,
|
||||
"performed_by": map[string]any{
|
||||
"iss": issuer,
|
||||
"sub": userSub,
|
||||
"session_id": pv.SessionID,
|
||||
},
|
||||
"performed_at": performedAt.UTC().Format(time.RFC3339),
|
||||
"verification_digits": pv.VerificationDigits,
|
||||
}
|
||||
return signer.Statement{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": []any{
|
||||
map[string]any{
|
||||
"name": "approval/" + pv.ID,
|
||||
"digest": map[string]any{"sha256": digest},
|
||||
},
|
||||
},
|
||||
"predicateType": "https://basicstack.de/attestations/approval/v1",
|
||||
"predicate": predicate,
|
||||
}
|
||||
}
|
||||
97
services/approval-service/internal/sessions/sessions.go
Normal file
97
services/approval-service/internal/sessions/sessions.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Package sessions implements the Session → Preview → Confirm pipeline on
|
||||
// top of a Store. Keeping it one layer above the HTTP handlers lets the
|
||||
// unit tests exercise business logic without an httptest server.
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||||
)
|
||||
|
||||
// IDGenerator returns opaque collision-resistant IDs. The default
|
||||
// implementation uses crypto/rand; tests inject a deterministic one.
|
||||
type IDGenerator func(prefix string) (string, error)
|
||||
|
||||
// DefaultIDGenerator produces "<prefix>_" + 24 random base64url chars.
|
||||
func DefaultIDGenerator(prefix string) (string, error) {
|
||||
b := make([]byte, 18)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("id: %w", err)
|
||||
}
|
||||
return prefix + "_" + base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Manager orchestrates sessions on top of a Store. Its only state is the
|
||||
// clock and ID generator; all persistence happens through Store.
|
||||
type Manager struct {
|
||||
Store store.Store
|
||||
Now func() time.Time
|
||||
GenID IDGenerator
|
||||
SessionTTL time.Duration
|
||||
ChallengeTTL time.Duration
|
||||
}
|
||||
|
||||
// New constructs a Manager with safe defaults for Now, GenID, SessionTTL
|
||||
// (15m) and ChallengeTTL (2m) when the caller leaves them zero.
|
||||
func New(s store.Store) *Manager {
|
||||
return &Manager{
|
||||
Store: s,
|
||||
Now: time.Now,
|
||||
GenID: DefaultIDGenerator,
|
||||
SessionTTL: 15 * time.Minute,
|
||||
ChallengeTTL: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// StartSession creates a fresh approval_session for userSub.
|
||||
func (m *Manager) StartSession(ctx context.Context, userSub string) (store.Session, error) {
|
||||
if userSub == "" {
|
||||
return store.Session{}, errors.New("sessions: userSub is empty")
|
||||
}
|
||||
id, err := m.GenID("sess")
|
||||
if err != nil {
|
||||
return store.Session{}, err
|
||||
}
|
||||
now := m.Now()
|
||||
s := store.Session{
|
||||
ID: id,
|
||||
UserSub: userSub,
|
||||
ActiveUntil: now.Add(m.SessionTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := m.Store.CreateSession(ctx, s); err != nil {
|
||||
return store.Session{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ErrSessionExpired is returned by GetActiveSession when the session
|
||||
// row exists but active_until is in the past.
|
||||
var ErrSessionExpired = errors.New("sessions: session expired")
|
||||
|
||||
// GetActiveSession returns the session when it is still within
|
||||
// active_until. Enforces session_id ↔ user_sub binding (the caller passes
|
||||
// the authenticated userSub so a leaked session_id cannot be used from
|
||||
// another account).
|
||||
func (m *Manager) GetActiveSession(ctx context.Context, sessionID, userSub string) (store.Session, error) {
|
||||
s, err := m.Store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return store.Session{}, err
|
||||
}
|
||||
if s.UserSub != userSub {
|
||||
// Explicit NotFound so handlers return 404 instead of leaking the
|
||||
// mismatch — a probing attacker cannot distinguish "wrong user"
|
||||
// from "unknown session".
|
||||
return store.Session{}, store.ErrNotFound
|
||||
}
|
||||
if !m.Now().Before(s.ActiveUntil) {
|
||||
return store.Session{}, ErrSessionExpired
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
59
services/approval-service/internal/sessions/sessions_test.go
Normal file
59
services/approval-service/internal/sessions/sessions_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package sessions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/store"
|
||||
)
|
||||
|
||||
func TestStartSession_UsesTTL(t *testing.T) {
|
||||
st := store.NewMemory()
|
||||
m := New(st)
|
||||
m.SessionTTL = 5 * time.Minute
|
||||
m.Now = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
|
||||
s, err := m.StartSession(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC)
|
||||
if !s.ActiveUntil.Equal(want) {
|
||||
t.Errorf("ActiveUntil = %v want %v", s.ActiveUntil, want)
|
||||
}
|
||||
if s.UserSub != "alice" {
|
||||
t.Errorf("UserSub = %q", s.UserSub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSession_WrongUser(t *testing.T) {
|
||||
st := store.NewMemory()
|
||||
m := New(st)
|
||||
m.Now = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }
|
||||
s, err := m.StartSession(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
_, err = m.GetActiveSession(context.Background(), s.ID, "bob")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSession_Expired(t *testing.T) {
|
||||
st := store.NewMemory()
|
||||
m := New(st)
|
||||
m.SessionTTL = time.Minute
|
||||
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
m.Now = func() time.Time { return t0 }
|
||||
s, err := m.StartSession(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Now = func() time.Time { return t0.Add(2 * time.Minute) }
|
||||
_, err = m.GetActiveSession(context.Background(), s.ID, "alice")
|
||||
if !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("want ErrSessionExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
134
services/approval-service/internal/signer/signer.go
Normal file
134
services/approval-service/internal/signer/signer.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Package signer calls the signing-service's POST /api/v1/sign/dsse
|
||||
// endpoint per ADR-0005. The wire types are duplicated here (not imported
|
||||
// from the signing-service's internal package) so the approval-service
|
||||
// stays an independent module.
|
||||
package signer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Statement is the in-toto v1 Statement handed to the signing-service.
|
||||
// Loosely typed via `map[string]any` so arbitrary predicate payloads can
|
||||
// flow through without an approval-service-local schema definition.
|
||||
type Statement map[string]any
|
||||
|
||||
// EnvelopeSignature mirrors the DSSE spec.
|
||||
type EnvelopeSignature struct {
|
||||
KeyID string `json:"keyid"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
// Envelope is the DSSE envelope returned by the signing-service.
|
||||
type Envelope struct {
|
||||
PayloadType string `json:"payloadType"`
|
||||
Payload string `json:"payload"`
|
||||
Signatures []EnvelopeSignature `json:"signatures"`
|
||||
}
|
||||
|
||||
// TokenSource supplies the Bearer token the signing-service expects. The
|
||||
// production wiring will plug in a Pocket-ID client-credentials source;
|
||||
// tests use StaticToken.
|
||||
type TokenSource interface {
|
||||
Token(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// StaticToken is a trivial constant TokenSource.
|
||||
type StaticToken string
|
||||
|
||||
// Token returns s.
|
||||
func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil }
|
||||
|
||||
// ErrSignerUnavailable is returned when the signing-service refuses the
|
||||
// connection, times out, or answers 5xx. The HTTP layer maps this to 502
|
||||
// as DEV-608 requires.
|
||||
var ErrSignerUnavailable = errors.New("signer: signing-service unavailable")
|
||||
|
||||
// Client is the DSSE sign client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
TokenSource TokenSource
|
||||
}
|
||||
|
||||
type signRequest struct {
|
||||
KeyRef string `json:"key_ref"`
|
||||
Statement Statement `json:"statement"`
|
||||
}
|
||||
|
||||
type signResponse struct {
|
||||
Envelope Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
SignedAt time.Time `json:"signed_at"`
|
||||
}
|
||||
|
||||
// SignDSSE calls the signing-service and returns the DSSE envelope plus
|
||||
// its content hash. A connection-level or 5xx response from the signing-
|
||||
// service is reported as ErrSignerUnavailable.
|
||||
func (c Client) SignDSSE(ctx context.Context, keyRef string, stmt Statement) (Envelope, string, error) {
|
||||
if c.BaseURL == "" {
|
||||
return Envelope{}, "", errors.New("signer.Client: BaseURL is empty")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt})
|
||||
if err != nil {
|
||||
return Envelope{}, "", fmt.Errorf("marshal sign request: %w", err)
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse")
|
||||
if err != nil {
|
||||
return Envelope{}, "", fmt.Errorf("join signing-service url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Envelope{}, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.TokenSource != nil {
|
||||
tok, err := c.TokenSource.Token(ctx)
|
||||
if err != nil {
|
||||
return Envelope{}, "", fmt.Errorf("token: %w", err)
|
||||
}
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return Envelope{}, "", fmt.Errorf("%w: %v", ErrSignerUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 500 {
|
||||
return Envelope{}, "", fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Envelope{}, "", fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var out signResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Envelope{}, "", fmt.Errorf("decode sign response: %w", err)
|
||||
}
|
||||
if out.ContentHash == "" {
|
||||
return Envelope{}, "", errors.New("signer: response missing content_hash")
|
||||
}
|
||||
return out.Envelope, out.ContentHash, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
88
services/approval-service/internal/signer/signer_test.go
Normal file
88
services/approval-service/internal/signer/signer_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignDSSE_HappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/sign/dsse" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
|
||||
t.Errorf("auth header = %q", got)
|
||||
}
|
||||
var body signRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.KeyRef != "key-x" {
|
||||
t.Errorf("keyRef = %q", body.KeyRef)
|
||||
}
|
||||
resp := signResponse{
|
||||
Envelope: Envelope{PayloadType: "application/vnd.in-toto+json", Payload: "aGk"},
|
||||
ContentHash: "sha256:deadbeef",
|
||||
SignedAt: time.Now(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Client{BaseURL: srv.URL, TokenSource: StaticToken("tok")}
|
||||
env, hash, err := c.SignDSSE(context.Background(), "key-x", Statement{"_type": "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("SignDSSE: %v", err)
|
||||
}
|
||||
if hash != "sha256:deadbeef" {
|
||||
t.Errorf("hash = %q", hash)
|
||||
}
|
||||
if env.PayloadType != "application/vnd.in-toto+json" {
|
||||
t.Errorf("payloadType = %q", env.PayloadType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDSSE_5xxMapsToUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"})
|
||||
if !errors.Is(err, ErrSignerUnavailable) {
|
||||
t.Fatalf("want ErrSignerUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDSSE_ConnRefusedMapsToUnavailable(t *testing.T) {
|
||||
// Nonexistent address — the dial will fail immediately.
|
||||
c := Client{
|
||||
BaseURL: "http://127.0.0.1:1", // reserved, almost always refused
|
||||
HTTPClient: &http.Client{Timeout: 500 * time.Millisecond},
|
||||
}
|
||||
_, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"})
|
||||
if !errors.Is(err, ErrSignerUnavailable) {
|
||||
t.Fatalf("want ErrSignerUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDSSE_4xxDoesNotLookUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if errors.Is(err, ErrSignerUnavailable) {
|
||||
t.Fatalf("4xx must not be reported as ErrSignerUnavailable: %v", err)
|
||||
}
|
||||
}
|
||||
136
services/approval-service/internal/store/memory.go
Normal file
136
services/approval-service/internal/store/memory.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is the Phase-1 in-process Store. Thread-safe via a single RWMutex;
|
||||
// each call is cheap enough that per-table locks would be over-engineering.
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]Session
|
||||
previews map[string]Preview
|
||||
approvals map[string]Approval
|
||||
}
|
||||
|
||||
// NewMemory returns an empty Memory store.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
sessions: map[string]Session{},
|
||||
previews: map[string]Preview{},
|
||||
approvals: map[string]Approval{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Memory) CreateSession(_ context.Context, s Session) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessions[s.ID] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetSession(_ context.Context, id string) (Session, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Memory) SavePreview(_ context.Context, p Preview) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.previews[p.ID] = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetPreview(_ context.Context, id string) (Preview, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
p, ok := m.previews[id]
|
||||
if !ok {
|
||||
return Preview{}, ErrNotFound
|
||||
}
|
||||
// Return a copy so callers cannot mutate our byte slices.
|
||||
return clonePreview(p), nil
|
||||
}
|
||||
|
||||
// ConsumePreview marks the preview as consumed if it is still valid and
|
||||
// unconsumed. The returned Preview is the pre-mutation copy.
|
||||
func (m *Memory) ConsumePreview(_ context.Context, id string, now time.Time) (Preview, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.previews[id]
|
||||
if !ok {
|
||||
return Preview{}, ErrNotFound
|
||||
}
|
||||
if p.ConsumedAt != nil {
|
||||
return Preview{}, ErrAlreadyConsumed
|
||||
}
|
||||
snapshot := clonePreview(p)
|
||||
ts := now
|
||||
p.ConsumedAt = &ts
|
||||
m.previews[id] = p
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (m *Memory) SaveApproval(_ context.Context, a Approval) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.approvals[a.ID] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) MarkApprovalLogged(_ context.Context, id string, logIndex int64, loggedAt time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
a, ok := m.approvals[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
a.TLogStatus = TLogLogged
|
||||
a.LogIndex = &logIndex
|
||||
a.LoggedAt = &loggedAt
|
||||
a.TLogError = ""
|
||||
m.approvals[id] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) MarkApprovalTLogError(_ context.Context, id string, errorMsg string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
a, ok := m.approvals[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Keep status at attestation-pending — a reconciler will retry. We
|
||||
// only record the last error message for diagnostics.
|
||||
a.TLogError = errorMsg
|
||||
m.approvals[id] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetApproval is a test-side accessor; not part of the Store interface
|
||||
// because the HTTP handlers never need it.
|
||||
func (m *Memory) GetApproval(id string) (Approval, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
a, ok := m.approvals[id]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
func clonePreview(p Preview) Preview {
|
||||
out := p
|
||||
out.ContentCanonical = append([]byte(nil), p.ContentCanonical...)
|
||||
out.Salt = append([]byte(nil), p.Salt...)
|
||||
out.Nonce = append([]byte(nil), p.Nonce...)
|
||||
if p.ConsumedAt != nil {
|
||||
t := *p.ConsumedAt
|
||||
out.ConsumedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
41
services/approval-service/internal/store/postgres.go
Normal file
41
services/approval-service/internal/store/postgres.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Postgres is the production Store. **Skeleton** mirroring the pattern used
|
||||
// by services/admin-api/internal/store/postgres.go: every method returns
|
||||
// errPGUnimplemented until the pgxpool wiring lands. The schema is
|
||||
// authoritative in db/trust/migrations/0004_approval_sessions.*.sql.
|
||||
type Postgres struct {
|
||||
// pool *pgxpool.Pool // kept out of go.mod in the skeleton
|
||||
}
|
||||
|
||||
var errPGUnimplemented = errors.New("postgres store is a skeleton; use NewMemory for Phase 1")
|
||||
|
||||
// NewPostgres returns a zero-value Postgres. Real wiring is Phase 2.
|
||||
func NewPostgres( /* conn string, logger */ ) (*Postgres, error) {
|
||||
return &Postgres{}, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateSession(_ context.Context, _ Session) error { return errPGUnimplemented }
|
||||
func (p *Postgres) GetSession(_ context.Context, _ string) (Session, error) {
|
||||
return Session{}, errPGUnimplemented
|
||||
}
|
||||
func (p *Postgres) SavePreview(_ context.Context, _ Preview) error { return errPGUnimplemented }
|
||||
func (p *Postgres) GetPreview(_ context.Context, _ string) (Preview, error) {
|
||||
return Preview{}, errPGUnimplemented
|
||||
}
|
||||
func (p *Postgres) ConsumePreview(_ context.Context, _ string, _ time.Time) (Preview, error) {
|
||||
return Preview{}, errPGUnimplemented
|
||||
}
|
||||
func (p *Postgres) SaveApproval(_ context.Context, _ Approval) error { return errPGUnimplemented }
|
||||
func (p *Postgres) MarkApprovalLogged(_ context.Context, _ string, _ int64, _ time.Time) error {
|
||||
return errPGUnimplemented
|
||||
}
|
||||
func (p *Postgres) MarkApprovalTLogError(_ context.Context, _ string, _ string) error {
|
||||
return errPGUnimplemented
|
||||
}
|
||||
90
services/approval-service/internal/store/store.go
Normal file
90
services/approval-service/internal/store/store.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Package store defines the persistence contract for approval_sessions,
|
||||
// approval_previews, and approvals. Phase 1 ships an in-memory impl; the
|
||||
// Postgres schema is in db/trust/migrations/0004_approval_sessions.*.sql
|
||||
// and wired via a skeleton Postgres type (see postgres.go) so the HTTP
|
||||
// layer stays unchanged when the real pgxpool wiring lands.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by lookup methods when the row does not exist
|
||||
// or has expired.
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// ErrAlreadyConsumed is returned by ConsumePreview when the preview has
|
||||
// already been consumed — the confirm endpoint MUST reject a replay.
|
||||
var ErrAlreadyConsumed = errors.New("store: preview already consumed")
|
||||
|
||||
// Session is a per-browser-login approval session. active_until bounds
|
||||
// how long previews may be created against it.
|
||||
type Session struct {
|
||||
ID string
|
||||
UserSub string
|
||||
ActiveUntil time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Preview is a server-rendered approval payload that has not yet been
|
||||
// confirmed. ContentCanonical lets the confirm endpoint recompute
|
||||
// H_content deterministically from the stored row.
|
||||
type Preview struct {
|
||||
ID string
|
||||
SessionID string
|
||||
UserSub string
|
||||
ContentHash string
|
||||
ContentCanonical []byte
|
||||
Salt []byte
|
||||
Nonce []byte
|
||||
VerificationDigits string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
ConsumedAt *time.Time
|
||||
}
|
||||
|
||||
// TLogStatus is the three-valued state of the Transparency-Log submit.
|
||||
type TLogStatus string
|
||||
|
||||
const (
|
||||
TLogPending TLogStatus = "attestation-pending"
|
||||
TLogLogged TLogStatus = "logged"
|
||||
TLogFailed TLogStatus = "failed"
|
||||
)
|
||||
|
||||
// Approval is the confirmed, DSSE-signed approval record. If TLogStatus
|
||||
// is TLogPending/TLogFailed, log_index is nil and the attest-reconciler
|
||||
// (or a future worker) will replay the Transparency-Log submit against
|
||||
// the server's idempotent /api/v1/log/entries contract.
|
||||
type Approval struct {
|
||||
ID string
|
||||
PreviewID string
|
||||
SessionID string
|
||||
UserSub string
|
||||
ContentHash string
|
||||
Envelope signer.Envelope
|
||||
AttestationRef string
|
||||
TLogStatus TLogStatus
|
||||
LogIndex *int64
|
||||
TLogError string
|
||||
ConfirmedAt time.Time
|
||||
LoggedAt *time.Time
|
||||
}
|
||||
|
||||
// Store is the full persistence surface the HTTP service depends on.
|
||||
type Store interface {
|
||||
CreateSession(ctx context.Context, s Session) error
|
||||
GetSession(ctx context.Context, id string) (Session, error)
|
||||
|
||||
SavePreview(ctx context.Context, p Preview) error
|
||||
GetPreview(ctx context.Context, id string) (Preview, error)
|
||||
ConsumePreview(ctx context.Context, id string, now time.Time) (Preview, error)
|
||||
|
||||
SaveApproval(ctx context.Context, a Approval) error
|
||||
MarkApprovalLogged(ctx context.Context, id string, logIndex int64, loggedAt time.Time) error
|
||||
MarkApprovalTLogError(ctx context.Context, id string, errorMsg string) error
|
||||
}
|
||||
122
services/approval-service/internal/tlog/submitter.go
Normal file
122
services/approval-service/internal/tlog/submitter.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Package tlog calls the transparency-log's POST /api/v1/log/entries
|
||||
// endpoint. Idempotent on the server side: resubmitting the same
|
||||
// (envelope, content_hash) returns the same log_index + inclusion proof.
|
||||
// See DEV-600 and services/transparency-log for the server-side contract.
|
||||
package tlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
||||
)
|
||||
|
||||
// InclusionProof mirrors the server-side ADR-0005 inclusion proof.
|
||||
type InclusionProof struct {
|
||||
TreeSize int64 `json:"tree_size"`
|
||||
TreeRootHash string `json:"tree_root_hash"`
|
||||
SignedTreeHeadRef string `json:"signed_tree_head_ref"`
|
||||
Hashes []string `json:"hashes"`
|
||||
}
|
||||
|
||||
// Receipt is what the transparency-log hands back on a successful submit.
|
||||
type Receipt struct {
|
||||
LogIndex int64 `json:"log_index"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
InclusionProof InclusionProof `json:"inclusion_proof"`
|
||||
}
|
||||
|
||||
// ErrTLogUnavailable classifies connection errors and 5xx responses from
|
||||
// the transparency-log. Callers (the HTTP /confirm handler) use this to
|
||||
// decide between returning a logged approval and an attestation-pending
|
||||
// one — the Approval row is persisted either way.
|
||||
var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable")
|
||||
|
||||
// Client is the tlog submit client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
TokenSource signer.TokenSource
|
||||
}
|
||||
|
||||
type submitRequest struct {
|
||||
Envelope signer.Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
}
|
||||
|
||||
// Submit sends env + its contentHash to the transparency-log. Returns the
|
||||
// server receipt on 200/201 (idempotent repeat / fresh insert) and
|
||||
// ErrTLogUnavailable on any transport-level or 5xx failure. All other
|
||||
// non-2xx responses are reported with the raw status.
|
||||
func (c Client) Submit(ctx context.Context, env signer.Envelope, contentHash string) (Receipt, error) {
|
||||
if c.BaseURL == "" {
|
||||
return Receipt{}, errors.New("tlog.Client: BaseURL is empty")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
body, err := json.Marshal(submitRequest{Envelope: env, ContentHash: contentHash})
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("marshal submit: %w", err)
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries")
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("join tlog url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Receipt{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.TokenSource != nil {
|
||||
tok, err := c.TokenSource.Token(ctx)
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("token: %w", err)
|
||||
}
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK, resp.StatusCode == http.StatusCreated:
|
||||
// ok
|
||||
case resp.StatusCode >= 500:
|
||||
return Receipt{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
return Receipt{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var out Receipt
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Receipt{}, fmt.Errorf("decode receipt: %w", err)
|
||||
}
|
||||
if out.ContentHash != contentHash {
|
||||
return out, fmt.Errorf("tlog returned content_hash %q != sent %q", out.ContentHash, contentHash)
|
||||
}
|
||||
if out.LogIndex < 0 {
|
||||
return out, fmt.Errorf("tlog returned negative log_index %d", out.LogIndex)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
225
services/approval-service/internal/webauthn/webauthn.go
Normal file
225
services/approval-service/internal/webauthn/webauthn.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// Package webauthn is the approval-service's Phase-1 WebAuthn verifier.
|
||||
//
|
||||
// It is NOT the full go-webauthn/webauthn library — Phase 1 only needs to
|
||||
// verify assertions against mock credentials registered in-memory for the
|
||||
// test agent. The data types on the wire (`client_data_json`,
|
||||
// `authenticator_data`, `signature`, `credential_id`) stay WebAuthn
|
||||
// compatible so a Phase-2 swap to github.com/go-webauthn/webauthn is a
|
||||
// pure dependency change, not an API break for the browser.
|
||||
//
|
||||
// Threat model this implementation addresses:
|
||||
//
|
||||
// - The challenge the browser signs is bound to H_content via
|
||||
// content.ChallengeFor (nonce || sha256(canonical content)). An
|
||||
// attacker rewriting the preview payload after the human saw it would
|
||||
// have to find a hash collision, so we check the stored challenge
|
||||
// bytes-for-bytes.
|
||||
// - The authenticator type field (`type` in clientDataJSON) MUST be
|
||||
// "webauthn.get", so a reused registration ceremony signature is
|
||||
// rejected.
|
||||
// - The RP ID hash in authenticator_data MUST match our configured RP;
|
||||
// otherwise an assertion scoped to a different origin would be accepted.
|
||||
//
|
||||
// What this implementation deliberately does NOT do:
|
||||
//
|
||||
// - It does not enforce user verification flags (UV bit). Phase 2 will.
|
||||
// - It does not implement the WebAuthn attestation path (registration);
|
||||
// credentials are inserted directly via Store.AddCredential.
|
||||
// - It only knows about Ed25519 public keys — all our mock credentials
|
||||
// use Ed25519. ES256/RS256 land with the library swap in Phase 2.
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Assertion mirrors the WebAuthn PublicKeyCredential assertion response
|
||||
// (base64url-encoded fields; raw bytes are decoded by Verify).
|
||||
type Assertion struct {
|
||||
CredentialID string `json:"credential_id"`
|
||||
ClientDataJSON string `json:"client_data_json"`
|
||||
AuthenticatorData string `json:"authenticator_data"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// Credential is a registered WebAuthn credential bound to a user sub.
|
||||
type Credential struct {
|
||||
ID string // base64url, matches assertion.credential_id
|
||||
UserSub string // Pocket-ID subject
|
||||
PublicKey ed25519.PublicKey // Phase 1: only Ed25519.
|
||||
}
|
||||
|
||||
// Store is the in-memory credential registry.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
creds map[string]Credential // key = CredentialID
|
||||
}
|
||||
|
||||
// NewStore constructs an empty in-memory credential store.
|
||||
func NewStore() *Store {
|
||||
return &Store{creds: map[string]Credential{}}
|
||||
}
|
||||
|
||||
// AddCredential registers c. Used by test agents and the Phase-2 passkey
|
||||
// enrollment flow.
|
||||
func (s *Store) AddCredential(c Credential) error {
|
||||
if c.ID == "" {
|
||||
return errors.New("webauthn: credential ID is empty")
|
||||
}
|
||||
if c.UserSub == "" {
|
||||
return errors.New("webauthn: credential has no user sub")
|
||||
}
|
||||
if len(c.PublicKey) != ed25519.PublicKeySize {
|
||||
return errors.New("webauthn: credential public key must be Ed25519 (32 bytes)")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.creds[c.ID] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the credential registered under id.
|
||||
func (s *Store) Get(id string) (Credential, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
c, ok := s.creds[id]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// VerifyConfig configures Verify's RP and origin checks.
|
||||
type VerifyConfig struct {
|
||||
// RPID is the hostname the authenticator scoped the credential to.
|
||||
// authenticator_data starts with sha256(RPID).
|
||||
RPID string
|
||||
// AllowedOrigins is the exact set of accepted browser origins (e.g.
|
||||
// "https://trust.basicstack.de"). The assertion's clientDataJSON
|
||||
// `origin` MUST be one of these.
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// ErrChallengeMismatch means the challenge in clientDataJSON did not match
|
||||
// what the preview endpoint stored — i.e. H_content was tampered with.
|
||||
var ErrChallengeMismatch = errors.New("webauthn: challenge in clientDataJSON does not match stored challenge")
|
||||
|
||||
// ErrCredentialUnknown means the asserted credential is not registered.
|
||||
var ErrCredentialUnknown = errors.New("webauthn: credential not registered")
|
||||
|
||||
// ErrAssertionInvalid is returned for any lower-level verification failure
|
||||
// (bad origin, wrong type, bad signature). The caller maps this to HTTP
|
||||
// 401 to avoid leaking which specific check failed to a probing attacker.
|
||||
var ErrAssertionInvalid = errors.New("webauthn: assertion invalid")
|
||||
|
||||
// Verify checks a WebAuthn assertion against (a) the expected challenge
|
||||
// bytes and (b) a registered credential. On success it returns the
|
||||
// matched credential.
|
||||
func Verify(cfg VerifyConfig, store *Store, a Assertion, expectedChallenge []byte) (Credential, error) {
|
||||
if store == nil {
|
||||
return Credential{}, errors.New("webauthn: store is nil")
|
||||
}
|
||||
if cfg.RPID == "" {
|
||||
return Credential{}, errors.New("webauthn: RPID is empty")
|
||||
}
|
||||
if len(cfg.AllowedOrigins) == 0 {
|
||||
return Credential{}, errors.New("webauthn: no allowed origins configured")
|
||||
}
|
||||
|
||||
cred, ok := store.Get(a.CredentialID)
|
||||
if !ok {
|
||||
return Credential{}, ErrCredentialUnknown
|
||||
}
|
||||
|
||||
cdJSON, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.ClientDataJSON))
|
||||
if err != nil {
|
||||
return cred, fmt.Errorf("%w: client_data_json b64: %v", ErrAssertionInvalid, err)
|
||||
}
|
||||
authData, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.AuthenticatorData))
|
||||
if err != nil {
|
||||
return cred, fmt.Errorf("%w: authenticator_data b64: %v", ErrAssertionInvalid, err)
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.Signature))
|
||||
if err != nil {
|
||||
return cred, fmt.Errorf("%w: signature b64: %v", ErrAssertionInvalid, err)
|
||||
}
|
||||
|
||||
var cd clientData
|
||||
if err := json.Unmarshal(cdJSON, &cd); err != nil {
|
||||
return cred, fmt.Errorf("%w: client_data decode: %v", ErrAssertionInvalid, err)
|
||||
}
|
||||
if cd.Type != "webauthn.get" {
|
||||
return cred, fmt.Errorf("%w: type %q", ErrAssertionInvalid, cd.Type)
|
||||
}
|
||||
if !originAllowed(cfg.AllowedOrigins, cd.Origin) {
|
||||
return cred, fmt.Errorf("%w: origin %q not allowed", ErrAssertionInvalid, cd.Origin)
|
||||
}
|
||||
|
||||
gotChal, err := base64.RawURLEncoding.DecodeString(stripB64Pad(cd.Challenge))
|
||||
if err != nil {
|
||||
return cred, fmt.Errorf("%w: challenge b64: %v", ErrAssertionInvalid, err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(gotChal, expectedChallenge) != 1 {
|
||||
return cred, ErrChallengeMismatch
|
||||
}
|
||||
|
||||
// authenticator_data layout per the WebAuthn spec:
|
||||
// rpIdHash (32) || flags (1) || signCount (4) || <attested cred data>?
|
||||
if len(authData) < 37 {
|
||||
return cred, fmt.Errorf("%w: authenticator_data too short", ErrAssertionInvalid)
|
||||
}
|
||||
rpHash := sha256.Sum256([]byte(cfg.RPID))
|
||||
if subtle.ConstantTimeCompare(authData[:32], rpHash[:]) != 1 {
|
||||
return cred, fmt.Errorf("%w: rpIdHash mismatch", ErrAssertionInvalid)
|
||||
}
|
||||
|
||||
// Signed-over bytes per the spec: authenticator_data || sha256(clientDataJSON).
|
||||
cdHash := sha256.Sum256(cdJSON)
|
||||
signed := make([]byte, 0, len(authData)+len(cdHash))
|
||||
signed = append(signed, authData...)
|
||||
signed = append(signed, cdHash[:]...)
|
||||
|
||||
if !ed25519.Verify(cred.PublicKey, signed, sig) {
|
||||
return cred, fmt.Errorf("%w: ed25519 signature verification failed", ErrAssertionInvalid)
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
// clientData is the subset of CollectedClientData we care about.
|
||||
type clientData struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
func originAllowed(allowed []string, got string) bool {
|
||||
for _, o := range allowed {
|
||||
if strings.EqualFold(o, got) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stripB64Pad lets us accept both padded (`...==`) and unpadded base64url
|
||||
// since different browsers emit different forms.
|
||||
func stripB64Pad(s string) string {
|
||||
return strings.TrimRight(s, "=")
|
||||
}
|
||||
|
||||
// MakeAuthenticatorData is a test helper: builds a minimal
|
||||
// authenticator_data (rpIdHash || flags=0x01 || signCount=0) for the
|
||||
// given RP ID. Production enrollment never comes through this path.
|
||||
func MakeAuthenticatorData(rpID string) []byte {
|
||||
sum := sha256.Sum256([]byte(rpID))
|
||||
buf := make([]byte, 32+1+4)
|
||||
copy(buf, sum[:])
|
||||
buf[32] = 0x01 // UP (user present); UV left zero for the Phase-1 shim.
|
||||
// sign count stays zero
|
||||
return buf
|
||||
}
|
||||
134
services/approval-service/internal/webauthn/webauthn_test.go
Normal file
134
services/approval-service/internal/webauthn/webauthn_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package webauthn
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerify_HappyPath(t *testing.T) {
|
||||
pub, priv, cred, store := setupCredential(t, "alice")
|
||||
_ = pub
|
||||
challenge := []byte("challenge-bytes-just-for-the-test")
|
||||
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
|
||||
|
||||
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||||
got, err := Verify(cfg, store, a, challenge)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.ID != cred.ID {
|
||||
t.Errorf("credential id mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WrongChallenge(t *testing.T) {
|
||||
_, priv, cred, store := setupCredential(t, "alice")
|
||||
challenge := []byte("original-challenge-bytes-xxxxxxxx")
|
||||
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
|
||||
|
||||
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||||
_, err := Verify(cfg, store, a, []byte("different-challenge-bytes-yyyyyy"))
|
||||
if !errors.Is(err, ErrChallengeMismatch) {
|
||||
t.Fatalf("want ErrChallengeMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_UnknownCredential(t *testing.T) {
|
||||
store := NewStore()
|
||||
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||||
_, err := Verify(cfg, store, Assertion{CredentialID: "nope"}, []byte("c"))
|
||||
if !errors.Is(err, ErrCredentialUnknown) {
|
||||
t.Fatalf("want ErrCredentialUnknown, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WrongOrigin(t *testing.T) {
|
||||
_, priv, cred, store := setupCredential(t, "alice")
|
||||
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
|
||||
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://evil.example", challenge)
|
||||
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||||
_, err := Verify(cfg, store, a, challenge)
|
||||
if err == nil || !errors.Is(err, ErrAssertionInvalid) {
|
||||
t.Fatalf("want ErrAssertionInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WrongType(t *testing.T) {
|
||||
_, priv, cred, store := setupCredential(t, "alice")
|
||||
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
|
||||
a := makeAssertionWithType(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge, "webauthn.create")
|
||||
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||||
_, err := Verify(cfg, store, a, challenge)
|
||||
if !errors.Is(err, ErrAssertionInvalid) {
|
||||
t.Fatalf("want ErrAssertionInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_BadSignature(t *testing.T) {
|
||||
_, priv, cred, store := setupCredential(t, "alice")
|
||||
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
|
||||
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
|
||||
// Tamper with the signature.
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(a.Signature)
|
||||
sig[0] ^= 0xff
|
||||
a.Signature = base64.RawURLEncoding.EncodeToString(sig)
|
||||
|
||||
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
|
||||
_, err := Verify(cfg, store, a, challenge)
|
||||
if !errors.Is(err, ErrAssertionInvalid) {
|
||||
t.Fatalf("want ErrAssertionInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
func setupCredential(t *testing.T, userSub string) (ed25519.PublicKey, ed25519.PrivateKey, Credential, *Store) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
idBytes := make([]byte, 16)
|
||||
_, _ = rand.Read(idBytes)
|
||||
cred := Credential{
|
||||
ID: base64.RawURLEncoding.EncodeToString(idBytes),
|
||||
UserSub: userSub,
|
||||
PublicKey: pub,
|
||||
}
|
||||
store := NewStore()
|
||||
if err := store.AddCredential(cred); err != nil {
|
||||
t.Fatalf("AddCredential: %v", err)
|
||||
}
|
||||
return pub, priv, cred, store
|
||||
}
|
||||
|
||||
// makeAssertion signs a mock WebAuthn assertion with the given private
|
||||
// key over the standard authenticator_data || sha256(clientDataJSON).
|
||||
func makeAssertion(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte) Assertion {
|
||||
return makeAssertionWithType(t, priv, credID, rpID, origin, challenge, "webauthn.get")
|
||||
}
|
||||
|
||||
func makeAssertionWithType(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte, typ string) Assertion {
|
||||
t.Helper()
|
||||
cd := map[string]string{
|
||||
"type": typ,
|
||||
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
|
||||
"origin": origin,
|
||||
}
|
||||
cdBytes, _ := json.Marshal(cd)
|
||||
authData := MakeAuthenticatorData(rpID)
|
||||
cdHash := sha256.Sum256(cdBytes)
|
||||
signed := append(append([]byte(nil), authData...), cdHash[:]...)
|
||||
sig := ed25519.Sign(priv, signed)
|
||||
return Assertion{
|
||||
CredentialID: credID,
|
||||
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes),
|
||||
AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData),
|
||||
Signature: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}
|
||||
}
|
||||
65
services/approval-service/pkg/httpapi/types.go
Normal file
65
services/approval-service/pkg/httpapi/types.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Package httpapi exposes the stable wire types of the approval-service.
|
||||
// Clients (admin UIs, CLI integrations, Phase-2 webauthn library adapters)
|
||||
// import this package; the internal/httpapi handlers reuse these same
|
||||
// structs so the on-wire shape matches the exported contract.
|
||||
package httpapi
|
||||
|
||||
import "time"
|
||||
|
||||
// CreateSessionResponse is returned by POST /api/v1/sessions.
|
||||
type CreateSessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserSub string `json:"user_sub"`
|
||||
ActiveUntil time.Time `json:"active_until"`
|
||||
}
|
||||
|
||||
// PreviewRequest is the body of POST /api/v1/approvals/preview.
|
||||
type PreviewRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Content map[string]any `json:"content"`
|
||||
}
|
||||
|
||||
// PreviewResponse is returned by POST /api/v1/approvals/preview.
|
||||
type PreviewResponse struct {
|
||||
PreviewID string `json:"preview_id"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
VerificationDigits string `json:"verification_digits"`
|
||||
WebauthnChallenge string `json:"webauthn_challenge"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Assertion mirrors the WebAuthn PublicKeyCredentialAssertionResponse.
|
||||
// All fields are base64url-encoded.
|
||||
type Assertion struct {
|
||||
CredentialID string `json:"credential_id"`
|
||||
ClientDataJSON string `json:"client_data_json"`
|
||||
AuthenticatorData string `json:"authenticator_data"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// ConfirmRequest is the body of POST /api/v1/approvals/confirm.
|
||||
type ConfirmRequest struct {
|
||||
PreviewID string `json:"preview_id"`
|
||||
Assertion Assertion `json:"assertion"`
|
||||
}
|
||||
|
||||
// ConfirmResponse is returned by POST /api/v1/approvals/confirm. LogIndex
|
||||
// is nil (and TLogStatus is "attestation-pending") when the TLog submit
|
||||
// failed; the Approval row is persisted either way.
|
||||
type ConfirmResponse struct {
|
||||
ApprovalID string `json:"approval_id"`
|
||||
AttestationRef string `json:"attestation_ref"`
|
||||
LogIndex *int64 `json:"log_index"`
|
||||
TLogStatus string `json:"tlog_status"`
|
||||
}
|
||||
|
||||
// Error is the stable error envelope.
|
||||
type Error struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorBody is the inner typed error.
|
||||
type ErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
|
@ -9,17 +9,21 @@ ENV CGO_ENABLED=0 \
|
|||
|
||||
WORKDIR /src
|
||||
|
||||
# Only the signing-service module is needed. Copy the module files first so
|
||||
# the dependency download layer is cacheable independently of source edits.
|
||||
COPY services/signing-service/go.mod ./
|
||||
# go.sum will be copied in when the module gains external deps; today the
|
||||
# module is stdlib-only.
|
||||
# The image reproduces the repo layout under /src so Go module paths and
|
||||
# the OPA policy's relative location (`../../../../policies/sinks/...`
|
||||
# in tests) line up both in CI and inside the image.
|
||||
COPY services/signing-service/go.mod services/signing-service/go.sum ./services/signing-service/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download || true
|
||||
cd services/signing-service && go mod download
|
||||
|
||||
COPY services/signing-service/ ./
|
||||
COPY services/signing-service/ ./services/signing-service/
|
||||
# The OPA Rego policy ships with the image for dev / smoke runs. GitOps
|
||||
# mounts it from a ConfigMap at /etc/signing-service/policies at runtime
|
||||
# and wins over the baked-in copy.
|
||||
COPY policies/sinks/signing_service.rego ./policies/sinks/signing_service.rego
|
||||
|
||||
WORKDIR /src/services/signing-service
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go vet ./... && \
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
// Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005.
|
||||
//
|
||||
// Dev mode is self-contained: in-memory Ed25519 keyring, AllowAll policy,
|
||||
// static bearer-to-subject table. Prod wiring (OpenBao Transit, OPA, Pocket
|
||||
// ID JWKS) is in follow-up subtasks; the interfaces are already in place so
|
||||
// swapping backends does not touch the HTTP layer.
|
||||
// Modes
|
||||
//
|
||||
// - Dev (default): memory signer, AllowAll policy, static bearer table.
|
||||
// Still used by CI and local integration tests.
|
||||
// - Prod: Pocket-ID JWKS validator, K8s TokenReview-backed workload
|
||||
// identity, trust-api Postgres agent registry, OPA Rego policy. Any
|
||||
// combination of the four may be enabled via flags; main.go flips the
|
||||
// Server composition accordingly.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -23,6 +27,7 @@ import (
|
|||
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/httpapi"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||||
)
|
||||
|
|
@ -35,8 +40,21 @@ func main() {
|
|||
keysFromEnv := flag.Bool("keys-from-env", false,
|
||||
"when true (memory backend), read KEY_<slug>=<hex> env for each key_ref instead of generating")
|
||||
bearers := flag.String("bearers", "dev-token=agent-admin-api",
|
||||
"comma-separated bearer=subject pairs for the static token validator")
|
||||
"comma-separated bearer=subject pairs for the static token validator (ignored when -pocket-id-issuer is set)")
|
||||
genDevKeyAndExit := flag.Bool("gen-dev-key", false, "generate one dev Ed25519 key and exit (prints hex)")
|
||||
|
||||
// Prod wiring flags (DEV-609):
|
||||
pocketIDIssuer := flag.String("pocket-id-issuer", "",
|
||||
"Pocket-ID issuer URL for OIDC discovery; when set, the Pocket-ID JWKS validator replaces the static bearer table")
|
||||
pocketIDAudience := flag.String("pocket-id-audience", "",
|
||||
"expected `aud` claim on Pocket-ID tokens; empty disables aud check")
|
||||
k8sTokenReview := flag.String("k8s-tokenreview", "off",
|
||||
"on|off: when on, require X-Workload-Token and perform TokenReview against the Kubernetes API server (via in-cluster SA credentials)")
|
||||
registryDSN := flag.String("registry-dsn", "",
|
||||
"Postgres DSN for the trust-api agent registry (e.g. postgres://signing-service:...@trust-pg.agent-trust/trust?sslmode=verify-full); empty disables the structured policy path")
|
||||
policyBundle := flag.String("policy-bundle", "",
|
||||
"path to the OPA Rego policy file for the signing-service (e.g. /etc/signing-service/policies/signing_service.rego); empty disables the structured policy path")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
|
@ -51,52 +69,75 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
var sgn signer.Signer
|
||||
switch *backend {
|
||||
case "memory":
|
||||
mem := signer.NewInMemory()
|
||||
for _, kr := range splitCSV(*keyRefs) {
|
||||
var priv ed25519.PrivateKey
|
||||
if *keysFromEnv {
|
||||
envName := envNameForKey(kr)
|
||||
raw := os.Getenv(envName)
|
||||
if raw == "" {
|
||||
logger.Error("missing env for key", "env", envName)
|
||||
os.Exit(1)
|
||||
}
|
||||
p, err := signer.DecodeHexEd25519Key(raw)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
sgn, err := buildSigner(*backend, *keyRefs, *keysFromEnv, logger)
|
||||
if err != nil {
|
||||
logger.Error("bad env key", "env", envName, "err", err)
|
||||
logger.Error("signer init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
priv = p
|
||||
|
||||
var validator token.Validator
|
||||
if *pocketIDIssuer != "" {
|
||||
pv, err := token.NewPocketIDValidator(ctx, *pocketIDIssuer, *pocketIDAudience)
|
||||
if err != nil {
|
||||
logger.Error("pocket-id init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
validator = pv
|
||||
logger.Info("pocket-id validator wired", "issuer", *pocketIDIssuer, "audience", *pocketIDAudience)
|
||||
} else {
|
||||
_, p, err := ed25519.GenerateKey(rand.Reader)
|
||||
validator = parseBearers(*bearers)
|
||||
logger.Warn("signing-service: static bearer table in use — DEV ONLY")
|
||||
}
|
||||
|
||||
var reviewer token.TokenReviewer
|
||||
if strings.EqualFold(*k8sTokenReview, "on") {
|
||||
tr, err := token.NewInClusterTokenReviewer()
|
||||
if err != nil {
|
||||
logger.Error("gen key", "err", err)
|
||||
logger.Error("in-cluster tokenreview init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
priv = p
|
||||
logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr)
|
||||
reviewer = tr
|
||||
logger.Info("k8s tokenreview wired")
|
||||
}
|
||||
mem.AddKey(kr, priv)
|
||||
}
|
||||
sgn = mem
|
||||
case "openbao":
|
||||
logger.Error("openbao backend not wired yet; use -backend=memory for dev")
|
||||
|
||||
var reg registry.Resolver
|
||||
var pgReg *registry.Postgres
|
||||
if *registryDSN != "" {
|
||||
pr, err := registry.NewPostgres(ctx, *registryDSN)
|
||||
if err != nil {
|
||||
logger.Error("registry init", "err", err)
|
||||
os.Exit(1)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "unknown -backend; expected memory|openbao")
|
||||
os.Exit(2)
|
||||
}
|
||||
reg = pr
|
||||
pgReg = pr
|
||||
logger.Info("trust-api registry wired")
|
||||
}
|
||||
|
||||
var structured policy.StructuredDecider
|
||||
if *policyBundle != "" {
|
||||
opa, err := policy.NewOPAEvaluator(ctx, *policyBundle)
|
||||
if err != nil {
|
||||
logger.Error("opa policy init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
structured = opa
|
||||
logger.Info("opa policy wired", "bundle", *policyBundle)
|
||||
}
|
||||
|
||||
srv := httpapi.Server{
|
||||
Signer: sgn,
|
||||
Validator: validator,
|
||||
TokenReviewer: reviewer,
|
||||
Registry: reg,
|
||||
StructuredPolicy: structured,
|
||||
Policy: policy.AllowAll{},
|
||||
Validator: parseBearers(*bearers),
|
||||
Logger: logger,
|
||||
Now: time.Now,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv.Mount(mux)
|
||||
|
||||
|
|
@ -106,18 +147,64 @@ func main() {
|
|||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
logger.Warn("signing-service: AllowAll policy and static bearers are DEV ONLY")
|
||||
logger.Info("signing-service listening", "addr", *listen, "backend", *backend)
|
||||
logger.Info("signing-service listening",
|
||||
"addr", *listen,
|
||||
"backend", *backend,
|
||||
"pocket_id", *pocketIDIssuer != "",
|
||||
"k8s_tokenreview", reviewer != nil,
|
||||
"registry", reg != nil,
|
||||
"opa", structured != nil,
|
||||
)
|
||||
if structured == nil || reg == nil || reviewer == nil {
|
||||
logger.Warn("signing-service: structured double-identity path NOT fully wired",
|
||||
"opa", structured != nil, "registry", reg != nil, "tokenreview", reviewer != nil)
|
||||
}
|
||||
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("http serve", "err", err)
|
||||
}
|
||||
}()
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = hsrv.Shutdown(ctx)
|
||||
_ = hsrv.Shutdown(shutdownCtx)
|
||||
if pgReg != nil {
|
||||
pgReg.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func buildSigner(backend, keyRefs string, keysFromEnv bool, logger *slog.Logger) (signer.Signer, error) {
|
||||
switch backend {
|
||||
case "memory":
|
||||
mem := signer.NewInMemory()
|
||||
for _, kr := range splitCSV(keyRefs) {
|
||||
var priv ed25519.PrivateKey
|
||||
if keysFromEnv {
|
||||
envName := envNameForKey(kr)
|
||||
raw := os.Getenv(envName)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("missing env %q for key_ref %q", envName, kr)
|
||||
}
|
||||
p, err := signer.DecodeHexEd25519Key(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad env key %q: %w", envName, err)
|
||||
}
|
||||
priv = p
|
||||
} else {
|
||||
_, p, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gen key %q: %w", kr, err)
|
||||
}
|
||||
priv = p
|
||||
logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr)
|
||||
}
|
||||
mem.AddKey(kr, priv)
|
||||
}
|
||||
return mem, nil
|
||||
case "openbao":
|
||||
return nil, fmt.Errorf("openbao backend not wired yet; use -backend=memory")
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown -backend %q; expected memory|openbao", backend)
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,45 @@
|
|||
module basicstack.de/proofedmesh/services/signing-service
|
||||
|
||||
go 1.23
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/open-policy-agent/opa v1.21.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/gobwas/glob v1.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.4.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.37 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/yashtewari/glob-intersection v0.2.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
|
|
|||
144
services/signing-service/go.sum
Normal file
144
services/signing-service/go.sum
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dgraph-io/badger/v4 v4.9.6 h1:IQqMPVGLNCQr1b4Mu8lHkYm/xyqFRsyKaFEtyLi9CCQ=
|
||||
github.com/dgraph-io/badger/v4 v4.9.6/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
||||
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
|
||||
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8=
|
||||
github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
||||
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/open-policy-agent/opa v1.21.4 h1:zTVuy8yd8BM715t8WxMzAIkIX4w1x3MMHEv99IGUY2w=
|
||||
github.com/open-policy-agent/opa v1.21.4/go.mod h1:mHDS61rXyiZ+9bLINSK634BcbPZcsZChPvSwFVMj88g=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc=
|
||||
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vektah/gqlparser/v2 v2.5.37 h1:jbb1Ilv+xBklV6653tKb4oVUupPNTLb5LmrnBKVI12Y=
|
||||
github.com/vektah/gqlparser/v2 v2.5.37/go.mod h1:9O4Ox6Ngd3Y12bMD3w6i3CRQXh8W1oC1q0m6olCymDM=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
|
||||
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -11,14 +12,47 @@ import (
|
|||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||||
)
|
||||
|
||||
// Server composes the per-request pipeline:
|
||||
//
|
||||
// 1. Parse the Pocket-ID JWT from Authorization and resolve `sub`.
|
||||
// 2. If a TokenReviewer is configured, pull X-Workload-Token and resolve
|
||||
// the authenticated (ns, sa). Omitted TokenReviewer disables the K8s
|
||||
// side of the double-identity check — only useful for dev/CI.
|
||||
// 3. Resolve both identities against the agent registry; feed both rows
|
||||
// to the structured policy. If no structured policy is wired, the
|
||||
// legacy subject+keyRef Decider is used instead.
|
||||
// 4. Sign.
|
||||
//
|
||||
// Fields are plumbed through the struct so main.go can decide at startup
|
||||
// which backends to install.
|
||||
type Server struct {
|
||||
Signer signer.Signer
|
||||
Policy policy.Decider
|
||||
Validator token.Validator
|
||||
|
||||
// TokenReviewer is OPTIONAL. When nil, the double-identity path is
|
||||
// skipped and the policy input carries only the Pocket-ID side (the
|
||||
// legacy Decider path is used instead).
|
||||
TokenReviewer token.TokenReviewer
|
||||
|
||||
// Registry is OPTIONAL (and only consulted when both TokenReviewer
|
||||
// and StructuredPolicy are present). The structured policy evaluates
|
||||
// the two lookups it receives via Input.Registry.
|
||||
Registry registry.Resolver
|
||||
|
||||
// StructuredPolicy is OPTIONAL. When present AND Registry/TokenReviewer
|
||||
// are present, the handler uses the full double-identity flow. When
|
||||
// nil, the handler falls back to the subject+keyRef Policy below.
|
||||
StructuredPolicy policy.StructuredDecider
|
||||
|
||||
// Policy is the legacy subject+keyRef decider (AllowAll in dev,
|
||||
// StructuredAdapter for prod wiring).
|
||||
Policy policy.Decider
|
||||
|
||||
Logger *slog.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
|
@ -89,6 +123,23 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Decide: structured double-identity path, or legacy subject+keyRef.
|
||||
if s.useStructuredPath() {
|
||||
dec, err := s.decideStructured(ctx, subject, req.KeyRef, r)
|
||||
if err != nil {
|
||||
var statusErr *httpStatusError
|
||||
if errors.As(err, &statusErr) {
|
||||
writeErr(w, statusErr.Status, statusErr.Code, statusErr.Message)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error())
|
||||
return
|
||||
}
|
||||
if !dec.Allow {
|
||||
writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
dec, err := s.Policy.Allow(ctx, subject, req.KeyRef)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error())
|
||||
|
|
@ -98,6 +149,7 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
|||
writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement)
|
||||
if err != nil {
|
||||
|
|
@ -114,7 +166,11 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if s.Logger != nil {
|
||||
s.Logger.Info("signed", "subject", subject, "key_ref", req.KeyRef, "content_hash", signed.ContentHash)
|
||||
s.Logger.Info("signed",
|
||||
"subject", subject,
|
||||
"key_ref", req.KeyRef,
|
||||
"content_hash", signed.ContentHash,
|
||||
)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
|
@ -129,6 +185,66 @@ func (s Server) handleSign(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s Server) useStructuredPath() bool {
|
||||
return s.StructuredPolicy != nil && s.TokenReviewer != nil && s.Registry != nil
|
||||
}
|
||||
|
||||
// decideStructured runs the full double-identity flow and hands the two
|
||||
// registry rows + both identities to the structured policy.
|
||||
func (s Server) decideStructured(ctx context.Context, pocketIDSub, keyRef string, r *http.Request) (policy.Decision, error) {
|
||||
workloadToken, err := token.FromWorkloadHeader(r)
|
||||
if err != nil {
|
||||
return policy.Decision{}, &httpStatusError{
|
||||
Status: http.StatusUnauthorized, Code: "unauthorized",
|
||||
Message: "missing X-Workload-Token header",
|
||||
}
|
||||
}
|
||||
k8sID, err := s.TokenReviewer.Review(ctx, workloadToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, token.ErrWorkloadAuthFailed) || errors.Is(err, token.ErrNoWorkloadToken) {
|
||||
return policy.Decision{}, &httpStatusError{
|
||||
Status: http.StatusUnauthorized, Code: "unauthorized",
|
||||
Message: fmt.Sprintf("workload token: %v", err),
|
||||
}
|
||||
}
|
||||
return policy.Decision{}, fmt.Errorf("tokenreview: %w", err)
|
||||
}
|
||||
|
||||
input := policy.Input{
|
||||
PocketIDClient: pocketIDSub,
|
||||
K8s: policy.InputK8s{
|
||||
Namespace: k8sID.Namespace,
|
||||
ServiceAccount: k8sID.ServiceAccount,
|
||||
},
|
||||
KeyRef: keyRef,
|
||||
Registry: policy.InputRegistry{},
|
||||
}
|
||||
|
||||
if byP, err := s.Registry.ByPocketID(ctx, pocketIDSub); err == nil {
|
||||
input.Registry.ByPocketID = policy.FromAgent(byP)
|
||||
} else if !errors.Is(err, registry.ErrNotFound) {
|
||||
return policy.Decision{}, fmt.Errorf("registry.ByPocketID: %w", err)
|
||||
}
|
||||
|
||||
if byK, err := s.Registry.ByK8sIdentity(ctx, k8sID.Namespace, k8sID.ServiceAccount); err == nil {
|
||||
input.Registry.ByK8s = policy.FromAgent(byK)
|
||||
} else if !errors.Is(err, registry.ErrNotFound) {
|
||||
return policy.Decision{}, fmt.Errorf("registry.ByK8sIdentity: %w", err)
|
||||
}
|
||||
|
||||
return s.StructuredPolicy.Decide(ctx, input)
|
||||
}
|
||||
|
||||
// httpStatusError lets decideStructured return a 401 (not 500) when the
|
||||
// workload-identity side of the double-check fails.
|
||||
type httpStatusError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *httpStatusError) Error() string { return e.Message }
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
|
|
|||
381
services/signing-service/internal/httpapi/sign_prod_test.go
Normal file
381
services/signing-service/internal/httpapi/sign_prod_test.go
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||||
)
|
||||
|
||||
// mockPocketID issues RS256 JWTs and serves OIDC discovery + JWKS over
|
||||
// httptest so the signing-service's Pocket-ID validator thinks it is
|
||||
// talking to real Pocket ID. The suite exercises OIDC-Discovery +
|
||||
// JWK-Cache + kid rotation.
|
||||
type mockPocketID struct {
|
||||
srv *httptest.Server
|
||||
keys map[string]*rsa.PrivateKey // active kid → key
|
||||
// activeKIDs returns the order in which keys appear in the JWKS.
|
||||
activeKIDs []string
|
||||
}
|
||||
|
||||
func newMockPocketID(t *testing.T) *mockPocketID {
|
||||
t.Helper()
|
||||
m := &mockPocketID{keys: map[string]*rsa.PrivateKey{}}
|
||||
k1, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.keys["k1"] = k1
|
||||
m.activeKIDs = []string{"k1"}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"issuer":"%s",
|
||||
"authorization_endpoint":"%s/auth",
|
||||
"token_endpoint":"%s/token",
|
||||
"jwks_uri":"%s/jwks",
|
||||
"id_token_signing_alg_values_supported":["RS256"]
|
||||
}`, m.issuer(), m.issuer(), m.issuer(), m.issuer())
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var keys []map[string]any
|
||||
for _, kid := range m.activeKIDs {
|
||||
k := m.keys[kid]
|
||||
n := base64.RawURLEncoding.EncodeToString(k.N.Bytes())
|
||||
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes())
|
||||
keys = append(keys, map[string]any{
|
||||
"kty": "RSA", "use": "sig", "kid": kid, "alg": "RS256", "n": n, "e": e,
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})
|
||||
})
|
||||
m.srv = httptest.NewServer(mux)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockPocketID) issuer() string { return m.srv.URL }
|
||||
|
||||
func (m *mockPocketID) close() { m.srv.Close() }
|
||||
|
||||
func (m *mockPocketID) rotateAddKey(t *testing.T, kid string) {
|
||||
t.Helper()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.keys[kid] = k
|
||||
m.activeKIDs = append(m.activeKIDs, kid)
|
||||
}
|
||||
|
||||
func (m *mockPocketID) signToken(t *testing.T, kid, sub, aud string) string {
|
||||
t.Helper()
|
||||
k, ok := m.keys[kid]
|
||||
if !ok {
|
||||
t.Fatalf("no key %q", kid)
|
||||
}
|
||||
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}
|
||||
claims := map[string]any{
|
||||
"iss": m.issuer(),
|
||||
"sub": sub,
|
||||
"aud": aud,
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
}
|
||||
hJ, _ := json.Marshal(header)
|
||||
cJ, _ := json.Marshal(claims)
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(hJ) + "." + base64.RawURLEncoding.EncodeToString(cJ)
|
||||
h := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, h[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// ----- fake TokenReviewer -----
|
||||
|
||||
func newFakeTokenReviewer(table map[string]token.K8sIdentity) token.TokenReviewer {
|
||||
return token.StaticTokenReviewer(table)
|
||||
}
|
||||
|
||||
// ----- fake registry seeded with Phase-1 Phase 1 agents -----
|
||||
|
||||
func newPhase1Registry() registry.Resolver {
|
||||
return registry.NewStatic([]registry.Agent{
|
||||
{
|
||||
AgentID: "agent-approval-service",
|
||||
PocketIDClient: "agent-approval-service",
|
||||
K8sNamespace: "agent-trust",
|
||||
K8sServiceAccount: "approval-service",
|
||||
AllowedActions: []string{"expense.submit"},
|
||||
},
|
||||
{
|
||||
AgentID: "agent-testagent-transform",
|
||||
PocketIDClient: "agent-testagent-transform",
|
||||
K8sNamespace: "agent-trust",
|
||||
K8sServiceAccount: "testagent-transform",
|
||||
AllowedActions: []string{"expense.normalize", "expense.map_to_sink"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func loadPolicy(t *testing.T) policy.StructuredDecider {
|
||||
t.Helper()
|
||||
p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego")
|
||||
opa, err := policy.NewOPAEvaluator(context.Background(), p)
|
||||
if err != nil {
|
||||
t.Fatalf("load policy: %v", err)
|
||||
}
|
||||
return opa
|
||||
}
|
||||
|
||||
// ----- harness wiring everything together -----
|
||||
|
||||
type prodHarness struct {
|
||||
hsrv *httptest.Server
|
||||
mockPID *mockPocketID
|
||||
approvalPub ed25519.PublicKey
|
||||
transformPub ed25519.PublicKey
|
||||
workloadToken map[string]token.K8sIdentity
|
||||
approvalBearer string
|
||||
transformBearer string
|
||||
}
|
||||
|
||||
func newProdHarness(t *testing.T) *prodHarness {
|
||||
t.Helper()
|
||||
m := newMockPocketID(t)
|
||||
t.Cleanup(m.close)
|
||||
|
||||
ctx := context.Background()
|
||||
pv, err := token.NewPocketIDValidator(ctx, m.issuer(), "trust-api")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signerBackend := signer.NewInMemory()
|
||||
_, approvalPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signerBackend.AddKey("trust-api/approval-signer-2026-q4", approvalPriv)
|
||||
_, transformPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signerBackend.AddKey("trust-api/agent-testagent-transform-2026-q4", transformPriv)
|
||||
|
||||
wl := map[string]token.K8sIdentity{
|
||||
"wl-approval": {Namespace: "agent-trust", ServiceAccount: "approval-service"},
|
||||
"wl-transform": {Namespace: "agent-trust", ServiceAccount: "testagent-transform"},
|
||||
}
|
||||
|
||||
srv := Server{
|
||||
Signer: signerBackend,
|
||||
Validator: pv,
|
||||
TokenReviewer: newFakeTokenReviewer(wl),
|
||||
Registry: newPhase1Registry(),
|
||||
StructuredPolicy: loadPolicy(t),
|
||||
Policy: policy.AllowAll{}, // unused in the structured path
|
||||
Now: func() time.Time { return time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
srv.Mount(mux)
|
||||
hsrv := httptest.NewServer(mux)
|
||||
t.Cleanup(hsrv.Close)
|
||||
|
||||
return &prodHarness{
|
||||
hsrv: hsrv,
|
||||
mockPID: m,
|
||||
approvalPub: approvalPriv.Public().(ed25519.PublicKey),
|
||||
transformPub: transformPriv.Public().(ed25519.PublicKey),
|
||||
workloadToken: wl,
|
||||
approvalBearer: m.signToken(t, "k1", "agent-approval-service", "trust-api"),
|
||||
transformBearer: m.signToken(t, "k1", "agent-testagent-transform", "trust-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func sign(h *prodHarness, bearer, workloadToken, body string) (*http.Response, error) {
|
||||
req, _ := http.NewRequest("POST", h.hsrv.URL+"/api/v1/sign/dsse", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
if workloadToken != "" {
|
||||
req.Header.Set(token.WorkloadTokenHeader, workloadToken)
|
||||
}
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
const approvalStatement = `{
|
||||
"key_ref":"trust-api/approval-signer-2026-q4",
|
||||
"statement":{
|
||||
"_type":"https://in-toto.io/Statement/v1",
|
||||
"subject":[{"name":"approval/APR-1","digest":{"sha256":"abcdef"}}],
|
||||
"predicateType":"https://basicstack.de/attestations/approval/v1",
|
||||
"predicate":{"approval_id":"APR-1"}
|
||||
}
|
||||
}`
|
||||
|
||||
const transformStatement = `{
|
||||
"key_ref":"trust-api/agent-testagent-transform-2026-q4",
|
||||
"statement":{
|
||||
"_type":"https://in-toto.io/Statement/v1",
|
||||
"subject":[{"name":"transform/TR-1","digest":{"sha256":"abcdef"}}],
|
||||
"predicateType":"https://basicstack.de/attestations/transform/v1",
|
||||
"predicate":{"transform_id":"TR-1"}
|
||||
}
|
||||
}`
|
||||
|
||||
// --- happy paths ---
|
||||
|
||||
func TestProd_HappyPath_ApprovalService(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
resp, err := sign(h, h.approvalBearer, "wl-approval", approvalStatement)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := jsonBody(resp)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
assertSignatureVerifies(t, resp, h.approvalPub, "application/vnd.in-toto+json")
|
||||
}
|
||||
|
||||
func TestProd_HappyPath_TestAgentTransform(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
resp, err := sign(h, h.transformBearer, "wl-transform", transformStatement)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := jsonBody(resp)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
assertSignatureVerifies(t, resp, h.transformPub, "application/vnd.in-toto+json")
|
||||
}
|
||||
|
||||
// --- denies ---
|
||||
|
||||
func TestProd_DeniesOnRegistryMismatch(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
// Pocket-ID says Approval Service, but the X-Workload-Token is the
|
||||
// transform agent's. Registry lookups land on different rows → 403.
|
||||
resp, _ := sign(h, h.approvalBearer, "wl-transform", approvalStatement)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
var e errResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Error.Code != "signing_denied" {
|
||||
t.Errorf("expected signing_denied code, got %q (%s)", e.Error.Code, e.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProd_DeniesOnPolicyWrongKeyRef(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
// Approval service identity on both sides, but asks for the transform
|
||||
// agent's key_ref. Policy must deny.
|
||||
body := strings.ReplaceAll(approvalStatement,
|
||||
"trust-api/approval-signer-2026-q4",
|
||||
"trust-api/agent-testagent-transform-2026-q4")
|
||||
resp, _ := sign(h, h.approvalBearer, "wl-approval", body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProd_RejectsMissingWorkloadToken(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
resp, _ := sign(h, h.approvalBearer, "", approvalStatement)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProd_RejectsBadWorkloadToken(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
resp, _ := sign(h, h.approvalBearer, "wl-does-not-exist", approvalStatement)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProd_RejectsBadPocketIDToken(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
resp, _ := sign(h, "not.a.jwt", "wl-approval", approvalStatement)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- kid rotation ---
|
||||
|
||||
func TestProd_PocketID_KidRotation(t *testing.T) {
|
||||
h := newProdHarness(t)
|
||||
// Baseline works with k1.
|
||||
resp, _ := sign(h, h.approvalBearer, "wl-approval", approvalStatement)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("baseline: expected 200 got %d", resp.StatusCode)
|
||||
}
|
||||
// Rotate: new kid k2 appears in JWKS. A token signed with k2 must verify
|
||||
// after the validator refreshes the JWKS (go-oidc does this on kid miss).
|
||||
h.mockPID.rotateAddKey(t, "k2")
|
||||
bearer := h.mockPID.signToken(t, "k2", "agent-approval-service", "trust-api")
|
||||
resp2, _ := sign(h, bearer, "wl-approval", approvalStatement)
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
b, _ := jsonBody(resp2)
|
||||
t.Fatalf("after rotation: expected 200 got %d: %s", resp2.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func jsonBody(resp *http.Response) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
_, err := buf.ReadFrom(resp.Body)
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func assertSignatureVerifies(t *testing.T, resp *http.Response, pub ed25519.PublicKey, payloadType string) {
|
||||
t.Helper()
|
||||
var out signResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(out.Envelope.Payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload)))
|
||||
pae = append(pae, payload...)
|
||||
sig, err := base64.RawURLEncoding.DecodeString(out.Envelope.Signatures[0].Sig)
|
||||
if err != nil {
|
||||
t.Fatalf("decode sig: %v", err)
|
||||
}
|
||||
if !ed25519.Verify(pub, pae, sig) {
|
||||
t.Fatal("returned signature does not verify")
|
||||
}
|
||||
}
|
||||
|
||||
192
services/signing-service/internal/policy/opa.go
Normal file
192
services/signing-service/internal/policy/opa.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// OPA-backed policy decider for the signing-service.
|
||||
//
|
||||
// Loads a Rego policy file (default: policies/sinks/signing_service.rego)
|
||||
// at startup, compiles it once with the OPA Go SDK, and evaluates it per
|
||||
// request against the input the HTTP handler assembles from the two
|
||||
// identity lookups + the requested key_ref.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||||
"github.com/open-policy-agent/opa/rego"
|
||||
)
|
||||
|
||||
// Input is the structured input the signing-service HTTP handler builds
|
||||
// for every /api/v1/sign/dsse call. It is passed verbatim to Rego under
|
||||
// the top-level `input` document.
|
||||
type Input struct {
|
||||
PocketIDClient string `json:"pocket_id_client"`
|
||||
K8s InputK8s `json:"k8s"`
|
||||
KeyRef string `json:"key_ref"`
|
||||
Registry InputRegistry `json:"registry"`
|
||||
}
|
||||
|
||||
// InputK8s is the authenticated Kubernetes identity (from TokenReview).
|
||||
type InputK8s struct {
|
||||
Namespace string `json:"namespace"`
|
||||
ServiceAccount string `json:"service_account"`
|
||||
}
|
||||
|
||||
// InputRegistry carries both registry lookups (by pocket id + by k8s).
|
||||
// Each may be nil if the lookup returned ErrNotFound; the policy treats
|
||||
// a nil row as a hard identity failure.
|
||||
type InputRegistry struct {
|
||||
ByPocketID *InputRegistryRow `json:"by_pocket_id"`
|
||||
ByK8s *InputRegistryRow `json:"by_k8s"`
|
||||
}
|
||||
|
||||
// InputRegistryRow is a stripped-down `agents` row.
|
||||
type InputRegistryRow struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
PocketIDClient string `json:"pocket_id_client"`
|
||||
K8sNamespace string `json:"k8s_namespace"`
|
||||
K8sServiceAccount string `json:"k8s_service_account"`
|
||||
AllowedActions []string `json:"allowed_actions"`
|
||||
}
|
||||
|
||||
// FromAgent builds a non-nil InputRegistryRow from a registry.Agent.
|
||||
func FromAgent(a registry.Agent) *InputRegistryRow {
|
||||
return &InputRegistryRow{
|
||||
AgentID: a.AgentID,
|
||||
PocketIDClient: a.PocketIDClient,
|
||||
K8sNamespace: a.K8sNamespace,
|
||||
K8sServiceAccount: a.K8sServiceAccount,
|
||||
AllowedActions: a.AllowedActions,
|
||||
}
|
||||
}
|
||||
|
||||
// StructuredDecider is a lower-level decider interface that policies that
|
||||
// need the full Input (not just subject/keyRef) implement. The signing
|
||||
// handler uses this when a StructuredDecider is wired; otherwise it falls
|
||||
// back to the subject/keyRef Decider.
|
||||
type StructuredDecider interface {
|
||||
Decide(ctx context.Context, in Input) (Decision, error)
|
||||
}
|
||||
|
||||
// OPAEvaluator is a StructuredDecider backed by an in-process Rego engine.
|
||||
type OPAEvaluator struct {
|
||||
allow rego.PreparedEvalQuery
|
||||
denyReas rego.PreparedEvalQuery
|
||||
modulePkg string
|
||||
src string
|
||||
}
|
||||
|
||||
// NewOPAEvaluator loads and compiles the Rego policy at path.
|
||||
// The policy package MUST be `proofedmesh.sinks.signing_service` (the
|
||||
// default policy file ships with that package name).
|
||||
func NewOPAEvaluator(ctx context.Context, path string) (*OPAEvaluator, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: read rego: %w", err)
|
||||
}
|
||||
return newOPAEvaluatorFromSource(ctx, path, string(src))
|
||||
}
|
||||
|
||||
func newOPAEvaluatorFromSource(ctx context.Context, name, src string) (*OPAEvaluator, error) {
|
||||
pkg := "proofedmesh.sinks.signing_service"
|
||||
allowQ, err := rego.New(
|
||||
rego.Query("data."+pkg+".allow"),
|
||||
rego.Module(name, src),
|
||||
).PrepareForEval(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: prepare allow: %w", err)
|
||||
}
|
||||
denyQ, err := rego.New(
|
||||
rego.Query("data."+pkg+".deny_reason"),
|
||||
rego.Module(name, src),
|
||||
).PrepareForEval(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: prepare deny_reason: %w", err)
|
||||
}
|
||||
return &OPAEvaluator{allow: allowQ, denyReas: denyQ, modulePkg: pkg, src: src}, nil
|
||||
}
|
||||
|
||||
// Decide evaluates the Rego policy against the given input.
|
||||
func (o *OPAEvaluator) Decide(ctx context.Context, in Input) (Decision, error) {
|
||||
// Rego evaluates `input` as the parsed JSON-like document. Marshal
|
||||
// through json tags to get deterministic snake_case keys.
|
||||
raw, err := toRegoInput(in)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
rs, err := o.allow.Eval(ctx, rego.EvalInput(raw))
|
||||
if err != nil {
|
||||
return Decision{}, fmt.Errorf("policy: eval allow: %w", err)
|
||||
}
|
||||
allowed := false
|
||||
if len(rs) > 0 && len(rs[0].Expressions) > 0 {
|
||||
if v, ok := rs[0].Expressions[0].Value.(bool); ok {
|
||||
allowed = v
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
return Decision{Allow: true}, nil
|
||||
}
|
||||
// Collect deny_reason set for a usable 403 message.
|
||||
drs, err := o.denyReas.Eval(ctx, rego.EvalInput(raw))
|
||||
if err != nil {
|
||||
return Decision{}, fmt.Errorf("policy: eval deny_reason: %w", err)
|
||||
}
|
||||
reason := "signing not permitted by policy"
|
||||
if len(drs) > 0 && len(drs[0].Expressions) > 0 {
|
||||
if arr, ok := drs[0].Expressions[0].Value.([]any); ok && len(arr) > 0 {
|
||||
reason = joinReasons(arr)
|
||||
}
|
||||
}
|
||||
return Decision{Allow: false, Reason: reason}, nil
|
||||
}
|
||||
|
||||
// StructuredAdapter wraps a StructuredDecider so it also satisfies the
|
||||
// legacy subject+keyRef Decider contract (used by the HTTP layer's
|
||||
// fallback path and existing tests).
|
||||
type StructuredAdapter struct {
|
||||
Decider StructuredDecider
|
||||
Build func(subject, keyRef string) Input
|
||||
}
|
||||
|
||||
func (a StructuredAdapter) Allow(ctx context.Context, subject, keyRef string) (Decision, error) {
|
||||
if a.Build == nil {
|
||||
return Decision{}, errors.New("policy.StructuredAdapter: Build func is nil")
|
||||
}
|
||||
return a.Decider.Decide(ctx, a.Build(subject, keyRef))
|
||||
}
|
||||
|
||||
func toRegoInput(in Input) (map[string]any, error) {
|
||||
// Round-trip through JSON to collapse to anonymous maps/slices Rego
|
||||
// handles directly, honouring our json tags (including nil-ness of
|
||||
// *InputRegistryRow).
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: marshal input: %w", err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, fmt.Errorf("policy: unmarshal input: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func joinReasons(arr []any) string {
|
||||
// Keep this tiny; policy reasons are small sets of short strings.
|
||||
out := ""
|
||||
for i, v := range arr {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
out += "; "
|
||||
}
|
||||
out += s
|
||||
}
|
||||
if out == "" {
|
||||
return "signing not permitted by policy"
|
||||
}
|
||||
return out
|
||||
}
|
||||
147
services/signing-service/internal/policy/opa_test.go
Normal file
147
services/signing-service/internal/policy/opa_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadRealPolicy compiles the committed policies/sinks/signing_service.rego
|
||||
// so the tests fail if we rename the package or break the file.
|
||||
func loadRealPolicy(t *testing.T) *OPAEvaluator {
|
||||
t.Helper()
|
||||
// From services/signing-service, walk up to repo root (two parents),
|
||||
// then into policies/sinks/signing_service.rego.
|
||||
p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego")
|
||||
opa, err := NewOPAEvaluator(context.Background(), p)
|
||||
if err != nil {
|
||||
t.Fatalf("load policy: %v", err)
|
||||
}
|
||||
return opa
|
||||
}
|
||||
|
||||
func makeInput() Input {
|
||||
by := InputRegistryRow{
|
||||
AgentID: "agent-approval-service",
|
||||
PocketIDClient: "agent-approval-service",
|
||||
K8sNamespace: "agent-trust",
|
||||
K8sServiceAccount: "approval-service",
|
||||
AllowedActions: []string{"expense.submit"},
|
||||
}
|
||||
return Input{
|
||||
PocketIDClient: "agent-approval-service",
|
||||
K8s: InputK8s{
|
||||
Namespace: "agent-trust",
|
||||
ServiceAccount: "approval-service",
|
||||
},
|
||||
KeyRef: "trust-api/approval-signer-2026-q4",
|
||||
Registry: InputRegistry{
|
||||
ByPocketID: &by,
|
||||
ByK8s: &by,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPA_Allows_ApprovalService_HappyPath(t *testing.T) {
|
||||
opa := loadRealPolicy(t)
|
||||
dec, err := opa.Decide(context.Background(), makeInput())
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if !dec.Allow {
|
||||
t.Fatalf("expected allow, got deny: %q", dec.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPA_Allows_TestAgentTransform_HappyPath(t *testing.T) {
|
||||
opa := loadRealPolicy(t)
|
||||
by := InputRegistryRow{
|
||||
AgentID: "agent-testagent-transform",
|
||||
PocketIDClient: "agent-testagent-transform",
|
||||
K8sNamespace: "agent-trust",
|
||||
K8sServiceAccount: "testagent-transform",
|
||||
}
|
||||
in := Input{
|
||||
PocketIDClient: "agent-testagent-transform",
|
||||
K8s: InputK8s{Namespace: "agent-trust", ServiceAccount: "testagent-transform"},
|
||||
KeyRef: "trust-api/agent-testagent-transform-2026-q4",
|
||||
Registry: InputRegistry{ByPocketID: &by, ByK8s: &by},
|
||||
}
|
||||
dec, err := opa.Decide(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if !dec.Allow {
|
||||
t.Fatalf("expected allow, got deny: %q", dec.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPA_Denies_RegistryMismatch(t *testing.T) {
|
||||
opa := loadRealPolicy(t)
|
||||
in := makeInput()
|
||||
// By-K8s returns a DIFFERENT agent row than By-PocketID — the double
|
||||
// identity check must deny.
|
||||
byK := InputRegistryRow{
|
||||
AgentID: "agent-testagent-transform",
|
||||
PocketIDClient: "agent-testagent-transform",
|
||||
K8sNamespace: "agent-trust",
|
||||
K8sServiceAccount: "testagent-transform",
|
||||
}
|
||||
in.Registry.ByK8s = &byK
|
||||
dec, err := opa.Decide(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if dec.Allow {
|
||||
t.Fatalf("expected deny on registry mismatch, got allow")
|
||||
}
|
||||
if !strings.Contains(dec.Reason, "do not resolve to the same registry agent") {
|
||||
t.Errorf("unexpected reason: %q", dec.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPA_Denies_SubjectMismatchFromCaller(t *testing.T) {
|
||||
opa := loadRealPolicy(t)
|
||||
in := makeInput()
|
||||
// Caller presented a different Pocket-ID subject than the one stored
|
||||
// in the registry row — the policy must deny because the row no longer
|
||||
// matches the authenticated identity.
|
||||
in.PocketIDClient = "spoofed-client"
|
||||
dec, err := opa.Decide(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if dec.Allow {
|
||||
t.Fatalf("expected deny when caller subject mismatches registry, got allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPA_Denies_WrongKeyRefForRole(t *testing.T) {
|
||||
opa := loadRealPolicy(t)
|
||||
in := makeInput()
|
||||
in.KeyRef = "trust-api/agent-testagent-transform-2026-q4" // belongs to other agent
|
||||
dec, err := opa.Decide(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if dec.Allow {
|
||||
t.Fatalf("expected deny on wrong key_ref, got allow")
|
||||
}
|
||||
if !strings.Contains(dec.Reason, "not permitted to sign") {
|
||||
t.Errorf("unexpected reason: %q", dec.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPA_Denies_NilRegistryRow(t *testing.T) {
|
||||
opa := loadRealPolicy(t)
|
||||
in := makeInput()
|
||||
in.Registry.ByK8s = nil
|
||||
dec, err := opa.Decide(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if dec.Allow {
|
||||
t.Fatalf("expected deny on missing registry row, got allow")
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ package policy
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Decision carries both the boolean verdict and (on deny) a human-readable
|
||||
|
|
@ -27,11 +26,3 @@ func (AllowAll) Allow(_ context.Context, _, _ string) (Decision, error) {
|
|||
return Decision{Allow: true}, nil
|
||||
}
|
||||
|
||||
// OPA is a stub for the real Rego-backed policy evaluator.
|
||||
type OPA struct{}
|
||||
|
||||
var errNotWired = errors.New("policy.OPA: not wired yet (follow-up subtask)")
|
||||
|
||||
func (OPA) Allow(_ context.Context, _, _ string) (Decision, error) {
|
||||
return Decision{}, errNotWired
|
||||
}
|
||||
|
|
|
|||
125
services/signing-service/internal/registry/registry.go
Normal file
125
services/signing-service/internal/registry/registry.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Package registry resolves ProofedMesh agent identities against the
|
||||
// trust-api `agents` table (db/trust/migrations/0002_agents.up.sql).
|
||||
//
|
||||
// The signing-service calls this package twice per request — once by
|
||||
// pocket_id_client (from the Pocket-ID JWT `sub` claim) and once by
|
||||
// (k8s_namespace, k8s_service_account) (from a Kubernetes TokenReview).
|
||||
// The results are handed to OPA which denies unless both lookups land on
|
||||
// the same, internally consistent agent row.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound means no agent row matched the given identity. Callers
|
||||
// should treat this as a hard identity failure (403), never a 500.
|
||||
var ErrNotFound = errors.New("registry: agent not found")
|
||||
|
||||
// Agent is the subset of the trust-api `agents` row that the signing
|
||||
// service needs for identity reconciliation and policy evaluation.
|
||||
type Agent struct {
|
||||
AgentID string
|
||||
PocketIDClient string
|
||||
K8sNamespace string
|
||||
K8sServiceAccount string
|
||||
DisplayName string
|
||||
AllowedActions []string
|
||||
}
|
||||
|
||||
// Resolver abstracts the backing store so tests can swap in an in-memory
|
||||
// implementation.
|
||||
type Resolver interface {
|
||||
ByPocketID(ctx context.Context, client string) (Agent, error)
|
||||
ByK8sIdentity(ctx context.Context, namespace, serviceAccount string) (Agent, error)
|
||||
}
|
||||
|
||||
// Static is an in-memory resolver suitable for unit tests and the
|
||||
// never-happens case where we want to pin the signing-service to a
|
||||
// deterministic set of identities.
|
||||
type Static struct {
|
||||
ByPocketIDMap map[string]Agent
|
||||
ByK8sIdentityMap map[string]Agent // key: ns + "/" + sa
|
||||
}
|
||||
|
||||
// NewStatic builds a Static resolver from a slice of agents.
|
||||
func NewStatic(agents []Agent) *Static {
|
||||
byP := make(map[string]Agent, len(agents))
|
||||
byK := make(map[string]Agent, len(agents))
|
||||
for _, a := range agents {
|
||||
byP[a.PocketIDClient] = a
|
||||
byK[a.K8sNamespace+"/"+a.K8sServiceAccount] = a
|
||||
}
|
||||
return &Static{ByPocketIDMap: byP, ByK8sIdentityMap: byK}
|
||||
}
|
||||
|
||||
func (s *Static) ByPocketID(_ context.Context, client string) (Agent, error) {
|
||||
a, ok := s.ByPocketIDMap[client]
|
||||
if !ok {
|
||||
return Agent{}, ErrNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Static) ByK8sIdentity(_ context.Context, namespace, serviceAccount string) (Agent, error) {
|
||||
a, ok := s.ByK8sIdentityMap[namespace+"/"+serviceAccount]
|
||||
if !ok {
|
||||
return Agent{}, ErrNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Postgres is the production resolver, backed by the trust-api database.
|
||||
type Postgres struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgres opens a connection pool at the given DSN. Caller owns Close.
|
||||
func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registry: pgxpool.New: %w", err)
|
||||
}
|
||||
return &Postgres{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the pool.
|
||||
func (p *Postgres) Close() {
|
||||
if p.pool != nil {
|
||||
p.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
const selectCols = `agent_id, pocket_id_client, k8s_namespace, k8s_service_account, display_name, allowed_actions`
|
||||
|
||||
func (p *Postgres) ByPocketID(ctx context.Context, client string) (Agent, error) {
|
||||
row := p.pool.QueryRow(ctx, `SELECT `+selectCols+` FROM agents WHERE pocket_id_client = $1`, client)
|
||||
return scanAgent(row)
|
||||
}
|
||||
|
||||
func (p *Postgres) ByK8sIdentity(ctx context.Context, namespace, serviceAccount string) (Agent, error) {
|
||||
row := p.pool.QueryRow(ctx,
|
||||
`SELECT `+selectCols+` FROM agents WHERE k8s_namespace = $1 AND k8s_service_account = $2`,
|
||||
namespace, serviceAccount)
|
||||
return scanAgent(row)
|
||||
}
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanAgent(row scannable) (Agent, error) {
|
||||
var a Agent
|
||||
if err := row.Scan(&a.AgentID, &a.PocketIDClient, &a.K8sNamespace, &a.K8sServiceAccount, &a.DisplayName, &a.AllowedActions); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Agent{}, ErrNotFound
|
||||
}
|
||||
return Agent{}, fmt.Errorf("registry: scan: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
29
services/signing-service/internal/registry/registry_test.go
Normal file
29
services/signing-service/internal/registry/registry_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatic_LookupBothWays(t *testing.T) {
|
||||
s := NewStatic([]Agent{
|
||||
{AgentID: "agent-approval-service", PocketIDClient: "agent-approval-service", K8sNamespace: "agent-trust", K8sServiceAccount: "approval-service"},
|
||||
{AgentID: "agent-testagent-transform", PocketIDClient: "agent-testagent-transform", K8sNamespace: "agent-trust", K8sServiceAccount: "testagent-transform"},
|
||||
})
|
||||
ctx := context.Background()
|
||||
got, err := s.ByPocketID(ctx, "agent-approval-service")
|
||||
if err != nil || got.AgentID != "agent-approval-service" {
|
||||
t.Fatalf("ByPocketID: got %+v, err %v", got, err)
|
||||
}
|
||||
got, err = s.ByK8sIdentity(ctx, "agent-trust", "testagent-transform")
|
||||
if err != nil || got.AgentID != "agent-testagent-transform" {
|
||||
t.Fatalf("ByK8sIdentity: got %+v, err %v", got, err)
|
||||
}
|
||||
if _, err := s.ByPocketID(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.ByK8sIdentity(ctx, "agent-trust", "nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
223
services/signing-service/internal/token/k8s_tokenreview.go
Normal file
223
services/signing-service/internal/token/k8s_tokenreview.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// Kubernetes TokenReview-backed workload-identity validator.
|
||||
//
|
||||
// The signing-service receives the workload's projected ServiceAccount
|
||||
// token in the `X-Workload-Token` header. We submit that token to the
|
||||
// cluster via `authentication.k8s.io/v1 TokenReview` using the pod's own
|
||||
// in-cluster credentials, and return the authenticated
|
||||
// `system:serviceaccount:<ns>:<sa>` as (namespace, serviceAccount).
|
||||
//
|
||||
// This file deliberately does not import k8s.io/client-go to keep the
|
||||
// dependency surface narrow; a hand-rolled HTTPS POST against the API
|
||||
// server is all that's required.
|
||||
package token
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// WorkloadTokenHeader is the HTTP header carrying the workload's
|
||||
// projected SA token (not the Authorization header).
|
||||
WorkloadTokenHeader = "X-Workload-Token"
|
||||
|
||||
defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
)
|
||||
|
||||
// ErrNoWorkloadToken is returned when the X-Workload-Token header is
|
||||
// missing. Caller MUST map this to a 401.
|
||||
var ErrNoWorkloadToken = errors.New("tokenreview: missing X-Workload-Token header")
|
||||
|
||||
// ErrWorkloadAuthFailed is returned when TokenReview reports the token is
|
||||
// invalid or unauthenticated. Caller MUST map this to a 401.
|
||||
var ErrWorkloadAuthFailed = errors.New("tokenreview: workload token not authenticated")
|
||||
|
||||
// K8sIdentity is the subset of a TokenReview UserInfo we care about.
|
||||
type K8sIdentity struct {
|
||||
Namespace string
|
||||
ServiceAccount string
|
||||
// UID is the ServiceAccount's UID (useful for audit; may be empty for
|
||||
// non-SA callers which should be rejected before this type is built).
|
||||
UID string
|
||||
}
|
||||
|
||||
// TokenReviewer validates a workload-presented token and returns the
|
||||
// authenticated ServiceAccount identity.
|
||||
type TokenReviewer interface {
|
||||
Review(ctx context.Context, workloadToken string) (K8sIdentity, error)
|
||||
}
|
||||
|
||||
// FromWorkloadHeader extracts the workload token from the X-Workload-Token
|
||||
// header; it does NOT accept Bearer prefixes because this header is
|
||||
// documented as raw-token.
|
||||
func FromWorkloadHeader(r *http.Request) (string, error) {
|
||||
t := strings.TrimSpace(r.Header.Get(WorkloadTokenHeader))
|
||||
if t == "" {
|
||||
return "", ErrNoWorkloadToken
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// StaticTokenReviewer is a test seam: a fixed table from workload token
|
||||
// → identity. All production paths use K8sTokenReviewer.
|
||||
type StaticTokenReviewer map[string]K8sIdentity
|
||||
|
||||
func (s StaticTokenReviewer) Review(_ context.Context, workloadToken string) (K8sIdentity, error) {
|
||||
id, ok := s[workloadToken]
|
||||
if !ok {
|
||||
return K8sIdentity{}, ErrWorkloadAuthFailed
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// K8sTokenReviewer talks to a Kubernetes API server.
|
||||
type K8sTokenReviewer struct {
|
||||
APIServer string // e.g. "https://kubernetes.default.svc"
|
||||
BearerToken string // pod's own SA token (not the workload token)
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewInClusterTokenReviewer loads the pod's SA token and the cluster CA
|
||||
// from the standard in-cluster paths and wires up a TLS-pinned client.
|
||||
// The returned reviewer talks to `https://kubernetes.default.svc`.
|
||||
func NewInClusterTokenReviewer() (*K8sTokenReviewer, error) {
|
||||
return NewTokenReviewerFromPaths(
|
||||
"https://kubernetes.default.svc",
|
||||
defaultSATokenPath,
|
||||
defaultCAPath,
|
||||
)
|
||||
}
|
||||
|
||||
// NewTokenReviewerFromPaths loads SA token & CA from explicit paths; the
|
||||
// in-cluster helper above is a thin wrapper.
|
||||
func NewTokenReviewerFromPaths(apiServer, tokenPath, caPath string) (*K8sTokenReviewer, error) {
|
||||
tokenBytes, err := os.ReadFile(tokenPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tokenreview: read SA token: %w", err)
|
||||
}
|
||||
caBytes, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tokenreview: read CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caBytes) {
|
||||
return nil, errors.New("tokenreview: CA pem invalid")
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}
|
||||
return &K8sTokenReviewer{
|
||||
APIServer: apiServer,
|
||||
BearerToken: strings.TrimSpace(string(tokenBytes)),
|
||||
HTTPClient: hc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tokenReviewRequest / tokenReviewResponse mirror just the fields we
|
||||
// read, so we don't pull in client-go's typed meta.
|
||||
type tokenReviewRequest struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Spec tokenReviewRequestSpec `json:"spec"`
|
||||
}
|
||||
|
||||
type tokenReviewRequestSpec struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type tokenReviewResponse struct {
|
||||
Status tokenReviewStatus `json:"status"`
|
||||
}
|
||||
|
||||
type tokenReviewStatus struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
User tokenReviewUserInfo `json:"user"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type tokenReviewUserInfo struct {
|
||||
Username string `json:"username"`
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
func (r *K8sTokenReviewer) Review(ctx context.Context, workloadToken string) (K8sIdentity, error) {
|
||||
if workloadToken == "" {
|
||||
return K8sIdentity{}, ErrNoWorkloadToken
|
||||
}
|
||||
payload := tokenReviewRequest{
|
||||
APIVersion: "authentication.k8s.io/v1",
|
||||
Kind: "TokenReview",
|
||||
Spec: tokenReviewRequestSpec{Token: workloadToken},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return K8sIdentity{}, fmt.Errorf("tokenreview: marshal: %w", err)
|
||||
}
|
||||
url := strings.TrimRight(r.APIServer, "/") + "/apis/authentication.k8s.io/v1/tokenreviews"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return K8sIdentity{}, fmt.Errorf("tokenreview: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.BearerToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := r.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return K8sIdentity{}, fmt.Errorf("tokenreview: POST: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return K8sIdentity{}, fmt.Errorf("tokenreview: api server %d: %s", resp.StatusCode, truncate(string(raw), 256))
|
||||
}
|
||||
var out tokenReviewResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return K8sIdentity{}, fmt.Errorf("tokenreview: decode: %w", err)
|
||||
}
|
||||
if !out.Status.Authenticated {
|
||||
if out.Status.Error != "" {
|
||||
return K8sIdentity{}, fmt.Errorf("%w: %s", ErrWorkloadAuthFailed, out.Status.Error)
|
||||
}
|
||||
return K8sIdentity{}, ErrWorkloadAuthFailed
|
||||
}
|
||||
ns, sa, ok := parseSAUsername(out.Status.User.Username)
|
||||
if !ok {
|
||||
return K8sIdentity{}, fmt.Errorf("%w: not a ServiceAccount: %q", ErrWorkloadAuthFailed, out.Status.User.Username)
|
||||
}
|
||||
return K8sIdentity{Namespace: ns, ServiceAccount: sa, UID: out.Status.User.UID}, nil
|
||||
}
|
||||
|
||||
// parseSAUsername extracts (ns, sa) from `system:serviceaccount:<ns>:<sa>`.
|
||||
func parseSAUsername(u string) (string, string, bool) {
|
||||
const prefix = "system:serviceaccount:"
|
||||
if !strings.HasPrefix(u, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
rest := u[len(prefix):]
|
||||
i := strings.IndexByte(rest, ':')
|
||||
if i <= 0 || i == len(rest)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return rest[:i], rest[i+1:], true
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
76
services/signing-service/internal/token/pocket_id.go
Normal file
76
services/signing-service/internal/token/pocket_id.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Pocket-ID OIDC-backed Bearer token validator.
|
||||
//
|
||||
// Phase 1 wires this up for the production path: the signing-service
|
||||
// discovers the issuer, caches the JWKS, and auto-refreshes on `kid`
|
||||
// rotation via coreos/go-oidc's RemoteKeySet.
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
// PocketIDValidator verifies a Bearer JWT against a Pocket-ID issuer and
|
||||
// returns the `sub` claim (which is the Pocket-ID client id).
|
||||
type PocketIDValidator struct {
|
||||
verifier *oidc.IDTokenVerifier
|
||||
issuer string
|
||||
audience string
|
||||
}
|
||||
|
||||
// NewPocketIDValidator performs OIDC discovery and returns a validator.
|
||||
// If audience is non-empty, the verifier enforces the `aud` claim.
|
||||
// SkipClientIDCheck is used when audience is empty because Pocket ID
|
||||
// subject tokens may be addressed to a service name rather than a client.
|
||||
func NewPocketIDValidator(ctx context.Context, issuer, audience string) (*PocketIDValidator, error) {
|
||||
if issuer == "" {
|
||||
return nil, errors.New("token.PocketID: empty issuer")
|
||||
}
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token.PocketID: oidc discovery for %q: %w", issuer, err)
|
||||
}
|
||||
cfg := &oidc.Config{
|
||||
ClientID: audience,
|
||||
SkipClientIDCheck: audience == "",
|
||||
}
|
||||
v := provider.Verifier(cfg)
|
||||
return &PocketIDValidator{verifier: v, issuer: issuer, audience: audience}, nil
|
||||
}
|
||||
|
||||
// Validate implements Validator. It parses, verifies and returns `sub`.
|
||||
func (p *PocketIDValidator) Validate(ctx context.Context, bearer string) (string, error) {
|
||||
if bearer == "" {
|
||||
return "", ErrInvalid
|
||||
}
|
||||
idTok, err := p.verifier.Verify(ctx, bearer)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||
}
|
||||
if idTok.Subject == "" {
|
||||
return "", fmt.Errorf("%w: missing sub claim", ErrInvalid)
|
||||
}
|
||||
return idTok.Subject, nil
|
||||
}
|
||||
|
||||
// Issuer returns the configured issuer URL (for logs / healthz).
|
||||
func (p *PocketIDValidator) Issuer() string { return p.issuer }
|
||||
|
||||
// NewPocketIDValidatorWithHTTPClient is the same as NewPocketIDValidator
|
||||
// but uses the supplied HTTP client (test seams, custom timeouts).
|
||||
func NewPocketIDValidatorWithHTTPClient(ctx context.Context, issuer, audience string, client *http.Client) (*PocketIDValidator, error) {
|
||||
if client != nil {
|
||||
ctx = oidc.ClientContext(ctx, client)
|
||||
}
|
||||
return NewPocketIDValidator(ctx, issuer, audience)
|
||||
}
|
||||
|
||||
// SignerRefreshInterval is advisory — go-oidc refreshes on `kid` miss but
|
||||
// callers who want proactive refresh can wrap this in a timer. We expose
|
||||
// it so cmd/ can decide a cadence.
|
||||
const SignerRefreshInterval = 15 * time.Minute
|
||||
|
|
@ -32,13 +32,6 @@ func (s Static) Validate(_ context.Context, bearer string) (string, error) {
|
|||
return sub, nil
|
||||
}
|
||||
|
||||
// PocketID is a stub for a JWKS-based Pocket ID token verifier.
|
||||
type PocketID struct{}
|
||||
|
||||
func (PocketID) Validate(_ context.Context, _ string) (string, error) {
|
||||
return "", errors.New("token.PocketID: not wired yet (follow-up subtask)")
|
||||
}
|
||||
|
||||
// FromAuthHeader extracts the bearer from an "Authorization: Bearer …" header.
|
||||
func FromAuthHeader(r *http.Request) (string, error) {
|
||||
h := r.Header.Get("Authorization")
|
||||
|
|
|
|||
35
services/testagent-transform/Dockerfile
Normal file
35
services/testagent-transform/Dockerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---------- build ----------
|
||||
FROM golang:1.23-alpine3.21 AS build
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
GOFLAGS="-trimpath"
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY services/testagent-transform/go.mod ./services/testagent-transform/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
cd services/testagent-transform && go mod download
|
||||
|
||||
COPY services/testagent-transform/ ./services/testagent-transform/
|
||||
|
||||
WORKDIR /src/services/testagent-transform
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go vet ./... && \
|
||||
go test ./... && \
|
||||
go build -ldflags="-s -w" -o /out/testagent-transform ./cmd/testagent-transform
|
||||
|
||||
# ---------- runtime ----------
|
||||
# Distroless static+nonroot. The projected ServiceAccount token lives on
|
||||
# a tmpfs volume mounted by kubelet into /var/run/secrets/tokens/trust-api,
|
||||
# so the container only needs read access to that path at runtime.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=build /out/testagent-transform /testagent-transform
|
||||
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/testagent-transform"]
|
||||
105
services/testagent-transform/README.md
Normal file
105
services/testagent-transform/README.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# testagent-transform
|
||||
|
||||
Phase-1 end-to-end demonstrator of the ProofedMesh trust chain's **Transform**
|
||||
step ([DEV-610](/DEV/issues/DEV-610), parent
|
||||
[DEV-606](/DEV/issues/DEV-606)). It is a one-shot CLI, not an HTTP service:
|
||||
given an approval attestation and a `handover_payload`, it produces a signed
|
||||
transform attestation, logs it to the transparency-log, and hands the payload
|
||||
to the Phase-1 dummy-sink. The exit code is 0 on success.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Fetch approval.** `GET {TRANSFORM_TLOG_URL}/api/v1/log/entries/{approval_ref}`.
|
||||
The TLog returns the stored DSSE envelope plus `log_index`.
|
||||
2. **Verify.** The returned envelope is DSSE-verified against the pinned
|
||||
approval-signer keyring (`APPROVAL_SIGNER_KEYS`). On failure the agent
|
||||
stops before any signing call — nothing new is attested on top of an
|
||||
untrusted approval.
|
||||
3. **Build transform statement.** in-toto v1 Statement with
|
||||
`predicateType = https://basicstack.de/attestations/transform/v1`,
|
||||
`operation = expense.map_to_sink`, `materials[0].attestation_ref =
|
||||
approval_ref`, and `subject[0].digest.sha256 = sha256(canonical(handover_payload))`.
|
||||
`canonical` here is byte-identical to the approval-service's canonical
|
||||
serialisation so a downstream verifier reproduces the same hash.
|
||||
4. **Sign.** `POST {TRANSFORM_SIGNING_URL}/api/v1/sign/dsse` with BOTH
|
||||
identities set as headers:
|
||||
- `Authorization: Bearer <pocket-id-access-token>` (`client_credentials`
|
||||
grant against `POCKET_ID_ISSUER`)
|
||||
- `X-Workload-Token: <projected-sa-token>` (read from
|
||||
`TRANSFORM_WORKLOAD_TOKEN_PATH`, default
|
||||
`/var/run/secrets/tokens/trust-api`)
|
||||
The signing-service's structured policy evaluates BOTH identities against
|
||||
the agent registry and OPA; a Pocket-ID bearer alone is not enough. See
|
||||
`services/signing-service/internal/httpapi/sign.go`.
|
||||
5. **Log.** `POST {TRANSFORM_TLOG_URL}/api/v1/log/entries` with the
|
||||
envelope + `content_hash` from step 4.
|
||||
6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/handover` with the
|
||||
canonical `handover_payload` bytes. Headers set:
|
||||
- `X-Payload-Schema: sink.dummy.v1`
|
||||
- `X-Attestation-Ref: <transform content_hash>`
|
||||
|
||||
## Non-scope
|
||||
|
||||
- No read-side attestations (Phase 2).
|
||||
- No persistent store; the agent is stateless and idempotent given the same
|
||||
`(approval_ref, handover_payload)` input (the TLog deduplicates on
|
||||
`content_hash`).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
| ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `TRANSFORM_TLOG_URL` | *(required)* | Base URL of transparency-log. |
|
||||
| `TRANSFORM_SIGNING_URL` | *(required)* | Base URL of signing-service. |
|
||||
| `TRANSFORM_SINK_URL` | *(required)* | Base URL of dummy-sink. |
|
||||
| `TRANSFORM_WORKLOAD_TOKEN_PATH` | `/var/run/secrets/tokens/trust-api` | Projected ServiceAccount token path. |
|
||||
| `POCKET_ID_ISSUER` | *(required)* | Pocket-ID OIDC issuer URL. |
|
||||
| `POCKET_ID_CLIENT_ID` | *(required)* | OIDC client id for this agent. |
|
||||
| `POCKET_ID_CLIENT_SECRET` | *(required)* | OIDC client secret for this agent. |
|
||||
| `POCKET_ID_AUDIENCE` | `trust-api` | OIDC token audience. |
|
||||
| `APPROVAL_SIGNER_KEYS` | *(required)* | Comma-separated `kid=base64(public-ed25519)` entries. |
|
||||
| `TRANSFORM_ACTOR` | `agent-testagent-transform` | Registry id written into the attestation. |
|
||||
| `TRANSFORM_OPERATION` | `expense.map_to_sink` | Transform operation name (must match OPA policy). |
|
||||
| `TRANSFORM_SINK_TARGET` | `sink:dummy-sink` | Logical sink identifier. |
|
||||
| `TRANSFORM_SIGNING_KEY_REF` | `trust-api/agent-testagent-transform-2026-q4` | OpenBao Transit key ref for the transform signer. |
|
||||
| `TRANSFORM_INPUT_SCHEMA` / `_VERSION` | `expense.canonical` / `1.2.0` | Input schema name/version carried in the attestation. |
|
||||
| `TRANSFORM_OUTPUT_SCHEMA` / `_VERSION` | `sink.db.expense` / `1.2.0` | Output schema name/version carried in the attestation. |
|
||||
| `TRANSFORM_VERSION` | `0.1.0` | Agent's own version string, carried in the attestation. |
|
||||
| `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Value sent on the sink's `X-Payload-Schema` header. |
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
testagent-transform \
|
||||
-workflow WF-0042 \
|
||||
-approval-ref sha256:... \
|
||||
-handover ./handover.json
|
||||
```
|
||||
|
||||
`handover.json` must be a JSON object; the agent canonicalises it before
|
||||
hashing + sending to the sink.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
cd services/testagent-transform
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The end-to-end `transform` tests spin up four `httptest` servers — approval
|
||||
read, signing-service, transparency-log, dummy-sink — and run the full
|
||||
pipeline against them:
|
||||
|
||||
- **Happy path.** All four backends respond; the subject digest the agent
|
||||
signs equals the sha256 of the canonical bytes POSTed to the sink; the
|
||||
TLog's recorded content_hash equals the agent's returned `TransformRef`.
|
||||
- **Invalid approval signature.** The approval mock returns a DSSE envelope
|
||||
whose Ed25519 signature does not verify against the pinned public key.
|
||||
The agent fails with `ErrInvalidApproval` and never calls the
|
||||
signing-service, TLog, or sink.
|
||||
- **Signing denied.** The signing-service answers `403 signing_denied`.
|
||||
The agent surfaces `signer.ErrSigningDenied` and never calls the TLog or
|
||||
sink.
|
||||
- **Missing Pocket-ID token.** The TokenSource returns `""`. The signer
|
||||
client rejects before any network traffic.
|
||||
227
services/testagent-transform/cmd/testagent-transform/main.go
Normal file
227
services/testagent-transform/cmd/testagent-transform/main.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// Command testagent-transform is the Phase-1 end-to-end demonstrator of
|
||||
// the ProofedMesh trust chain's Transform step. It is intentionally a
|
||||
// one-shot CLI: given an approval attestation_ref + a handover_payload,
|
||||
// it fetches/verifies the approval, builds a transform attestation,
|
||||
// signs it against the signing-service with BOTH identities, logs the
|
||||
// envelope to the transparency-log, and POSTs the handover_payload bytes
|
||||
// to the dummy-sink. The exit code is 0 on success, non-zero on any error.
|
||||
//
|
||||
// See services/testagent-transform/README.md for how env vars map to the
|
||||
// Runner wiring and how the agent reads its two identities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/approval"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/sink"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/tlog"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/transform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background(), os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "testagent-transform:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("testagent-transform", flag.ContinueOnError)
|
||||
var (
|
||||
workflowID = fs.String("workflow", "", "workflow id to tag on the transform attestation")
|
||||
approvalRef = fs.String("approval-ref", "", "sha256:<hex> content_hash of the approval attestation to fetch")
|
||||
handoverPath = fs.String("handover", "", "path to the handover_payload JSON file")
|
||||
approvalKeysEnv = fs.String("approval-keys-env", "APPROVAL_SIGNER_KEYS", "env var listing approval signer keys as kid=base64(public)")
|
||||
)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(*approvalKeysEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *workflowID == "" || *approvalRef == "" || *handoverPath == "" {
|
||||
return errors.New("-workflow, -approval-ref, and -handover are required")
|
||||
}
|
||||
payload, err := loadHandoverPayload(*handoverPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runner := &transform.Runner{
|
||||
Config: cfg.transformCfg,
|
||||
Approvals: approval.Client{
|
||||
BaseURL: cfg.tlogURL,
|
||||
},
|
||||
ApprovalKeyring: cfg.approvalKeyring,
|
||||
Signer: signer.Client{
|
||||
BaseURL: cfg.signingURL,
|
||||
PocketIDToken: cfg.pocketIDSource,
|
||||
WorkloadToken: cfg.workloadSource,
|
||||
},
|
||||
TLog: tlog.Client{BaseURL: cfg.tlogURL},
|
||||
Sink: sink.Client{BaseURL: cfg.sinkURL},
|
||||
Now: time.Now,
|
||||
}
|
||||
|
||||
res, err := runner.Execute(ctx, transform.Request{
|
||||
WorkflowID: *workflowID,
|
||||
ApprovalRef: *approvalRef,
|
||||
HandoverPayload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("transform complete",
|
||||
"workflow_id", *workflowID,
|
||||
"approval_ref", *approvalRef,
|
||||
"transform_ref", res.TransformRef,
|
||||
"log_index", res.LogIndex,
|
||||
"sink_status", res.SinkStatus,
|
||||
"handover_sha256", res.HandoverHashHex,
|
||||
)
|
||||
fmt.Fprintln(os.Stdout, res.TransformRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
type runtimeConfig struct {
|
||||
tlogURL string
|
||||
signingURL string
|
||||
sinkURL string
|
||||
pocketIDSource auth.TokenSource
|
||||
workloadSource auth.TokenSource
|
||||
approvalKeyring dsse.Keyring
|
||||
transformCfg transform.Config
|
||||
}
|
||||
|
||||
func loadConfig(keysEnv string) (runtimeConfig, error) {
|
||||
var cfg runtimeConfig
|
||||
cfg.tlogURL = envRequired("TRANSFORM_TLOG_URL")
|
||||
cfg.signingURL = envRequired("TRANSFORM_SIGNING_URL")
|
||||
cfg.sinkURL = envRequired("TRANSFORM_SINK_URL")
|
||||
|
||||
if cfg.tlogURL == "" || cfg.signingURL == "" || cfg.sinkURL == "" {
|
||||
return cfg, errors.New("TRANSFORM_TLOG_URL, TRANSFORM_SIGNING_URL, TRANSFORM_SINK_URL are required")
|
||||
}
|
||||
|
||||
issuer := envRequired("POCKET_ID_ISSUER")
|
||||
clientID := envRequired("POCKET_ID_CLIENT_ID")
|
||||
clientSecret := envRequired("POCKET_ID_CLIENT_SECRET")
|
||||
if issuer == "" || clientID == "" || clientSecret == "" {
|
||||
return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required")
|
||||
}
|
||||
cfg.pocketIDSource = &auth.PocketIDTokenSource{
|
||||
Issuer: issuer,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"),
|
||||
}
|
||||
|
||||
saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api")
|
||||
cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath}
|
||||
|
||||
kr, err := loadKeyring(keysEnv)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if len(kr) == 0 {
|
||||
return cfg, fmt.Errorf("%s must list at least one approval signer key", keysEnv)
|
||||
}
|
||||
cfg.approvalKeyring = kr
|
||||
|
||||
cfg.transformCfg = transform.Config{
|
||||
Actor: envDefault("TRANSFORM_ACTOR", "agent-testagent-transform"),
|
||||
Operation: envDefault("TRANSFORM_OPERATION", "expense.map_to_sink"),
|
||||
SinkTarget: envDefault("TRANSFORM_SINK_TARGET", "sink:dummy-sink"),
|
||||
SigningKeyRef: envDefault("TRANSFORM_SIGNING_KEY_REF", "trust-api/agent-testagent-transform-2026-q4"),
|
||||
Input: transform.SchemaRef{Name: envDefault("TRANSFORM_INPUT_SCHEMA", "expense.canonical"), Version: envDefault("TRANSFORM_INPUT_VERSION", "1.2.0")},
|
||||
Output: transform.SchemaRef{Name: envDefault("TRANSFORM_OUTPUT_SCHEMA", "sink.db.expense"), Version: envDefault("TRANSFORM_OUTPUT_VERSION", "1.2.0")},
|
||||
TransformVersion: envDefault("TRANSFORM_VERSION", "0.1.0"),
|
||||
SinkPayloadSchema: envDefault("TRANSFORM_SINK_PAYLOAD_SCHEMA", "sink.dummy.v1"),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadHandoverPayload(path string) (map[string]any, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read handover payload %q: %w", path, err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode handover payload %q: %w", path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadKeyring(envName string) (dsse.Keyring, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(envName))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
kr := dsse.Keyring{}
|
||||
for _, entry := range strings.Split(raw, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(entry, '=')
|
||||
if eq <= 0 {
|
||||
return nil, fmt.Errorf("%s: entry %q must be kid=base64(public-key)", envName, entry)
|
||||
}
|
||||
kid := entry[:eq]
|
||||
pubEnc := entry[eq+1:]
|
||||
pubRaw, err := decodePubKey(pubEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: kid %q: %w", envName, kid, err)
|
||||
}
|
||||
if len(pubRaw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("%s: kid %q: expected %d-byte ed25519 public key, got %d", envName, kid, ed25519.PublicKeySize, len(pubRaw))
|
||||
}
|
||||
kr[kid] = ed25519.PublicKey(pubRaw)
|
||||
}
|
||||
return kr, nil
|
||||
}
|
||||
|
||||
// decodePubKey accepts base64url (no padding) or hex.
|
||||
func decodePubKey(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("public key is not base64url/base64/hex of a 32-byte ed25519 key")
|
||||
}
|
||||
|
||||
func envRequired(name string) string {
|
||||
return strings.TrimSpace(os.Getenv(name))
|
||||
}
|
||||
|
||||
func envDefault(name, def string) string {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
3
services/testagent-transform/go.mod
Normal file
3
services/testagent-transform/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module basicstack.de/proofedmesh/services/testagent-transform
|
||||
|
||||
go 1.23
|
||||
119
services/testagent-transform/internal/approval/client.go
Normal file
119
services/testagent-transform/internal/approval/client.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Package approval retrieves an approval attestation from the transparency
|
||||
// log by its content_hash (`attestation_ref`).
|
||||
//
|
||||
// The TLog's write path (POST /api/v1/log/entries) is defined in
|
||||
// docs/adr/0005-signing-service-and-transparency-log-api.md. The read path
|
||||
// is this agent's companion: GET /api/v1/log/entries/{content_hash}, which
|
||||
// returns the originally-submitted DSSE envelope plus its log metadata so
|
||||
// a downstream verifier can replay the signature check without trusting
|
||||
// the TLog's own storage of the plaintext.
|
||||
//
|
||||
// Phase-1 scope: the agent assumes the TLog exposes this GET endpoint.
|
||||
// For CI/dev the end-to-end test wires an httptest handler of the exact
|
||||
// same shape.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
)
|
||||
|
||||
// Entry is the response the testagent-transform expects from
|
||||
// GET /api/v1/log/entries/{content_hash}.
|
||||
type Entry struct {
|
||||
ContentHash string `json:"content_hash"`
|
||||
Envelope dsse.Envelope `json:"envelope"`
|
||||
LogIndex int64 `json:"log_index"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
}
|
||||
|
||||
// ErrNotFound is returned on a 404 from the TLog read endpoint.
|
||||
var ErrNotFound = errors.New("approval: attestation_ref not in transparency-log")
|
||||
|
||||
// ErrTLogUnavailable classifies connection errors and 5xx responses.
|
||||
var ErrTLogUnavailable = errors.New("approval: transparency-log unavailable")
|
||||
|
||||
// Client is the TLog read client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
TokenSource auth.TokenSource
|
||||
}
|
||||
|
||||
// Fetch retrieves the DSSE envelope and log metadata for attestationRef.
|
||||
// attestationRef must be `sha256:<64 hex>` (the stable content_hash).
|
||||
func (c Client) Fetch(ctx context.Context, attestationRef string) (Entry, error) {
|
||||
if c.BaseURL == "" {
|
||||
return Entry{}, errors.New("approval.Client: BaseURL is empty")
|
||||
}
|
||||
ref := strings.TrimSpace(attestationRef)
|
||||
if ref == "" {
|
||||
return Entry{}, errors.New("approval.Client: attestation_ref is empty")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries/", url.PathEscape(ref))
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("approval: build url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.TokenSource != nil {
|
||||
tok, err := c.TokenSource.Token(ctx)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("approval token: %w", err)
|
||||
}
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
// fall through
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return Entry{}, ErrNotFound
|
||||
case resp.StatusCode >= 500:
|
||||
return Entry{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
return Entry{}, fmt.Errorf("approval: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var out Entry
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Entry{}, fmt.Errorf("approval: decode response: %w", err)
|
||||
}
|
||||
if out.ContentHash == "" {
|
||||
return Entry{}, errors.New("approval: response missing content_hash")
|
||||
}
|
||||
if out.ContentHash != ref {
|
||||
return Entry{}, fmt.Errorf("approval: TLog returned content_hash %q != requested %q", out.ContentHash, ref)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
)
|
||||
|
||||
func TestFetch_HappyPath(t *testing.T) {
|
||||
ref := "sha256:" + "aa" + stringRepeat("b", 62)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/log/entries/"+ref {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Entry{
|
||||
ContentHash: ref,
|
||||
Envelope: dsse.Envelope{PayloadType: "x", Payload: "aGk"},
|
||||
LogIndex: 99,
|
||||
LoggedAt: time.Now(),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
e, err := c.Fetch(context.Background(), ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if e.LogIndex != 99 || e.ContentHash != ref {
|
||||
t.Fatalf("entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_404MapsToNotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.NotFound(w, nil)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, err := c.Fetch(context.Background(), "sha256:x")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_5xxMapsToUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, err := c.Fetch(context.Background(), "sha256:x")
|
||||
if !errors.Is(err, ErrTLogUnavailable) {
|
||||
t.Fatalf("want ErrTLogUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_ContentHashMismatchIsReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Entry{ContentHash: "sha256:other", Envelope: dsse.Envelope{}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, err := c.Fetch(context.Background(), "sha256:expected")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on content_hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func stringRepeat(s string, n int) string {
|
||||
out := make([]byte, 0, len(s)*n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, s...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
178
services/testagent-transform/internal/auth/pocket_id.go
Normal file
178
services/testagent-transform/internal/auth/pocket_id.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PocketIDTokenSource issues Bearer tokens using the Pocket-ID OIDC
|
||||
// `client_credentials` grant and caches each token until shortly before its
|
||||
// expiry. Safe for concurrent use.
|
||||
//
|
||||
// Mirrors services/admin-api/internal/attest/pocket_id_token_source.go in
|
||||
// shape so the signing-service's Pocket-ID validator sees the same token
|
||||
// format admin-api and approval-service already send.
|
||||
type PocketIDTokenSource struct {
|
||||
// Issuer is the Pocket-ID base URL, e.g. "https://auth.basicstack.de".
|
||||
// Trailing slash optional.
|
||||
Issuer string
|
||||
// ClientID / ClientSecret identify the testagent-transform's Pocket-ID
|
||||
// OIDC client. Sourced from env (POCKET_ID_CLIENT_ID /
|
||||
// POCKET_ID_CLIENT_SECRET) in production so they do not land in
|
||||
// process listings.
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// Scope / Audience are optional OIDC parameters; empty when the
|
||||
// server-side policy does not require them.
|
||||
Scope string
|
||||
Audience string
|
||||
// Client overrides the default HTTP client. Default: 10s timeout.
|
||||
Client *http.Client
|
||||
// RefreshLeeway is the window before token expiry at which the cache
|
||||
// is considered invalid. Zero defaults to 30s.
|
||||
RefreshLeeway time.Duration
|
||||
// Now is injected for deterministic tests. Zero defaults to time.Now.
|
||||
Now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
tokenEndpoint string
|
||||
cachedToken string
|
||||
cachedExpiry time.Time
|
||||
}
|
||||
|
||||
type pocketIDTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type pocketIDDiscoveryDoc struct {
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
// Token returns a cached access token when still valid, or fetches a fresh
|
||||
// one from Pocket-ID.
|
||||
func (p *PocketIDTokenSource) Token(ctx context.Context) (string, error) {
|
||||
if p == nil {
|
||||
return "", errors.New("auth.PocketIDTokenSource: nil receiver")
|
||||
}
|
||||
if p.Issuer == "" {
|
||||
return "", errors.New("auth.PocketIDTokenSource: Issuer is required")
|
||||
}
|
||||
if p.ClientID == "" || p.ClientSecret == "" {
|
||||
return "", errors.New("auth.PocketIDTokenSource: ClientID and ClientSecret are required")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
leeway := p.RefreshLeeway
|
||||
if leeway <= 0 {
|
||||
leeway = 30 * time.Second
|
||||
}
|
||||
now := p.nowLocked()
|
||||
if p.cachedToken != "" && now.Add(leeway).Before(p.cachedExpiry) {
|
||||
return p.cachedToken, nil
|
||||
}
|
||||
|
||||
if p.tokenEndpoint == "" {
|
||||
if err := p.discoverLocked(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
tok, expiry, err := p.fetchLocked(ctx, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.cachedToken = tok
|
||||
p.cachedExpiry = expiry
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (p *PocketIDTokenSource) nowLocked() time.Time {
|
||||
if p.Now != nil {
|
||||
return p.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (p *PocketIDTokenSource) httpClient() *http.Client {
|
||||
if p.Client != nil {
|
||||
return p.Client
|
||||
}
|
||||
return &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func (p *PocketIDTokenSource) discoverLocked(ctx context.Context) error {
|
||||
base := strings.TrimRight(p.Issuer, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/openid-configuration", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket id discovery: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocket id discovery: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("pocket id discovery: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var doc pocketIDDiscoveryDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return fmt.Errorf("pocket id discovery: decode: %w", err)
|
||||
}
|
||||
if doc.TokenEndpoint == "" {
|
||||
return errors.New("pocket id discovery: token_endpoint missing from OIDC configuration")
|
||||
}
|
||||
p.tokenEndpoint = doc.TokenEndpoint
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PocketIDTokenSource) fetchLocked(ctx context.Context, now time.Time) (string, time.Time, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
if p.Scope != "" {
|
||||
form.Set("scope", p.Scope)
|
||||
}
|
||||
if p.Audience != "" {
|
||||
form.Set("audience", p.Audience)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("pocket id token: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(p.ClientID, p.ClientSecret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("pocket id token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", time.Time{}, fmt.Errorf("pocket id token: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var out pocketIDTokenResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("pocket id token: decode: %w", err)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return "", time.Time{}, errors.New("pocket id token: response missing access_token")
|
||||
}
|
||||
if out.ExpiresIn <= 0 {
|
||||
return "", time.Time{}, errors.New("pocket id token: response has non-positive expires_in")
|
||||
}
|
||||
return out.AccessToken, now.Add(time.Duration(out.ExpiresIn) * time.Second), nil
|
||||
}
|
||||
80
services/testagent-transform/internal/auth/sa_token.go
Normal file
80
services/testagent-transform/internal/auth/sa_token.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProjectedSATokenSource reads the kubelet-written projected
|
||||
// ServiceAccount token from a mounted Volume (typically
|
||||
// `/var/run/secrets/tokens/trust-api`) and re-reads it on each call so
|
||||
// kubelet's periodic refresh is picked up without a service restart.
|
||||
//
|
||||
// A short in-process cache avoids a syscall on every signing-service call;
|
||||
// kubelet refreshes projected tokens well before their `exp`, so a few
|
||||
// seconds of staleness is fine. The cache is bypassed by setting
|
||||
// CacheTTL <= 0.
|
||||
type ProjectedSATokenSource struct {
|
||||
// Path is the absolute path to the projected token file. Required.
|
||||
// The Volume is typically defined with:
|
||||
// projected:
|
||||
// sources:
|
||||
// - serviceAccountToken:
|
||||
// path: trust-api
|
||||
// audience: trust-api
|
||||
// expirationSeconds: 3600
|
||||
Path string
|
||||
// CacheTTL bounds how long an already-read token value is reused
|
||||
// without re-reading the file. Zero defaults to 30 seconds. Negative
|
||||
// disables caching.
|
||||
CacheTTL time.Duration
|
||||
// Now is injected for deterministic tests. Zero defaults to time.Now.
|
||||
Now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cachedToken string
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// Token returns the current projected ServiceAccount token.
|
||||
func (p *ProjectedSATokenSource) Token(_ context.Context) (string, error) {
|
||||
if p == nil {
|
||||
return "", fmt.Errorf("auth.ProjectedSATokenSource: nil receiver")
|
||||
}
|
||||
if p.Path == "" {
|
||||
return "", fmt.Errorf("auth.ProjectedSATokenSource: Path is required")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
ttl := p.CacheTTL
|
||||
if ttl == 0 {
|
||||
ttl = 30 * time.Second
|
||||
}
|
||||
now := p.now()
|
||||
if ttl > 0 && p.cachedToken != "" && now.Sub(p.cachedAt) < ttl {
|
||||
return p.cachedToken, nil
|
||||
}
|
||||
raw, err := os.ReadFile(p.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read projected SA token %q: %w", p.Path, err)
|
||||
}
|
||||
tok := strings.TrimSpace(string(raw))
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("projected SA token at %q is empty", p.Path)
|
||||
}
|
||||
p.cachedToken = tok
|
||||
p.cachedAt = now
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (p *ProjectedSATokenSource) now() time.Time {
|
||||
if p.Now != nil {
|
||||
return p.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
84
services/testagent-transform/internal/auth/sa_token_test.go
Normal file
84
services/testagent-transform/internal/auth/sa_token_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProjectedSATokenSource_ReadsTokenFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust-api")
|
||||
if err := os.WriteFile(path, []byte("tok-abc\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := &ProjectedSATokenSource{Path: path}
|
||||
tok, err := src.Token(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok != "tok-abc" {
|
||||
t.Fatalf("tok = %q", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedSATokenSource_CachesWithinTTL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust-api")
|
||||
if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC)
|
||||
nowOffset := time.Duration(0)
|
||||
src := &ProjectedSATokenSource{
|
||||
Path: path,
|
||||
CacheTTL: time.Second,
|
||||
Now: func() time.Time { return start.Add(nowOffset) },
|
||||
}
|
||||
first, err := src.Token(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Overwrite the file; cache should hide the new value.
|
||||
if err := os.WriteFile(path, []byte("v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nowOffset = 500 * time.Millisecond
|
||||
second, err := src.Token(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != "v1" || second != "v1" {
|
||||
t.Fatalf("cache missed: first=%q second=%q", first, second)
|
||||
}
|
||||
// Past TTL: must re-read.
|
||||
nowOffset = 2 * time.Second
|
||||
third, err := src.Token(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if third != "v2" {
|
||||
t.Fatalf("expected v2 after TTL expiry, got %q", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedSATokenSource_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trust-api")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := &ProjectedSATokenSource{Path: path}
|
||||
if _, err := src.Token(context.Background()); err == nil {
|
||||
t.Fatal("expected error on empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedSATokenSource_MissingFile(t *testing.T) {
|
||||
src := &ProjectedSATokenSource{Path: "/nonexistent/trust-api"}
|
||||
if _, err := src.Token(context.Background()); err == nil {
|
||||
t.Fatal("expected error on missing token file")
|
||||
}
|
||||
}
|
||||
30
services/testagent-transform/internal/auth/token_source.go
Normal file
30
services/testagent-transform/internal/auth/token_source.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Package auth exposes the two identity sources the testagent-transform
|
||||
// uses to call the signing-service:
|
||||
//
|
||||
// 1. Pocket-ID client_credentials access token (Authorization: Bearer …)
|
||||
// 2. Projected ServiceAccount token (X-Workload-Token …) read from a
|
||||
// kubelet-managed Volume (`projected: { serviceAccountToken: … }`).
|
||||
//
|
||||
// Both are modelled as the same TokenSource interface so the orchestration
|
||||
// code never cares about the identity's origin.
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// TokenSource returns a bearer-style token. The caller is expected to
|
||||
// retry on error; implementations may cache internally.
|
||||
type TokenSource interface {
|
||||
Token(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// StaticToken is a constant TokenSource. Primarily used in tests.
|
||||
type StaticToken string
|
||||
|
||||
// Token returns s.
|
||||
func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil }
|
||||
|
||||
// TokenFunc adapts a plain function to TokenSource.
|
||||
type TokenFunc func(ctx context.Context) (string, error)
|
||||
|
||||
// Token calls f.
|
||||
func (f TokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) }
|
||||
102
services/testagent-transform/internal/canonical/canonical.go
Normal file
102
services/testagent-transform/internal/canonical/canonical.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Package canonical implements the deterministic JSON serialisation the
|
||||
// testagent-transform uses to compute the handover_payload content hash.
|
||||
//
|
||||
// MUST stay byte-for-byte compatible with
|
||||
// services/approval-service/internal/canonical/canonical.go and
|
||||
// services/signing-service/internal/canonical/canonical.go so a subject
|
||||
// digest signed here reproduces identically on the verifier side.
|
||||
package canonical
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SHA256Hex returns the hex-encoded SHA-256 of b.
|
||||
func SHA256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Marshal returns the canonical JSON encoding of v (sorted object keys).
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
first, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var any_ any
|
||||
if err := json.Unmarshal(first, &any_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeSorted(&buf, any_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Hash returns the sha256:<hex> content hash of v's canonical JSON along
|
||||
// with the raw canonical bytes.
|
||||
func Hash(v any) (string, []byte, error) {
|
||||
raw, err := Marshal(v)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "sha256:" + SHA256Hex(raw), raw, nil
|
||||
}
|
||||
|
||||
func writeSorted(buf *bytes.Buffer, v any) error {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
buf.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
buf.WriteString("true")
|
||||
} else {
|
||||
buf.WriteString("false")
|
||||
}
|
||||
case float64:
|
||||
b, _ := json.Marshal(x)
|
||||
buf.Write(b)
|
||||
case string:
|
||||
b, _ := json.Marshal(x)
|
||||
buf.Write(b)
|
||||
case []any:
|
||||
buf.WriteByte('[')
|
||||
for i, el := range x {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
if err := writeSorted(buf, el); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
kb, _ := json.Marshal(k)
|
||||
buf.Write(kb)
|
||||
buf.WriteByte(':')
|
||||
if err := writeSorted(buf, x[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("canonical.writeSorted: unsupported type %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
99
services/testagent-transform/internal/dsse/dsse.go
Normal file
99
services/testagent-transform/internal/dsse/dsse.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Package dsse models the DSSE envelope shape the signing-service produces
|
||||
// and verifies an envelope's Ed25519 signature using the DSSEv1 Pre-
|
||||
// Authentication Encoding (PAE) from the DSSE spec.
|
||||
//
|
||||
// The envelope shape mirrors
|
||||
// services/approval-service/internal/signer.Envelope so an approval fetched
|
||||
// from the transparency-log round-trips through this package byte-for-byte.
|
||||
package dsse
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// EnvelopeSignature mirrors the DSSE spec.
|
||||
type EnvelopeSignature struct {
|
||||
KeyID string `json:"keyid"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
// Envelope is the DSSE envelope the signing-service returns. Payload is
|
||||
// base64url (no padding) of the canonical in-toto Statement bytes.
|
||||
type Envelope struct {
|
||||
PayloadType string `json:"payloadType"`
|
||||
Payload string `json:"payload"`
|
||||
Signatures []EnvelopeSignature `json:"signatures"`
|
||||
}
|
||||
|
||||
// Keyring maps keyid (as embedded in the envelope signature) to the
|
||||
// Ed25519 public key that signed it. The testagent-transform ships a
|
||||
// pinned keyring for the approval-signer key; a Phase-2 verifier would
|
||||
// pull the key material from the Agent Registry (`agent_keys` table).
|
||||
type Keyring map[string]ed25519.PublicKey
|
||||
|
||||
// ErrNoSignatures is returned when the envelope has no signatures to try.
|
||||
var ErrNoSignatures = errors.New("dsse: envelope has no signatures")
|
||||
|
||||
// ErrUnknownKey is returned when none of the envelope's signatures has a
|
||||
// keyid that is present in the verifier's keyring.
|
||||
var ErrUnknownKey = errors.New("dsse: no signature keyid is in the keyring")
|
||||
|
||||
// ErrSignatureInvalid is returned when at least one keyring-matching
|
||||
// signature was tried and none verified.
|
||||
var ErrSignatureInvalid = errors.New("dsse: no signature verified against its keyring entry")
|
||||
|
||||
// Verify checks that at least one signature on env matches a public key in
|
||||
// kr. The payload is base64url-decoded, the DSSEv1 PAE is reconstructed,
|
||||
// and ed25519.Verify is applied. On success the decoded payload bytes are
|
||||
// returned so the caller can parse the in-toto Statement.
|
||||
//
|
||||
// Returns ErrUnknownKey when the envelope has signatures but none list a
|
||||
// keyid that is in the keyring (so no verification was even attempted),
|
||||
// and ErrSignatureInvalid when attempts ran but all failed.
|
||||
func Verify(env Envelope, kr Keyring) ([]byte, error) {
|
||||
if len(env.Signatures) == 0 {
|
||||
return nil, ErrNoSignatures
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(env.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dsse: decode payload: %w", err)
|
||||
}
|
||||
pae := pae(env.PayloadType, payload)
|
||||
|
||||
tried := false
|
||||
for _, sig := range env.Signatures {
|
||||
pub, ok := kr[sig.KeyID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tried = true
|
||||
raw, err := base64.RawURLEncoding.DecodeString(sig.Sig)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ed25519.Verify(pub, pae, raw) {
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
if !tried {
|
||||
return nil, ErrUnknownKey
|
||||
}
|
||||
return nil, ErrSignatureInvalid
|
||||
}
|
||||
|
||||
// pae reconstructs the DSSEv1 Pre-Authentication Encoding:
|
||||
//
|
||||
// "DSSEv1 " || len(payloadType) || " " || payloadType || " " || len(payload) || " " || payload
|
||||
//
|
||||
// (ASCII decimal lengths, bytes.) See
|
||||
// https://github.com/secure-systems-lab/dsse/blob/master/protocol.md.
|
||||
func pae(payloadType string, payload []byte) []byte {
|
||||
prefix := fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload))
|
||||
out := make([]byte, 0, len(prefix)+len(payload))
|
||||
out = append(out, prefix...)
|
||||
out = append(out, payload...)
|
||||
return out
|
||||
}
|
||||
70
services/testagent-transform/internal/dsse/dsse_test.go
Normal file
70
services/testagent-transform/internal/dsse/dsse_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package dsse
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerify_HappyPath(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := []byte(`{"hello":"world"}`)
|
||||
env := signEnv(t, "application/vnd.in-toto+json", payload, "k1", priv)
|
||||
got, err := Verify(env, Keyring{"k1": pub})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("payload = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_UnknownKeyID(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
env := signEnv(t, "x", []byte("hi"), "k1", priv)
|
||||
_, err := Verify(env, Keyring{"other-kid": otherPub})
|
||||
if !errors.Is(err, ErrUnknownKey) {
|
||||
t.Fatalf("want ErrUnknownKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_SignatureInvalid(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
// Build an envelope whose signature was made by a DIFFERENT private
|
||||
// key but is listed under the keyring's keyid.
|
||||
_, otherPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
env := signEnv(t, "x", []byte("hi"), "k1", otherPriv)
|
||||
_, err := Verify(env, Keyring{"k1": pub})
|
||||
if !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("want ErrSignatureInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_NoSignatures(t *testing.T) {
|
||||
_, err := Verify(Envelope{PayloadType: "x", Payload: base64.RawURLEncoding.EncodeToString([]byte("hi"))}, Keyring{})
|
||||
if !errors.Is(err, ErrNoSignatures) {
|
||||
t.Fatalf("want ErrNoSignatures, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signEnv(t *testing.T, payloadType string, payload []byte, keyID string, priv ed25519.PrivateKey) Envelope {
|
||||
t.Helper()
|
||||
pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload)))
|
||||
pae = append(pae, payload...)
|
||||
sig := ed25519.Sign(priv, pae)
|
||||
return Envelope{
|
||||
PayloadType: payloadType,
|
||||
Payload: base64.RawURLEncoding.EncodeToString(payload),
|
||||
Signatures: []EnvelopeSignature{{
|
||||
KeyID: keyID,
|
||||
Sig: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}},
|
||||
}
|
||||
}
|
||||
150
services/testagent-transform/internal/signer/client.go
Normal file
150
services/testagent-transform/internal/signer/client.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Package signer calls the signing-service's POST /api/v1/sign/dsse per
|
||||
// ADR-0005 using the double-identity flow:
|
||||
//
|
||||
// - Authorization: Bearer <pocket-id-access-token> (OIDC client identity)
|
||||
// - X-Workload-Token: <projected-sa-token> (K8s workload identity)
|
||||
//
|
||||
// The signing-service's structured policy evaluates BOTH identities against
|
||||
// the Agent Registry and the OPA policy — a Pocket-ID identity alone is not
|
||||
// enough to sign a transform statement in prod. See
|
||||
// services/signing-service/internal/httpapi/sign.go#useStructuredPath.
|
||||
package signer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
)
|
||||
|
||||
// WorkloadTokenHeader is the HTTP header carrying the projected
|
||||
// ServiceAccount token. Kept in sync with
|
||||
// services/signing-service/internal/token.WorkloadTokenHeader; duplicated
|
||||
// here so this module stays independent.
|
||||
const WorkloadTokenHeader = "X-Workload-Token"
|
||||
|
||||
// Statement is the in-toto v1 Statement handed to the signing-service.
|
||||
// Loose typing lets arbitrary predicate payloads flow through without a
|
||||
// client-side schema registry; the signing-service validates the required
|
||||
// fields.
|
||||
type Statement map[string]any
|
||||
|
||||
// SignedResult pairs the returned DSSE envelope with its content_hash.
|
||||
// content_hash is the stable attestation ID that the transparency-log is
|
||||
// addressed by.
|
||||
type SignedResult struct {
|
||||
Envelope dsse.Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
SignedAt time.Time `json:"signed_at"`
|
||||
}
|
||||
|
||||
// ErrSignerUnavailable is returned on connection errors or 5xx responses.
|
||||
var ErrSignerUnavailable = errors.New("signer: signing-service unavailable")
|
||||
|
||||
// ErrSigningDenied is returned on 403 (OPA / registry mismatch). The
|
||||
// transform agent surfaces this to the caller — it is NOT retryable with
|
||||
// the same identities.
|
||||
var ErrSigningDenied = errors.New("signer: signing-service denied the request")
|
||||
|
||||
// Client is the DSSE sign client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
PocketIDToken auth.TokenSource // Authorization: Bearer …
|
||||
WorkloadToken auth.TokenSource // X-Workload-Token: …
|
||||
}
|
||||
|
||||
type signRequest struct {
|
||||
KeyRef string `json:"key_ref"`
|
||||
Statement Statement `json:"statement"`
|
||||
}
|
||||
|
||||
// Sign calls POST /api/v1/sign/dsse. The signing-service returns a DSSE
|
||||
// envelope plus the stable content_hash of the envelope.
|
||||
func (c Client) Sign(ctx context.Context, keyRef string, stmt Statement) (SignedResult, error) {
|
||||
if c.BaseURL == "" {
|
||||
return SignedResult{}, errors.New("signer.Client: BaseURL is empty")
|
||||
}
|
||||
if c.PocketIDToken == nil {
|
||||
return SignedResult{}, errors.New("signer.Client: PocketIDToken is required")
|
||||
}
|
||||
if c.WorkloadToken == nil {
|
||||
return SignedResult{}, errors.New("signer.Client: WorkloadToken is required")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt})
|
||||
if err != nil {
|
||||
return SignedResult{}, fmt.Errorf("signer: marshal request: %w", err)
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse")
|
||||
if err != nil {
|
||||
return SignedResult{}, fmt.Errorf("signer: build url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return SignedResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
pTok, err := c.PocketIDToken.Token(ctx)
|
||||
if err != nil {
|
||||
return SignedResult{}, fmt.Errorf("signer: pocket-id token: %w", err)
|
||||
}
|
||||
if pTok == "" {
|
||||
return SignedResult{}, errors.New("signer: pocket-id token is empty")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+pTok)
|
||||
|
||||
wTok, err := c.WorkloadToken.Token(ctx)
|
||||
if err != nil {
|
||||
return SignedResult{}, fmt.Errorf("signer: workload token: %w", err)
|
||||
}
|
||||
if wTok == "" {
|
||||
return SignedResult{}, errors.New("signer: workload token is empty")
|
||||
}
|
||||
req.Header.Set(WorkloadTokenHeader, wTok)
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return SignedResult{}, fmt.Errorf("%w: %v", ErrSignerUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
// fall through
|
||||
case resp.StatusCode == http.StatusForbidden:
|
||||
return SignedResult{}, fmt.Errorf("%w: %s", ErrSigningDenied, truncate(raw, 256))
|
||||
case resp.StatusCode >= 500:
|
||||
return SignedResult{}, fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
return SignedResult{}, fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var out SignedResult
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return SignedResult{}, fmt.Errorf("signer: decode response: %w", err)
|
||||
}
|
||||
if out.ContentHash == "" {
|
||||
return SignedResult{}, errors.New("signer: response missing content_hash")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
120
services/testagent-transform/internal/signer/client_test.go
Normal file
120
services/testagent-transform/internal/signer/client_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package signer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
)
|
||||
|
||||
const (
|
||||
testBearer = "pocket-id-bearer-xyz"
|
||||
testWorkload = "wl-tok"
|
||||
)
|
||||
|
||||
func TestSign_SendsBothIdentities(t *testing.T) {
|
||||
seen := struct {
|
||||
auth string
|
||||
workload string
|
||||
keyRef string
|
||||
}{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen.auth = r.Header.Get("Authorization")
|
||||
seen.workload = r.Header.Get(WorkloadTokenHeader)
|
||||
var body struct {
|
||||
KeyRef string `json:"key_ref"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
seen.keyRef = body.KeyRef
|
||||
resp := SignedResult{
|
||||
Envelope: dsse.Envelope{PayloadType: "application/vnd.in-toto+json", Payload: "aGk"},
|
||||
ContentHash: "sha256:deadbeef",
|
||||
SignedAt: time.Now(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Client{
|
||||
BaseURL: srv.URL,
|
||||
PocketIDToken: auth.StaticToken(testBearer),
|
||||
WorkloadToken: auth.StaticToken(testWorkload),
|
||||
}
|
||||
res, err := c.Sign(context.Background(), "key-x", Statement{"_type": "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if res.ContentHash != "sha256:deadbeef" {
|
||||
t.Fatalf("ContentHash = %q", res.ContentHash)
|
||||
}
|
||||
if seen.auth != "Bearer "+testBearer {
|
||||
t.Fatalf("Authorization = %q", seen.auth)
|
||||
}
|
||||
if seen.workload != testWorkload {
|
||||
t.Fatalf("X-Workload-Token = %q", seen.workload)
|
||||
}
|
||||
if seen.keyRef != "key-x" {
|
||||
t.Fatalf("key_ref = %q", seen.keyRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_403MapsToSigningDenied(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"signing_denied","message":"nope"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL, PocketIDToken: auth.StaticToken("a"), WorkloadToken: auth.StaticToken("b")}
|
||||
_, err := c.Sign(context.Background(), "k", Statement{"_type": "x"})
|
||||
if !errors.Is(err, ErrSigningDenied) {
|
||||
t.Fatalf("want ErrSigningDenied, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_5xxMapsToUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL, PocketIDToken: auth.StaticToken("a"), WorkloadToken: auth.StaticToken("b")}
|
||||
_, err := c.Sign(context.Background(), "k", Statement{"_type": "x"})
|
||||
if !errors.Is(err, ErrSignerUnavailable) {
|
||||
t.Fatalf("want ErrSignerUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_ConnRefusedMapsToUnavailable(t *testing.T) {
|
||||
c := Client{
|
||||
BaseURL: "http://127.0.0.1:1",
|
||||
HTTPClient: &http.Client{Timeout: 500 * time.Millisecond},
|
||||
PocketIDToken: auth.StaticToken("a"),
|
||||
WorkloadToken: auth.StaticToken("b"),
|
||||
}
|
||||
_, err := c.Sign(context.Background(), "k", Statement{"_type": "x"})
|
||||
if !errors.Is(err, ErrSignerUnavailable) {
|
||||
t.Fatalf("want ErrSignerUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_RequiresWorkloadToken(t *testing.T) {
|
||||
c := Client{BaseURL: "http://localhost", PocketIDToken: auth.StaticToken("a")}
|
||||
_, err := c.Sign(context.Background(), "k", Statement{"_type": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when WorkloadToken source is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_RequiresPocketIDToken(t *testing.T) {
|
||||
c := Client{BaseURL: "http://localhost", WorkloadToken: auth.StaticToken("a")}
|
||||
_, err := c.Sign(context.Background(), "k", Statement{"_type": "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PocketIDToken source is missing")
|
||||
}
|
||||
}
|
||||
108
services/testagent-transform/internal/sink/client.go
Normal file
108
services/testagent-transform/internal/sink/client.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Package sink posts a handover_payload to the (phase-1) dummy sink.
|
||||
//
|
||||
// The payload is sent as the canonical JSON bytes the agent already hashed
|
||||
// into the transform attestation's subject digest — a byte-for-byte match
|
||||
// is a hard requirement for a downstream verifier, so we send those bytes
|
||||
// directly rather than re-marshaling.
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
)
|
||||
|
||||
// SchemaHeader carries the payload's schema identifier so the sink knows
|
||||
// which decoder to pick. Phase-1 sinks are told to expect "sink.dummy.v1".
|
||||
const SchemaHeader = "X-Payload-Schema"
|
||||
|
||||
// AttestationHeader carries the transform attestation's content_hash so
|
||||
// the sink can resolve the full attestation chain in the TLog without
|
||||
// re-parsing the signed envelope.
|
||||
const AttestationHeader = "X-Attestation-Ref"
|
||||
|
||||
// ErrSinkUnavailable classifies connection errors and 5xx responses.
|
||||
var ErrSinkUnavailable = errors.New("sink: dummy-sink unavailable")
|
||||
|
||||
// Client is the dummy-sink POST client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
TokenSource auth.TokenSource
|
||||
}
|
||||
|
||||
// DeliverResult is what the agent reports upstream after a sink POST.
|
||||
type DeliverResult struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
// Deliver POSTs payloadBytes to the sink. The caller passes the schema
|
||||
// identifier (e.g. "sink.dummy.v1") and the transform attestation's
|
||||
// content_hash so both go on the request headers.
|
||||
func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (DeliverResult, error) {
|
||||
if c.BaseURL == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: BaseURL is empty")
|
||||
}
|
||||
if schema == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: schema is required")
|
||||
}
|
||||
if attestationRef == "" {
|
||||
return DeliverResult{}, errors.New("sink.Client: attestationRef is required")
|
||||
}
|
||||
if len(payloadBytes) == 0 {
|
||||
return DeliverResult{}, errors.New("sink.Client: payloadBytes is empty")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/handover")
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("sink: build url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return DeliverResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set(SchemaHeader, schema)
|
||||
req.Header.Set(AttestationHeader, attestationRef)
|
||||
if c.TokenSource != nil {
|
||||
tok, err := c.TokenSource.Token(ctx)
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("sink: token: %w", err)
|
||||
}
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return DeliverResult{Status: resp.StatusCode}, nil
|
||||
case resp.StatusCode >= 500:
|
||||
return DeliverResult{}, fmt.Errorf("%w: status %d: %s", ErrSinkUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
return DeliverResult{}, fmt.Errorf("sink: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
119
services/testagent-transform/internal/tlog/client.go
Normal file
119
services/testagent-transform/internal/tlog/client.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Package tlog submits attestation envelopes to the transparency-log per
|
||||
// POST /api/v1/log/entries (ADR-0005). The TLog is idempotent on
|
||||
// content_hash: re-submitting the same (envelope, content_hash) returns
|
||||
// the original log_index.
|
||||
package tlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
)
|
||||
|
||||
// InclusionProof mirrors the server-side ADR-0005 inclusion proof.
|
||||
type InclusionProof struct {
|
||||
TreeSize int64 `json:"tree_size"`
|
||||
TreeRootHash string `json:"tree_root_hash"`
|
||||
SignedTreeHeadRef string `json:"signed_tree_head_ref"`
|
||||
Hashes []string `json:"hashes"`
|
||||
}
|
||||
|
||||
// Receipt is what the TLog returns on a successful submit.
|
||||
type Receipt struct {
|
||||
LogIndex int64 `json:"log_index"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
LoggedAt time.Time `json:"logged_at"`
|
||||
InclusionProof InclusionProof `json:"inclusion_proof"`
|
||||
}
|
||||
|
||||
// ErrTLogUnavailable classifies connection errors and 5xx responses.
|
||||
var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable")
|
||||
|
||||
// Client is the TLog submit client.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
TokenSource auth.TokenSource
|
||||
}
|
||||
|
||||
type submitRequest struct {
|
||||
Envelope dsse.Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
}
|
||||
|
||||
// Submit posts env + its contentHash to the TLog. Returns the receipt on
|
||||
// 200/201 (idempotent repeat / fresh insert) and ErrTLogUnavailable on
|
||||
// transport/5xx. Other non-2xx responses are reported with the raw status.
|
||||
func (c Client) Submit(ctx context.Context, env dsse.Envelope, contentHash string) (Receipt, error) {
|
||||
if c.BaseURL == "" {
|
||||
return Receipt{}, errors.New("tlog.Client: BaseURL is empty")
|
||||
}
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
body, err := json.Marshal(submitRequest{Envelope: env, ContentHash: contentHash})
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("tlog: marshal submit: %w", err)
|
||||
}
|
||||
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries")
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("tlog: build url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Receipt{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.TokenSource != nil {
|
||||
tok, err := c.TokenSource.Token(ctx)
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("tlog: token: %w", err)
|
||||
}
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return Receipt{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK, resp.StatusCode == http.StatusCreated:
|
||||
// ok
|
||||
case resp.StatusCode >= 500:
|
||||
return Receipt{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256))
|
||||
default:
|
||||
return Receipt{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256))
|
||||
}
|
||||
var out Receipt
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Receipt{}, fmt.Errorf("tlog: decode receipt: %w", err)
|
||||
}
|
||||
if out.ContentHash != contentHash {
|
||||
return out, fmt.Errorf("tlog returned content_hash %q != sent %q", out.ContentHash, contentHash)
|
||||
}
|
||||
if out.LogIndex < 0 {
|
||||
return out, fmt.Errorf("tlog returned negative log_index %d", out.LogIndex)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) <= n {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:n]) + "…"
|
||||
}
|
||||
72
services/testagent-transform/internal/tlog/client_test.go
Normal file
72
services/testagent-transform/internal/tlog/client_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package tlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
)
|
||||
|
||||
func TestSubmit_HappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/log/entries" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Envelope dsse.Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(Receipt{
|
||||
LogIndex: 7,
|
||||
ContentHash: body.ContentHash,
|
||||
LoggedAt: time.Now(),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
r, err := c.Submit(context.Background(), dsse.Envelope{PayloadType: "x", Payload: "aGk"}, "sha256:abc")
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
if r.LogIndex != 7 || r.ContentHash != "sha256:abc" {
|
||||
t.Fatalf("receipt = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_5xxMapsToUnavailable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, err := c.Submit(context.Background(), dsse.Envelope{}, "sha256:abc")
|
||||
if !errors.Is(err, ErrTLogUnavailable) {
|
||||
t.Fatalf("want ErrTLogUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_ContentHashMismatchIsReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(Receipt{
|
||||
LogIndex: 1,
|
||||
ContentHash: "sha256:different",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL}
|
||||
_, err := c.Submit(context.Background(), dsse.Envelope{}, "sha256:expected")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on content_hash mismatch")
|
||||
}
|
||||
}
|
||||
228
services/testagent-transform/internal/transform/runner.go
Normal file
228
services/testagent-transform/internal/transform/runner.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// Package transform wires the five Phase-1 steps of the testagent-transform
|
||||
// into a single Execute call:
|
||||
//
|
||||
// 1. Fetch the approval attestation from the transparency-log by
|
||||
// attestation_ref.
|
||||
// 2. Verify the fetched DSSE envelope against a pinned approval-signer
|
||||
// keyring.
|
||||
// 3. Build an in-toto v1 Statement with predicateType
|
||||
// "https://basicstack.de/attestations/transform/v1", operation
|
||||
// "expense.map_to_sink", and subject.digest.sha256 =
|
||||
// canonical_sha256(handover_payload).
|
||||
// 4. Call the signing-service with BOTH identities (Pocket-ID bearer +
|
||||
// projected SA token) to obtain a DSSE envelope + content_hash.
|
||||
// 5. Submit the envelope to the transparency-log.
|
||||
// 6. POST the exact canonical handover_payload bytes to the dummy-sink.
|
||||
//
|
||||
// Failures are classified so a Phase-1 ops dashboard can distinguish an
|
||||
// invalid upstream approval (reject at step 2, before any new signature
|
||||
// is created) from a signing-service denial (step 4).
|
||||
package transform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/approval"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/canonical"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/sink"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/tlog"
|
||||
)
|
||||
|
||||
// SchemaRef names and versions a schema. Mirrors the architecture doc's
|
||||
// `schema.input`/`schema.output` shape.
|
||||
type SchemaRef struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Config holds the per-agent fixed parameters. All fields are required.
|
||||
type Config struct {
|
||||
// Actor is the agent's registry id (e.g. "agent-testagent-transform").
|
||||
Actor string
|
||||
// Operation is the OPA-vetted transformation name
|
||||
// ("expense.map_to_sink" in Phase 1).
|
||||
Operation string
|
||||
// SinkTarget is the logical sink identifier the architecture doc uses
|
||||
// to tie the transform to a sink policy ("sink:dummy-sink" here).
|
||||
SinkTarget string
|
||||
// SigningKeyRef is the OpenBao Transit key reference the
|
||||
// signing-service uses on behalf of this agent
|
||||
// ("trust-api/agent-testagent-transform-2026-q4" in Phase 1).
|
||||
SigningKeyRef string
|
||||
// Input / Output schemas carried through on the attestation so the
|
||||
// verifier knows which decoder to run.
|
||||
Input, Output SchemaRef
|
||||
// TransformVersion is the agent's own version string, carried into
|
||||
// the attestation so bugfixes are auditable.
|
||||
TransformVersion string
|
||||
// SinkPayloadSchema is the schema identifier the sink expects on the
|
||||
// `X-Payload-Schema` header. Phase-1: "sink.dummy.v1".
|
||||
SinkPayloadSchema string
|
||||
}
|
||||
|
||||
// ApprovalFetcher pulls an approval entry from the TLog by attestation_ref.
|
||||
type ApprovalFetcher interface {
|
||||
Fetch(ctx context.Context, attestationRef string) (approval.Entry, error)
|
||||
}
|
||||
|
||||
// SignerClient produces a signed DSSE envelope. Prod wiring is a
|
||||
// signer.Client; tests swap in a fake.
|
||||
type SignerClient interface {
|
||||
Sign(ctx context.Context, keyRef string, stmt signer.Statement) (signer.SignedResult, error)
|
||||
}
|
||||
|
||||
// TLogSubmitter adds the envelope to the transparency log.
|
||||
type TLogSubmitter interface {
|
||||
Submit(ctx context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error)
|
||||
}
|
||||
|
||||
// SinkDeliverer posts the handover_payload bytes to the sink.
|
||||
type SinkDeliverer interface {
|
||||
Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (sink.DeliverResult, error)
|
||||
}
|
||||
|
||||
// Request is a single transform invocation. HandoverPayload is JSON-shaped
|
||||
// (`map[string]any` after unmarshal); the agent canonicalises it to compute
|
||||
// the subject digest and ships the same canonical bytes to the sink.
|
||||
type Request struct {
|
||||
WorkflowID string
|
||||
ApprovalRef string
|
||||
HandoverPayload map[string]any
|
||||
}
|
||||
|
||||
// Result is what the agent reports after a successful Execute.
|
||||
type Result struct {
|
||||
TransformRef string
|
||||
LogIndex int64
|
||||
SinkStatus int
|
||||
HandoverHashHex string
|
||||
}
|
||||
|
||||
// Runner is the orchestration root.
|
||||
type Runner struct {
|
||||
Config
|
||||
|
||||
Approvals ApprovalFetcher
|
||||
ApprovalKeyring dsse.Keyring
|
||||
Signer SignerClient
|
||||
TLog TLogSubmitter
|
||||
Sink SinkDeliverer
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// ErrInvalidApproval wraps a DSSE verification failure on the fetched
|
||||
// approval envelope. The runner stops immediately — no new attestation
|
||||
// should be issued on top of an untrusted approval.
|
||||
var ErrInvalidApproval = errors.New("transform: fetched approval attestation did not verify")
|
||||
|
||||
// Execute runs the five-step pipeline.
|
||||
func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) {
|
||||
if err := r.validate(req); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
ap, err := r.Approvals.Fetch(ctx, req.ApprovalRef)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("fetch approval %s: %w", req.ApprovalRef, err)
|
||||
}
|
||||
|
||||
if _, err := dsse.Verify(ap.Envelope, r.ApprovalKeyring); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: %v", ErrInvalidApproval, err)
|
||||
}
|
||||
|
||||
payloadBytes, err := canonical.Marshal(req.HandoverPayload)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("canonical handover_payload: %w", err)
|
||||
}
|
||||
payloadHashHex := canonical.SHA256Hex(payloadBytes)
|
||||
|
||||
stmt := r.buildStatement(req, payloadHashHex)
|
||||
|
||||
signed, err := r.Signer.Sign(ctx, r.SigningKeyRef, stmt)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sign transform statement: %w", err)
|
||||
}
|
||||
|
||||
receipt, err := r.TLog.Submit(ctx, signed.Envelope, signed.ContentHash)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("tlog submit transform: %w", err)
|
||||
}
|
||||
|
||||
deliv, err := r.Sink.Deliver(ctx, r.SinkPayloadSchema, signed.ContentHash, payloadBytes)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sink deliver: %w", err)
|
||||
}
|
||||
|
||||
return Result{
|
||||
TransformRef: signed.ContentHash,
|
||||
LogIndex: receipt.LogIndex,
|
||||
SinkStatus: deliv.Status,
|
||||
HandoverHashHex: payloadHashHex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Runner) validate(req Request) error {
|
||||
if r.Approvals == nil || r.ApprovalKeyring == nil || r.Signer == nil || r.TLog == nil || r.Sink == nil {
|
||||
return errors.New("transform.Runner: missing dependency wiring")
|
||||
}
|
||||
if r.Actor == "" || r.Operation == "" || r.SinkTarget == "" || r.SigningKeyRef == "" {
|
||||
return errors.New("transform.Runner: Config missing required field (Actor/Operation/SinkTarget/SigningKeyRef)")
|
||||
}
|
||||
if r.SinkPayloadSchema == "" {
|
||||
return errors.New("transform.Runner: Config.SinkPayloadSchema is required")
|
||||
}
|
||||
if r.Input.Name == "" || r.Input.Version == "" || r.Output.Name == "" || r.Output.Version == "" {
|
||||
return errors.New("transform.Runner: Config.Input/Output schema is required")
|
||||
}
|
||||
if req.WorkflowID == "" || req.ApprovalRef == "" || len(req.HandoverPayload) == 0 {
|
||||
return errors.New("transform.Runner: Request missing required field (WorkflowID/ApprovalRef/HandoverPayload)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) buildStatement(req Request, payloadHashHex string) signer.Statement {
|
||||
issuedAt := r.now().UTC().Format(time.RFC3339)
|
||||
return signer.Statement{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": []any{
|
||||
map[string]any{
|
||||
"name": "transform/" + req.WorkflowID + "/handover",
|
||||
"digest": map[string]any{
|
||||
"sha256": payloadHashHex,
|
||||
},
|
||||
},
|
||||
},
|
||||
"predicateType": "https://basicstack.de/attestations/transform/v1",
|
||||
"predicate": map[string]any{
|
||||
"workflow_id": req.WorkflowID,
|
||||
"actor": r.Actor,
|
||||
"operation": r.Operation,
|
||||
"sink_target": r.SinkTarget,
|
||||
"approval_ref": req.ApprovalRef,
|
||||
"materials": []any{
|
||||
map[string]any{
|
||||
"name": "approval/" + req.ApprovalRef,
|
||||
"attestation_ref": req.ApprovalRef,
|
||||
},
|
||||
},
|
||||
"schema": map[string]any{
|
||||
"input": map[string]any{"name": r.Input.Name, "version": r.Input.Version},
|
||||
"output": map[string]any{"name": r.Output.Name, "version": r.Output.Version},
|
||||
},
|
||||
"transform_version": r.TransformVersion,
|
||||
"issued_at": issuedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
405
services/testagent-transform/internal/transform/runner_test.go
Normal file
405
services/testagent-transform/internal/transform/runner_test.go
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
package transform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/approval"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/auth"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/canonical"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/dsse"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/signer"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/sink"
|
||||
"basicstack.de/proofedmesh/services/testagent-transform/internal/tlog"
|
||||
)
|
||||
|
||||
// mocks wires httptest servers for approval-service (via TLog GET),
|
||||
// signing-service, transparency-log (POST), and the dummy-sink.
|
||||
type mocks struct {
|
||||
approvalSrv *httptest.Server
|
||||
signSrv *httptest.Server
|
||||
tlogSrv *httptest.Server
|
||||
sinkSrv *httptest.Server
|
||||
|
||||
approvalPub ed25519.PublicKey
|
||||
transformPub ed25519.PublicKey
|
||||
transformPriv ed25519.PrivateKey
|
||||
|
||||
sinkPayload []byte
|
||||
sinkSchema string
|
||||
sinkAttestRef string
|
||||
sinkCalled bool
|
||||
|
||||
tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries
|
||||
}
|
||||
|
||||
// buildMocks creates all four backends. By default the approval envelope
|
||||
// is validly signed by approvalPriv (keyring key id "approval-key").
|
||||
// Caller may override behaviour via opts before calling run.
|
||||
type mockOpts struct {
|
||||
// tamperApproval replaces the valid approval signature with a bogus
|
||||
// one so DSSE verify fails before any sign-call happens.
|
||||
tamperApproval bool
|
||||
// signDenies makes the signing-service answer 403 signing_denied so
|
||||
// the agent surfaces ErrSigningDenied.
|
||||
signDenies bool
|
||||
// pocketIDTokenEmpty makes the Pocket-ID TokenSource return "" so the
|
||||
// signer.Client refuses to even send the request.
|
||||
pocketIDTokenEmpty bool
|
||||
}
|
||||
|
||||
const (
|
||||
approvalKeyID = "approval-key"
|
||||
transformKeyID = "trust-api/agent-testagent-transform-2026-q4"
|
||||
pocketIDBearer = "pocket-id-access-token-for-testagent-transform"
|
||||
workloadToken = "projected-sa-token-trust-api"
|
||||
attestationRef = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
)
|
||||
|
||||
func buildMocks(t *testing.T, opts mockOpts) *mocks {
|
||||
t.Helper()
|
||||
m := &mocks{sinkSchema: "sink.dummy.v1"}
|
||||
|
||||
// Keys.
|
||||
approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transformPub, transformPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.approvalPub = approvalPub
|
||||
m.transformPub = transformPub
|
||||
m.transformPriv = transformPriv
|
||||
|
||||
// A real DSSE envelope the agent must fetch, verify, and reference.
|
||||
approvalPayload := map[string]any{
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"predicateType": "https://basicstack.de/attestations/approval/v1",
|
||||
"subject": []any{
|
||||
map[string]any{
|
||||
"name": "approval/APR-TEST",
|
||||
"digest": map[string]any{"sha256": "deadbeef"},
|
||||
},
|
||||
},
|
||||
"predicate": map[string]any{"approval_id": "APR-TEST"},
|
||||
}
|
||||
approvalEnv := dsseSign(t, "application/vnd.in-toto+json", approvalPayload, approvalKeyID, approvalPriv)
|
||||
if opts.tamperApproval {
|
||||
// Replace sig with bogus bytes so Verify fails.
|
||||
approvalEnv.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xAA}, ed25519.SignatureSize))
|
||||
}
|
||||
|
||||
// approvalSrv: TLog read endpoint GET /api/v1/log/entries/{content_hash}.
|
||||
m.approvalSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/v1/log/entries/"+attestationRef {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
entry := approval.Entry{
|
||||
ContentHash: attestationRef,
|
||||
Envelope: approvalEnv,
|
||||
LogIndex: 42,
|
||||
LoggedAt: time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(entry)
|
||||
}))
|
||||
|
||||
// signSrv: POST /api/v1/sign/dsse — requires BOTH identities and
|
||||
// returns a real DSSE envelope signed by transformPriv so the TLog
|
||||
// path can verify content_hash consistency.
|
||||
m.signSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/sign/dsse" || r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+pocketIDBearer {
|
||||
http.Error(w, `{"error":{"code":"unauthorized","message":"bad bearer"}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get(signer.WorkloadTokenHeader); got != workloadToken {
|
||||
http.Error(w, `{"error":{"code":"unauthorized","message":"bad workload token"}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if opts.signDenies {
|
||||
http.Error(w, `{"error":{"code":"signing_denied","message":"policy denies key_ref"}}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
KeyRef string `json:"key_ref"`
|
||||
Statement map[string]any `json:"statement"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "decode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.KeyRef != transformKeyID {
|
||||
http.Error(w, `{"error":{"code":"bad_request","message":"unexpected key_ref"}}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
env := dsseSign(t, "application/vnd.in-toto+json", body.Statement, transformKeyID, transformPriv)
|
||||
envRaw, _ := canonical.Marshal(env)
|
||||
contentHash := "sha256:" + canonical.SHA256Hex(envRaw)
|
||||
resp := signer.SignedResult{
|
||||
Envelope: env,
|
||||
ContentHash: contentHash,
|
||||
SignedAt: time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
|
||||
// tlogSrv: POST /api/v1/log/entries. Verifies content_hash matches
|
||||
// the request's envelope then returns a canned receipt.
|
||||
m.tlogSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/log/entries" || r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Envelope dsse.Envelope `json:"envelope"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "decode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Independent recompute: the TLog mock re-canonicalises and
|
||||
// confirms the submitted content_hash really does name this
|
||||
// envelope.
|
||||
envRaw, _ := canonical.Marshal(body.Envelope)
|
||||
got := "sha256:" + canonical.SHA256Hex(envRaw)
|
||||
if got != body.ContentHash {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"content_hash mismatch: got %s want %s"}`, got, body.ContentHash), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
m.tlogSubmitted = append(m.tlogSubmitted, body.ContentHash)
|
||||
rcpt := tlog.Receipt{
|
||||
LogIndex: 1234,
|
||||
ContentHash: body.ContentHash,
|
||||
LoggedAt: time.Date(2026, 10, 25, 20, 0, 1, 0, time.UTC),
|
||||
InclusionProof: tlog.InclusionProof{
|
||||
TreeSize: 1235,
|
||||
TreeRootHash: "sha256:" + strings.Repeat("a", 64),
|
||||
SignedTreeHeadRef: "sha256:" + strings.Repeat("b", 64),
|
||||
Hashes: []string{"sha256:" + strings.Repeat("c", 64)},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(rcpt)
|
||||
}))
|
||||
|
||||
// sinkSrv: POST /api/v1/handover; captures body + headers so the
|
||||
// test can assert the agent sent exactly the canonical bytes.
|
||||
m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/handover" || r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
m.sinkCalled = true
|
||||
m.sinkSchema = r.Header.Get(sink.SchemaHeader)
|
||||
m.sinkAttestRef = r.Header.Get(sink.AttestationHeader)
|
||||
m.sinkPayload, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
m.approvalSrv.Close()
|
||||
m.signSrv.Close()
|
||||
m.tlogSrv.Close()
|
||||
m.sinkSrv.Close()
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func newRunner(t *testing.T, m *mocks, opts mockOpts) *Runner {
|
||||
t.Helper()
|
||||
pocketIDToken := auth.StaticToken(pocketIDBearer)
|
||||
if opts.pocketIDTokenEmpty {
|
||||
pocketIDToken = ""
|
||||
}
|
||||
return &Runner{
|
||||
Config: Config{
|
||||
Actor: "agent-testagent-transform",
|
||||
Operation: "expense.map_to_sink",
|
||||
SinkTarget: "sink:dummy-sink",
|
||||
SigningKeyRef: transformKeyID,
|
||||
Input: SchemaRef{Name: "expense.canonical", Version: "1.2.0"},
|
||||
Output: SchemaRef{Name: "sink.db.expense", Version: "1.2.0"},
|
||||
TransformVersion: "0.1.0",
|
||||
SinkPayloadSchema: "sink.dummy.v1",
|
||||
},
|
||||
Approvals: approval.Client{
|
||||
BaseURL: m.approvalSrv.URL,
|
||||
},
|
||||
ApprovalKeyring: dsse.Keyring{approvalKeyID: m.approvalPub},
|
||||
Signer: signer.Client{
|
||||
BaseURL: m.signSrv.URL,
|
||||
PocketIDToken: pocketIDToken,
|
||||
WorkloadToken: auth.StaticToken(workloadToken),
|
||||
},
|
||||
TLog: tlog.Client{BaseURL: m.tlogSrv.URL},
|
||||
Sink: sink.Client{BaseURL: m.sinkSrv.URL},
|
||||
Now: func() time.Time { return time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
}
|
||||
|
||||
func sampleRequest() Request {
|
||||
return Request{
|
||||
WorkflowID: "WF-TEST-1",
|
||||
ApprovalRef: attestationRef,
|
||||
HandoverPayload: map[string]any{
|
||||
"schema": map[string]any{"name": "sink.dummy.v1", "version": "1.0.0"},
|
||||
"items": []any{
|
||||
map[string]any{"line": "1", "amount": "12.34"},
|
||||
},
|
||||
"workflow_id": "WF-TEST-1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- happy path ---
|
||||
|
||||
func TestExecute_HappyPath(t *testing.T) {
|
||||
m := buildMocks(t, mockOpts{})
|
||||
r := newRunner(t, m, mockOpts{})
|
||||
req := sampleRequest()
|
||||
|
||||
res, err := r.Execute(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// Content-hash of the handover_payload we sent to the sink must
|
||||
// equal the subject digest the agent signed into the transform
|
||||
// attestation.
|
||||
wantBytes, _ := canonical.Marshal(req.HandoverPayload)
|
||||
wantHash := canonical.SHA256Hex(wantBytes)
|
||||
if res.HandoverHashHex != wantHash {
|
||||
t.Fatalf("HandoverHashHex = %q, want %q", res.HandoverHashHex, wantHash)
|
||||
}
|
||||
if !m.sinkCalled {
|
||||
t.Fatal("sink was never called")
|
||||
}
|
||||
if !bytes.Equal(m.sinkPayload, wantBytes) {
|
||||
t.Fatalf("sink payload bytes differ:\n got %q\nwant %q", m.sinkPayload, wantBytes)
|
||||
}
|
||||
if m.sinkSchema != "sink.dummy.v1" {
|
||||
t.Fatalf("sink schema header = %q", m.sinkSchema)
|
||||
}
|
||||
if m.sinkAttestRef != res.TransformRef {
|
||||
t.Fatalf("sink attestation header = %q, want %q", m.sinkAttestRef, res.TransformRef)
|
||||
}
|
||||
|
||||
// TLog must have seen exactly our transform content_hash.
|
||||
if len(m.tlogSubmitted) != 1 || m.tlogSubmitted[0] != res.TransformRef {
|
||||
t.Fatalf("tlog submits = %v, want [%q]", m.tlogSubmitted, res.TransformRef)
|
||||
}
|
||||
if res.LogIndex == 0 {
|
||||
t.Fatalf("LogIndex not set")
|
||||
}
|
||||
if res.SinkStatus != http.StatusAccepted {
|
||||
t.Fatalf("SinkStatus = %d, want 202", res.SinkStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// --- negative: approval signature invalid ---
|
||||
|
||||
func TestExecute_InvalidApprovalSignature_StopsBeforeSign(t *testing.T) {
|
||||
m := buildMocks(t, mockOpts{tamperApproval: true})
|
||||
r := newRunner(t, m, mockOpts{})
|
||||
|
||||
_, err := r.Execute(context.Background(), sampleRequest())
|
||||
if err == nil {
|
||||
t.Fatal("expected error on invalid approval signature")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidApproval) {
|
||||
t.Fatalf("expected ErrInvalidApproval, got %v", err)
|
||||
}
|
||||
if len(m.tlogSubmitted) != 0 {
|
||||
t.Fatalf("tlog must not be touched when approval is invalid; saw %v", m.tlogSubmitted)
|
||||
}
|
||||
if m.sinkCalled {
|
||||
t.Fatal("sink must not be touched when approval is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// --- negative: signing-service refuses (403 signing_denied) ---
|
||||
|
||||
func TestExecute_SigningDenied_SurfacesError(t *testing.T) {
|
||||
m := buildMocks(t, mockOpts{signDenies: true})
|
||||
r := newRunner(t, m, mockOpts{})
|
||||
|
||||
_, err := r.Execute(context.Background(), sampleRequest())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when signing-service denies")
|
||||
}
|
||||
if !errors.Is(err, signer.ErrSigningDenied) {
|
||||
t.Fatalf("expected signer.ErrSigningDenied, got %v", err)
|
||||
}
|
||||
if len(m.tlogSubmitted) != 0 {
|
||||
t.Fatalf("tlog must not be touched on sign denial")
|
||||
}
|
||||
if m.sinkCalled {
|
||||
t.Fatal("sink must not be touched on sign denial")
|
||||
}
|
||||
}
|
||||
|
||||
// --- negative: both identities required ---
|
||||
|
||||
func TestExecute_MissingPocketIDToken_FailsBeforeNetwork(t *testing.T) {
|
||||
m := buildMocks(t, mockOpts{})
|
||||
r := newRunner(t, m, mockOpts{pocketIDTokenEmpty: true})
|
||||
|
||||
_, err := r.Execute(context.Background(), sampleRequest())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when Pocket-ID token is empty")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pocket-id token is empty") {
|
||||
t.Fatalf("expected pocket-id token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// dsseSign produces a DSSE envelope with payloadBytes = canonical(stmt)
|
||||
// and signatures = [ed25519(PAE(payloadType, payloadBytes))].
|
||||
func dsseSign(t *testing.T, payloadType string, stmt map[string]any, keyID string, priv ed25519.PrivateKey) dsse.Envelope {
|
||||
t.Helper()
|
||||
payloadBytes, err := canonical.Marshal(stmt)
|
||||
if err != nil {
|
||||
t.Fatalf("canonical: %v", err)
|
||||
}
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payloadBytes)))
|
||||
pae = append(pae, payloadBytes...)
|
||||
sig := ed25519.Sign(priv, pae)
|
||||
return dsse.Envelope{
|
||||
PayloadType: payloadType,
|
||||
Payload: payloadB64,
|
||||
Signatures: []dsse.EnvelopeSignature{{
|
||||
KeyID: keyID,
|
||||
Sig: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue