ProofedMesh/services/verification-service/README.md

157 lines
6.2 KiB
Markdown
Raw Permalink Normal View History

# 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.