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