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