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>
6.2 KiB
verification-service
Phase-1 Verifier for the ProofedMesh two-stage trust chain
(approval → transform) per
DEV-612, parent
DEV-606.
Validates an end-to-end chain for sink:dummy-sink and reserves a
decision_id exactly once so a replayed sink delivery can be refused
without re-running the signature math.
Endpoint
POST /api/v1/verify/chain
Content-Type: application/json
{
"decision_id": "dec-123",
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>",
"payload_hash": "sha256:<64-hex>",
"sink_target": "sink:dummy-sink"
}
Response (always 200 unless the TLog is unreachable):
{
"valid": true | false,
"replay": true | false,
"decision_id": "dec-123",
"actors": ["trust-api/approval-signer-2026-q4", "trust-api/agent-testagent-transform-2026-q4"],
"approval_ref": "sha256:…",
"transform_ref": "sha256:…",
"reasons": ["<reason code>", …]
}
The sink (dummy-sink) maps replay: true to 409 Conflict and the
rest of valid: false to 403 Forbidden; the verification-service
itself only uses non-200 status codes for transport failures (502 when
the transparency-log is unavailable, 400 for malformed bodies).
The handler also accepts target_sink as a legacy alias for
sink_target so the DEV-612 scope wording stays wire-compatible.
Chain rules (hard-coded, Phase 1)
In order; the first failure wins and populates reasons:
- DSSE signatures.
- Approval signed by
trust-api/approval-signer-2026-q4(configurable viaAPPROVAL_SIGNER_KEY_ID). - Transform signed by
trust-api/agent-testagent-transform-2026-q4(configurable viaTRANSFORM_SIGNER_KEY_ID). - Public keys loaded from
VERIFICATION_SIGNER_KEYSas a comma- / newline-separated spec:trust-api/approval-signer-2026-q4=pem:<PEM>,trust-api/agent-testagent-transform-2026-q4=hex:<64-hex>. Supported encodings:pem,hex,b64,b64u.
- Approval signed by
- Transparency-log inclusion proofs. RFC 6962 §2.1.1; the proof
returned by the TLog must close against the
tree_root_hashfor both envelopes. - Content-binding hash.
approval.subject.digest.sha256matchesstrip("sha256:", approval.predicate.content_hash).transform.subject.digest.sha256matchesstrip("sha256:", request.payload_hash).
allowed_transformationson the approval predicate covers the transform predicate'soperation.allowed_sinkson the approval predicate contains the chain request'ssink_target.- Replay guard.
decision_idis reserved by the replay store (verification_decisionstable). A second call with the same id returnsreplay: true, valid: false.
Approval predicates MUST additionally carry expires_at as an RFC 3339
timestamp. A verification call after that instant returns
approval_expired and does not reserve the decision.
Reason codes
Stable strings; dashboards grep them.
bad_requestapproval_fetch_failed,transform_fetch_failedapproval_signature_unknown_key,approval_signature_invalidtransform_signature_unknown_key,transform_signature_invalidapproval_inclusion_proof_invalid,transform_inclusion_proof_invalidapproval_predicate_malformed,transform_predicate_malformedapproval_hash_drift,transform_hash_driftoperation_not_in_allowed_transformationssink_not_in_allowed_sinksapproval_expiredreplay_decision_id
Expected predicate fields
The verifier is strict about the fields it reads. The approval
predicate (basicstack.de/attestations/approval/v1) MUST carry:
content_hash—sha256:<hex>of the approved canonical payload.allowed_transformations: ["<operation>", …]allowed_sinks: ["<sink_id>", …]expires_at— RFC 3339 timestamp.
The transform predicate
(basicstack.de/attestations/transform/v1) MUST carry:
operation— the OPA-vetted transformation name (e.g.expense.map_to_sink).
The approval-service (DEV-608) currently emits content_hash,
performed_by, performed_at, verification_digits and
approval_schema. The three new fields (allowed_transformations,
allowed_sinks, expires_at) must be added to the approval predicate
upstream before the Phase-1 end-to-end smoke
(DEV-613) can pass; this verifier returns
approval_predicate_malformed until they are present.
Configuration
| Env var | Flag | Default | Required |
|---|---|---|---|
TLOG_URL |
-tlog-url |
— | yes |
TLOG_READ_TOKEN |
-tlog-token |
empty | no |
APPROVAL_SIGNER_KEY_ID |
-approval-key-id |
trust-api/approval-signer-2026-q4 |
no |
TRANSFORM_SIGNER_KEY_ID |
-transform-key-id |
trust-api/agent-testagent-transform-2026-q4 |
no |
VERIFICATION_SIGNER_KEYS |
-signer-keys |
— | yes |
VERIFICATION_STORE_BACKEND |
-backend |
memory |
no |
The replay store is in-memory in Phase 1. The Postgres backend is a
stub mirroring the pattern used by the approval-service's Postgres
skeleton; the schema is authoritative in
db/trust/migrations/0005_verification_decisions.{up,down}.sql.
Build + test
cd services/verification-service
go vet ./...
go test ./...
The CI pipeline (.forgejo/workflows/validate.yml) iterates every
services/*/go.mod so the module is picked up automatically.
Non-scope (Phase 2)
- A second transform agent / N-stage chains.
- OPA for verification. The rules above are hard-coded here; moving them to OPA is tracked under the Phase-2 chain-policy work.
- Live agent-registry lookups for transform signer keys. The
registry.Keyringinterface is the swap-point when the HTTP-backed registry lands.