diff --git a/docs/decision-envelope-signature.md b/docs/decision-envelope-signature.md new file mode 100644 index 0000000..7a31f98 --- /dev/null +++ b/docs/decision-envelope-signature.md @@ -0,0 +1,119 @@ +# Decision-envelope signature + +Status: chosen (FLEX-WP-0024-T02) +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 +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. + +## Shape + +Detached Ed25519 signature over the **canonical envelope with the signature +field omitted**. Transport mTLS is declined: it authenticates a connection that +no longer exists, and a later audit of the record has nothing to verify. + +```json +"signature": { + "mode": "signed", + "alg": "ed25519", + "kid": "flex-auth-envelope-2026-09", + "value": "" +} +``` + +When no signing key is configured, emit absence stated, not a missing key: + +```json +"signature": { "mode": "unsigned" } +``` + +`mode` is required and load-bearing, same argument as `provenance.caller.mode` +(`FLEX-DEC-2026-009`). A reader who cannot tell signed from unsigned will treat +an unsigned allow as authentic. + +The field is **not** in `binding`. Putting it there would change +`request_digest` and break every consumer replay join. It is how the envelope +was produced, so it sits beside `provenance`, as a sibling of it, because a +signature over the envelope must cover provenance too. + +## Canonical form to sign + +Same constructor as `docs/canonical-request-digest.md`: Go `encoding/json` +(map keys sorted) of the envelope **after** `signature` is cleared. + +1. Build the complete envelope (id, effect, binding, lifetime, provenance, + caller, caring, …). +2. Set `signature` to omitted / nil. +3. `json.Marshal` the envelope. That byte string is the signed material. +4. Sign those bytes with Ed25519. Attach `{mode, alg, kid, value}`. + +Do not sign a hand-built subset. An implicit “everything except X, Y, Z” +list is what `FLEX-DEC-2026-007` had to correct. The rule is one sentence: +**the bytes of the envelope as emitted, with `signature` absent.** + +`id` and `provenance.decision_time` **are** signed. They are part of the +artifact. They are still not part of `request_digest`. + +## Algorithm + +Ed25519. Pure signature, 64-byte value, no digest-then-sign parameter to get +wrong, and it matches the estate’s SSH signing primitive without implying this +key is an SSH key. + +`alg` is a constant `ed25519` in v1. A later algorithm is a new `alg` value +and a new `kid`, not a silent swap. + +## Custody — flex-auth does not mint this key + +`warden route find "decision envelope signing key"` currently returns no +lane. That is the defect this task names, not a reason to put a private key +in this repository. + +| Field | Value | +| --- | --- | +| Proposed catalog id | `flex-auth-decision-envelope-signing-key` | +| Owner | OpenBao (`railiance-platform`), not flex-auth | +| `warden_executes` | `false` | +| Path (proposed) | `platform/workloads/flex-auth/envelope-signing` | +| Fields | `ED25519_PRIVATE_KEY` (seed, PEM or raw), never logged | +| Public half | published, not secret: `ED25519_PUBLIC_KEY` plus `kid` | + +flex-auth **decides the shape**. ops-mason / railiance-platform **build the +lane**. The pin loads the private key as a mounted Secret, the same way +caller-auth loads a TokenReview credential. A developer `check` with no key +emits `mode: unsigned`. + +A test-only key may exist under `internal/sign/testdata/` so T03 fixtures +can prove verify-success and verify-failure without a production secret. +That key is not the custody path. + +## Rotation and consumer discovery + +Consumers must not pin a single public key as “the” responder. + +- Every signature carries `kid`. +- Current and previous public keys are published together (a two-key + document: `keys: [{kid, alg, public_key}, …]`). +- A verifier accepts a signature iff `kid` names a currently published key + and the signature verifies with that key. +- Rotation: publish the new public key, start signing with it, keep the old + public key until every in-flight `lifetime` has expired, then drop the old + `kid`. Default allow TTL is 15m, so the overlap window is measured in + hours, not months. +- Discovery for in-cluster consumers: the pin serves `GET /v1/keys` with + only public material. Discovery for audit of a stored envelope: the `kid` + plus the published key document from the same `policy_package_digest` + era. Do not invent a cross-era mapping. + +A consumer that cannot rotate trust has a pinned key that will one day be +wrong. That is why `kid` is on the signature and why `/v1/keys` is a list. + +## What T03 implements (and what it does not) + +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. diff --git a/docs/decision-record-contract.md b/docs/decision-record-contract.md index fb466ab..f95a9a8 100644 --- a/docs/decision-record-contract.md +++ b/docs/decision-record-contract.md @@ -40,6 +40,7 @@ same shape. | `provenance.input_claim_digests` | SHA-256 per request-time claim class (`context`, `caring_context`) | | `provenance.decision_time` | UTC timestamp used to compute `lifetime` | | `provenance.caller` | How the request was authenticated to the PDP. Additive; **not** decision material. `mode` is required (`disabled` / `warn` / `enforce`). A `principal` recorded under `warn` was observed, not enforced. Under `disabled` the object is `{"mode":"disabled"}` with no principal. `not_after` is the reviewed token `exp`. **Does not affect `request_digest`.** See `FLEX-DEC-2026-009` | +| `signature` | Detached Ed25519 signature over the canonical envelope with this field omitted. `mode` is `signed` or `unsigned` (absence stated). Additive; **does not affect `request_digest`.** See [`decision-envelope-signature.md`](decision-envelope-signature.md) (`FLEX-WP-0024`) | `reason`, `diagnostics`, and CARING prose are not an authorization contract. diff --git a/examples/caring/decision_envelope.json b/examples/caring/decision_envelope.json index 920eea9..81d3547 100644 --- a/examples/caring/decision_envelope.json +++ b/examples/caring/decision_envelope.json @@ -68,6 +68,9 @@ "mode": "disabled" } }, + "signature": { + "mode": "unsigned" + }, "caring": { "profile": "caring-0.4.0-rc2", "descriptor": { diff --git a/examples/secrets-engine/replay/README.md b/examples/secrets-engine/replay/README.md index 3955eb9..df4f499 100644 --- a/examples/secrets-engine/replay/README.md +++ b/examples/secrets-engine/replay/README.md @@ -8,6 +8,9 @@ to verify its digest join (`627810b`) unchanged. `FLEX-WP-0021-T03`. | `decision_rotate.json` | `../check_request_allow_rotate.json` — plain allow, empty context | | `decision_destroy_dual_control.json` | `../check_request_allow_destroy_dual_control.json` — dual control, valid approval-claim | | `decision_wrong_tenant_deny.json` | `../check_request_deny_wrong_tenant.json` — foreign tenant, denied `wrong_tenant` | +| `decision_rotate_signed.json` | same rotate envelope, Ed25519-signed with the testdata key (`FLEX-WP-0024-T03`) | +| `decision_rotate_signed_tampered.json` | the signed rotate envelope with `effect`/`reason` altered after signing | +| `keys.json` | public half of the testdata key (`kid=testdata-ed25519`). Not a production key | Regenerate either with: @@ -18,6 +21,25 @@ go run ./cmd/flex-auth check \ -request examples/secrets-engine/check_request_allow_rotate.json ``` +## Envelope signature (FLEX-WP-0024) + +A consumer verifier is untested until it has seen both a valid signature and +an invalid one. These two files are that pair: + +```bash +# genuine — must verify +# tampered — must fail +# public key: keys.json kid testdata-ed25519 +``` + +The signed material is `json.Marshal` of the envelope with `signature` +omitted (`docs/decision-envelope-signature.md`). `request_digest` is the same +on the unsigned rotate fixture, the signed fixture, and the tampered fixture, +because the signature is not binding material. + +The testdata private seed is `0x42` repeated 32 times. It is **not** the +OpenBao custody path. + ## What is stable and what is not **Stable across runs** — these are the fields to pin a contract test against: diff --git a/examples/secrets-engine/replay/decision_rotate_signed.json b/examples/secrets-engine/replay/decision_rotate_signed.json new file mode 100644 index 0000000..a98dedb --- /dev/null +++ b/examples/secrets-engine/replay/decision_rotate_signed.json @@ -0,0 +1,118 @@ +{ + "id": "decision:414734bb30381ff7", + "contract_version": "flex-auth.decision-record.v1", + "request_id": "check:secrets-engine-rotate", + "effect": "allow", + "reason": "catalog_lane_policy_matched", + "matched_policy_version": "v2", + "matched_rule": "catalog_lane_policy_matched", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "binding": { + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "action": "rotate", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345", + "submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a" + }, + "lifetime": { + "kind": "ttl", + "ttl": "15m", + "not_before": "2026-09-07T07:10:23Z", + "expires_at": "2026-09-07T07:25:23Z" + }, + "diagnostics": { + "action": "rotate", + "matched_relationship": "", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_status": "ready", + "registry_overrode": [], + "registry_resource": false, + "registry_subject": true + }, + "provenance": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", + "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", + "decision_time": "2026-09-07T07:10:23Z" + }, + "signature": { + "mode": "signed", + "alg": "ed25519", + "kid": "testdata-ed25519", + "value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ" + }, + "caring": { + "profile": "caring-0.4.0-rc2", + "conformance_findings": [ + { + "code": "CARING-DESCRIPTOR-MISSING", + "severity": "warning", + "message": "no CARING descriptor matched the request", + "fields": [ + "caring_context" + ] + } + ] + } +} diff --git a/examples/secrets-engine/replay/decision_rotate_signed_tampered.json b/examples/secrets-engine/replay/decision_rotate_signed_tampered.json new file mode 100644 index 0000000..c8a6070 --- /dev/null +++ b/examples/secrets-engine/replay/decision_rotate_signed_tampered.json @@ -0,0 +1,118 @@ +{ + "id": "decision:414734bb30381ff7", + "contract_version": "flex-auth.decision-record.v1", + "request_id": "check:secrets-engine-rotate", + "effect": "deny", + "reason": "tampered_after_signing", + "matched_policy_version": "v2", + "matched_rule": "catalog_lane_policy_matched", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "binding": { + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "action": "rotate", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345", + "submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a" + }, + "lifetime": { + "kind": "ttl", + "ttl": "15m", + "not_before": "2026-09-07T07:10:23Z", + "expires_at": "2026-09-07T07:25:23Z" + }, + "diagnostics": { + "action": "rotate", + "matched_relationship": "", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_status": "ready", + "registry_overrode": [], + "registry_resource": false, + "registry_subject": true + }, + "provenance": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v2", + "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", + "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", + "decision_time": "2026-09-07T07:10:23Z" + }, + "signature": { + "mode": "signed", + "alg": "ed25519", + "kid": "testdata-ed25519", + "value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ" + }, + "caring": { + "profile": "caring-0.4.0-rc2", + "conformance_findings": [ + { + "code": "CARING-DESCRIPTOR-MISSING", + "severity": "warning", + "message": "no CARING descriptor matched the request", + "fields": [ + "caring_context" + ] + } + ] + } +} diff --git a/examples/secrets-engine/replay/keys.json b/examples/secrets-engine/replay/keys.json new file mode 100644 index 0000000..f902928 --- /dev/null +++ b/examples/secrets-engine/replay/keys.json @@ -0,0 +1,11 @@ +{ + "algorithm": "ed25519", + "keys": [ + { + "kid": "testdata-ed25519", + "alg": "ed25519", + "public_key": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI", + "note": "Well-known non-production seed 0x42 repeated. Not a custody path. FLEX-WP-0024-T03 fixtures only." + } + ] +} diff --git a/internal/decision/engine.go b/internal/decision/engine.go index c8b6a73..e826030 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -382,6 +382,9 @@ func (e *Engine) envelope(ctx context.Context, request, submitted api.CheckReque Now: e.now(), }) envelope.ID = decisionID(e.policy.Metadata, request, envelope) + if envelope.Signature == nil { + envelope.Signature = api.UnsignedEnvelopeSignature() + } return envelope } diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go index ac6afa9..75dbb95 100644 --- a/internal/decision/engine_test.go +++ b/internal/decision/engine_test.go @@ -72,6 +72,9 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) { if got.Provenance.Caller == nil || got.Provenance.Caller.Mode != "disabled" || got.Provenance.Caller.Principal != "" { t.Errorf("got.Provenance.Caller = %+v; want disabled with no principal", got.Provenance.Caller) } + if got.Signature == nil || got.Signature.Mode != api.EnvelopeSignatureUnsigned { + t.Errorf("got.Signature = %+v; want unsigned", got.Signature) + } } func TestCallerProvenanceDoesNotChangeRequestDigest(t *testing.T) { diff --git a/internal/sign/sign.go b/internal/sign/sign.go new file mode 100644 index 0000000..0d312ea --- /dev/null +++ b/internal/sign/sign.go @@ -0,0 +1,76 @@ +// Package sign attaches a detached Ed25519 signature to a decision envelope +// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with +// the signature field omitted. +package sign + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/netkingdom/flex-auth/pkg/api" +) + +const AlgorithmEd25519 = "ed25519" + +// CanonicalBytes is the exact payload that is signed and verified: the +// envelope as emitted, with signature absent. +func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) { + envelope.Signature = nil + data, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("marshal canonical envelope: %w", err) + } + return data, nil +} + +func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error { + if envelope == nil { + return fmt.Errorf("envelope is required") + } + if len(private) != ed25519.PrivateKeySize { + return fmt.Errorf("ed25519 private key has length %d", len(private)) + } + if kid == "" { + return fmt.Errorf("kid is required") + } + payload, err := CanonicalBytes(*envelope) + if err != nil { + return err + } + sig := ed25519.Sign(private, payload) + envelope.Signature = &api.EnvelopeSignature{ + Mode: api.EnvelopeSignatureSigned, + Alg: AlgorithmEd25519, + Kid: kid, + Value: base64.RawURLEncoding.EncodeToString(sig), + } + return nil +} + +func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error { + sig := envelope.Signature + if sig == nil || sig.Mode != api.EnvelopeSignatureSigned { + return fmt.Errorf("envelope is not signed") + } + if sig.Alg != AlgorithmEd25519 { + return fmt.Errorf("unsupported signature alg %q", sig.Alg) + } + pub, ok := keys[sig.Kid] + if !ok { + return fmt.Errorf("unknown signature kid %q", sig.Kid) + } + raw, err := base64.RawURLEncoding.DecodeString(sig.Value) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + payload, err := CanonicalBytes(envelope) + if err != nil { + return err + } + if !ed25519.Verify(pub, payload, raw) { + return fmt.Errorf("signature does not match envelope") + } + return nil +} diff --git a/internal/sign/sign_test.go b/internal/sign/sign_test.go new file mode 100644 index 0000000..9befa30 --- /dev/null +++ b/internal/sign/sign_test.go @@ -0,0 +1,108 @@ +package sign_test + +import ( + "crypto/ed25519" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/netkingdom/flex-auth/internal/sign" + "github.com/netkingdom/flex-auth/pkg/api" +) + +// testdataSeed is a well-known non-production key. It exists so fixtures can +// prove verification without an OpenBao lane (FLEX-WP-0024-T02). +var testdataSeed = bytes32(0x42) + +func bytes32(b byte) []byte { + out := make([]byte, ed25519.SeedSize) + for i := range out { + out[i] = b + } + return out +} + +func fixtureKey() (ed25519.PublicKey, ed25519.PrivateKey) { + priv := ed25519.NewKeyFromSeed(testdataSeed) + return priv.Public().(ed25519.PublicKey), priv +} + +func TestSignAndVerifyRoundTrip(t *testing.T) { + pub, priv := fixtureKey() + envelope := sampleEnvelope("allow") + digest := envelope.Binding.RequestDigest + if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil { + t.Fatal(err) + } + if envelope.Binding.RequestDigest != digest { + t.Fatalf("request_digest moved from %s to %s", digest, envelope.Binding.RequestDigest) + } + if envelope.Signature == nil || envelope.Signature.Mode != api.EnvelopeSignatureSigned { + t.Fatalf("signature = %+v", envelope.Signature) + } + if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err != nil { + t.Fatalf("Verify: %v", err) + } +} + +func TestAlteredEnvelopeFailsVerification(t *testing.T) { + pub, priv := fixtureKey() + envelope := sampleEnvelope("allow") + if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil { + t.Fatal(err) + } + envelope.Effect = api.DecisionEffectDeny + if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err == nil { + t.Fatal("tampered envelope verified") + } +} + +func TestUnsignedEnvelopeDoesNotVerify(t *testing.T) { + pub, _ := fixtureKey() + envelope := sampleEnvelope("allow") + envelope.Signature = api.UnsignedEnvelopeSignature() + if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err == nil { + t.Fatal("unsigned envelope verified") + } +} + +func TestReplayFixturesProveSuccessAndFailure(t *testing.T) { + pub, _ := fixtureKey() + keys := map[string]ed25519.PublicKey{"testdata-ed25519": pub} + + signed := loadEnvelope(t, filepath.Join("..", "..", "examples", "secrets-engine", "replay", "decision_rotate_signed.json")) + if err := sign.Verify(signed, keys); err != nil { + t.Fatalf("genuine fixture: %v", err) + } + tampered := loadEnvelope(t, filepath.Join("..", "..", "examples", "secrets-engine", "replay", "decision_rotate_signed_tampered.json")) + if err := sign.Verify(tampered, keys); err == nil { + t.Fatal("tampered fixture verified") + } +} + +func sampleEnvelope(effect string) api.DecisionEnvelope { + return api.DecisionEnvelope{ + ID: "decision:test", + ContractVersion: api.DecisionRecordContractV1, + Effect: api.DecisionEffect(effect), + Resource: api.ResourceRef{ID: "lane:test", System: "secrets-engine"}, + Subject: api.SubjectRef{ID: "secrets-engine", Type: "service"}, + Binding: &api.DecisionBinding{RequestDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Action: "rotate"}, + Provenance: api.DecisionProvenance{Evaluator: "flex-auth/local", Mode: "standalone"}, + Signature: api.UnsignedEnvelopeSignature(), + } +} + +func loadEnvelope(t *testing.T, path string) api.DecisionEnvelope { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var envelope api.DecisionEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatal(err) + } + return envelope +} diff --git a/internal/sign/write_fixtures_test.go b/internal/sign/write_fixtures_test.go new file mode 100644 index 0000000..239f39f --- /dev/null +++ b/internal/sign/write_fixtures_test.go @@ -0,0 +1,45 @@ +package sign_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/netkingdom/flex-auth/internal/sign" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestWriteReplaySignatureFixtures(t *testing.T) { + if os.Getenv("UPDATE_FIXTURES") != "1" { + t.Skip("set UPDATE_FIXTURES=1 to regenerate signed replay fixtures") + } + dir := filepath.Join("..", "..", "examples", "secrets-engine", "replay") + data, err := os.ReadFile(filepath.Join(dir, "decision_rotate.json")) + if err != nil { + t.Fatal(err) + } + var envelope api.DecisionEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatal(err) + } + _, priv := fixtureKey() + if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil { + t.Fatal(err) + } + writeJSON(t, filepath.Join(dir, "decision_rotate_signed.json"), envelope) + envelope.Effect = api.DecisionEffectDeny + envelope.Reason = "tampered_after_signing" + writeJSON(t, filepath.Join(dir, "decision_rotate_signed_tampered.json"), envelope) +} + +func writeJSON(t *testing.T, path string, value any) { + t.Helper() + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index 5a2b6bb..bde56cb 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -229,9 +229,32 @@ type DecisionEnvelope struct { Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"` Provenance DecisionProvenance `json:"provenance" yaml:"provenance"` + Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"` Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"` } +// EnvelopeSignatureMode is how the responder authenticates the envelope. +type EnvelopeSignatureMode string + +const ( + EnvelopeSignatureSigned EnvelopeSignatureMode = "signed" + EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned" +) + +// EnvelopeSignature is a detached signature over the canonical envelope with +// this field omitted (FLEX-WP-0024). It is not decision material and is not +// part of request_digest. +type EnvelopeSignature struct { + Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"` + Alg string `json:"alg,omitempty" yaml:"alg,omitempty"` + Kid string `json:"kid,omitempty" yaml:"kid,omitempty"` + Value string `json:"value,omitempty" yaml:"value,omitempty"` +} + +func UnsignedEnvelopeSignature() *EnvelopeSignature { + return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned} +} + // DecisionLifetimeKind identifies how an allow ends. type DecisionLifetimeKind string diff --git a/schemas/decision_envelope.schema.json b/schemas/decision_envelope.schema.json index 3beed73..2a76a50 100644 --- a/schemas/decision_envelope.schema.json +++ b/schemas/decision_envelope.schema.json @@ -69,6 +69,10 @@ "provenance": { "$ref": "#/$defs/provenance" }, + "signature": { + "$ref": "#/$defs/envelope_signature", + "description": "Detached Ed25519 signature over the canonical envelope with this field omitted. Additive: request_digest is unaffected (FLEX-WP-0024). mode unsigned states absence rather than omitting the field." + }, "caring": { "$ref": "#/$defs/caring_decision_metadata" } @@ -235,6 +239,33 @@ } } }, + "envelope_signature": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "enum": [ + "signed", + "unsigned" + ] + }, + "alg": { + "const": "ed25519" + }, + "kid": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1, + "description": "base64url Ed25519 signature over json.Marshal of the envelope with signature omitted" + } + } + }, "caller_provenance": { "type": "object", "additionalProperties": false, diff --git a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md index 9379b13..c4f6e6c 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: active +status: finished 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-06" +updated: "2026-09-14" state_hub_workstream_id: "90577acd-6910-548d-a13e-1dbfdfb8ed27" --- @@ -68,7 +68,7 @@ that does not exist resolves to the same address. ```task id: FLEX-WP-0024-T02 -status: todo +status: done priority: high state_hub_task_id: "5482f3cd-36cb-55e0-8c52-0ca2340ed4d7" ``` @@ -97,11 +97,17 @@ Owner: `flex-auth`; key custody is **not** ours to invent. Gate: the canonical form, the algorithm, and the custody path are written down and reviewed before any signing code exists. +**Done 2026-09-14.** Written in `docs/decision-envelope-signature.md`: detached +Ed25519 over `json.Marshal` of the envelope with `signature` omitted; unsigned +is `{mode: unsigned}`; production key lives in OpenBao, not this repo +(proposed catalog id `flex-auth-decision-envelope-signing-key`). Test-only seed +is documented and is not the custody path. + ## 3. Implement signing and verification ```task id: FLEX-WP-0024-T03 -status: wait +status: done priority: high state_hub_task_id: "041612ea-1be4-5997-8c32-49f8bcc50855" ``` @@ -125,6 +131,12 @@ Owner: `flex-auth`. Gate: an altered envelope fails verification, an unaltered one passes, and a consumer can perform both from the fixtures alone. +**Done 2026-09-14.** Additive `signature` on the schema and envelope. Engine +emits `{mode: unsigned}` when no key is configured. Fixture pair: +`examples/secrets-engine/replay/decision_rotate_signed.json` verifies, +`decision_rotate_signed_tampered.json` does not, same `request_digest`. Public +key in `keys.json`. Tests in `internal/sign`. + ## 4. Report the gap to gate-house ```task