ProofedMesh/services/verification-service
CTO (Paperclip) 0ae29ae342
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
verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612)
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>
2026-10-25 20:35:35 +00:00
..
cmd/verification-service verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612) 2026-10-25 20:35:35 +00:00
internal verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612) 2026-10-25 20:35:35 +00:00
Dockerfile verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612) 2026-10-25 20:35:35 +00:00
go.mod verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612) 2026-10-25 20:35:35 +00:00
README.md verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612) 2026-10-25 20:35:35 +00:00

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:

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