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

4.8 KiB
Raw Blame History

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.

"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:

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