Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 16s
validate / JSON Schema (ajv) (push) Successful in 20s
build-images / signing-service image (push) Successful in 24s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s
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>
156 lines
6.2 KiB
Markdown
156 lines
6.2 KiB
Markdown
# verification-service
|
|
|
|
Phase-1 Verifier for the ProofedMesh two-stage trust chain
|
|
(`approval → transform`) per
|
|
[DEV-612](/DEV/issues/DEV-612), parent
|
|
[DEV-606](/DEV/issues/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`:
|
|
|
|
1. **DSSE signatures.**
|
|
- Approval signed by `trust-api/approval-signer-2026-q4`
|
|
(configurable via `APPROVAL_SIGNER_KEY_ID`).
|
|
- Transform signed by `trust-api/agent-testagent-transform-2026-q4`
|
|
(configurable via `TRANSFORM_SIGNER_KEY_ID`).
|
|
- Public keys loaded from `VERIFICATION_SIGNER_KEYS` as 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`.
|
|
2. **Transparency-log inclusion proofs.** RFC 6962 §2.1.1; the proof
|
|
returned by the TLog must close against the `tree_root_hash` for
|
|
both envelopes.
|
|
3. **Content-binding hash.**
|
|
- `approval.subject.digest.sha256` matches
|
|
`strip("sha256:", approval.predicate.content_hash)`.
|
|
- `transform.subject.digest.sha256` matches
|
|
`strip("sha256:", request.payload_hash)`.
|
|
4. **`allowed_transformations`** on the approval predicate covers the
|
|
transform predicate's `operation`.
|
|
5. **`allowed_sinks`** on the approval predicate contains the chain
|
|
request's `sink_target`.
|
|
6. **Replay guard.** `decision_id` is reserved by the replay store
|
|
(`verification_decisions` table). A second call with the same id
|
|
returns `replay: 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_request`
|
|
- `approval_fetch_failed`, `transform_fetch_failed`
|
|
- `approval_signature_unknown_key`, `approval_signature_invalid`
|
|
- `transform_signature_unknown_key`, `transform_signature_invalid`
|
|
- `approval_inclusion_proof_invalid`, `transform_inclusion_proof_invalid`
|
|
- `approval_predicate_malformed`, `transform_predicate_malformed`
|
|
- `approval_hash_drift`, `transform_hash_drift`
|
|
- `operation_not_in_allowed_transformations`
|
|
- `sink_not_in_allowed_sinks`
|
|
- `approval_expired`
|
|
- `replay_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](/DEV/issues/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.Keyring` interface is the swap-point when the HTTP-backed
|
|
registry lands.
|