Approve CCR-2026-0050 for Access scope (sent to railiance-platform).
Add the exact consumer pin derived at candidate 84c78e0 and an offline
projection from the Platform custody receipt to the secrets-engine
pdp-trust.v1 bytes plus a public receipt. No key, rollout or admission.
T07 waits on founder decision 4fab263d.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 809600@bnt-lap001
Assistant-Session: 40ba228f-c8a0-4ec9-bb29-3129951afdc4
8.9 KiB
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 original response channel was unauthenticated (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.
- Build the complete envelope (id, effect, binding, lifetime, provenance, caller, caring, …).
- Set
signatureto omitted / nil. json.Marshalthe envelope. That byte string is the signed material.- 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
kidnames 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
lifetimehas expired, then drop the oldkid. 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/keyswith only public material. Discovery for audit of a stored envelope: thekidplus the published key document from the samepolicy_package_digestera. 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.
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.
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).