From ec21518e25df2a58444f19ff3413ce0b83383d3c Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 16 Oct 2026 07:51:43 +0200 Subject: [PATCH] fix(auth): sign served decisions before recording and returning them Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786 --- .../access-engine/templates/deployment.yaml | 32 +++++++++- charts/access-engine/values.yaml | 8 +++ charts/flex-auth/templates/deployment.yaml | 32 +++++++++- charts/flex-auth/values.yaml | 8 +++ cmd/access-engine/envelope_signing.go | 42 ++++++++++++ cmd/access-engine/envelope_signing_test.go | 55 ++++++++++++++++ cmd/access-engine/main.go | 5 ++ docs/decision-envelope-signature.md | 51 ++++++++++++++- internal/decision/engine.go | 40 ++++++++++-- internal/decision/envelope_signing_test.go | 64 +++++++++++++++++++ ...-WP-0024-decision-envelope-authenticity.md | 38 ++++++++++- 11 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 cmd/access-engine/envelope_signing.go create mode 100644 cmd/access-engine/envelope_signing_test.go create mode 100644 internal/decision/envelope_signing_test.go diff --git a/charts/access-engine/templates/deployment.yaml b/charts/access-engine/templates/deployment.yaml index 644b2fe..c9dd6d6 100644 --- a/charts/access-engine/templates/deployment.yaml +++ b/charts/access-engine/templates/deployment.yaml @@ -12,6 +12,14 @@ {{- fail "auditDelivery.mountPath must be an absolute normalized directory" }} {{- end }} {{- end }} +{{- if .Values.envelopeSigning.enabled }} +{{- if ne (first .Values.args) "serve" }} +{{- fail "envelopeSigning currently supports serve decision pins only" }} +{{- end }} +{{- if or (not (hasPrefix "/" .Values.envelopeSigning.mountPath)) (contains ".." .Values.envelopeSigning.mountPath) }} +{{- fail "envelopeSigning.mountPath must be an absolute normalized directory" }} +{{- end }} +{{- end }} apiVersion: apps/v1 kind: Deployment metadata: @@ -53,6 +61,12 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} args: {{- toYaml .Values.args | nindent 12 }} + {{- if .Values.envelopeSigning.enabled }} + - --envelope-signing-key-file + - {{ printf "%s/seed" .Values.envelopeSigning.mountPath | quote }} + - --envelope-signing-kid + - {{ required "envelopeSigning.kid must be independently admitted" .Values.envelopeSigning.kid | quote }} + {{- end }} {{- if .Values.outbox.enabled }} - --outbox-dir - {{ .Values.outbox.mountPath | quote }} @@ -114,8 +128,13 @@ spec: drop: - ALL readOnlyRootFilesystem: true - {{- if or .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "access-engine.callerAuth.enabled" .) "true") }} + {{- if or .Values.envelopeSigning.enabled .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "access-engine.callerAuth.enabled" .) "true") }} volumeMounts: + {{- if .Values.envelopeSigning.enabled }} + - mountPath: {{ .Values.envelopeSigning.mountPath | quote }} + name: access-engine-envelope-signing + readOnly: true + {{- end }} {{- if .Values.auditDelivery.enabled }} - mountPath: {{ .Values.auditDelivery.mountPath | quote }} name: access-engine-audit @@ -131,8 +150,17 @@ spec: readOnly: true {{- end }} {{- end }} - {{- if or .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "access-engine.callerAuth.enabled" .) "true") }} + {{- if or .Values.envelopeSigning.enabled .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "access-engine.callerAuth.enabled" .) "true") }} volumes: + {{- if .Values.envelopeSigning.enabled }} + - name: access-engine-envelope-signing + secret: + secretName: {{ required "envelopeSigning.secretName must name an admitted existing Secret" .Values.envelopeSigning.secretName | quote }} + defaultMode: 0440 + items: + - key: {{ required "envelopeSigning.secretKey is required" .Values.envelopeSigning.secretKey | quote }} + path: seed + {{- end }} {{- if .Values.auditDelivery.enabled }} - name: access-engine-audit secret: diff --git a/charts/access-engine/values.yaml b/charts/access-engine/values.yaml index 20989a7..4f967b4 100644 --- a/charts/access-engine/values.yaml +++ b/charts/access-engine/values.yaml @@ -85,3 +85,11 @@ resources: limits: cpu: 300m memory: 192Mi + +# Inert until an independently admitted signer and existing custody Secret exist. +envelopeSigning: + enabled: false + kid: "" + secretName: "" + secretKey: "" + mountPath: /var/run/secrets/decision-envelope-signing diff --git a/charts/flex-auth/templates/deployment.yaml b/charts/flex-auth/templates/deployment.yaml index db9dd04..f0a0fd0 100644 --- a/charts/flex-auth/templates/deployment.yaml +++ b/charts/flex-auth/templates/deployment.yaml @@ -12,6 +12,14 @@ {{- fail "auditDelivery.mountPath must be an absolute normalized directory" }} {{- end }} {{- end }} +{{- if .Values.envelopeSigning.enabled }} +{{- if ne (first .Values.args) "serve" }} +{{- fail "envelopeSigning currently supports serve decision pins only" }} +{{- end }} +{{- if or (not (hasPrefix "/" .Values.envelopeSigning.mountPath)) (contains ".." .Values.envelopeSigning.mountPath) }} +{{- fail "envelopeSigning.mountPath must be an absolute normalized directory" }} +{{- end }} +{{- end }} apiVersion: apps/v1 kind: Deployment metadata: @@ -52,6 +60,12 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} args: {{- toYaml .Values.args | nindent 12 }} + {{- if .Values.envelopeSigning.enabled }} + - --envelope-signing-key-file + - {{ printf "%s/seed" .Values.envelopeSigning.mountPath | quote }} + - --envelope-signing-kid + - {{ required "envelopeSigning.kid must be independently admitted" .Values.envelopeSigning.kid | quote }} + {{- end }} {{- if .Values.outbox.enabled }} - --outbox-dir - {{ .Values.outbox.mountPath | quote }} @@ -107,8 +121,13 @@ spec: drop: - ALL readOnlyRootFilesystem: true - {{- if or .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "flex-auth.callerAuth.enabled" .) "true") }} + {{- if or .Values.envelopeSigning.enabled .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "flex-auth.callerAuth.enabled" .) "true") }} volumeMounts: + {{- if .Values.envelopeSigning.enabled }} + - mountPath: {{ .Values.envelopeSigning.mountPath | quote }} + name: flex-auth-envelope-signing + readOnly: true + {{- end }} {{- if .Values.auditDelivery.enabled }} - mountPath: {{ .Values.auditDelivery.mountPath | quote }} name: flex-auth-audit @@ -124,8 +143,17 @@ spec: readOnly: true {{- end }} {{- end }} - {{- if or .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "flex-auth.callerAuth.enabled" .) "true") }} + {{- if or .Values.envelopeSigning.enabled .Values.outbox.enabled .Values.auditDelivery.enabled (eq (include "flex-auth.callerAuth.enabled" .) "true") }} volumes: + {{- if .Values.envelopeSigning.enabled }} + - name: flex-auth-envelope-signing + secret: + secretName: {{ required "envelopeSigning.secretName must name an admitted existing Secret" .Values.envelopeSigning.secretName | quote }} + defaultMode: 0440 + items: + - key: {{ required "envelopeSigning.secretKey is required" .Values.envelopeSigning.secretKey | quote }} + path: seed + {{- end }} {{- if .Values.auditDelivery.enabled }} - name: flex-auth-audit secret: diff --git a/charts/flex-auth/values.yaml b/charts/flex-auth/values.yaml index f2228da..2b780ed 100644 --- a/charts/flex-auth/values.yaml +++ b/charts/flex-auth/values.yaml @@ -85,3 +85,11 @@ resources: limits: cpu: 300m memory: 192Mi + +# Inert until an independently admitted signer and existing custody Secret exist. +envelopeSigning: + enabled: false + kid: "" + secretName: "" + secretKey: "" + mountPath: /var/run/secrets/decision-envelope-signing diff --git a/cmd/access-engine/envelope_signing.go b/cmd/access-engine/envelope_signing.go new file mode 100644 index 0000000..9201dca --- /dev/null +++ b/cmd/access-engine/envelope_signing.go @@ -0,0 +1,42 @@ +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "io" + "os" + "strings" + + "github.com/netkingdom/access-engine/internal/decision" +) + +// configureEnvelopeSigning reads a mounted seed once at startup. Never accept +// the secret in argv, an environment value, an HTTP request or a log message. +func configureEnvelopeSigning(engine *decision.Engine, path, kid string) error { + if path == "" && kid == "" { + return nil + } + if path == "" || strings.TrimSpace(kid) == "" { + return fmt.Errorf("envelope signing requires key file and kid") + } + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("envelope signing key file unavailable") + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0027 != 0 { + return fmt.Errorf("envelope signing key must be a regular file without group-write or world permissions") + } + raw, err := io.ReadAll(io.LimitReader(file, 129)) + if err != nil || len(raw) > 128 { + return fmt.Errorf("invalid envelope signing key file") + } + encoded := strings.TrimSpace(string(raw)) + seed, err := base64.RawURLEncoding.Strict().DecodeString(encoded) + if err != nil || len(seed) != ed25519.SeedSize { + return fmt.Errorf("invalid envelope signing seed") + } + return engine.SetEnvelopeSigningKey(ed25519.NewKeyFromSeed(seed), kid) +} diff --git a/cmd/access-engine/envelope_signing_test.go b/cmd/access-engine/envelope_signing_test.go new file mode 100644 index 0000000..117fce1 --- /dev/null +++ b/cmd/access-engine/envelope_signing_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestEnvelopeSigningStartupConfiguration(t *testing.T) { + engine, err := buildEngine(context.Background(), examplePath("registry_snapshot.json"), examplePath("policy_package.md"), "") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "seed") + if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(make([]byte, 32))), 0600); err != nil { + t.Fatal(err) + } + if err := configureEnvelopeSigning(engine, path, "synthetic"); err != nil { + t.Fatal(err) + } + for _, input := range []struct{ path, kid string }{{path, ""}, {"", "kid"}, {path + "missing", "kid"}} { + if configureEnvelopeSigning(engine, input.path, input.kid) == nil { + t.Fatal("invalid startup config accepted") + } + } + if err := os.Chmod(path, 0440); err != nil { + t.Fatal(err) + } + if err := configureEnvelopeSigning(engine, path, "synthetic"); err != nil { + t.Fatalf("projected 0440 mount refused: %v", err) + } + if err := os.Chmod(path, 0620); err != nil { + t.Fatal(err) + } + if configureEnvelopeSigning(engine, path, "kid") == nil { + t.Fatal("group-writable key accepted") + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if configureEnvelopeSigning(engine, path, "kid") == nil { + t.Fatal("world-readable key accepted") + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("invalid seed"), 0600); err != nil { + t.Fatal(err) + } + if configureEnvelopeSigning(engine, path, "kid") == nil { + t.Fatal("invalid seed accepted") + } +} diff --git a/cmd/access-engine/main.go b/cmd/access-engine/main.go index 29a738b..194827c 100644 --- a/cmd/access-engine/main.go +++ b/cmd/access-engine/main.go @@ -330,6 +330,8 @@ func runServe(args []string, stdout, stderr io.Writer) int { registryPath := fs.String("registry", "", "registry snapshot JSON file") policyPath := fs.String("policy", "", "policy package Markdown file") logPath := fs.String("log", "", "optional JSONL decision log path") + signingKeyFile := fs.String("envelope-signing-key-file", "", "owner-mounted base64url Ed25519 seed file") + signingKID := fs.String("envelope-signing-kid", "", "independently admitted envelope signing key id") outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log") emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine") auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats") @@ -369,6 +371,9 @@ func runServe(args []string, stdout, stderr io.Writer) int { if err != nil { return fail(stderr, err) } + if err := configureEnvelopeSigning(engine, *signingKeyFile, *signingKID); err != nil { + return fail(stderr, err) + } var outbox *emission.Outbox if *outboxDir != "" { outbox, err = emission.Open(*outboxDir, *emissionSource) diff --git a/docs/decision-envelope-signature.md b/docs/decision-envelope-signature.md index 7a31f98..9d79b23 100644 --- a/docs/decision-envelope-signature.md +++ b/docs/decision-envelope-signature.md @@ -5,7 +5,7 @@ Date: 2026-09-14 Contract: additive on `flex-auth.decision-record.v1` Does **not** change `request_digest`. -The response channel is unauthenticated today (`FLEX-DEC-2026-010`). Digests +The original response channel was unauthenticated (`FLEX-DEC-2026-010`). Digests cannot fix that: every input to them is sent by the caller or published. Fail-closed cannot fix that either: it covers a missing PDP, not a lying one. @@ -117,3 +117,52 @@ T03 adds the field, emits unsigned when unconfigured, and ships a fixture pair: one genuine signed envelope and one whose payload was altered after signing. It does **not** mint the production key and does **not** wait for the OpenBao lane to exist before the code path is testable. + +## NetKingdom target source follow-through (2026-10-16) + +Owner source review of Secrets Engine R2 patch +`98c4300c42fe93759a55297fe6629f1d4aeed249c3625f7fd37c8ab523a08ca9` +verified unchanged `api`/`internal/sign` bytes and producer replay fixtures at +`62ac2b4e99dec2005c7c407fe633612578a7907d`. The consumer runs the Go producer +verifier and uses its returned typed envelope, then independently checks issuer, +policy package/version/digest, exact submitted request and lifetime. This avoids +another envelope canonicalization implementation or PDP. + +That revision implemented `Sign` only for fixtures: `serve` could not load a key +or sign responses. The follow-through adds `serve --envelope-signing-key-file` +and `--envelope-signing-kid`. Configure both or neither. The mounted file contains +one unpadded base64url 32-byte Ed25519 seed, optionally with trailing whitespace; +it must be regular and have no group-write or world permissions (0440 is accepted). No production key is supplied +or minted here. Configuration failures abort startup. The key is read once: +rotation requires an owner rollout/restart, not an assumed hot reload. + +Signing occurs after the complete envelope is constructed and before the durable +recorder, for check, batch and list decisions alike. Returned and recorded objects +therefore agree. Existing unsigned developer mode remains explicit; authenticated +Secrets Engine production refuses it. The signature schema, canonical bytes and +request digest are unchanged, so this patch does not require a consumer vendored +source refresh. + +Both retained `flex-auth` and new `access-engine` charts expose an inert +`envelopeSigning` block. Enabling requires `kid`, `secretName`, `secretKey`, and an +absolute `mountPath`. They mount an existing custody-owned Secret read-only; +they do not provision it. The existing Secrets pin values remain unchanged and +unsigned until an exact reviewed image/values rollout is admitted. + +The `/v1/keys` route described earlier is a discovery design, **not an implemented +route**. It must never bootstrap independent trust. For this first consumer, the +owner transports reviewed public keys plus exact issuer/policy pins independently +into the consumer's admission file. No runtime discovery dependency is needed. +Rotation admits new public key alongside old in every consumer, proves new-key +acceptance, rolls producer to new seed/kid, waits past the maximum outstanding +allow lifetime plus bounded clock uncertainty, removes old key, and proves old-key +refusal. A revoked/compromised old key is removed immediately rather than retained +for overlap. Retain public verification material in audit custody for historic +records without treating it as current runtime authority. + +Rollout remains blocked on independent public-key and policy-pin admission; +OpenBao/Platform custody and exact secret mount; reviewed producer image/digest; +owner-bound loopback tunnel and caller audience; installation of the reviewed +consumer verifier binary (the Python wheel alone omits it); exact consumer source +and hash drift check; and native positive/negative/rotation plus record-custody +receipts. Local fixtures and chart renders do not establish those outcomes. diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 185fd08..3bc524f 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -3,6 +3,7 @@ package decision import ( "bytes" "context" + "crypto/ed25519" "crypto/sha256" "encoding/hex" "encoding/json" @@ -18,18 +19,21 @@ import ( "github.com/netkingdom/access-engine/internal/callerauth" "github.com/netkingdom/access-engine/internal/policy" "github.com/netkingdom/access-engine/internal/registry" + "github.com/netkingdom/access-engine/internal/sign" "github.com/netkingdom/access-engine/pkg/api" ) // Engine evaluates deterministic standalone authorization checks against a // local registry and one validated policy package. type Engine struct { - store *registry.Store - policy *policy.Package - mu sync.RWMutex - history map[string]api.DecisionEnvelope - log DecisionRecorder - clock func() time.Time + store *registry.Store + policy *policy.Package + mu sync.RWMutex + history map[string]api.DecisionEnvelope + log DecisionRecorder + clock func() time.Time + signingKey ed25519.PrivateKey + signingKID string } // DecisionRecorder persists decision envelopes. @@ -91,6 +95,19 @@ func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, e }, nil } +// SetEnvelopeSigningKey installs an owner-custodied key before serving requests. +// The key is copied so caller mutation cannot change the admitted signer. +func (e *Engine) SetEnvelopeSigningKey(key ed25519.PrivateKey, kid string) error { + if len(key) != ed25519.PrivateKeySize || strings.TrimSpace(kid) == "" { + return fmt.Errorf("valid Ed25519 private key and kid are required") + } + e.mu.Lock() + defer e.mu.Unlock() + e.signingKey = append(ed25519.PrivateKey(nil), key...) + e.signingKID = kid + return nil +} + // SetDecisionLog attaches a local decision recorder to the engine. func (e *Engine) SetDecisionLog(log DecisionRecorder) { e.mu.Lock() @@ -124,7 +141,16 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci return api.DecisionEnvelope{}, err } - return e.envelope(ctx, normalized, request, expectation, facts), nil + envelope := e.envelope(ctx, normalized, request, expectation, facts) + e.mu.RLock() + key, kid := e.signingKey, e.signingKID + e.mu.RUnlock() + if len(key) != 0 { + if err := sign.Sign(&envelope, key, kid); err != nil { + return api.DecisionEnvelope{}, fmt.Errorf("decision signing failed: %w", err) + } + } + return envelope, nil } // Check evaluates one subject/action/resource request. The decision is diff --git a/internal/decision/envelope_signing_test.go b/internal/decision/envelope_signing_test.go new file mode 100644 index 0000000..16b4843 --- /dev/null +++ b/internal/decision/envelope_signing_test.go @@ -0,0 +1,64 @@ +package decision_test + +import ( + "context" + "crypto/ed25519" + "path/filepath" + "reflect" + "testing" + + "github.com/netkingdom/access-engine/internal/sign" + "github.com/netkingdom/access-engine/pkg/api" +) + +type signingRecorder struct{ records []api.DecisionEnvelope } + +func (r *signingRecorder) Append(e api.DecisionEnvelope) error { + r.records = append(r.records, e) + return nil +} + +func TestSignerAuthenticatesReturnedAndRecordedEnvelope(t *testing.T) { + engine := newTestEngine(t) + var request api.CheckRequest + loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request) + unsigned, err := engine.Check(context.Background(), request) + if err != nil { + t.Fatal(err) + } + // Synthetic deterministic seed only; no estate credential is read. + key := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + public := append(ed25519.PublicKey(nil), key.Public().(ed25519.PublicKey)...) + if err := engine.SetEnvelopeSigningKey(key, "synthetic-current"); err != nil { + t.Fatal(err) + } + key[0] ^= 1 // Setter must not retain the caller's mutable key slice. + recorder := &signingRecorder{} + engine.SetDecisionLog(recorder) + signed, err := engine.Check(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if err := sign.Verify(signed, map[string]ed25519.PublicKey{"synthetic-current": public}); err != nil { + t.Fatal(err) + } + if len(recorder.records) != 1 || !reflect.DeepEqual(signed, recorder.records[0]) { + t.Fatal("recorded envelope differs from authenticated return") + } + if signed.Binding.RequestDigest != unsigned.Binding.RequestDigest { + t.Fatal("signing changed request digest") + } + signed.Provenance.PolicyVersion = "altered" + if sign.Verify(signed, map[string]ed25519.PublicKey{"synthetic-current": public}) == nil { + t.Fatal("altered provenance verified") + } +} +func TestSignerRejectsInvalidConfiguration(t *testing.T) { + engine := newTestEngine(t) + if engine.SetEnvelopeSigningKey(nil, "kid") == nil { + t.Fatal("missing key accepted") + } + if engine.SetEnvelopeSigningKey(ed25519.NewKeyFromSeed(make([]byte, 32)), "") == nil { + t.Fatal("missing kid accepted") + } +} diff --git a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md index c4f6e6c..1eaeac5 100644 --- a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md +++ b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md @@ -4,7 +4,7 @@ type: workplan title: "Sign the decision envelope: the response channel is unauthenticated" domain: infotech repo: flex-auth -status: finished +status: active owner: claude topic_slug: netkingdom planning_priority: P1 @@ -15,7 +15,7 @@ related_workplans: - FLEX-WP-0019 - FLEX-WP-0023 created: "2026-09-06" -updated: "2026-09-14" +updated: "2026-10-16" state_hub_workstream_id: "90577acd-6910-548d-a13e-1dbfdfb8ed27" --- @@ -163,3 +163,37 @@ adoption of v0.8 — the condition already exists under v0.7 and is not created this version. §16 already carries the same observation one layer up, about the standard's own publication path having no digest or freeze discipline; F1 is that observation applied to the artifact the standard regulates. + +## 5. Wire owner signing into the decision pin and retained deployment charts + +```task +id: FLEX-WP-0024-T05 +status: done +priority: high +``` + +2026-10-16 source follow-through: NetKingdom target Secrets Engine R2 owner review +found that the completed T03 signature package was used only by fixtures. Add +explicit mounted seed/kid configuration for `serve`, sign the final envelope before +recording, and expose inert existing-Secret mounts in both retained/new charts. +The API/sign verifier sources remain byte-identical. Local source validation and +review are in the Custodian `docs/changes/netkingdom-target-2026-10-16/access-verifier-review/`. +This is source preparation only, not signer admission or deployed acceptance. + +## 6. Admit custody, consumer trust, exact packages and native signed decisions + +```task +id: FLEX-WP-0024-T06 +status: wait +priority: high +``` + +Wait on Platform/OpenBao key custody, independent public-key and issuer/policy-pin +admission, exact image and existing-Secret values rollout, owner-bound caller/tunnel, +and Secrets Engine verifier installation. Follow `docs/decision-envelope-signature.md` +for overlap/removal semantics. Retain signed producer record/consumer acceptance, +forged/altered/unsigned/wrong-pin/expired denial and rotation-removal receipts; +then update actual deployed status. No signer or runtime trust is admitted by the +source review. This task owns the producer-side native gate; SECRETS-WP-0014 owns +consumer admission/enforcement. The old fixture completion never established this +gate, so the workplan is reopened rather than claiming it already passed.