fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.
Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.
The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.
Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.
274 tests pass. Production still fails closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 14:17:38 +02:00
|
|
|
"""Digest join verified against real flex-auth DecisionEnvelopes (FLEX-WP-0021-T03).
|
|
|
|
|
|
|
|
|
|
These replace a hand-maintained pin that was computed *including* the request
|
|
|
|
|
`id`. docs/canonical-request-digest.md excludes `id`, `policy_version` and
|
|
|
|
|
`caring_context` from the hashed material, and both real envelopes confirm it:
|
|
|
|
|
a digest computed over the old material matches no issued decision, which would
|
|
|
|
|
have failed closed against every correct allow.
|
|
|
|
|
|
|
|
|
|
Both fixtures are required. `provenance.input_claim_digests.context` appears
|
|
|
|
|
only when the request carries a non-empty context, so a validator asserting it
|
|
|
|
|
is always present passes `destroy` and fails `rotate`.
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from secrets_engine.authorization import (
|
|
|
|
|
digest_material,
|
|
|
|
|
request_digest,
|
|
|
|
|
validate_decision_envelope,
|
|
|
|
|
)
|
|
|
|
|
from secrets_engine.errors import DecisionError
|
|
|
|
|
|
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay"
|
|
|
|
|
|
|
|
|
|
PACKAGE_DIGEST = "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c"
|
|
|
|
|
SNAPSHOT_DIGEST = "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb"
|
|
|
|
|
|
|
|
|
|
CASES = {
|
|
|
|
|
"rotate": {
|
|
|
|
|
"file": "decision_rotate.json",
|
|
|
|
|
"digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
|
|
|
|
|
"action": "rotate",
|
|
|
|
|
"context_claim_digest": None,
|
|
|
|
|
},
|
|
|
|
|
"destroy": {
|
|
|
|
|
"file": "decision_destroy_dual_control.json",
|
2026-09-06 14:20:16 +02:00
|
|
|
"digest": "sha256:fc155dba88f8ab18b3032ed086ba2f455158c6981106e7829d520ab7b036bdf3",
|
fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.
Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.
The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.
Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.
274 tests pass. Production still fails closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 14:17:38 +02:00
|
|
|
"action": "destroy",
|
2026-09-06 14:20:16 +02:00
|
|
|
"context_claim_digest": "sha256:b0d2203cd2b43a9ba573c21c038154c131afba4255e313affd7ecf810e2cc221",
|
fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.
Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.
The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.
Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.
274 tests pass. Production still fails closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 14:17:38 +02:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _envelope(name):
|
|
|
|
|
return json.loads((FIXTURES / CASES[name]["file"]).read_text())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _request_from(envelope):
|
|
|
|
|
"""Rebuild the normalized tuple the binding carries.
|
|
|
|
|
|
|
|
|
|
Per the contract, a consumer re-hashing the *original unenriched* request
|
|
|
|
|
will not match a decision that turned on registry attributes; the binding is
|
|
|
|
|
the evaluator's statement of what it hashed.
|
|
|
|
|
"""
|
|
|
|
|
binding = envelope["binding"]
|
|
|
|
|
request = {"id": envelope["request_id"]}
|
|
|
|
|
for key in ("tenant", "subject", "action", "resource"):
|
|
|
|
|
if binding.get(key) is not None:
|
|
|
|
|
request[key] = binding[key]
|
|
|
|
|
if binding.get("context") is not None:
|
|
|
|
|
request["context"] = binding["context"]
|
|
|
|
|
return request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_lifetime(envelope):
|
|
|
|
|
"""Lifetime moves with the clock and must never be pinned."""
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
envelope["lifetime"]["not_before"] = (now - timedelta(minutes=1)).strftime(
|
|
|
|
|
"%Y-%m-%dT%H:%M:%SZ"
|
|
|
|
|
)
|
|
|
|
|
envelope["lifetime"]["expires_at"] = (now + timedelta(minutes=14)).strftime(
|
|
|
|
|
"%Y-%m-%dT%H:%M:%SZ"
|
|
|
|
|
)
|
|
|
|
|
return envelope
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_request_digest_matches_the_issued_decision(name):
|
|
|
|
|
envelope = _envelope(name)
|
|
|
|
|
expected = CASES[name]["digest"]
|
|
|
|
|
assert envelope["binding"]["request_digest"] == expected, "fixture drifted"
|
|
|
|
|
assert request_digest(_request_from(envelope)) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_correlation_fields_are_not_hashed(name):
|
|
|
|
|
"""id, policy_version and caring_context must not move the digest."""
|
|
|
|
|
request = _request_from(envelope := _envelope(name))
|
|
|
|
|
baseline = request_digest(request)
|
|
|
|
|
assert baseline == envelope["binding"]["request_digest"]
|
|
|
|
|
for field, value in (
|
|
|
|
|
("id", "check:some-other-correlation-id"),
|
|
|
|
|
("policy_version", "v99"),
|
|
|
|
|
("caring_context", {"anything": "here"}),
|
|
|
|
|
):
|
|
|
|
|
assert request_digest({**request, field: value}) == baseline, field
|
|
|
|
|
stripped = {k: v for k, v in request.items() if k != "id"}
|
|
|
|
|
assert request_digest(stripped) == baseline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_digest_material_is_exactly_the_published_tuple(name):
|
|
|
|
|
material = digest_material(_request_from(_envelope(name)))
|
|
|
|
|
assert set(material) <= {"tenant", "subject", "action", "resource", "context"}
|
|
|
|
|
assert "id" not in material
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_real_envelope_validates_against_the_published_package(name):
|
|
|
|
|
envelope = _refresh_lifetime(_envelope(name))
|
|
|
|
|
result = validate_decision_envelope(
|
|
|
|
|
envelope,
|
|
|
|
|
_request_from(envelope),
|
|
|
|
|
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
|
|
|
|
accepted_policy_versions={"v1"},
|
|
|
|
|
)
|
|
|
|
|
assert result.action == CASES[name]["action"]
|
|
|
|
|
assert result.subject_id == "secrets-engine"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_provenance_digests_are_pinned(name):
|
|
|
|
|
provenance = _envelope(name)["provenance"]
|
|
|
|
|
assert provenance["policy_package_digest"] == PACKAGE_DIGEST
|
|
|
|
|
assert provenance["registry_snapshot_digest"] == SNAPSHOT_DIGEST
|
|
|
|
|
assert provenance["evaluator"] == "flex-auth/local"
|
|
|
|
|
assert provenance["mode"] == "standalone"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_input_claim_digest_is_present_only_with_a_context(name):
|
|
|
|
|
"""The reason two fixtures exist: this field is conditional."""
|
|
|
|
|
provenance = _envelope(name)["provenance"]
|
|
|
|
|
expected = CASES[name]["context_claim_digest"]
|
|
|
|
|
actual = (provenance.get("input_claim_digests") or {}).get("context")
|
|
|
|
|
assert actual == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("name", list(CASES))
|
|
|
|
|
def test_a_tampered_binding_field_breaks_the_digest(name):
|
|
|
|
|
envelope = _envelope(name)
|
|
|
|
|
request = _request_from(envelope)
|
|
|
|
|
request["action"] = "handoff"
|
|
|
|
|
assert request_digest(request) != envelope["binding"]["request_digest"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_expired_real_envelope_fails_closed():
|
|
|
|
|
"""The shipped lifetime is 15m from allow_ttl and has long since passed."""
|
|
|
|
|
with pytest.raises(DecisionError, match="lifetime has expired"):
|
|
|
|
|
envelope = _envelope("rotate")
|
|
|
|
|
validate_decision_envelope(
|
|
|
|
|
envelope,
|
|
|
|
|
_request_from(envelope),
|
|
|
|
|
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
|
|
|
|
|
accepted_policy_versions={"v1"},
|
|
|
|
|
)
|
2026-09-06 14:20:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_embedded_claim_uses_approval_engine_vocabulary_not_ours():
|
|
|
|
|
"""The published vocabulary gap, asserted rather than described.
|
|
|
|
|
|
|
|
|
|
The dual-control request carries a complete approval-claim in
|
|
|
|
|
context.approval. Its binding speaks approval-engine's language while the
|
|
|
|
|
decision speaks ours, and no mapping between them is published -- which is
|
|
|
|
|
why this engine compares pdp_digest and refuses to derive a native digest
|
|
|
|
|
from its own vocabulary.
|
|
|
|
|
"""
|
|
|
|
|
envelope = _envelope("destroy")
|
|
|
|
|
claim = envelope["binding"]["context"]["approval"]
|
|
|
|
|
assert claim["kind"] == "approval-claim"
|
|
|
|
|
assert claim["issuer"] == "approval-engine"
|
|
|
|
|
assert claim["valid_now"] is True
|
|
|
|
|
assert claim["binding"]["action"] == "secrets.kv.destroy"
|
|
|
|
|
assert envelope["binding"]["action"] == "destroy"
|
|
|
|
|
assert claim["binding"]["action"] != envelope["binding"]["action"]
|
|
|
|
|
assert set(claim["binding"]["target"]) == {"id", "stage"}
|
|
|
|
|
assert claim["binding"]["target"]["id"] != envelope["resource"]["id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest():
|
|
|
|
|
"""Carrying the claim inside a hashed context makes the two unequal.
|
|
|
|
|
|
|
|
|
|
context is part of the digest material, so embedding an approval-claim
|
|
|
|
|
changes the request digest of the very request that carries it. A
|
|
|
|
|
pdp_digest recorded at issue time therefore cannot equal the final digest
|
|
|
|
|
of the dual-control request. Raised with approval-engine and flex-auth; this
|
|
|
|
|
test records the property so a future change is visible rather than silent.
|
|
|
|
|
"""
|
|
|
|
|
envelope = _envelope("destroy")
|
|
|
|
|
binding = envelope["binding"]
|
|
|
|
|
pdp = binding["context"]["approval"]["binding"]["pdp_digest"]
|
|
|
|
|
request = _request_from(envelope)
|
|
|
|
|
assert request_digest(request) == binding["request_digest"]
|
|
|
|
|
assert pdp != binding["request_digest"]
|
|
|
|
|
without_claim = dict(request)
|
|
|
|
|
without_claim["context"] = {
|
|
|
|
|
k: v for k, v in binding["context"].items() if k != "approval"
|
|
|
|
|
}
|
|
|
|
|
assert pdp != request_digest(without_claim)
|