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>
153 lines
4.4 KiB
Go
153 lines
4.4 KiB
Go
// Package httpapi exposes the verification-service's single endpoint:
|
|
// POST /api/v1/verify/chain, per DEV-612.
|
|
//
|
|
// Request:
|
|
//
|
|
// {
|
|
// "decision_id": "<caller-chosen opaque id>",
|
|
// "approval_ref": "sha256:<64-hex>",
|
|
// "transform_ref": "sha256:<64-hex>",
|
|
// "payload_hash": "sha256:<64-hex>",
|
|
// "sink_target": "sink:dummy-sink"
|
|
// }
|
|
//
|
|
// Response:
|
|
//
|
|
// {
|
|
// "valid": true|false,
|
|
// "replay": true|false,
|
|
// "decision_id": "<echoed>",
|
|
// "actors": ["<approval key_id>", "<transform key_id>"],
|
|
// "approval_ref": "sha256:…",
|
|
// "transform_ref": "sha256:…",
|
|
// "reasons": ["<reason code>", …]
|
|
// }
|
|
//
|
|
// Status codes:
|
|
// - 200 OK, valid=true — chain passed, decision_id reserved.
|
|
// - 200 OK, valid=false — chain failed; `reasons` lists the
|
|
// specific guards that fired. The sink maps `replay: true` to 409
|
|
// and the rest to 403; verification-service itself never signals
|
|
// "forbidden" via a status code because a 403 would occlude the
|
|
// structured reasons the sink needs.
|
|
// - 400 Bad Request — malformed body.
|
|
// - 502 Bad Gateway — TLog unavailable (transport failure).
|
|
// - 500 Internal Server Error — unexpected internal error.
|
|
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
|
)
|
|
|
|
// Server wires the chain verifier to the HTTP transport.
|
|
type Server struct {
|
|
Verifier *chain.Verifier
|
|
Logger *slog.Logger
|
|
MaxBody int64 // 0 → default 1 MiB
|
|
}
|
|
|
|
// Mount registers the endpoints on mux.
|
|
func (s *Server) Mount(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /api/v1/verify/chain", s.handleVerifyChain)
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
type verifyRequest struct {
|
|
DecisionID string `json:"decision_id"`
|
|
ApprovalRef string `json:"approval_ref"`
|
|
TransformRef string `json:"transform_ref"`
|
|
PayloadHash string `json:"payload_hash"`
|
|
// SinkTarget is the canonical field. Historical siblings (dummy-sink's
|
|
// verify.Client) send `sink_target`; the DEV-612 scope description
|
|
// used `target_sink`; we accept either on the wire for compatibility
|
|
// and prefer `sink_target` when both are present.
|
|
SinkTarget string `json:"sink_target,omitempty"`
|
|
TargetSink string `json:"target_sink,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
|
|
logger := s.logger()
|
|
max := s.MaxBody
|
|
if max <= 0 {
|
|
max = 1 << 20
|
|
}
|
|
ct := r.Header.Get("Content-Type")
|
|
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
|
writeErr(w, http.StatusBadRequest, "content-type must be application/json")
|
|
return
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, max+1))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
|
return
|
|
}
|
|
if int64(len(raw)) > max {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "body too large")
|
|
return
|
|
}
|
|
var req verifyRequest
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "decode: "+err.Error())
|
|
return
|
|
}
|
|
|
|
sink := req.SinkTarget
|
|
if sink == "" {
|
|
sink = req.TargetSink
|
|
}
|
|
|
|
verdict, err := s.Verifier.Verify(r.Context(), chain.Request{
|
|
DecisionID: req.DecisionID,
|
|
ApprovalRef: req.ApprovalRef,
|
|
TransformRef: req.TransformRef,
|
|
PayloadHash: req.PayloadHash,
|
|
SinkTarget: sink,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, tlog.ErrTLogUnavailable) {
|
|
logger.Warn("tlog unavailable", "err", err)
|
|
writeErr(w, http.StatusBadGateway, "transparency-log unavailable")
|
|
return
|
|
}
|
|
logger.Error("chain verify failed", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, verdict)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
type errBody struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, errBody{Error: msg})
|
|
}
|
|
|
|
func (s *Server) logger() *slog.Logger {
|
|
if s.Logger != nil {
|
|
return s.Logger
|
|
}
|
|
return slog.Default()
|
|
}
|