157 lines
6.2 KiB
Markdown
157 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.
|