secrets-engine's first live request rejected our allow: binding. request_digest is computed over material they never sent, because we enrich subject and resource from the registry before hashing. Answering that meant reading the enrichment path, which had a worse defect in it. Enrichment was additive-if-absent — addAttribute wrote a registry value only where the request had no value for that key. So where a caller supplied a key, the caller's value won and the registry's never applied. Every registry ceiling and allowlist was advisory. Verified against the shipped ops-warden package, each one added key on an otherwise-denied request: max_ttl_hours: 99 registry says 8 -> allowed a 12h certificate allowed_principals registry allowlist -> disallowed_principal bypassed allowed_subjects registry allowlist -> unknown_subject bypassed The third is the one to read twice: a subject the registry does not know authorized itself by naming itself in the allowlist it was being checked against. Not remotely reachable today — the PEP builds the CheckRequest, ops-warden sends no resource.attributes, and enforce admits one identity. It is a defence-in-depth failure: any path that lets attacker-influenced data into a CheckRequest field became a full policy bypass rather than a bounded input problem. Callers sending resource.attributes is not hypothetical; secrets-engine does it on every request. Registry facts now win, and diagnostics.registry_overrode names every displaced key, because a registry that silently discards a contradicting claim hides that a caller asserted authority it did not have. subject.type is carved out, and the reason is a finding of its own. Making the registry win there denied every secrets-engine allow: the registry's type is CARING vocabulary (Human, Agent, Automation, Service) and the request's is the protected system's actor vocabulary (service, adm, agt, atm). Two fields sharing a name; substituting one for the other is translation rather than identity, which GH-DEC-2026-008 ruled against. Note what surfaced it — the registry's type had been dead data since the field existed, because the caller's value always won. Also publishes binding.submitted_request_digest, over the request exactly as sent. request_digest was published as the consumer replay test and cannot be one. Nothing is lost hashing the pre-enrichment form: enrichment is a function of the request and the snapshot, and registry_snapshot_digest already pins the snapshot. Existing pins do not move. All three replay fixtures' request_digest and approval_binding_digest values are byte-identical — those requests contradict no registry fact. A field to add, not a value to correct. Regression tests verified failing against the old behaviour before being kept. FLEX-DEC-2026-012; FLEX-WP-0025 carries the residual, that a policy still cannot tell a fact from an assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014aQMM1dPXaPiXVn6DwwtLd Assistant: claude-code Assistant-Model: opus Assistant-Process: 715613@bnt-lap001 Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
161 lines
6.4 KiB
Markdown
161 lines
6.4 KiB
Markdown
# Canonical request digest
|
|
|
|
Status: published
|
|
Date: 2026-09-02
|
|
Standard: security-layer-model_v0.7 §6.4.2
|
|
|
|
The digest is the mechanical test for replaying a flex-auth verdict. A consumer
|
|
may reuse an allow **iff** a new request produces the same digest **and** the
|
|
decision's lifetime still holds. Negative caching of a deny uses the same test.
|
|
|
|
Constructor: `api.NewDecisionBinding` / `api.RequestDigest`.
|
|
Field: `DecisionEnvelope.binding.request_digest`.
|
|
Format: `sha256:` plus 64 lowercase hex characters.
|
|
|
|
## What is hashed
|
|
|
|
Canonical JSON (Go `encoding/json`, which sorts map keys) of:
|
|
|
|
| Field | Source |
|
|
| --- | --- |
|
|
| `tenant` | `CheckRequest.tenant` |
|
|
| `subject` | normalized `CheckRequest.subject` |
|
|
| `action` | `CheckRequest.action` |
|
|
| `resource` | normalized `CheckRequest.resource` |
|
|
| `context` | `CheckRequest.context` |
|
|
|
|
Empty optional maps and omitempty strings are omitted. Attribute maps inside
|
|
subject and resource are part of the material once the evaluator has enriched
|
|
them from the registry.
|
|
|
|
**Not hashed:** `id`, `policy_version`, and `caring_context`. The request id is
|
|
correlation only. Policy version is recorded in provenance. CARING context is
|
|
hashed separately as `provenance.input_claim_digests.caring_context`.
|
|
|
|
## Normalization
|
|
|
|
1. Tenant on the request is copied onto subject and resource when those refs
|
|
omit it.
|
|
2. A registry hit copies type, tenant, and selected attributes onto the refs
|
|
the digest sees.
|
|
3. Context is a shallow copy. Key order is not significant.
|
|
|
|
A consumer that re-hashes the **original** unenriched request will not match a
|
|
decision that turned on registry attributes. Compare structured `binding`
|
|
fields to the proposed action, and treat `request_digest` as the evaluator's
|
|
statement of what it hashed. To recompute independently, hash the same
|
|
normalized tuple the binding carries (tenant, subject, action, resource,
|
|
context).
|
|
|
|
## Stability
|
|
|
|
The digest is stable across process restarts for the same normalized tuple.
|
|
It is **not** stable across:
|
|
|
|
- a change to any hashed field, including registry-enriched attributes
|
|
- a change to this contract (would require a new `contract_version`)
|
|
|
|
It does not include the policy package, snapshot, or clock. Those live in
|
|
provenance and `lifetime`. Two allows over the same tuple but different
|
|
snapshots share a digest and differ in `registry_snapshot_digest`.
|
|
|
|
## Worked example — permitted retry
|
|
|
|
Request (after enrichment):
|
|
|
|
```json
|
|
{
|
|
"tenant": "tenant:alpha",
|
|
"subject": {"id": "user:alice", "type": "Human", "tenant": "tenant:alpha"},
|
|
"action": "read",
|
|
"resource": {
|
|
"id": "document:internal-note",
|
|
"type": "document",
|
|
"system": "markitect-tool",
|
|
"tenant": "tenant:alpha"
|
|
},
|
|
"context": {"purpose": "project-delivery"}
|
|
}
|
|
```
|
|
|
|
The evaluator stores `binding.request_digest` for that tuple and
|
|
`lifetime.expires_at` in the future. The same subject, action, resource, and
|
|
context presented again before expiry is a permitted retry of that allow.
|
|
|
|
## Worked example — refused replay
|
|
|
|
Any of the following refuses replay:
|
|
|
|
- `action` changes from `read` to `destroy` (digest mismatch)
|
|
- `resource.attributes.stage` changes from `build` to `production` (digest mismatch)
|
|
- `lifetime.expires_at` is in the past (lifetime does not hold)
|
|
- the stored effect is `deny` and the consumer is attempting to treat a cached
|
|
refusal as still binding after a policy change — negative cache is valid only
|
|
for the same digest **and** while the recorded lifetime would have held; a
|
|
new `Check` is required once either fails
|
|
|
|
Local fixtures, State Hub `/decisions/{uuid}` records, and free-form
|
|
`decided_by` values are not this test.
|
|
|
|
## The approval-binding digest
|
|
|
|
`binding.approval_binding_digest` is this same digest computed with an approval
|
|
claim removed from `context`. It appears only when the request carried one on
|
|
`context.approval`.
|
|
|
|
### Why a second digest exists
|
|
|
|
An approval's `pdp_digest` is recorded **at issue time**. The request that later
|
|
carries that claim inside its hashed context has a different `request_digest` by
|
|
construction, because the claim is part of the context that is hashed. So:
|
|
|
|
> A `pdp_digest` recorded at issue time can never equal the `request_digest` of
|
|
> the request that carries the claim.
|
|
|
|
`secrets-engine` found this while verifying a replay fixture, and it is not a
|
|
fixture defect — it is circular by construction. Left unresolved it would make
|
|
`GH-DEC-2026-008` unimplementable for exactly the case it was written for: a
|
|
dual-control action whose claim travels in context.
|
|
|
|
`approval_binding_digest` is stable across attaching the claim. An approval
|
|
issued against a claim-free Check records that Check's `request_digest`, and the
|
|
later claim-bearing request reproduces the same value here.
|
|
|
|
### The comparison
|
|
|
|
```text
|
|
claim.binding.pdp_digest == decision.binding.approval_binding_digest
|
|
```
|
|
|
|
Never against `request_digest`. Comparing to `request_digest` cannot succeed on a
|
|
claim-bearing request.
|
|
|
|
### It is not a replay identity
|
|
|
|
`request_digest` remains the §6.4.2 replay test and still covers the approval
|
|
claim. `approval_binding_digest` deliberately does not: two requests differing
|
|
only in which approval was presented share it, **and their decisions do not** —
|
|
one allows, the other denies `dual_control_required`.
|
|
|
|
Collapsing the two would let an allow obtained with a valid claim be replayed
|
|
against a request carrying none. That is why this is an additional field rather
|
|
than a redefinition of the existing one, and why `pkg/api/approval_binding_test.go`
|
|
asserts the two functions **disagree** on a claim-bearing request. A test that a
|
|
distinction is real is how a distinction that looks like duplication is defended.
|
|
|
|
## Correction, 2026-09-07: which digest a consumer computes
|
|
|
|
This document describes the canonical form. It does **not** follow that
|
|
`binding.request_digest` is the value a consumer reproduces.
|
|
|
|
`request_digest` is computed over the **enriched** request — after the evaluator
|
|
overlays registry facts onto subject and resource — so a consumer recomputing it
|
|
over what it sent gets a different value on every request whose subject or
|
|
resource the registry knows. `secrets-engine` found this on its first live
|
|
request.
|
|
|
|
**The consumer's §6.4.2 replay test is `binding.submitted_request_digest`**: this
|
|
same canonical form applied to the request exactly as received. Everything below
|
|
about canonicalisation is unchanged and applies to both; what changed is which
|
|
*material* a consumer hashes. See
|
|
[`request-enrichment.md`](request-enrichment.md) and `FLEX-DEC-2026-012`.
|