2026-09-14 09:57:50 +02:00
|
|
|
|
# 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`.
|
|
|
|
|
|
|
2026-10-16 07:51:43 +02:00
|
|
|
|
The original response channel was unauthenticated (`FLEX-DEC-2026-010`). Digests
|
2026-09-14 09:57:50 +02:00
|
|
|
|
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.
|
2026-10-16 07:51:43 +02:00
|
|
|
|
|
|
|
|
|
|
## 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.
|
2026-10-21 01:22:46 +02:00
|
|
|
|
|
|
|
|
|
|
## Public receipt for consumer admission (2026-10-21)
|
|
|
|
|
|
|
|
|
|
|
|
The consumer's trust file has a closed field set (`schema`, `evaluator`, `mode`,
|
|
|
|
|
|
`policy_package`, `policy_version`, `policy_package_digest`, `keys{kid: pubkey}`), so
|
|
|
|
|
|
provenance travels beside it, not in it. `tools/prepare_signer_public_receipt.py`
|
|
|
|
|
|
reads only the public fields of the Platform `railiance-platform.signer-cas-receipt.v1`
|
|
|
|
|
|
plus `docs/native-signer-public-pin.json`, refuses non-final custody, a kid other
|
|
|
|
|
|
than the reviewed rollout kid, a malformed key or fingerprint, or any private-looking
|
|
|
|
|
|
field, and writes two files: the exact trust bytes the founder signs, and
|
|
|
|
|
|
`access-engine.signer-public-receipt.v1` carrying kid, public key, fingerprint, pin,
|
|
|
|
|
|
the trust file's SHA-256 and the custody/CCR/plan/pin hashes. The Access owner
|
|
|
|
|
|
authenticates the receipt by committing it under `docs/evidence/`. Neither file
|
|
|
|
|
|
admits trust; the founder signature does (Secrets `docs/founder-admission.md`).
|