// 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:". 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 }