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>
455 lines
16 KiB
Go
455 lines
16 KiB
Go
// Package chain implements the Phase-1 two-stage (approval -> transform)
|
|
// verification chain described in DEV-612. Rules are hard-coded here;
|
|
// OPA for verification arrives in Phase 2.
|
|
//
|
|
// The six checks (see DEV-612 scope) are, in order:
|
|
//
|
|
// 1. Both DSSE signatures verify against their signer public keys
|
|
// (approval: `trust-api/approval-signer-2026-q4`; transform:
|
|
// `trust-api/agent-testagent-transform-2026-q4` via the Agent
|
|
// Registry).
|
|
// 2. TLog inclusion proofs for both refs exist and reproduce the
|
|
// signed tree root (RFC 6962 §2.1.1).
|
|
// 3. Content-binding: approval.subject.digest.sha256 ==
|
|
// strip("sha256:", approval.predicate.content_hash), and
|
|
// transform.subject.digest.sha256 == strip("sha256:", payload_hash).
|
|
// This closes the chain from the signed statement to the raw bytes
|
|
// the sink persists.
|
|
// 4. approval.predicate.allowed_transformations covers
|
|
// transform.predicate.operation.
|
|
// 5. approval.predicate.allowed_sinks contains sink_target.
|
|
// 6. Replay guard: Store.Reserve succeeds for decision_id.
|
|
//
|
|
// Any failure returns valid=false with a reason code in Verdict.Reasons;
|
|
// the HTTP layer maps those codes to a stable JSON response.
|
|
package chain
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/store"
|
|
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
|
|
)
|
|
|
|
// Request is the input to Verify.
|
|
type Request struct {
|
|
DecisionID string
|
|
ApprovalRef string
|
|
TransformRef string
|
|
PayloadHash string
|
|
SinkTarget string
|
|
}
|
|
|
|
// Verdict is the output of Verify.
|
|
type Verdict struct {
|
|
Valid bool `json:"valid"`
|
|
Replay bool `json:"replay"`
|
|
DecisionID string `json:"decision_id"`
|
|
Actors []string `json:"actors,omitempty"`
|
|
ApprovalRef string `json:"approval_ref,omitempty"`
|
|
TransformRef string `json:"transform_ref,omitempty"`
|
|
Reasons []string `json:"reasons,omitempty"`
|
|
}
|
|
|
|
// Reason codes. Stable strings; dashboards grep them. Keep in sync with
|
|
// the HTTP response contract and the dummy-sink's reaction (403 vs 409).
|
|
const (
|
|
ReasonBadRequest = "bad_request"
|
|
ReasonApprovalFetch = "approval_fetch_failed"
|
|
ReasonTransformFetch = "transform_fetch_failed"
|
|
ReasonApprovalSignatureUnknown = "approval_signature_unknown_key"
|
|
ReasonApprovalSignatureInvalid = "approval_signature_invalid"
|
|
ReasonTransformSignatureUnknown = "transform_signature_unknown_key"
|
|
ReasonTransformSignatureInvalid = "transform_signature_invalid"
|
|
ReasonApprovalInclusionInvalid = "approval_inclusion_proof_invalid"
|
|
ReasonTransformInclusionInvalid = "transform_inclusion_proof_invalid"
|
|
ReasonApprovalPredicateMalformed = "approval_predicate_malformed"
|
|
ReasonTransformPredicateMalformed = "transform_predicate_malformed"
|
|
ReasonApprovalHashDrift = "approval_hash_drift"
|
|
ReasonTransformHashDrift = "transform_hash_drift"
|
|
ReasonSinkNotAllowed = "sink_not_in_allowed_sinks"
|
|
ReasonTransformationNotAllowed = "operation_not_in_allowed_transformations"
|
|
ReasonApprovalExpired = "approval_expired"
|
|
ReasonReplay = "replay_decision_id"
|
|
)
|
|
|
|
// Verifier wires the chain-check dependencies.
|
|
type Verifier struct {
|
|
TLog tlog.Fetcher
|
|
Keyring registry.Keyring
|
|
Store store.Store
|
|
Now func() time.Time
|
|
Approval ApprovalConfig
|
|
}
|
|
|
|
// ApprovalConfig constrains which keys and schema the approval predicate
|
|
// is expected to have. The key_id prefix pins the DSSE keyid to the
|
|
// configured signer period; a mismatched keyid is rejected before any
|
|
// signature math runs.
|
|
type ApprovalConfig struct {
|
|
// ExpectedKeyID is the full DSSE keyid for the active approval signer
|
|
// (e.g. "trust-api/approval-signer-2026-q4"). The verifier accepts
|
|
// signatures whose keyid matches this value exactly.
|
|
ExpectedKeyID string
|
|
// TransformKeyID is the full DSSE keyid for the active transform
|
|
// signer (e.g. "trust-api/agent-testagent-transform-2026-q4"). Phase 1
|
|
// has one transform agent; Phase 2 will replace this with a lookup
|
|
// against the agent registry.
|
|
TransformKeyID string
|
|
}
|
|
|
|
// Verify runs the full chain check. It never returns a Go error for a
|
|
// chain rejection: the Verdict carries the reasons. A Go error is only
|
|
// returned for transport-level problems that prevent us from deciding
|
|
// (TLog down, store panics, etc.).
|
|
func (v *Verifier) Verify(ctx context.Context, req Request) (Verdict, error) {
|
|
verdict := Verdict{
|
|
DecisionID: req.DecisionID,
|
|
ApprovalRef: req.ApprovalRef,
|
|
TransformRef: req.TransformRef,
|
|
}
|
|
if err := validateRequest(req); err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonBadRequest+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
|
|
// 1a. Fetch approval + verify DSSE signature.
|
|
apEntry, err := v.TLog.Fetch(ctx, req.ApprovalRef)
|
|
if err != nil {
|
|
if errors.Is(err, tlog.ErrNotFound) {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalFetch+": not_found")
|
|
return verdict, nil
|
|
}
|
|
return verdict, fmt.Errorf("approval fetch: %w", err)
|
|
}
|
|
trEntry, err := v.TLog.Fetch(ctx, req.TransformRef)
|
|
if err != nil {
|
|
if errors.Is(err, tlog.ErrNotFound) {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformFetch+": not_found")
|
|
return verdict, nil
|
|
}
|
|
return verdict, fmt.Errorf("transform fetch: %w", err)
|
|
}
|
|
|
|
// 1b. Signature verification for approval.
|
|
apKr, err := v.keyringFor(ctx, v.Approval.ExpectedKeyID)
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureUnknown+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
apPayload, apKey, err := dsse.Verify(apEntry.Envelope, apKr)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, dsse.ErrUnknownKey):
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureUnknown)
|
|
case errors.Is(err, dsse.ErrSignatureInvalid):
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureInvalid)
|
|
default:
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureInvalid+": "+err.Error())
|
|
}
|
|
return verdict, nil
|
|
}
|
|
|
|
// 1c. Signature verification for transform.
|
|
trKr, err := v.keyringFor(ctx, v.Approval.TransformKeyID)
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureUnknown+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
trPayload, trKey, err := dsse.Verify(trEntry.Envelope, trKr)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, dsse.ErrUnknownKey):
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureUnknown)
|
|
case errors.Is(err, dsse.ErrSignatureInvalid):
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureInvalid)
|
|
default:
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureInvalid+": "+err.Error())
|
|
}
|
|
return verdict, nil
|
|
}
|
|
|
|
// 2. Inclusion proofs.
|
|
if err := tlog.VerifyEntry(apEntry); err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalInclusionInvalid+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
if err := tlog.VerifyEntry(trEntry); err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformInclusionInvalid+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
|
|
// 3. Parse predicates (needed for checks 3-5) and verify content binding.
|
|
ap, err := parseStatement(apPayload)
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
tr, err := parseStatement(trPayload)
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
|
|
apContentHash, err := ap.ContentHash()
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
if ap.SubjectDigest() != stripSha256Prefix(apContentHash) {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalHashDrift+": subject.digest.sha256 != predicate.content_hash")
|
|
return verdict, nil
|
|
}
|
|
if tr.SubjectDigest() != stripSha256Prefix(req.PayloadHash) {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformHashDrift+": subject.digest.sha256 != request.payload_hash")
|
|
return verdict, nil
|
|
}
|
|
|
|
// 4. allowed_transformations covers operation.
|
|
op, err := tr.Operation()
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonTransformPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
allowedOps, err := ap.AllowedTransformations()
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
if !containsString(allowedOps, op) {
|
|
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: %q not in %v", ReasonTransformationNotAllowed, op, allowedOps))
|
|
return verdict, nil
|
|
}
|
|
|
|
// 5. allowed_sinks contains sink_target.
|
|
allowedSinks, err := ap.AllowedSinks()
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
if !containsString(allowedSinks, req.SinkTarget) {
|
|
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: %q not in %v", ReasonSinkNotAllowed, req.SinkTarget, allowedSinks))
|
|
return verdict, nil
|
|
}
|
|
|
|
// Expiry check belongs with the approval predicate.
|
|
expiresAt, err := ap.ExpiresAt()
|
|
if err != nil {
|
|
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
|
|
return verdict, nil
|
|
}
|
|
if !v.now().Before(expiresAt) {
|
|
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: expires_at=%s now=%s", ReasonApprovalExpired, expiresAt.Format(time.RFC3339), v.now().Format(time.RFC3339)))
|
|
return verdict, nil
|
|
}
|
|
|
|
// 6. Replay guard. Reserve atomically; a repeated decision_id lands
|
|
// here only after passing checks 1-5 on the first call, so we can
|
|
// safely treat it as replay without recomputing anything.
|
|
decision := store.Decision{
|
|
DecisionID: req.DecisionID,
|
|
ApprovalRef: req.ApprovalRef,
|
|
TransformRef: req.TransformRef,
|
|
PayloadHash: req.PayloadHash,
|
|
SinkTarget: req.SinkTarget,
|
|
ApprovalKey: apKey,
|
|
TransformKey: trKey,
|
|
CreatedAt: v.now().UTC(),
|
|
}
|
|
if err := v.Store.Reserve(ctx, decision); err != nil {
|
|
if errors.Is(err, store.ErrReplay) {
|
|
verdict.Replay = true
|
|
verdict.Reasons = append(verdict.Reasons, ReasonReplay)
|
|
return verdict, nil
|
|
}
|
|
return verdict, fmt.Errorf("reserve decision: %w", err)
|
|
}
|
|
|
|
verdict.Valid = true
|
|
verdict.Actors = []string{apKey, trKey}
|
|
return verdict, nil
|
|
}
|
|
|
|
func validateRequest(req Request) error {
|
|
if strings.TrimSpace(req.DecisionID) == "" {
|
|
return errors.New("decision_id is required")
|
|
}
|
|
if !isSha256Ref(req.ApprovalRef) {
|
|
return errors.New("approval_ref must be sha256:<64 hex>")
|
|
}
|
|
if !isSha256Ref(req.TransformRef) {
|
|
return errors.New("transform_ref must be sha256:<64 hex>")
|
|
}
|
|
if !isSha256Ref(req.PayloadHash) {
|
|
return errors.New("payload_hash must be sha256:<64 hex>")
|
|
}
|
|
if strings.TrimSpace(req.SinkTarget) == "" {
|
|
return errors.New("sink_target is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSha256Ref(s string) bool {
|
|
if !strings.HasPrefix(s, "sha256:") {
|
|
return false
|
|
}
|
|
hex := s[len("sha256:"):]
|
|
if len(hex) != 64 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(hex); i++ {
|
|
c := hex[i]
|
|
if !('0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func stripSha256Prefix(s string) string {
|
|
return strings.TrimPrefix(s, "sha256:")
|
|
}
|
|
|
|
func containsString(xs []string, want string) bool {
|
|
for _, x := range xs {
|
|
if x == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// keyringFor returns a dsse.Keyring with a single mapping (expectedKeyID
|
|
// -> public key fetched from the registry). Restricting the keyring to
|
|
// the configured signer id makes DSSE verification refuse a signature
|
|
// that happens to be valid against some *other* key the registry knows
|
|
// — a defensive measure against key confusion in a shared keyring.
|
|
func (v *Verifier) keyringFor(ctx context.Context, expectedKeyID string) (dsse.Keyring, error) {
|
|
if expectedKeyID == "" {
|
|
return nil, errors.New("expected key_id is unset in verifier config")
|
|
}
|
|
pub, err := v.Keyring.Lookup(ctx, expectedKeyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dsse.Keyring{expectedKeyID: pub}, nil
|
|
}
|
|
|
|
func (v *Verifier) now() time.Time {
|
|
if v.Now != nil {
|
|
return v.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// Statement is the parsed in-toto v1 Statement with helpers for the
|
|
// specific predicate fields the chain check needs. Loosely typed so the
|
|
// verifier is forward-compatible with additional predicate fields.
|
|
type Statement struct {
|
|
Raw map[string]any
|
|
}
|
|
|
|
func parseStatement(payload []byte) (Statement, error) {
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err != nil {
|
|
return Statement{}, fmt.Errorf("decode statement: %w", err)
|
|
}
|
|
if _, ok := m["predicate"].(map[string]any); !ok {
|
|
return Statement{}, errors.New("statement has no predicate object")
|
|
}
|
|
return Statement{Raw: m}, nil
|
|
}
|
|
|
|
// SubjectDigest returns the sha256 digest hex (no prefix) of the first
|
|
// subject on the statement. The approval and transform statements both
|
|
// have a single subject per Phase-1 convention.
|
|
func (s Statement) SubjectDigest() string {
|
|
subs, ok := s.Raw["subject"].([]any)
|
|
if !ok || len(subs) == 0 {
|
|
return ""
|
|
}
|
|
first, ok := subs[0].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
digest, ok := first["digest"].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
sha, _ := digest["sha256"].(string)
|
|
return sha
|
|
}
|
|
|
|
func (s Statement) predicate() map[string]any {
|
|
m, _ := s.Raw["predicate"].(map[string]any)
|
|
return m
|
|
}
|
|
|
|
// ContentHash returns predicate.content_hash as "sha256:<hex>". Required
|
|
// on approval statements; the chain verifier uses it to cross-check the
|
|
// subject digest and so detect a mismatched preview-vs-approval row.
|
|
func (s Statement) ContentHash() (string, error) {
|
|
v, ok := s.predicate()["content_hash"].(string)
|
|
if !ok || v == "" {
|
|
return "", errors.New("predicate.content_hash is missing")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// Operation returns predicate.operation (transform statements).
|
|
func (s Statement) Operation() (string, error) {
|
|
v, ok := s.predicate()["operation"].(string)
|
|
if !ok || v == "" {
|
|
return "", errors.New("predicate.operation is missing")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// AllowedTransformations returns predicate.allowed_transformations
|
|
// (approval statements). The predicate MUST carry this field; a missing
|
|
// or empty list is a malformed approval, not an implicit allow-all.
|
|
func (s Statement) AllowedTransformations() ([]string, error) {
|
|
return s.stringArray("allowed_transformations")
|
|
}
|
|
|
|
// AllowedSinks returns predicate.allowed_sinks (approval statements).
|
|
// Same discipline as AllowedTransformations.
|
|
func (s Statement) AllowedSinks() ([]string, error) {
|
|
return s.stringArray("allowed_sinks")
|
|
}
|
|
|
|
// ExpiresAt returns predicate.expires_at (approval statements) as a
|
|
// time.Time. The predicate MUST carry an RFC 3339 timestamp; the chain
|
|
// verifier refuses to approve a sink write past that instant.
|
|
func (s Statement) ExpiresAt() (time.Time, error) {
|
|
v, ok := s.predicate()["expires_at"].(string)
|
|
if !ok || v == "" {
|
|
return time.Time{}, errors.New("predicate.expires_at is missing")
|
|
}
|
|
return time.Parse(time.RFC3339, v)
|
|
}
|
|
|
|
func (s Statement) stringArray(key string) ([]string, error) {
|
|
raw, ok := s.predicate()[key].([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("predicate.%s is missing or not an array", key)
|
|
}
|
|
out := make([]string, 0, len(raw))
|
|
for i, v := range raw {
|
|
str, ok := v.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("predicate.%s[%d] is not a string", key, i)
|
|
}
|
|
out = append(out, str)
|
|
}
|
|
return out, nil
|
|
}
|