diff --git a/Makefile b/Makefile index e0d5b237c..25bfc1d68 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,10 @@ build: cp bin/helm-ai-kernel bin/helm test: - cd core && go test ./pkg/... ./cmd/release-permit-verify/... -count=1 + cd core && go test ./pkg/... ./cmd/release-permit-verify/... ./cmd/promotion-permit-verify/... -count=1 test-cli: - cd core && go test ./cmd/helm-ai-kernel ./cmd/release-permit-verify -count=1 + cd core && go test ./cmd/helm-ai-kernel ./cmd/release-permit-verify ./cmd/promotion-permit-verify -count=1 test-race: cd core && go test ./pkg/... -count=1 -race diff --git a/core/cmd/promotion-permit-verify/main.go b/core/cmd/promotion-permit-verify/main.go new file mode 100644 index 000000000..46dd9edf6 --- /dev/null +++ b/core/cmd/promotion-permit-verify/main.go @@ -0,0 +1,256 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "sort" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/promotionpermit" +) + +const maxInputBytes = 32 << 20 + +var promotionInputKeys = []string{ + "schema", "target_environment", "release_manifest_ref", "release_manifest_generation", + "release_manifest_hash", "release_manifest_status", "platform_overlay_ref", "platform_overlay_hash", + "apps_overlay_ref", "apps_overlay_hash", "protected_environment", "apps_empty_intent", +} + +var verificationContextKeys = []string{ + "schema", "observed_at", "maximum_permit_ttl", "expected_policy_epoch", "emergency_fence", + "verdict_trust", "approval_trust", "approval_consumption_ref", "approval_authority", + "connector_release_trust", "current_connector_release", "permit", "dependency", "route_binding", "route_artifacts", +} + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, output io.Writer) error { + flags := flag.NewFlagSet("promotion-permit-verify", flag.ContinueOnError) + flags.SetOutput(io.Discard) + var envelopePath, promotionInputPath, promotionInputRef, releaseManifestPath string + var platformOverlayPath, appsOverlayPath, inputSchemaPath, verificationContextPath string + flags.StringVar(&envelopePath, "envelope", "", "immutable LaunchEffectAuthorizationEnvelope JSON") + flags.StringVar(&promotionInputPath, "promotion-input", "", "production promotion input JSON") + flags.StringVar(&promotionInputRef, "promotion-input-ref", "", "source-owned ref bound as promotion_permit_ref") + flags.StringVar(&releaseManifestPath, "release-manifest", "", "release manifest bytes bound by the promotion input") + flags.StringVar(&platformOverlayPath, "platform-overlay", "", "production platform overlay bytes bound by the promotion input") + flags.StringVar(&appsOverlayPath, "apps-overlay", "", "production apps overlay bytes bound by the promotion input") + flags.StringVar(&inputSchemaPath, "input-schema", "", "base-owned DEPLOY_PRODUCTION_ACTIVATE JSON schema; caller establishes provenance") + flags.StringVar(&verificationContextPath, "verification-context", "", "operator-supplied verification context; base-owned provenance must be established outside this CLI") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("positional arguments are not accepted") + } + for name, value := range map[string]string{ + "--envelope": envelopePath, "--promotion-input": promotionInputPath, + "--promotion-input-ref": promotionInputRef, "--release-manifest": releaseManifestPath, + "--platform-overlay": platformOverlayPath, "--apps-overlay": appsOverlayPath, + "--input-schema": inputSchemaPath, "--verification-context": verificationContextPath, + } { + if value == "" { + return fmt.Errorf("%s is required", name) + } + } + + var envelope contracts.LaunchEffectAuthorizationEnvelope + if _, err := decodeStrictFile(envelopePath, &envelope, nil); err != nil { + return fmt.Errorf("read launch authorization envelope: %w", err) + } + var input promotionpermit.Input + if _, err := decodeStrictFile(promotionInputPath, &input, promotionInputKeys); err != nil { + return fmt.Errorf("read promotion input: %w", err) + } + var verificationContext verificationContextInput + if _, err := decodeStrictFile(verificationContextPath, &verificationContext, verificationContextKeys); err != nil { + return fmt.Errorf("read verification context: %w", err) + } + releaseManifest, err := readRegularFile(releaseManifestPath) + if err != nil { + return fmt.Errorf("read release manifest: %w", err) + } + platformOverlay, err := readRegularFile(platformOverlayPath) + if err != nil { + return fmt.Errorf("read platform overlay: %w", err) + } + appsOverlay, err := readRegularFile(appsOverlayPath) + if err != nil { + return fmt.Errorf("read apps overlay: %w", err) + } + inputSchema, err := readRegularFile(inputSchemaPath) + if err != nil { + return fmt.Errorf("read input schema: %w", err) + } + launchContext, err := verificationContext.launchContext(envelope, inputSchema) + if err != nil { + return fmt.Errorf("build verification context: %w", err) + } + if err := promotionpermit.Verify(envelope, promotionpermit.VerificationContext{ + PromotionInputRef: promotionInputRef, + PromotionInput: input, + ReleaseManifest: releaseManifest, + PlatformOverlay: platformOverlay, + AppsOverlay: appsOverlay, + Launch: launchContext, + }); err != nil { + return err + } + digest, err := input.Hash() + if err != nil { + return err + } + _, err = fmt.Fprintf(output, "production promotion preflight verified against supplied context: %s\n", digest) + return err +} + +func decodeStrictFile(path string, destination any, requiredKeys []string) ([]byte, error) { + content, err := readRegularFile(path) + if err != nil { + return nil, err + } + if !json.Valid(content) { + return nil, errors.New("input is not exactly one valid JSON value") + } + if err := rejectDuplicateKeys(content); err != nil { + return nil, err + } + if len(requiredKeys) != 0 { + var object map[string]json.RawMessage + if err := json.Unmarshal(content, &object); err != nil { + return nil, errors.New("input must be a JSON object") + } + actual := make([]string, 0, len(object)) + for key := range object { + actual = append(actual, key) + } + sort.Strings(actual) + expected := append([]string(nil), requiredKeys...) + sort.Strings(expected) + if fmt.Sprint(actual) != fmt.Sprint(expected) { + return nil, fmt.Errorf("input keys must be exactly %v", expected) + } + } + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return nil, err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("input contains more than one JSON value") + } + return nil, fmt.Errorf("read trailing data: %w", err) + } + return content, nil +} + +func readRegularFile(path string) ([]byte, error) { + pathInfo, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !pathInfo.Mode().IsRegular() || pathInfo.Size() > maxInputBytes { + return nil, fmt.Errorf("input must be a regular file no larger than %d bytes", maxInputBytes) + } + // #nosec G304 -- every path is an explicit offline verifier input. + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + openInfo, err := file.Stat() + if err != nil { + return nil, err + } + if !openInfo.Mode().IsRegular() || !os.SameFile(pathInfo, openInfo) { + return nil, errors.New("input changed while opening") + } + content, err := io.ReadAll(io.LimitReader(file, maxInputBytes+1)) + if err != nil { + return nil, err + } + if len(content) > maxInputBytes { + return nil, fmt.Errorf("input exceeds %d bytes", maxInputBytes) + } + return content, nil +} + +func rejectDuplicateKeys(content []byte) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.UseNumber() + if err := scanJSONValue(decoder); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("input contains more than one JSON value") + } + return err + } + return nil +} + +func scanJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + if token == nil { + return errors.New("JSON null values are not accepted") + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := map[string]struct{}{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("object key is not a string") + } + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate JSON key %q", key) + } + seen[key] = struct{}{} + if err := scanJSONValue(decoder); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil || end != json.Delim('}') { + return errors.New("JSON object did not end with }") + } + case '[': + for decoder.More() { + if err := scanJSONValue(decoder); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil || end != json.Delim(']') { + return errors.New("JSON array did not end with ]") + } + default: + return fmt.Errorf("unexpected JSON delimiter %q", delimiter) + } + return nil +} diff --git a/core/cmd/promotion-permit-verify/main_test.go b/core/cmd/promotion-permit-verify/main_test.go new file mode 100644 index 000000000..938b10961 --- /dev/null +++ b/core/cmd/promotion-permit-verify/main_test.go @@ -0,0 +1,105 @@ +// quantum_posture: these verifier tests exercise existing classical Ed25519 +// trust inputs only; they add no post-quantum assurance claim. +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/promotionpermit" +) + +func TestDecodeStrictPromotionInputRejectsAmbiguousJSON(t *testing.T) { + encoded, err := json.Marshal(promotionpermit.Input{ + Schema: promotionpermit.InputSchemaV1, TargetEnvironment: "production", + ReleaseManifestRef: "release:1", ReleaseManifestGeneration: 1, + ReleaseManifestHash: "sha256:" + strings.Repeat("a", 64), ReleaseManifestStatus: promotionpermit.ReleaseManifestStatusProductionCandidate, + PlatformOverlayRef: "platform:1", PlatformOverlayHash: "sha256:" + strings.Repeat("b", 64), + AppsOverlayRef: "apps:1", AppsOverlayHash: "sha256:" + strings.Repeat("c", 64), + ProtectedEnvironment: "production", AppsEmptyIntent: false, + }) + if err != nil { + t.Fatal(err) + } + valid := string(encoded) + tests := []struct { + name string + content string + want string + }{ + {name: "duplicate", content: strings.Replace(valid, `"schema":`, `"schema":"forged","schema":`, 1), want: `duplicate JSON key "schema"`}, + {name: "unknown", content: strings.TrimSuffix(valid, "}") + `,"unexpected":true}`, want: "input keys must be exactly"}, + {name: "missing explicit bool", content: strings.Replace(valid, `,"apps_empty_intent":false`, "", 1), want: "input keys must be exactly"}, + {name: "null explicit bool", content: strings.Replace(valid, `"apps_empty_intent":false`, `"apps_empty_intent":null`, 1), want: "JSON null values are not accepted"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var input promotionpermit.Input + _, err := decodeStrictFile(writeFixture(t, test.content), &input, promotionInputKeys) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("decodeStrictFile() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestRunRejectsDuplicateEnvelopeBeforeTrustResolution(t *testing.T) { + path := writeFixture(t, `{"effect_id":"DEPLOY_PRODUCTION_ACTIVATE","effect_id":"PROVIDER_PROVISION"}`) + err := run([]string{ + "--envelope", path, "--promotion-input", path, "--promotion-input-ref", "promotion:1", + "--release-manifest", path, "--platform-overlay", path, "--apps-overlay", path, + "--input-schema", path, "--verification-context", path, + }, &strings.Builder{}) + if err == nil || !strings.Contains(err.Error(), `duplicate JSON key "effect_id"`) { + t.Fatalf("run() error = %v, want duplicate envelope rejection", err) + } +} + +func TestReadRegularFileRejectsSymlink(t *testing.T) { + target := writeFixture(t, "trusted") + link := filepath.Join(t.TempDir(), "input") + if err := os.Symlink(target, link); err != nil { + t.Skipf("create symlink: %v", err) + } + if _, err := readRegularFile(link); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("readRegularFile() error = %v, want symlink rejection", err) + } +} + +func TestVerificationContextRejectsMalformedVerdictKey(t *testing.T) { + inputs := verificationContextInput{ + Schema: verificationContextSchemaV1, ObservedAt: time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC), + MaximumPermitTTL: "1m", ExpectedPolicyEpoch: "epoch-1", ApprovalConsumptionRef: "consumption:1", + VerdictTrust: trustKey{PublicKey: "ed25519:" + strings.Repeat("0", 62) + "zz"}, + } + _, err := inputs.launchContext(contracts.LaunchEffectAuthorizationEnvelope{}, []byte(`{"type":"object"}`)) + if err == nil || !strings.Contains(err.Error(), "verification context verdict key") { + t.Fatalf("launchContext() error = %v, want malformed verdict key rejection", err) + } +} + +func TestTrustedPermitRejectsWrongEffect(t *testing.T) { + now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + permit := permitBinding{ + EffectID: contracts.EffectTypeProviderProvision, EffectOrdinal: 1, SingleUse: true, + PermitIssuedAt: now, PermitExpiry: now.Add(time.Minute), KernelVerdictIssuedAt: now, + KernelVerdictExpiry: now.Add(time.Minute), DispatchDeadline: now.Add(30 * time.Second), + } + if err := permit.validate(); err == nil || !strings.Contains(err.Error(), contracts.EffectTypeDeployProductionActivate) { + t.Fatalf("validate() error = %v, want effect rejection", err) + } +} + +func writeFixture(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fixture") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/core/cmd/promotion-permit-verify/trust.go b/core/cmd/promotion-permit-verify/trust.go new file mode 100644 index 000000000..1bd8ceaae --- /dev/null +++ b/core/cmd/promotion-permit-verify/trust.go @@ -0,0 +1,471 @@ +// quantum_posture: this offline preflight verifies the existing classical +// Ed25519 approval, verdict, and connector authorities; it adds no PQ claim. +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/boundary/approvalceremony" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" + connectorregistry "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/registry/connectors" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +const verificationContextSchemaV1 = "helm.production-promotion-verification-context/v1" + +var trustedSHA256Pattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + +// verificationContextInput is not self-authenticating. The integration that +// invokes this CLI must load it from a base-owned trust source; the CLI only +// applies that supplied context to the candidate artifacts. +type verificationContextInput struct { + Schema string `json:"schema"` + ObservedAt time.Time `json:"observed_at"` + MaximumPermitTTL string `json:"maximum_permit_ttl"` + ExpectedPolicyEpoch string `json:"expected_policy_epoch"` + EmergencyFence emergencyFence `json:"emergency_fence"` + VerdictTrust trustKey `json:"verdict_trust"` + ApprovalTrust trustKey `json:"approval_trust"` + ApprovalConsumptionRef string `json:"approval_consumption_ref"` + ApprovalAuthority contracts.LaunchEffectApprovalAuthority `json:"approval_authority"` + ConnectorReleaseTrust connectorReleaseTrust `json:"connector_release_trust"` + CurrentConnectorRelease contracts.ConnectorReleaseAuthorityEnvelope `json:"current_connector_release"` + Permit permitBinding `json:"permit"` + Dependency artifactBinding `json:"dependency"` + RouteBinding contracts.LaunchRouteBinding `json:"route_binding"` + RouteArtifacts routeArtifacts `json:"route_artifacts"` +} + +type trustKey struct { + KernelTrustRootID string `json:"kernel_trust_root_id"` + SigningKeyRef string `json:"signing_key_ref"` + PublicKey string `json:"public_key"` +} + +type connectorReleaseTrust struct { + AuthorityID string `json:"authority_id"` + SigningKeyRef string `json:"signing_key_ref"` + PublicKey string `json:"public_key"` + Enabled bool `json:"enabled"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` +} + +type emergencyFence struct { + TenantID string `json:"tenant_id"` + WorkspaceID string `json:"workspace_id"` + EffectiveEpoch int64 `json:"effective_epoch"` + Active bool `json:"active"` +} + +type artifactBinding struct { + Ref string `json:"ref"` + Hash string `json:"hash"` +} + +type permitBinding struct { + EffectPermitRef string `json:"effect_permit_ref"` + EffectPermitHash string `json:"effect_permit_hash"` + PermitNonce string `json:"permit_nonce"` + ProofSessionRef string `json:"proof_session_ref"` + EvidenceReservationRef string `json:"evidence_reservation_ref"` + PermitIssuedAt time.Time `json:"permit_issued_at"` + PermitExpiry time.Time `json:"permit_expiry"` + KernelVerdictRef string `json:"kernel_verdict_ref"` + KernelVerdictHash string `json:"kernel_verdict_hash"` + KernelVerdictIssuedAt time.Time `json:"kernel_verdict_issued_at"` + KernelVerdictExpiry time.Time `json:"kernel_verdict_expiry"` + EffectID string `json:"effect_id"` + TenantID string `json:"tenant_id"` + WorkspaceID string `json:"workspace_id"` + MissionID string `json:"mission_id"` + Principal string `json:"principal"` + Audience string `json:"audience"` + KernelTrustRootID string `json:"kernel_trust_root_id"` + EffectOrdinal int `json:"effect_ordinal"` + InputSchemaHash string `json:"input_schema_hash"` + InputHash string `json:"input_hash"` + IdempotencyKey string `json:"idempotency_key"` + PlanHash string `json:"plan_hash"` + ApprovalArtifactRef string `json:"approval_artifact_ref"` + ApprovalArtifactHash string `json:"approval_artifact_hash"` + ApprovalConsumptionRef string `json:"approval_consumption_ref"` + ApprovalConsumptionHash string `json:"approval_consumption_hash"` + DispatchAdmissionRef string `json:"dispatch_admission_ref"` + DispatchAdmissionHash string `json:"dispatch_admission_hash"` + DependencySetRef string `json:"dependency_set_ref"` + DependencySetHash string `json:"dependency_set_hash"` + ConnectorID string `json:"connector_id"` + ConnectorContractHash string `json:"connector_contract_hash"` + ConnectorAuthorityRef string `json:"connector_authority_ref"` + ConnectorAuthorityHash string `json:"connector_authority_hash"` + ActionURN string `json:"action_urn"` + RequestBodyHash string `json:"request_body_hash"` + ArgsC14NHash string `json:"args_c14n_hash"` + PolicyEpoch string `json:"policy_epoch"` + EmergencyFenceEpoch int64 `json:"emergency_fence_epoch"` + DispatchDeadline time.Time `json:"dispatch_deadline"` + SingleUse bool `json:"single_use"` +} + +type routeArtifacts struct { + RepositoryAnalyses map[string]contracts.LaunchRepositoryAnalysis `json:"repository_analyses"` + WorkloadGraphs map[string]contracts.LaunchWorkloadGraph `json:"workload_graphs"` + ProviderProfiles map[string]contracts.LaunchProviderCapabilityProfile `json:"provider_profiles"` + ProviderCertifications map[string]contracts.LaunchProviderCertificationRecord `json:"provider_certifications"` + ConstraintSets map[string]contracts.LaunchConstraintSet `json:"constraint_sets"` + RouteQuotes map[string]contracts.LaunchRouteQuote `json:"route_quotes"` + CommercialEvidence map[string]contracts.LaunchCommercialEvidence `json:"commercial_evidence"` + FXSnapshots map[string]contracts.LaunchFXSnapshot `json:"fx_snapshots"` + TaxSnapshots map[string]contracts.LaunchTaxSnapshot `json:"tax_snapshots"` + OfferSnapshots map[string]contracts.LaunchOfferSnapshot `json:"offer_snapshots"` + ResourceGraphs map[string]contracts.LaunchResourceGraph `json:"resource_graphs"` + ProviderPayloadSets map[string]contracts.LaunchProviderPayloadSet `json:"provider_payload_sets"` + GeneratedSpecHashes map[string]string `json:"generated_spec_hashes"` + CertificationKeys map[string]string `json:"certification_keys"` + CurrentCertifications map[string]string `json:"current_certifications"` +} + +type staticRouteResolver struct { + artifacts routeArtifacts + certificationKeys map[string]ed25519.PublicKey +} + +func (inputs verificationContextInput) launchContext(envelope contracts.LaunchEffectAuthorizationEnvelope, schemaBytes []byte) (contracts.LaunchEffectEnvelopeVerificationContext, error) { + if inputs.Schema != verificationContextSchemaV1 { + return contracts.LaunchEffectEnvelopeVerificationContext{}, fmt.Errorf("verification context schema must equal %q", verificationContextSchemaV1) + } + if inputs.ObservedAt.IsZero() || inputs.ObservedAt.Location() != time.UTC { + return contracts.LaunchEffectEnvelopeVerificationContext{}, errors.New("verification context observed_at must be UTC") + } + maximumTTL, err := time.ParseDuration(inputs.MaximumPermitTTL) + if err != nil || maximumTTL <= 0 { + return contracts.LaunchEffectEnvelopeVerificationContext{}, errors.New("verification context maximum_permit_ttl must be positive") + } + if !boundedToken(inputs.ExpectedPolicyEpoch) || !boundedToken(inputs.ApprovalConsumptionRef) { + return contracts.LaunchEffectEnvelopeVerificationContext{}, errors.New("verification context policy epoch or approval consumption ref is invalid") + } + verdictKey, err := parsePublicKey(inputs.VerdictTrust.PublicKey) + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, fmt.Errorf("verification context verdict key: %w", err) + } + approvalKey, err := parsePublicKey(inputs.ApprovalTrust.PublicKey) + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, fmt.Errorf("verification context approval key: %w", err) + } + approvalVerifier, err := approvalceremony.NewEd25519GrantSignatureVerifier(approvalKey, inputs.ApprovalTrust.SigningKeyRef, inputs.ApprovalTrust.KernelTrustRootID) + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, err + } + releaseKey, err := inputs.ConnectorReleaseTrust.key() + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, err + } + releaseVerifier, err := connectorregistry.NewEd25519ReleaseAuthorityVerifier(inputs.ConnectorReleaseTrust.AuthorityID, []connectorregistry.TrustedReleaseAuthorityKey{releaseKey}) + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, err + } + routeResolver, err := newStaticRouteResolver(inputs.RouteArtifacts) + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, err + } + if err := inputs.Permit.validate(); err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, err + } + if !boundedToken(inputs.Dependency.Ref) || !trustedSHA256Pattern.MatchString(inputs.Dependency.Hash) { + return contracts.LaunchEffectEnvelopeVerificationContext{}, errors.New("verification context dependency binding is invalid") + } + + compiler := jsonschema.NewCompiler() + compiler.Draft = jsonschema.Draft2020 + const schemaURL = "https://helm.schemas.local/production-promotion/input.schema.json" + if err := compiler.AddResource(schemaURL, bytes.NewReader(schemaBytes)); err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, fmt.Errorf("load launch input schema: %w", err) + } + compiledSchema, err := compiler.Compile(schemaURL) + if err != nil { + return contracts.LaunchEffectEnvelopeVerificationContext{}, fmt.Errorf("compile launch input schema: %w", err) + } + + approval := inputs.ApprovalAuthority + currentRelease := inputs.CurrentConnectorRelease + permit := inputs.Permit.contract() + route := inputs.RouteBinding + fence := inputs.EmergencyFence + return contracts.LaunchEffectEnvelopeVerificationContext{ + Now: inputs.ObservedAt, + ResolveInputSchema: func(ref string) ([]byte, error) { + if ref != envelope.InputSchemaRef { + return nil, errors.New("input schema ref does not match verification context") + } + return append([]byte(nil), schemaBytes...), nil + }, + ValidateInput: func(ref, hash string, candidate map[string]any) error { + if ref != envelope.InputSchemaRef || hash != envelope.InputSchemaHash { + return errors.New("input schema identity does not match verification context") + } + return compiledSchema.Validate(candidate) + }, + ResolveRouteBinding: func(ref string) (contracts.LaunchRouteBinding, error) { + if ref != route.RouteID { + return contracts.LaunchRouteBinding{}, errors.New("route binding does not match verification context") + } + return route, nil + }, + RouteArtifacts: routeResolver, + ResolveApprovalAuthority: func(grantRef, grantHash, consumptionRef, consumptionHash string) (contracts.LaunchEffectApprovalAuthority, error) { + if grantRef != approval.Grant.GrantID || grantHash != approval.Grant.GrantHash || + consumptionRef != inputs.ApprovalConsumptionRef || consumptionHash != approval.Consumption.ConsumptionHash { + return contracts.LaunchEffectApprovalAuthority{}, errors.New("approval authority does not match verification context") + } + return approval, nil + }, + VerifyApprovalAuthority: func(candidate contracts.LaunchEffectApprovalAuthority) error { + if err := approvalVerifier.VerifyGrantSignature(candidate.Grant, candidate.GrantSignatureAlgorithm, candidate.GrantSignature); err != nil { + return err + } + if err := approvalVerifier.VerifyGrantConsumptionSignature(candidate.Consumption, candidate.ConsumptionSignatureAlgorithm, candidate.ConsumptionSignature); err != nil { + return err + } + return approvalVerifier.VerifyDispatchAdmissionSignature(candidate.DispatchAdmission, candidate.DispatchSignatureAlgorithm, candidate.DispatchSignature) + }, + VerifyDependencyState: func(ref, hash string) error { + if ref != inputs.Dependency.Ref || subtle.ConstantTimeCompare([]byte(hash), []byte(inputs.Dependency.Hash)) != 1 { + return errors.New("dependency state does not match verification context") + } + return nil + }, + ExpectedRequestBodyHash: inputs.Permit.RequestBodyHash, + ExpectedArgsC14NHash: inputs.Permit.ArgsC14NHash, + ExpectedPolicyEpoch: inputs.ExpectedPolicyEpoch, + MaximumPermitTTL: maximumTTL, + ResolveVerdictKeyForTrustRoot: func(rootID, keyRef string) (ed25519.PublicKey, error) { + if rootID != inputs.VerdictTrust.KernelTrustRootID || keyRef != inputs.VerdictTrust.SigningKeyRef { + return nil, errors.New("verdict signer does not match verification context") + } + return append(ed25519.PublicKey(nil), verdictKey...), nil + }, + ResolveEmergencyFence: func(tenantID, workspaceID string) (contracts.LaunchEmergencyFenceSnapshot, error) { + if tenantID != fence.TenantID || workspaceID != fence.WorkspaceID { + return contracts.LaunchEmergencyFenceSnapshot{}, errors.New("emergency fence scope does not match verification context") + } + return contracts.LaunchEmergencyFenceSnapshot{TenantID: fence.TenantID, WorkspaceID: fence.WorkspaceID, EffectiveEpoch: fence.EffectiveEpoch, Active: fence.Active}, nil + }, + ResolveCurrentConnectorRelease: func(candidate contracts.ApprovalConnectorAuthority) (contracts.ConnectorReleaseAuthorityEnvelope, error) { + if candidate.AuthorityHash != approval.Grant.ConnectorAuthority.AuthorityHash { + return contracts.ConnectorReleaseAuthorityEnvelope{}, errors.New("connector release lookup does not match verification context") + } + return currentRelease, nil + }, + VerifyCurrentConnectorRelease: releaseVerifier.VerifyCurrentCertifiedAt, + Permit: permit, + }, nil +} + +func (trust connectorReleaseTrust) key() (connectorregistry.TrustedReleaseAuthorityKey, error) { + publicKey, err := parsePublicKey(trust.PublicKey) + if err != nil { + return connectorregistry.TrustedReleaseAuthorityKey{}, fmt.Errorf("verification context connector release key: %w", err) + } + if !boundedToken(trust.AuthorityID) || !boundedToken(trust.SigningKeyRef) || !trust.Enabled || + trust.NotBefore.IsZero() || trust.NotAfter.IsZero() || trust.NotBefore.Location() != time.UTC || trust.NotAfter.Location() != time.UTC || !trust.NotAfter.After(trust.NotBefore) { + return connectorregistry.TrustedReleaseAuthorityKey{}, errors.New("verification context connector release key metadata is invalid") + } + return connectorregistry.TrustedReleaseAuthorityKey{ + AuthorityID: trust.AuthorityID, SigningKeyRef: trust.SigningKeyRef, PublicKey: publicKey, + Enabled: true, NotBefore: trust.NotBefore, NotAfter: trust.NotAfter, + }, nil +} + +func (binding permitBinding) validate() error { + if !binding.SingleUse || binding.EffectID != contracts.EffectTypeDeployProductionActivate || binding.EffectOrdinal < 0 || binding.EmergencyFenceEpoch < 0 { + return errors.New("verification context permit must be single-use DEPLOY_PRODUCTION_ACTIVATE") + } + for _, observed := range []time.Time{binding.PermitIssuedAt, binding.PermitExpiry, binding.KernelVerdictIssuedAt, binding.KernelVerdictExpiry, binding.DispatchDeadline} { + if observed.IsZero() || observed.Location() != time.UTC { + return errors.New("verification context permit times must be UTC") + } + } + return nil +} + +func (binding permitBinding) contract() contracts.LaunchEffectPermitBinding { + return contracts.LaunchEffectPermitBinding{ + EffectPermitRef: binding.EffectPermitRef, EffectPermitHash: binding.EffectPermitHash, PermitNonce: binding.PermitNonce, + ProofSessionRef: binding.ProofSessionRef, EvidenceReservationRef: binding.EvidenceReservationRef, + PermitIssuedAt: binding.PermitIssuedAt, PermitExpiry: binding.PermitExpiry, + KernelVerdictRef: binding.KernelVerdictRef, KernelVerdictHash: binding.KernelVerdictHash, + KernelVerdictIssuedAt: binding.KernelVerdictIssuedAt, KernelVerdictExpiry: binding.KernelVerdictExpiry, + EffectID: binding.EffectID, TenantID: binding.TenantID, WorkspaceID: binding.WorkspaceID, MissionID: binding.MissionID, + Principal: binding.Principal, Audience: binding.Audience, KernelTrustRootID: binding.KernelTrustRootID, EffectOrdinal: binding.EffectOrdinal, + InputSchemaHash: binding.InputSchemaHash, InputHash: binding.InputHash, IdempotencyKey: binding.IdempotencyKey, PlanHash: binding.PlanHash, + ApprovalArtifactRef: binding.ApprovalArtifactRef, ApprovalArtifactHash: binding.ApprovalArtifactHash, + ApprovalConsumptionRef: binding.ApprovalConsumptionRef, ApprovalConsumptionHash: binding.ApprovalConsumptionHash, + DispatchAdmissionRef: binding.DispatchAdmissionRef, DispatchAdmissionHash: binding.DispatchAdmissionHash, + DependencySetRef: binding.DependencySetRef, DependencySetHash: binding.DependencySetHash, + ConnectorID: binding.ConnectorID, ConnectorContractHash: binding.ConnectorContractHash, + ConnectorAuthorityRef: binding.ConnectorAuthorityRef, ConnectorAuthorityHash: binding.ConnectorAuthorityHash, + ActionURN: binding.ActionURN, RequestBodyHash: binding.RequestBodyHash, ArgsC14NHash: binding.ArgsC14NHash, + PolicyEpoch: binding.PolicyEpoch, EmergencyFenceEpoch: binding.EmergencyFenceEpoch, + DispatchDeadline: binding.DispatchDeadline, SingleUse: binding.SingleUse, + } +} + +func newStaticRouteResolver(artifacts routeArtifacts) (*staticRouteResolver, error) { + if artifacts.RepositoryAnalyses == nil || artifacts.WorkloadGraphs == nil || artifacts.ProviderProfiles == nil || + artifacts.ProviderCertifications == nil || artifacts.ConstraintSets == nil || artifacts.RouteQuotes == nil || + artifacts.CommercialEvidence == nil || artifacts.FXSnapshots == nil || artifacts.TaxSnapshots == nil || + artifacts.OfferSnapshots == nil || artifacts.ResourceGraphs == nil || artifacts.ProviderPayloadSets == nil || + artifacts.GeneratedSpecHashes == nil || artifacts.CertificationKeys == nil || artifacts.CurrentCertifications == nil { + return nil, errors.New("verification context route artifact maps must be explicit") + } + keys := make(map[string]ed25519.PublicKey, len(artifacts.CertificationKeys)) + for keyID, encoded := range artifacts.CertificationKeys { + if !boundedToken(keyID) { + return nil, errors.New("verification context route certification key ID is invalid") + } + key, err := parsePublicKey(encoded) + if err != nil { + return nil, fmt.Errorf("verification context route certification key %q: %w", keyID, err) + } + keys[keyID] = key + } + return &staticRouteResolver{artifacts: artifacts, certificationKeys: keys}, nil +} + +func (resolver *staticRouteResolver) ResolveLaunchRepositoryAnalysis(ref string) (contracts.LaunchRepositoryAnalysis, error) { + value, ok := resolver.artifacts.RepositoryAnalyses[ref] + if !ok { + return contracts.LaunchRepositoryAnalysis{}, missingRouteArtifact("repository analysis", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchWorkloadGraph(ref string) (contracts.LaunchWorkloadGraph, error) { + value, ok := resolver.artifacts.WorkloadGraphs[ref] + if !ok { + return contracts.LaunchWorkloadGraph{}, missingRouteArtifact("workload graph", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchProviderProfile(ref string) (contracts.LaunchProviderCapabilityProfile, error) { + value, ok := resolver.artifacts.ProviderProfiles[ref] + if !ok { + return contracts.LaunchProviderCapabilityProfile{}, missingRouteArtifact("provider profile", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchProviderCertification(ref string) (contracts.LaunchProviderCertificationRecord, error) { + value, ok := resolver.artifacts.ProviderCertifications[ref] + if !ok { + return contracts.LaunchProviderCertificationRecord{}, missingRouteArtifact("provider certification", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchConstraintSet(ref string) (contracts.LaunchConstraintSet, error) { + value, ok := resolver.artifacts.ConstraintSets[ref] + if !ok { + return contracts.LaunchConstraintSet{}, missingRouteArtifact("constraint set", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchRouteQuote(ref string) (contracts.LaunchRouteQuote, error) { + value, ok := resolver.artifacts.RouteQuotes[ref] + if !ok { + return contracts.LaunchRouteQuote{}, missingRouteArtifact("route quote", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchCommercialEvidence(ref string) (contracts.LaunchCommercialEvidence, error) { + value, ok := resolver.artifacts.CommercialEvidence[ref] + if !ok { + return contracts.LaunchCommercialEvidence{}, missingRouteArtifact("commercial evidence", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchFXSnapshot(ref string) (contracts.LaunchFXSnapshot, error) { + value, ok := resolver.artifacts.FXSnapshots[ref] + if !ok { + return contracts.LaunchFXSnapshot{}, missingRouteArtifact("FX snapshot", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchTaxSnapshot(ref string) (contracts.LaunchTaxSnapshot, error) { + value, ok := resolver.artifacts.TaxSnapshots[ref] + if !ok { + return contracts.LaunchTaxSnapshot{}, missingRouteArtifact("tax snapshot", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchOfferSnapshot(ref string) (contracts.LaunchOfferSnapshot, error) { + value, ok := resolver.artifacts.OfferSnapshots[ref] + if !ok { + return contracts.LaunchOfferSnapshot{}, missingRouteArtifact("offer snapshot", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchResourceGraph(ref string) (contracts.LaunchResourceGraph, error) { + value, ok := resolver.artifacts.ResourceGraphs[ref] + if !ok { + return contracts.LaunchResourceGraph{}, missingRouteArtifact("resource graph", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchProviderPayloadSet(ref string) (contracts.LaunchProviderPayloadSet, error) { + value, ok := resolver.artifacts.ProviderPayloadSets[ref] + if !ok { + return contracts.LaunchProviderPayloadSet{}, missingRouteArtifact("provider payload set", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchGeneratedSpecHash(ref string) (string, error) { + value, ok := resolver.artifacts.GeneratedSpecHashes[ref] + if !ok || !trustedSHA256Pattern.MatchString(value) { + return "", missingRouteArtifact("generated spec hash", ref) + } + return value, nil +} +func (resolver *staticRouteResolver) ResolveLaunchCertificationKey(keyID string) (ed25519.PublicKey, error) { + value, ok := resolver.certificationKeys[keyID] + if !ok { + return nil, missingRouteArtifact("certification key", keyID) + } + return append(ed25519.PublicKey(nil), value...), nil +} +func (resolver *staticRouteResolver) AssertLaunchCertificationCurrent(certificationID, recordHash string) error { + expected, ok := resolver.artifacts.CurrentCertifications[certificationID] + if !ok || subtle.ConstantTimeCompare([]byte(recordHash), []byte(expected)) != 1 { + return missingRouteArtifact("current certification", certificationID) + } + return nil +} + +func parsePublicKey(value string) (ed25519.PublicKey, error) { + if !strings.HasPrefix(value, "ed25519:") { + return nil, errors.New("public key must use ed25519 lowercase hex encoding") + } + encoded := strings.TrimPrefix(value, "ed25519:") + if len(encoded) != ed25519.PublicKeySize*2 || encoded != strings.ToLower(encoded) { + return nil, errors.New("public key encoding is invalid") + } + decoded, err := hex.DecodeString(encoded) + if err != nil || len(decoded) != ed25519.PublicKeySize { + return nil, errors.New("public key encoding is invalid") + } + return ed25519.PublicKey(decoded), nil +} + +func boundedToken(value string) bool { + return value != "" && len(value) <= 1024 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\r\n\t") +} + +func missingRouteArtifact(kind, ref string) error { + return fmt.Errorf("verification context %s %q not found", kind, ref) +} diff --git a/core/pkg/promotionpermit/input.go b/core/pkg/promotionpermit/input.go new file mode 100644 index 000000000..babde22d6 --- /dev/null +++ b/core/pkg/promotionpermit/input.go @@ -0,0 +1,192 @@ +// Package promotionpermit binds an exact production promotion candidate to +// the immutable release and GitOps inputs reviewed by HELM. +package promotionpermit + +import ( + "bytes" + "crypto/subtle" + "errors" + "fmt" + "io" + "regexp" + "strings" + "unicode" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/canonicalize" + "gopkg.in/yaml.v3" +) + +const ( + InputSchemaV1 = "helm.production-promotion-input/v1" + + ReleaseManifestStatusProductionCandidate = "production_candidate" + ReleaseManifestStatusProductionReleased = "production_released" +) + +const maxJCSSafeInteger = int64(1<<53 - 1) + +var ( + sha256ReferencePattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + canonicalTokenPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`) +) + +// Input is authority-free content. Its JCS digest is carried by the existing +// DEPLOY_PRODUCTION_ACTIVATE promotion_permit_hash field. +type Input struct { + Schema string `json:"schema"` + TargetEnvironment string `json:"target_environment"` + ReleaseManifestRef string `json:"release_manifest_ref"` + ReleaseManifestGeneration int64 `json:"release_manifest_generation"` + ReleaseManifestHash string `json:"release_manifest_hash"` + ReleaseManifestStatus string `json:"release_manifest_status"` + PlatformOverlayRef string `json:"platform_overlay_ref"` + PlatformOverlayHash string `json:"platform_overlay_hash"` + AppsOverlayRef string `json:"apps_overlay_ref"` + AppsOverlayHash string `json:"apps_overlay_hash"` + ProtectedEnvironment string `json:"protected_environment"` + AppsEmptyIntent bool `json:"apps_empty_intent"` +} + +func (input Input) Validate() error { + if input.Schema != InputSchemaV1 { + return fmt.Errorf("promotion input schema must equal %q", InputSchemaV1) + } + if input.TargetEnvironment != "production" { + return errors.New("promotion input target_environment must equal production") + } + if input.ReleaseManifestGeneration <= 0 || input.ReleaseManifestGeneration > maxJCSSafeInteger { + return errors.New("promotion input release_manifest_generation must be a positive JCS-safe integer") + } + switch input.ReleaseManifestStatus { + case ReleaseManifestStatusProductionCandidate, ReleaseManifestStatusProductionReleased: + default: + return errors.New("promotion input release_manifest_status must be production_candidate or production_released") + } + for field, value := range map[string]string{ + "release_manifest_ref": input.ReleaseManifestRef, + "platform_overlay_ref": input.PlatformOverlayRef, + "apps_overlay_ref": input.AppsOverlayRef, + } { + if !canonicalReference(value) { + return fmt.Errorf("promotion input %s must be a bounded non-empty reference", field) + } + } + for field, value := range map[string]string{ + "release_manifest_hash": input.ReleaseManifestHash, + "platform_overlay_hash": input.PlatformOverlayHash, + "apps_overlay_hash": input.AppsOverlayHash, + } { + if !sha256ReferencePattern.MatchString(value) { + return fmt.Errorf("promotion input %s must be a canonical SHA-256 reference", field) + } + } + if !canonicalTokenPattern.MatchString(input.ProtectedEnvironment) { + return errors.New("promotion input protected_environment must be a canonical token") + } + return nil +} + +func (input Input) CanonicalBytes() ([]byte, error) { + if err := input.Validate(); err != nil { + return nil, err + } + return canonicalize.JCS(input) +} + +func (input Input) Hash() (string, error) { + canonical, err := input.CanonicalBytes() + if err != nil { + return "", err + } + return canonicalize.ComputeArtifactHash(canonical), nil +} + +// VerifyArtifactBytes checks both the exact bytes named by the promotion input +// and the production fields those source-owned artifacts must carry. +func (input Input) VerifyArtifactBytes(releaseManifest, platformOverlay, appsOverlay []byte) error { + if err := input.Validate(); err != nil { + return err + } + for _, artifact := range []struct { + name string + expected string + content []byte + }{ + {name: "release manifest", expected: input.ReleaseManifestHash, content: releaseManifest}, + {name: "platform overlay", expected: input.PlatformOverlayHash, content: platformOverlay}, + {name: "apps overlay", expected: input.AppsOverlayHash, content: appsOverlay}, + } { + actual := canonicalize.ComputeArtifactHash(artifact.content) + if subtle.ConstantTimeCompare([]byte(actual), []byte(artifact.expected)) != 1 { + return fmt.Errorf("promotion input %s bytes do not match the approved hash", artifact.name) + } + } + var manifest struct { + Metadata struct { + Generation int64 `yaml:"generation"` + } `yaml:"metadata"` + Spec struct { + Status string `yaml:"status"` + Promotion struct { + TargetEnvironment string `yaml:"target_environment"` + } `yaml:"promotion"` + } `yaml:"spec"` + } + if err := decodeOneYAML(releaseManifest, &manifest); err != nil { + return fmt.Errorf("decode release manifest: %w", err) + } + if manifest.Metadata.Generation != input.ReleaseManifestGeneration || manifest.Spec.Status != input.ReleaseManifestStatus || manifest.Spec.Promotion.TargetEnvironment != input.TargetEnvironment { + return errors.New("release manifest generation, status, or target environment does not match promotion input") + } + type overlayPromotion struct { + ReleaseManifestGeneration int64 `yaml:"release_manifest_generation"` + ProtectedEnvironment string `yaml:"protected_environment"` + } + var platform struct { + Spec struct { + Promotion overlayPromotion `yaml:"production_promotion"` + } `yaml:"spec"` + } + if err := decodeOneYAML(platformOverlay, &platform); err != nil { + return fmt.Errorf("decode platform overlay: %w", err) + } + if platform.Spec.Promotion.ReleaseManifestGeneration != input.ReleaseManifestGeneration || platform.Spec.Promotion.ProtectedEnvironment != input.ProtectedEnvironment { + return errors.New("platform overlay generation or protected environment does not match promotion input") + } + var apps struct { + Spec struct { + Promotion overlayPromotion `yaml:"production_promotion"` + Applications *[]any `yaml:"applications"` + } `yaml:"spec"` + } + if err := decodeOneYAML(appsOverlay, &apps); err != nil { + return fmt.Errorf("decode apps overlay: %w", err) + } + if apps.Spec.Promotion.ReleaseManifestGeneration != input.ReleaseManifestGeneration || apps.Spec.Promotion.ProtectedEnvironment != input.ProtectedEnvironment { + return errors.New("apps overlay generation or protected environment does not match promotion input") + } + if apps.Spec.Applications == nil || (len(*apps.Spec.Applications) == 0) != input.AppsEmptyIntent { + return errors.New("apps overlay does not match explicit apps_empty_intent") + } + return nil +} + +func decodeOneYAML(content []byte, destination any) error { + decoder := yaml.NewDecoder(bytes.NewReader(content)) + if err := decoder.Decode(destination); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("input contains more than one YAML document") + } + return err + } + return nil +} + +func canonicalReference(value string) bool { + return value != "" && len(value) <= 1024 && strings.TrimSpace(value) == value && + strings.IndexFunc(value, unicode.IsSpace) == -1 && strings.IndexFunc(value, unicode.IsControl) == -1 +} diff --git a/core/pkg/promotionpermit/input_test.go b/core/pkg/promotionpermit/input_test.go new file mode 100644 index 000000000..12f25aad2 --- /dev/null +++ b/core/pkg/promotionpermit/input_test.go @@ -0,0 +1,135 @@ +package promotionpermit + +import ( + "strings" + "testing" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/canonicalize" + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +func TestInputCanonicalHashBindsEveryPromotionField(t *testing.T) { + input := validInput() + canonical, err := input.CanonicalBytes() + if err != nil { + t.Fatalf("CanonicalBytes() error = %v", err) + } + want := `{"apps_empty_intent":false,"apps_overlay_hash":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","apps_overlay_ref":"gitops-apps@sha256:ccc","platform_overlay_hash":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","platform_overlay_ref":"gitops-platform@sha256:bbb","protected_environment":"production","release_manifest_generation":23,"release_manifest_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","release_manifest_ref":"integration-mindburn-platform@sha256:aaa","release_manifest_status":"production_candidate","schema":"helm.production-promotion-input/v1","target_environment":"production"}` + if string(canonical) != want { + t.Fatalf("CanonicalBytes() = %s\nwant = %s", canonical, want) + } + hash, err := input.Hash() + if err != nil { + t.Fatalf("Hash() error = %v", err) + } + if wantHash := canonicalize.ComputeArtifactHash([]byte(want)); hash != wantHash { + t.Fatalf("Hash() = %s, want %s", hash, wantHash) + } +} + +func TestInputRejectsInvalidPromotionClaims(t *testing.T) { + tests := []struct { + name string + mutate func(*Input) + want string + }{ + {name: "wrong environment", mutate: func(input *Input) { input.TargetEnvironment = "staging" }, want: "target_environment"}, + {name: "zero generation", mutate: func(input *Input) { input.ReleaseManifestGeneration = 0 }, want: "generation"}, + {name: "unsafe generation", mutate: func(input *Input) { input.ReleaseManifestGeneration = maxJCSSafeInteger + 1 }, want: "JCS-safe"}, + {name: "unknown status", mutate: func(input *Input) { input.ReleaseManifestStatus = "readiness_freeze" }, want: "status"}, + {name: "noncanonical released status", mutate: func(input *Input) { input.ReleaseManifestStatus = "released" }, want: "status"}, + {name: "uppercase hash", mutate: func(input *Input) { input.AppsOverlayHash = "sha256:" + strings.Repeat("A", 64) }, want: "apps_overlay_hash"}, + {name: "noncanonical protected environment", mutate: func(input *Input) { input.ProtectedEnvironment = "Production Env" }, want: "protected_environment"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := validInput() + test.mutate(&input) + if err := input.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestInputAcceptsCanonicalProductionReleasedStatus(t *testing.T) { + input := validInput() + input.ReleaseManifestStatus = ReleaseManifestStatusProductionReleased + if err := input.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestInputVerifiesExactArtifactBytes(t *testing.T) { + release := []byte("metadata:\n generation: 23\nspec:\n status: production_candidate\n promotion:\n target_environment: production\n") + platform := []byte("spec:\n production_promotion:\n release_manifest_generation: 23\n protected_environment: production\n") + apps := []byte("spec:\n production_promotion:\n release_manifest_generation: 23\n protected_environment: production\n applications:\n - name: control-plane\n") + input := validInput() + input.ReleaseManifestHash = canonicalize.ComputeArtifactHash(release) + input.PlatformOverlayHash = canonicalize.ComputeArtifactHash(platform) + input.AppsOverlayHash = canonicalize.ComputeArtifactHash(apps) + if err := input.VerifyArtifactBytes(release, platform, apps); err != nil { + t.Fatalf("VerifyArtifactBytes() error = %v", err) + } + if err := input.VerifyArtifactBytes(release, platform, append(apps, '!')); err == nil || !strings.Contains(err.Error(), "apps overlay") { + t.Fatalf("VerifyArtifactBytes() error = %v, want apps overlay mismatch", err) + } +} + +func TestInputRequiresExplicitAppsEmptyIntent(t *testing.T) { + release := []byte("metadata:\n generation: 23\nspec:\n status: production_candidate\n promotion:\n target_environment: production\n") + platform := []byte("spec:\n production_promotion:\n release_manifest_generation: 23\n protected_environment: production\n") + apps := []byte("spec:\n production_promotion:\n release_manifest_generation: 23\n protected_environment: production\n applications: []\n") + input := validInput() + input.ReleaseManifestHash = canonicalize.ComputeArtifactHash(release) + input.PlatformOverlayHash = canonicalize.ComputeArtifactHash(platform) + input.AppsOverlayHash = canonicalize.ComputeArtifactHash(apps) + if err := input.VerifyArtifactBytes(release, platform, apps); err == nil || !strings.Contains(err.Error(), "apps_empty_intent") { + t.Fatalf("VerifyArtifactBytes() error = %v, want explicit intent mismatch", err) + } + input.AppsEmptyIntent = true + if err := input.VerifyArtifactBytes(release, platform, apps); err != nil { + t.Fatalf("VerifyArtifactBytes() with explicit empty intent error = %v", err) + } +} + +func TestInputBindsExistingLaunchPromotionFields(t *testing.T) { + input := validInput() + hash, err := input.Hash() + if err != nil { + t.Fatal(err) + } + envelope := contracts.LaunchEffectAuthorizationEnvelope{ + EffectID: contracts.EffectTypeDeployProductionActivate, + Input: map[string]any{ + "promotion_permit_ref": "promotion-input:23", + "promotion_permit_hash": hash, + "release_manifest_ref": input.ReleaseManifestRef, + "release_manifest_hash": input.ReleaseManifestHash, + }, + } + if err := input.VerifyEnvelopeBinding(envelope, "promotion-input:23"); err != nil { + t.Fatalf("VerifyEnvelopeBinding() error = %v", err) + } + envelope.Input["promotion_permit_hash"] = "sha256:" + strings.Repeat("f", 64) + if err := input.VerifyEnvelopeBinding(envelope, "promotion-input:23"); err == nil || !strings.Contains(err.Error(), "promotion_permit_hash") { + t.Fatalf("VerifyEnvelopeBinding() error = %v, want promotion hash mismatch", err) + } +} + +func validInput() Input { + return Input{ + Schema: InputSchemaV1, + TargetEnvironment: "production", + ReleaseManifestRef: "integration-mindburn-platform@sha256:aaa", + ReleaseManifestGeneration: 23, + ReleaseManifestHash: "sha256:" + strings.Repeat("a", 64), + ReleaseManifestStatus: ReleaseManifestStatusProductionCandidate, + PlatformOverlayRef: "gitops-platform@sha256:bbb", + PlatformOverlayHash: "sha256:" + strings.Repeat("b", 64), + AppsOverlayRef: "gitops-apps@sha256:ccc", + AppsOverlayHash: "sha256:" + strings.Repeat("c", 64), + ProtectedEnvironment: "production", + AppsEmptyIntent: false, + } +} diff --git a/core/pkg/promotionpermit/verify.go b/core/pkg/promotionpermit/verify.go new file mode 100644 index 000000000..483ef91c3 --- /dev/null +++ b/core/pkg/promotionpermit/verify.go @@ -0,0 +1,116 @@ +package promotionpermit + +import ( + "crypto/subtle" + "errors" + "fmt" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +type VerificationContext struct { + PromotionInputRef string + PromotionInput Input + ReleaseManifest []byte + PlatformOverlay []byte + AppsOverlay []byte + Launch contracts.LaunchEffectEnvelopeVerificationContext +} + +// Verify preflights static authority only. It never invokes the launch +// finalizer, consumes a permit, or crosses a connector seam. +func Verify(envelope contracts.LaunchEffectAuthorizationEnvelope, ctx VerificationContext) error { + if envelope.EffectID != contracts.EffectTypeDeployProductionActivate { + return errors.New("production promotion requires DEPLOY_PRODUCTION_ACTIVATE") + } + if ctx.Launch.ResolveApprovalAuthority == nil { + return errors.New("production promotion requires source-owned approval authority") + } + + var approval contracts.LaunchEffectApprovalAuthority + launch := ctx.Launch + resolveApproval := launch.ResolveApprovalAuthority + launch.ResolveApprovalAuthority = func(grantRef, grantHash, consumptionRef, consumptionHash string) (contracts.LaunchEffectApprovalAuthority, error) { + resolved, err := resolveApproval(grantRef, grantHash, consumptionRef, consumptionHash) + if err == nil { + approval = resolved + } + return resolved, err + } + if err := contracts.PreflightLaunchEffectAuthorizationEnvelope(envelope, launch); err != nil { + return fmt.Errorf("preflight production promotion authority: %w", err) + } + if err := ctx.PromotionInput.VerifyArtifactBytes(ctx.ReleaseManifest, ctx.PlatformOverlay, ctx.AppsOverlay); err != nil { + return err + } + if err := ctx.PromotionInput.VerifyEnvelopeBinding(envelope, ctx.PromotionInputRef); err != nil { + return err + } + if err := verifyCurrentFence(envelope, launch); err != nil { + return err + } + return verifyCurrentConnector(approval.Grant.ConnectorAuthority, launch) +} + +func (input Input) VerifyEnvelopeBinding(envelope contracts.LaunchEffectAuthorizationEnvelope, promotionInputRef string) error { + if err := input.Validate(); err != nil { + return err + } + if envelope.EffectID != contracts.EffectTypeDeployProductionActivate || envelope.Input == nil { + return errors.New("promotion input requires a DEPLOY_PRODUCTION_ACTIVATE envelope") + } + if !canonicalReference(promotionInputRef) { + return errors.New("promotion input reference must be independently supplied") + } + hash, err := input.Hash() + if err != nil { + return err + } + for field, expected := range map[string]string{ + "promotion_permit_ref": promotionInputRef, + "promotion_permit_hash": hash, + "release_manifest_ref": input.ReleaseManifestRef, + "release_manifest_hash": input.ReleaseManifestHash, + } { + actual, ok := envelope.Input[field].(string) + if !ok || subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 { + return fmt.Errorf("production promotion envelope input mismatch for %s", field) + } + } + return nil +} + +func verifyCurrentFence(envelope contracts.LaunchEffectAuthorizationEnvelope, ctx contracts.LaunchEffectEnvelopeVerificationContext) error { + if ctx.ResolveEmergencyFence == nil { + return errors.New("production promotion requires source-owned emergency fence state") + } + snapshot, err := ctx.ResolveEmergencyFence(envelope.TenantID, envelope.WorkspaceID) + if err != nil { + return fmt.Errorf("resolve production promotion emergency fence: %w", err) + } + if snapshot.TenantID != envelope.TenantID || snapshot.WorkspaceID != envelope.WorkspaceID || + snapshot.EffectiveEpoch < 0 || snapshot.EffectiveEpoch != envelope.EmergencyFenceEpoch { + return errors.New("production promotion emergency fence scope or epoch mismatch") + } + if snapshot.Active { + return errors.New("production promotion emergency fence is active") + } + return nil +} + +func verifyCurrentConnector(authority contracts.ApprovalConnectorAuthority, ctx contracts.LaunchEffectEnvelopeVerificationContext) error { + if ctx.ResolveCurrentConnectorRelease == nil || ctx.VerifyCurrentConnectorRelease == nil { + return errors.New("production promotion requires current connector release authority") + } + release, err := ctx.ResolveCurrentConnectorRelease(authority) + if err != nil { + return fmt.Errorf("resolve production promotion connector release: %w", err) + } + if err := ctx.VerifyCurrentConnectorRelease(release, ctx.Now); err != nil { + return fmt.Errorf("verify production promotion connector release: %w", err) + } + if err := authority.ValidateCurrentRelease(release.Authority); err != nil { + return fmt.Errorf("production promotion connector release is stale: %w", err) + } + return nil +} diff --git a/core/pkg/promotionpermit/verify_test.go b/core/pkg/promotionpermit/verify_test.go new file mode 100644 index 000000000..3f9d37ecf --- /dev/null +++ b/core/pkg/promotionpermit/verify_test.go @@ -0,0 +1,36 @@ +package promotionpermit + +import ( + "strings" + "testing" + + "github.com/Mindburn-Labs/helm-ai-kernel/core/pkg/contracts" +) + +func TestVerifyRejectsWrongEffectBeforeAuthorityResolution(t *testing.T) { + err := Verify(contracts.LaunchEffectAuthorizationEnvelope{EffectID: contracts.EffectTypeProviderProvision}, VerificationContext{}) + if err == nil || !strings.Contains(err.Error(), contracts.EffectTypeDeployProductionActivate) { + t.Fatalf("Verify() error = %v, want effect rejection", err) + } +} + +func TestVerifyCurrentFenceFailsClosed(t *testing.T) { + envelope := contracts.LaunchEffectAuthorizationEnvelope{TenantID: "tenant-1", WorkspaceID: "workspace-1", EmergencyFenceEpoch: 7} + for _, test := range []struct { + name string + snapshot contracts.LaunchEmergencyFenceSnapshot + want string + }{ + {name: "stale", snapshot: contracts.LaunchEmergencyFenceSnapshot{TenantID: "tenant-1", WorkspaceID: "workspace-1", EffectiveEpoch: 6}, want: "mismatch"}, + {name: "active", snapshot: contracts.LaunchEmergencyFenceSnapshot{TenantID: "tenant-1", WorkspaceID: "workspace-1", EffectiveEpoch: 7, Active: true}, want: "active"}, + } { + t.Run(test.name, func(t *testing.T) { + err := verifyCurrentFence(envelope, contracts.LaunchEffectEnvelopeVerificationContext{ + ResolveEmergencyFence: func(string, string) (contracts.LaunchEmergencyFenceSnapshot, error) { return test.snapshot, nil }, + }) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("verifyCurrentFence() error = %v, want substring %q", err, test.want) + } + }) + } +}