The verification-service and testagent-transform clients both call
`GET /api/v1/log/entries/{content_hash}` to read a stored envelope and
inclusion proof back out of the log, but the server previously only
implemented the POST (write) half of ADR-0005. Add:
- `Backend.Get` plus `backend.Entry` and `ErrEntryNotFound` so the
memory backend can serve the read; Trillian stays stubbed and
continues returning 503 (`ErrTrillianUnavailable`) until a later
subtask wires the real read path.
- `GET /api/v1/log/entries/{hash}` on the HTTP server, routing 404 /
503 / 500 with the same error envelope as the POST half.
- `GET /healthz` so the Phase-1 smoke-test harness can wait for the
service to come up instead of racing against its bind.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds services/verification-service exposing POST /api/v1/verify/chain
for sink:dummy-sink. The handler runs the six hard-coded chain checks
described in DEV-612 and reserves decision_id in a new
`verification_decisions` table so a replayed sink delivery is refused
without recomputing signatures.
- DSSE signature verification against configurable approval + transform
signer key_ids, loaded from VERIFICATION_SIGNER_KEYS (pem/hex/b64/b64u)
- RFC 6962 inclusion-proof verifier (shared leaf/node hashing with
services/transparency-log/pkg/backend)
- Content binding: subject.digest.sha256 must match predicate.content_hash
(approval) and request.payload_hash (transform)
- Hard-coded coverage of allowed_transformations / allowed_sinks /
expires_at predicates (OPA deferred to Phase 2 per the subtask)
- Replay guard via new db/trust/migrations/0005_verification_decisions
(in-memory store ships; Postgres skeleton mirrors sibling pattern)
- Dockerfile mirrors signing-service pattern (distroless static + nonroot)
- README documents endpoint, reason-code taxonomy, required predicate
fields, and the Phase-2 non-scope
Tests: happy path + 7 negatives (bad signature, missing inclusion proof,
hash drift, sink not allowed, expired, replay, operation not allowed).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Trivial Senken-Adapter per §7 of architecture V1.1. Accepts
{decision_id, payload_hash, payload, approval_ref, transform_ref}
on POST /api/v1/sink/dummy/deliver; calls verification-service's
/api/v1/verify/chain and branches:
- valid=true → atomic .tmp+rename write of payload to
/data/dummy-sink/<decision_id>.json, builds a
basicstack.de/attestations/handover/v1 statement, signs via
signing-service with the double identity flow (Pocket-ID bearer
+ projected SA token), submits DSSE envelope to the TLog and
returns 201 {handover_ref, log_index}.
- valid=false & replay=true → 409.
- valid=false → 403 {reasons:[…]}.
Replay protection lives in verification-service per the parent plan;
dummy-sink keeps no local replay store. The payload_hash field is
verified against both the raw and canonical form so a verifier can
reproduce either hash.
Tests use httptest-backed fakes for verify / sign / tlog and cover
the DEV-611 acceptance criteria plus the bad-request shapes:
- happy path writes the file, calls signer with the handover
statement, submits the envelope to the TLog
- 403 keeps the disk and never calls signer/tlog
- replay returns 409 without re-signing
- payload_hash mismatch rejects before verify
- malformed bodies return 400
- verify unavailable → 502, signing denied → 403
Co-Authored-By: Paperclip <noreply@paperclip.ing>
[DEV-609](/DEV/issues/DEV-609) bumped services/signing-service to
go 1.26.0 (required by github.com/open-policy-agent/opa v1.21.4).
admin-api replaces basicstack.de/proofedmesh/services/signing-service
with the sibling via a replace directive, so its go.mod effective
minimum is also 1.26 and the transitive indirect requirements (opa,
pgx, jwx, go-jose, levenshtein, …) must be listed. CI surfaced
this as:
go: updates to go.mod needed; to update it:
go mod tidy
Fix: `go mod tidy` on services/admin-api with the 1.26 toolchain.
Local `go vet` + `go test ./...` pass on all admin-api packages.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
signing-service depends on github.com/open-policy-agent/opa v1.21.4,
which requires Go >= 1.26.0. The CI Go image and the signing-service
Dockerfile were still pinned to golang:1.23-alpine3.21, so the
"Go (vet + test)" job and the signing-service image build both
failed on main as soon as [DEV-609](/DEV/issues/DEV-609) landed:
go: module ../signing-service requires go >= 1.26.0
(running go 1.23.12; GOTOOLCHAIN=local)
Fix: pin both to golang:1.26-alpine3.23 (oldest alpine tag that
exists for the 1.26 line). All other modules declare go 1.23 which
1.26 still accepts, so no downstream churn.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Minimal CLI agent process that end-to-end demonstrates the Transform step
of the ProofedMesh trust chain:
1. Fetches an approval attestation from the transparency-log by
attestation_ref (`sha256:<hex>`).
2. DSSE-verifies the fetched envelope against a pinned approval-signer
ed25519 keyring; stops before any signing call on failure.
3. Builds an in-toto v1 Statement with predicateType
`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)).
4. Calls signing-service /api/v1/sign/dsse with BOTH identities:
Authorization: Bearer <pocket-id client_credentials token> AND
X-Workload-Token: <projected SA token from
/var/run/secrets/tokens/trust-api>.
5. Submits the returned DSSE envelope + content_hash to the
transparency-log /api/v1/log/entries.
6. POSTs the canonical handover_payload bytes to the dummy-sink
(schema sink.dummy.v1), carrying the transform content_hash on the
X-Attestation-Ref header.
Tests use httptest for all four backends:
- Happy path (verifies the bytes sent to the sink match the signed
subject digest and that the TLog recorded the same content_hash the
agent returned).
- Invalid approval signature -> stops before sign-call; TLog and sink
untouched.
- Signing-service returns 403 signing_denied -> surfaced as
signer.ErrSigningDenied; TLog and sink untouched.
- Missing Pocket-ID token -> fails before any network call.
Includes PocketID client_credentials TokenSource (OIDC discovery + cache)
and ProjectedSATokenSource (reads kubelet-managed Volume) mirroring
admin-api/approval-service.
Non-scope: no read-side attestations (Phase 2), no K8s/CI image build
wiring (follow-up ticket).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
New services/approval-service with the Session → Preview → Confirm
pipeline required by DEV-606/DEV-608:
* POST /api/v1/sessions starts a Pocket-ID-bound approval session.
* POST /api/v1/approvals/preview renders content server-side, computes
H_content = sha256(canonical JSON), stores the (nonce, salt, canonical,
challenge) tuple and returns the human verification digits plus the
WebAuthn challenge = nonce || H_content.
* POST /api/v1/approvals/confirm verifies the WebAuthn assertion against
the stored challenge, DSSE-signs the Approval statement via the
signing-service (key_ref=trust-api/approval-signer-2026-q4), submits
to the transparency-log, and returns {approval_id, attestation_ref,
log_index}. TLog failures leave the row at attestation-pending for
the reconciler.
* db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables
approval_sessions, approval_previews, approvals (schema matches the
Phase 1 store contract).
* internal/content: canonical JSON + H_content + 6-digit verification
code (byte-compatible with signing-service/internal/canonical).
* internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays
WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn.
* internal/signer, internal/tlog: HTTP clients mapping 5xx / connection
errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the
HTTP layer can return 502 for signing failures and persist
attestation-pending on TLog failure.
* internal/service.Orchestrator ties the pipeline together and persists
the Approval BEFORE the TLog submit (never return a log_index for
an un-persisted approval).
Tests cover: happy path, tampered H_content → 400, expired challenge
→ 403, signer 5xx + conn-refused → 502, TLog failure → row stays
attestation-pending, duplicate confirm → 409, session expired → 403.
go vet clean, go test ./... passes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- services/transparency-log/pkg/backend/trillian.go: real gRPC client
on github.com/google/trillian v1.6.1. Submit() does QueueLeaf with the
canonical DSSE envelope as LeafValue and the raw content_hash bytes as
LeafIdentityHash, then polls GetLatestSignedLogRoot +
GetInclusionProofByHash until the leaf is sequenced. Idempotency uses
Trillian's ALREADY_EXISTS signal on the inner QueuedLogLeaf.Status, so
a repeated submit still returns 200 OK with the originally-assigned
log_index + a valid proof against the current STH.
- Unconfigured backend (missing endpoint or LogID) returns the historical
ErrTrillianUnavailable so the httpapi layer keeps mapping it to 503
instead of silently 500. Existing stub-returns-503 test updated.
- cmd/transparency-log: pointer-receiver Trillian wiring; flag docs
mention the production SRV name.
- services/transparency-log/Dockerfile: distroless multi-stage build
used by deploy/trillian/40-transparency-log.yaml.
- deploy/trillian/: full K8s stack for the agent-trust namespace —
MySQL 8.4 StatefulSet (schema init ConfigMap vendored from upstream
storage.sql v1.6.1), Trillian LogServer + LogSigner Deployments on
gcr.io/trillian-opensource-ci:v1.6.1, createtree Job that writes
the TreeID to a ConfigMap (re-apply safe), transparency-log
Deployment with --backend=trillian pulling TRILLIAN_LOG_ID from
the ConfigMap.
- deploy/trillian/README.md: first-time apply + smoke-test + secret
rotation + signer-key clarification (STH key is Trillian-internal;
DSSE envelope signing stays with signing-service / OpenBao).
- services/admin-api: go.mod / go.sum pick up the new transitive deps
(trillian + grpc) via the local replace on transparency-log.
- services/transparency-log/README.md: Backends table updated; next-
steps list no longer carries Trillian-deployment as a follow-up.
Deployed to the live cluster from this heartbeat: MySQL + LogServer +
LogSigner running in agent-trust, tree id 8482455225499059043, service
smoke-tested end-to-end (201 on first POST, 200 on idempotent repeat,
409 on content_hash mismatch, tree_size=2 with correct audit path).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Dockerfile: multi-stage (golang:1.23-alpine3.21 → distroless/static:nonroot),
CGO off, trimpath, stripped, UID/GID 65532, read-only rootfs friendly.
- deploy/signing-service/: ServiceAccount, Deployment (2 replicas, probes,
tight SecurityContext), ClusterIP Service on :8081, NetworkPolicy
(ingress from agent-trust, DNS egress), example Secret, and README with
the full dev + prod secret convention.
- .forgejo/workflows/build-images.yml: builds image on push + PR,
pushes :<git-sha> + :main to harbor.basicstack.de on main pushes only.
Clone pattern matches validate.yml (sha256 repo, manual git clone).
- README updates: service README gains Deployment section; repo README
layout now mentions services/signing-service, services/transparency-log,
deploy/, and the new workflow.
Scope matches DEV-605. OpenBao, OPA and Pocket-ID JWKS stay stubs and move
in their own follow-up subtasks; the prod secret shape is documented so
those subtasks can plug in without reshaping the Deployment.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- services/signing-service/pkg/httpapi exposes NewTestHandler so sibling
modules can build the real signing-service in-process (same pattern
transparency-log already uses).
- services/admin-api/internal/attest/interop_signing_test.go drives the real
attest.HTTPSigner against the real signing-service handler and asserts the
end-to-end ADR-0005 round-trip: envelope shape, keyid, content_hash
self-consistency across canonicalisers, Ed25519 signature verification
with the server's public key, and canonical-payload byte equality.
Closes the "Interop-Test: admin-api attest.HTTPSigner-Client erfolgreich
gegen diesen Server" acceptance criterion from DEV-599.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- services/attest-reconciler/ as its own go.mod (admin-api internal/ is not importable; shared code gets extracted when a 3rd consumer appears).
- Scheduler accepts "@every <dur>" or a bare Go duration; context-cancellation stops the loop cleanly.
- Store client defines PendingRateStore + Postgres skeleton for SELECT ... WHERE rate_change_attestation_ref IS NULL.
- Orchestrator + HTTPSigner + tlog.HTTPSubmitter mirror admin-api byte-for-byte so the TLog idempotency key collides on resubmits (kein Doppelsignieren).
- Prometheus text-format /metrics endpoint, dependency-free: attest_reconciler_dirty_rows_total, _reconciliation_attempts_total, _reconciliation_failures_total, _idempotent_noop_total.
- Tests with fake store + fake signer + fake submitter cover: happy path, already-attested rows are NOT re-signed, TLog idempotency across ticks, TLog-down failure path, pre-cancelled ctx.
- Top-level README reflects the new service; CI auto-discovers services/*/go.mod so no workflow change needed.
Co-Authored-By: Paperclip <noreply@paperclip.ing>