fix(auth): sign served decisions before recording and returning them
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
62ac2b4e99
commit
ec21518e25
11 changed files with 361 additions and 14 deletions
|
|
@ -12,6 +12,14 @@
|
||||||
{{- fail "auditDelivery.mountPath must be an absolute normalized directory" }}
|
{{- fail "auditDelivery.mountPath must be an absolute normalized directory" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- 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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -53,6 +61,12 @@ spec:
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
args:
|
args:
|
||||||
{{- toYaml .Values.args | nindent 12 }}
|
{{- 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 }}
|
{{- if .Values.outbox.enabled }}
|
||||||
- --outbox-dir
|
- --outbox-dir
|
||||||
- {{ .Values.outbox.mountPath | quote }}
|
- {{ .Values.outbox.mountPath | quote }}
|
||||||
|
|
@ -114,8 +128,13 @@ spec:
|
||||||
drop:
|
drop:
|
||||||
- ALL
|
- ALL
|
||||||
readOnlyRootFilesystem: true
|
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:
|
volumeMounts:
|
||||||
|
{{- if .Values.envelopeSigning.enabled }}
|
||||||
|
- mountPath: {{ .Values.envelopeSigning.mountPath | quote }}
|
||||||
|
name: access-engine-envelope-signing
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.auditDelivery.enabled }}
|
{{- if .Values.auditDelivery.enabled }}
|
||||||
- mountPath: {{ .Values.auditDelivery.mountPath | quote }}
|
- mountPath: {{ .Values.auditDelivery.mountPath | quote }}
|
||||||
name: access-engine-audit
|
name: access-engine-audit
|
||||||
|
|
@ -131,8 +150,17 @@ spec:
|
||||||
readOnly: true
|
readOnly: true
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- 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:
|
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 }}
|
{{- if .Values.auditDelivery.enabled }}
|
||||||
- name: access-engine-audit
|
- name: access-engine-audit
|
||||||
secret:
|
secret:
|
||||||
|
|
|
||||||
|
|
@ -85,3 +85,11 @@ resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 300m
|
cpu: 300m
|
||||||
memory: 192Mi
|
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
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
{{- fail "auditDelivery.mountPath must be an absolute normalized directory" }}
|
{{- fail "auditDelivery.mountPath must be an absolute normalized directory" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- 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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -52,6 +60,12 @@ spec:
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
args:
|
args:
|
||||||
{{- toYaml .Values.args | nindent 12 }}
|
{{- 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 }}
|
{{- if .Values.outbox.enabled }}
|
||||||
- --outbox-dir
|
- --outbox-dir
|
||||||
- {{ .Values.outbox.mountPath | quote }}
|
- {{ .Values.outbox.mountPath | quote }}
|
||||||
|
|
@ -107,8 +121,13 @@ spec:
|
||||||
drop:
|
drop:
|
||||||
- ALL
|
- ALL
|
||||||
readOnlyRootFilesystem: true
|
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:
|
volumeMounts:
|
||||||
|
{{- if .Values.envelopeSigning.enabled }}
|
||||||
|
- mountPath: {{ .Values.envelopeSigning.mountPath | quote }}
|
||||||
|
name: flex-auth-envelope-signing
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.auditDelivery.enabled }}
|
{{- if .Values.auditDelivery.enabled }}
|
||||||
- mountPath: {{ .Values.auditDelivery.mountPath | quote }}
|
- mountPath: {{ .Values.auditDelivery.mountPath | quote }}
|
||||||
name: flex-auth-audit
|
name: flex-auth-audit
|
||||||
|
|
@ -124,8 +143,17 @@ spec:
|
||||||
readOnly: true
|
readOnly: true
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- 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:
|
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 }}
|
{{- if .Values.auditDelivery.enabled }}
|
||||||
- name: flex-auth-audit
|
- name: flex-auth-audit
|
||||||
secret:
|
secret:
|
||||||
|
|
|
||||||
|
|
@ -85,3 +85,11 @@ resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 300m
|
cpu: 300m
|
||||||
memory: 192Mi
|
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
|
||||||
|
|
|
||||||
42
cmd/access-engine/envelope_signing.go
Normal file
42
cmd/access-engine/envelope_signing.go
Normal 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)
|
||||||
|
}
|
||||||
55
cmd/access-engine/envelope_signing_test.go
Normal file
55
cmd/access-engine/envelope_signing_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -330,6 +330,8 @@ func runServe(args []string, stdout, stderr io.Writer) int {
|
||||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
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")
|
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")
|
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")
|
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 {
|
if err != nil {
|
||||||
return fail(stderr, err)
|
return fail(stderr, err)
|
||||||
}
|
}
|
||||||
|
if err := configureEnvelopeSigning(engine, *signingKeyFile, *signingKID); err != nil {
|
||||||
|
return fail(stderr, err)
|
||||||
|
}
|
||||||
var outbox *emission.Outbox
|
var outbox *emission.Outbox
|
||||||
if *outboxDir != "" {
|
if *outboxDir != "" {
|
||||||
outbox, err = emission.Open(*outboxDir, *emissionSource)
|
outbox, err = emission.Open(*outboxDir, *emissionSource)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Date: 2026-09-14
|
||||||
Contract: additive on `flex-auth.decision-record.v1`
|
Contract: additive on `flex-auth.decision-record.v1`
|
||||||
Does **not** change `request_digest`.
|
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.
|
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.
|
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
|
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
|
signing. It does **not** mint the production key and does **not** wait for
|
||||||
the OpenBao lane to exist before the code path is testable.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package decision
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||||
"github.com/netkingdom/access-engine/internal/policy"
|
"github.com/netkingdom/access-engine/internal/policy"
|
||||||
"github.com/netkingdom/access-engine/internal/registry"
|
"github.com/netkingdom/access-engine/internal/registry"
|
||||||
|
"github.com/netkingdom/access-engine/internal/sign"
|
||||||
"github.com/netkingdom/access-engine/pkg/api"
|
"github.com/netkingdom/access-engine/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,6 +32,8 @@ type Engine struct {
|
||||||
history map[string]api.DecisionEnvelope
|
history map[string]api.DecisionEnvelope
|
||||||
log DecisionRecorder
|
log DecisionRecorder
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
|
signingKey ed25519.PrivateKey
|
||||||
|
signingKID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecisionRecorder persists decision envelopes.
|
// DecisionRecorder persists decision envelopes.
|
||||||
|
|
@ -91,6 +95,19 @@ func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, e
|
||||||
}, nil
|
}, 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.
|
// SetDecisionLog attaches a local decision recorder to the engine.
|
||||||
func (e *Engine) SetDecisionLog(log DecisionRecorder) {
|
func (e *Engine) SetDecisionLog(log DecisionRecorder) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
|
|
@ -124,7 +141,16 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
|
||||||
return api.DecisionEnvelope{}, err
|
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
|
// Check evaluates one subject/action/resource request. The decision is
|
||||||
|
|
|
||||||
64
internal/decision/envelope_signing_test.go
Normal file
64
internal/decision/envelope_signing_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Sign the decision envelope: the response channel is unauthenticated"
|
title: "Sign the decision envelope: the response channel is unauthenticated"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: flex-auth
|
repo: flex-auth
|
||||||
status: finished
|
status: active
|
||||||
owner: claude
|
owner: claude
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
planning_priority: P1
|
planning_priority: P1
|
||||||
|
|
@ -15,7 +15,7 @@ related_workplans:
|
||||||
- FLEX-WP-0019
|
- FLEX-WP-0019
|
||||||
- FLEX-WP-0023
|
- FLEX-WP-0023
|
||||||
created: "2026-09-06"
|
created: "2026-09-06"
|
||||||
updated: "2026-09-14"
|
updated: "2026-10-16"
|
||||||
state_hub_workstream_id: "90577acd-6910-548d-a13e-1dbfdfb8ed27"
|
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
|
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
|
standard's own publication path having no digest or freeze discipline; F1 is
|
||||||
that observation applied to the artifact the standard regulates.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue