ProofedMesh/services/verification-service/internal/chain/chain.go

456 lines
16 KiB
Go
Raw Normal View History

// 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
}