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
This commit is contained in:
parent
c074237aac
commit
127f83da4d
15 changed files with 697 additions and 4 deletions
119
docs/decision-envelope-signature.md
Normal file
119
docs/decision-envelope-signature.md
Normal file
|
|
@ -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": "<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 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue