flex-auth/docs/decision-envelope-signature.md
tegwick 127f83da4d
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 53s
Sign decision envelopes and close FLEX-WP-0024.
Detached Ed25519 over the canonical envelope with signature omitted.
Unsigned is stated, not implied. Testdata fixtures prove verify and
tamper failure without minting a production key. FLEX-WP-0025 is
finished with the validate check from the previous commit.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 09:57:50 +02:00

119 lines
4.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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": "<base64url Ed25519 signature>"
}
```
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 estates 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.