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.
|
||||
|
|
@ -40,6 +40,7 @@ same shape.
|
|||
| `provenance.input_claim_digests` | SHA-256 per request-time claim class (`context`, `caring_context`) |
|
||||
| `provenance.decision_time` | UTC timestamp used to compute `lifetime` |
|
||||
| `provenance.caller` | How the request was authenticated to the PDP. Additive; **not** decision material. `mode` is required (`disabled` / `warn` / `enforce`). A `principal` recorded under `warn` was observed, not enforced. Under `disabled` the object is `{"mode":"disabled"}` with no principal. `not_after` is the reviewed token `exp`. **Does not affect `request_digest`.** See `FLEX-DEC-2026-009` |
|
||||
| `signature` | Detached Ed25519 signature over the canonical envelope with this field omitted. `mode` is `signed` or `unsigned` (absence stated). Additive; **does not affect `request_digest`.** See [`decision-envelope-signature.md`](decision-envelope-signature.md) (`FLEX-WP-0024`) |
|
||||
|
||||
`reason`, `diagnostics`, and CARING prose are not an authorization contract.
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@
|
|||
"mode": "disabled"
|
||||
}
|
||||
},
|
||||
"signature": {
|
||||
"mode": "unsigned"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"descriptor": {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ to verify its digest join (`627810b`) unchanged. `FLEX-WP-0021-T03`.
|
|||
| `decision_rotate.json` | `../check_request_allow_rotate.json` — plain allow, empty context |
|
||||
| `decision_destroy_dual_control.json` | `../check_request_allow_destroy_dual_control.json` — dual control, valid approval-claim |
|
||||
| `decision_wrong_tenant_deny.json` | `../check_request_deny_wrong_tenant.json` — foreign tenant, denied `wrong_tenant` |
|
||||
| `decision_rotate_signed.json` | same rotate envelope, Ed25519-signed with the testdata key (`FLEX-WP-0024-T03`) |
|
||||
| `decision_rotate_signed_tampered.json` | the signed rotate envelope with `effect`/`reason` altered after signing |
|
||||
| `keys.json` | public half of the testdata key (`kid=testdata-ed25519`). Not a production key |
|
||||
|
||||
Regenerate either with:
|
||||
|
||||
|
|
@ -18,6 +21,25 @@ go run ./cmd/flex-auth check \
|
|||
-request examples/secrets-engine/check_request_allow_rotate.json
|
||||
```
|
||||
|
||||
## Envelope signature (FLEX-WP-0024)
|
||||
|
||||
A consumer verifier is untested until it has seen both a valid signature and
|
||||
an invalid one. These two files are that pair:
|
||||
|
||||
```bash
|
||||
# genuine — must verify
|
||||
# tampered — must fail
|
||||
# public key: keys.json kid testdata-ed25519
|
||||
```
|
||||
|
||||
The signed material is `json.Marshal` of the envelope with `signature`
|
||||
omitted (`docs/decision-envelope-signature.md`). `request_digest` is the same
|
||||
on the unsigned rotate fixture, the signed fixture, and the tampered fixture,
|
||||
because the signature is not binding material.
|
||||
|
||||
The testdata private seed is `0x42` repeated 32 times. It is **not** the
|
||||
OpenBao custody path.
|
||||
|
||||
## What is stable and what is not
|
||||
|
||||
**Stable across runs** — these are the fields to pin a contract test against:
|
||||
|
|
|
|||
118
examples/secrets-engine/replay/decision_rotate_signed.json
Normal file
118
examples/secrets-engine/replay/decision_rotate_signed.json
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"id": "decision:414734bb30381ff7",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "deny",
|
||||
"reason": "tampered_after_signing",
|
||||
"matched_policy_version": "v2",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
||||
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-07T07:10:23Z",
|
||||
"expires_at": "2026-09-07T07:25:23Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_overrode": [],
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v2",
|
||||
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-07T07:10:23Z"
|
||||
},
|
||||
"signature": {
|
||||
"mode": "signed",
|
||||
"alg": "ed25519",
|
||||
"kid": "testdata-ed25519",
|
||||
"value": "G45R7eb_7Dl7B8RO5HTgefcys6QabYwe7NZcF4ju_zpEAfEBWKVK5T5e5rP1GNJ5uuVusrw6Fd90tttg3_-CAQ"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
examples/secrets-engine/replay/keys.json
Normal file
11
examples/secrets-engine/replay/keys.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"algorithm": "ed25519",
|
||||
"keys": [
|
||||
{
|
||||
"kid": "testdata-ed25519",
|
||||
"alg": "ed25519",
|
||||
"public_key": "IVL40Zt5HSRFMkLhXy6rbLfP-ntqXtMAl5YOBpiB2xI",
|
||||
"note": "Well-known non-production seed 0x42 repeated. Not a custody path. FLEX-WP-0024-T03 fixtures only."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -382,6 +382,9 @@ func (e *Engine) envelope(ctx context.Context, request, submitted api.CheckReque
|
|||
Now: e.now(),
|
||||
})
|
||||
envelope.ID = decisionID(e.policy.Metadata, request, envelope)
|
||||
if envelope.Signature == nil {
|
||||
envelope.Signature = api.UnsignedEnvelopeSignature()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) {
|
|||
if got.Provenance.Caller == nil || got.Provenance.Caller.Mode != "disabled" || got.Provenance.Caller.Principal != "" {
|
||||
t.Errorf("got.Provenance.Caller = %+v; want disabled with no principal", got.Provenance.Caller)
|
||||
}
|
||||
if got.Signature == nil || got.Signature.Mode != api.EnvelopeSignatureUnsigned {
|
||||
t.Errorf("got.Signature = %+v; want unsigned", got.Signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallerProvenanceDoesNotChangeRequestDigest(t *testing.T) {
|
||||
|
|
|
|||
76
internal/sign/sign.go
Normal file
76
internal/sign/sign.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Package sign attaches a detached Ed25519 signature to a decision envelope
|
||||
// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with
|
||||
// the signature field omitted.
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
const AlgorithmEd25519 = "ed25519"
|
||||
|
||||
// CanonicalBytes is the exact payload that is signed and verified: the
|
||||
// envelope as emitted, with signature absent.
|
||||
func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) {
|
||||
envelope.Signature = nil
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal canonical envelope: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error {
|
||||
if envelope == nil {
|
||||
return fmt.Errorf("envelope is required")
|
||||
}
|
||||
if len(private) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("ed25519 private key has length %d", len(private))
|
||||
}
|
||||
if kid == "" {
|
||||
return fmt.Errorf("kid is required")
|
||||
}
|
||||
payload, err := CanonicalBytes(*envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sig := ed25519.Sign(private, payload)
|
||||
envelope.Signature = &api.EnvelopeSignature{
|
||||
Mode: api.EnvelopeSignatureSigned,
|
||||
Alg: AlgorithmEd25519,
|
||||
Kid: kid,
|
||||
Value: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error {
|
||||
sig := envelope.Signature
|
||||
if sig == nil || sig.Mode != api.EnvelopeSignatureSigned {
|
||||
return fmt.Errorf("envelope is not signed")
|
||||
}
|
||||
if sig.Alg != AlgorithmEd25519 {
|
||||
return fmt.Errorf("unsupported signature alg %q", sig.Alg)
|
||||
}
|
||||
pub, ok := keys[sig.Kid]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown signature kid %q", sig.Kid)
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(sig.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
payload, err := CanonicalBytes(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ed25519.Verify(pub, payload, raw) {
|
||||
return fmt.Errorf("signature does not match envelope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
108
internal/sign/sign_test.go
Normal file
108
internal/sign/sign_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package sign_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/sign"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// testdataSeed is a well-known non-production key. It exists so fixtures can
|
||||
// prove verification without an OpenBao lane (FLEX-WP-0024-T02).
|
||||
var testdataSeed = bytes32(0x42)
|
||||
|
||||
func bytes32(b byte) []byte {
|
||||
out := make([]byte, ed25519.SeedSize)
|
||||
for i := range out {
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fixtureKey() (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
priv := ed25519.NewKeyFromSeed(testdataSeed)
|
||||
return priv.Public().(ed25519.PublicKey), priv
|
||||
}
|
||||
|
||||
func TestSignAndVerifyRoundTrip(t *testing.T) {
|
||||
pub, priv := fixtureKey()
|
||||
envelope := sampleEnvelope("allow")
|
||||
digest := envelope.Binding.RequestDigest
|
||||
if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Binding.RequestDigest != digest {
|
||||
t.Fatalf("request_digest moved from %s to %s", digest, envelope.Binding.RequestDigest)
|
||||
}
|
||||
if envelope.Signature == nil || envelope.Signature.Mode != api.EnvelopeSignatureSigned {
|
||||
t.Fatalf("signature = %+v", envelope.Signature)
|
||||
}
|
||||
if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlteredEnvelopeFailsVerification(t *testing.T) {
|
||||
pub, priv := fixtureKey()
|
||||
envelope := sampleEnvelope("allow")
|
||||
if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envelope.Effect = api.DecisionEffectDeny
|
||||
if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err == nil {
|
||||
t.Fatal("tampered envelope verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsignedEnvelopeDoesNotVerify(t *testing.T) {
|
||||
pub, _ := fixtureKey()
|
||||
envelope := sampleEnvelope("allow")
|
||||
envelope.Signature = api.UnsignedEnvelopeSignature()
|
||||
if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err == nil {
|
||||
t.Fatal("unsigned envelope verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayFixturesProveSuccessAndFailure(t *testing.T) {
|
||||
pub, _ := fixtureKey()
|
||||
keys := map[string]ed25519.PublicKey{"testdata-ed25519": pub}
|
||||
|
||||
signed := loadEnvelope(t, filepath.Join("..", "..", "examples", "secrets-engine", "replay", "decision_rotate_signed.json"))
|
||||
if err := sign.Verify(signed, keys); err != nil {
|
||||
t.Fatalf("genuine fixture: %v", err)
|
||||
}
|
||||
tampered := loadEnvelope(t, filepath.Join("..", "..", "examples", "secrets-engine", "replay", "decision_rotate_signed_tampered.json"))
|
||||
if err := sign.Verify(tampered, keys); err == nil {
|
||||
t.Fatal("tampered fixture verified")
|
||||
}
|
||||
}
|
||||
|
||||
func sampleEnvelope(effect string) api.DecisionEnvelope {
|
||||
return api.DecisionEnvelope{
|
||||
ID: "decision:test",
|
||||
ContractVersion: api.DecisionRecordContractV1,
|
||||
Effect: api.DecisionEffect(effect),
|
||||
Resource: api.ResourceRef{ID: "lane:test", System: "secrets-engine"},
|
||||
Subject: api.SubjectRef{ID: "secrets-engine", Type: "service"},
|
||||
Binding: &api.DecisionBinding{RequestDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Action: "rotate"},
|
||||
Provenance: api.DecisionProvenance{Evaluator: "flex-auth/local", Mode: "standalone"},
|
||||
Signature: api.UnsignedEnvelopeSignature(),
|
||||
}
|
||||
}
|
||||
|
||||
func loadEnvelope(t *testing.T, path string) api.DecisionEnvelope {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope api.DecisionEnvelope
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
45
internal/sign/write_fixtures_test.go
Normal file
45
internal/sign/write_fixtures_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package sign_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/sign"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
func TestWriteReplaySignatureFixtures(t *testing.T) {
|
||||
if os.Getenv("UPDATE_FIXTURES") != "1" {
|
||||
t.Skip("set UPDATE_FIXTURES=1 to regenerate signed replay fixtures")
|
||||
}
|
||||
dir := filepath.Join("..", "..", "examples", "secrets-engine", "replay")
|
||||
data, err := os.ReadFile(filepath.Join(dir, "decision_rotate.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope api.DecisionEnvelope
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, priv := fixtureKey()
|
||||
if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeJSON(t, filepath.Join(dir, "decision_rotate_signed.json"), envelope)
|
||||
envelope.Effect = api.DecisionEffectDeny
|
||||
envelope.Reason = "tampered_after_signing"
|
||||
writeJSON(t, filepath.Join(dir, "decision_rotate_signed_tampered.json"), envelope)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -229,9 +229,32 @@ type DecisionEnvelope struct {
|
|||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
|
||||
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
|
||||
Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"`
|
||||
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
}
|
||||
|
||||
// EnvelopeSignatureMode is how the responder authenticates the envelope.
|
||||
type EnvelopeSignatureMode string
|
||||
|
||||
const (
|
||||
EnvelopeSignatureSigned EnvelopeSignatureMode = "signed"
|
||||
EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned"
|
||||
)
|
||||
|
||||
// EnvelopeSignature is a detached signature over the canonical envelope with
|
||||
// this field omitted (FLEX-WP-0024). It is not decision material and is not
|
||||
// part of request_digest.
|
||||
type EnvelopeSignature struct {
|
||||
Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"`
|
||||
Alg string `json:"alg,omitempty" yaml:"alg,omitempty"`
|
||||
Kid string `json:"kid,omitempty" yaml:"kid,omitempty"`
|
||||
Value string `json:"value,omitempty" yaml:"value,omitempty"`
|
||||
}
|
||||
|
||||
func UnsignedEnvelopeSignature() *EnvelopeSignature {
|
||||
return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned}
|
||||
}
|
||||
|
||||
// DecisionLifetimeKind identifies how an allow ends.
|
||||
type DecisionLifetimeKind string
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@
|
|||
"provenance": {
|
||||
"$ref": "#/$defs/provenance"
|
||||
},
|
||||
"signature": {
|
||||
"$ref": "#/$defs/envelope_signature",
|
||||
"description": "Detached Ed25519 signature over the canonical envelope with this field omitted. Additive: request_digest is unaffected (FLEX-WP-0024). mode unsigned states absence rather than omitting the field."
|
||||
},
|
||||
"caring": {
|
||||
"$ref": "#/$defs/caring_decision_metadata"
|
||||
}
|
||||
|
|
@ -235,6 +239,33 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"envelope_signature": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"enum": [
|
||||
"signed",
|
||||
"unsigned"
|
||||
]
|
||||
},
|
||||
"alg": {
|
||||
"const": "ed25519"
|
||||
},
|
||||
"kid": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "base64url Ed25519 signature over json.Marshal of the envelope with signature omitted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"caller_provenance": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Sign the decision envelope: the response channel is unauthenticated"
|
||||
domain: infotech
|
||||
repo: flex-auth
|
||||
status: active
|
||||
status: finished
|
||||
owner: claude
|
||||
topic_slug: netkingdom
|
||||
planning_priority: P1
|
||||
|
|
@ -15,7 +15,7 @@ related_workplans:
|
|||
- FLEX-WP-0019
|
||||
- FLEX-WP-0023
|
||||
created: "2026-09-06"
|
||||
updated: "2026-09-06"
|
||||
updated: "2026-09-14"
|
||||
state_hub_workstream_id: "90577acd-6910-548d-a13e-1dbfdfb8ed27"
|
||||
---
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ that does not exist resolves to the same address.
|
|||
|
||||
```task
|
||||
id: FLEX-WP-0024-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5482f3cd-36cb-55e0-8c52-0ca2340ed4d7"
|
||||
```
|
||||
|
|
@ -97,11 +97,17 @@ Owner: `flex-auth`; key custody is **not** ours to invent.
|
|||
Gate: the canonical form, the algorithm, and the custody path are written down
|
||||
and reviewed before any signing code exists.
|
||||
|
||||
**Done 2026-09-14.** Written in `docs/decision-envelope-signature.md`: detached
|
||||
Ed25519 over `json.Marshal` of the envelope with `signature` omitted; unsigned
|
||||
is `{mode: unsigned}`; production key lives in OpenBao, not this repo
|
||||
(proposed catalog id `flex-auth-decision-envelope-signing-key`). Test-only seed
|
||||
is documented and is not the custody path.
|
||||
|
||||
## 3. Implement signing and verification
|
||||
|
||||
```task
|
||||
id: FLEX-WP-0024-T03
|
||||
status: wait
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "041612ea-1be4-5997-8c32-49f8bcc50855"
|
||||
```
|
||||
|
|
@ -125,6 +131,12 @@ Owner: `flex-auth`.
|
|||
Gate: an altered envelope fails verification, an unaltered one passes, and a
|
||||
consumer can perform both from the fixtures alone.
|
||||
|
||||
**Done 2026-09-14.** Additive `signature` on the schema and envelope. Engine
|
||||
emits `{mode: unsigned}` when no key is configured. Fixture pair:
|
||||
`examples/secrets-engine/replay/decision_rotate_signed.json` verifies,
|
||||
`decision_rotate_signed_tampered.json` does not, same `request_digest`. Public
|
||||
key in `keys.json`. Tests in `internal/sign`.
|
||||
|
||||
## 4. Report the gap to gate-house
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue