fix(auth): sign served decisions before recording and returning them
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Failing after 1m27s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 07:51:43 +02:00
parent 62ac2b4e99
commit ec21518e25
11 changed files with 361 additions and 14 deletions

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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")
}
}

View file

@ -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.