Align authorization request digests with Go canonical wire profile
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 19:47:45 +02:00
parent d5ff4c7421
commit 3ffb6a138e
7 changed files with 399 additions and 9 deletions

View file

@ -0,0 +1,47 @@
# Access request digest interoperability profile
Source-only consumer repair, 2026-10-11. The authoritative algorithm remains
Access Engine `api.RequestDigest` / `api.ApprovalBindingDigest` using Go
`encoding/json`. The opaque `sha256:` prefix is unchanged. This is not a policy
change, native authorization or reinterpretation of a previously served approval.
Existing served approvals retain their original immutable request and digest;
a mismatching request needs a new request/approval rather than rewriting evidence.
The consumer matches literal UTF-8 Unicode, JSON control-character escaping,
Go's default HTML escaping of `<`, `>` and `&`, and unconditional escaping of
U+2028/U+2029, including nested map keys. Invalid Unicode scalar strings fail
closed. Struct fields retain their documented order; nested maps sort by key.
Empty optional attribute maps and context are omitted, matching Go's struct tags.
The optional top-level advisory `purpose` is hashed if present; this does not
admit it on enforcement requests, whose Access endpoint rejects that field.
## Numeric profile
Access HTTP decoding puts JSON numbers in `map[string]any` into binary64
`float64`; arbitrary Python JSON serialization is therefore not equivalent.
This consumer admits finite binary64 floats using shortest round-trip digits,
Go's fixed notation interval `[1e-6, 1e21)`, normalized scientific exponents,
integer-valued float formatting and signed negative zero. Integers outside
`[-(2^53-1), 2^53-1]` fail closed to prevent silently hashing the rounded value
Go would decode. Non-finite values and unsupported non-JSON values fail closed.
Fractional finite values and Unicode catalogue names remain supported. This
profile uses the actual HTTP JSON decode path; arbitrary Go-native values such
as `json.Number` or pre-HTTP big integers are not claimed to match.
## Separate fingerprints
`execution_contract.catalog_target_digest` remains the descriptor-local sorted
JSON UTF-8 fingerprint without Go HTML escaping. Approval Engine's claim-binding
digest and owner body hashes retain their distinct algorithms. Only full Access
request and approval-free Access binding digest computation uses this profile.
Removing `context.approval` reproduces the pre-claim request digest; a full
claim-bearing request continues to hash the entire approval claim.
## Verification
The new tests build a real oracle against the installed Access Engine source
and call its exported digest functions after actual `json.Decode`. They cover
Unicode/HTML/separators, nested keys, exact approval joins, optional omission,
supported integer boundaries and explicit unsupported-number refusals. Existing
normal canonical/replay/approval/descriptor fixtures remain unmodified. No Go
algorithm, policy package, credentials, default or deployment is changed.

View file

@ -0,0 +1,156 @@
{
"schema": "source-preparation-receipt/v1",
"record_id": "SECRETS-CANONICAL-20261011-R1",
"completed_preparation_record_sha256": "c29911e5908ca757d5572530741660438c3d9d1b8962b2ae605cac399ca550f7",
"formal_owner_review": false,
"native_execution": false,
"counterexample_source": "/tmp/access-platform-compiler-review-receipt/docs/evidence/2026-10-11-task-execution/compiler-canonical-counterexample.json",
"counterexample_sha256": "eadd598e7ffceaf1431bb5ec3642afa8c230604da1422d324c21dba4d6411c45",
"go_oracle_source_sha256": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300",
"checks": {
"go_oracle_tests": 34,
"deterministic_float_samples": 256,
"focused_final": 186,
"original_fixture_files_modified": false
},
"prior_observations": [
{
"kind": "incomplete_stage",
"result": "Initial collection errors / missing repo assets; full tracked asset copy corrected staging only."
},
{
"kind": "sandbox_custody",
"result": "Initial full staged suite 612 passed, 16 failed, 1 skipped before fractional-support revision. Initial receipt proved only one unchanged-source reproduction. Revision r2 now runs the exact same sixteen failures against unchanged authoritative source, all sixteen reproduce untrusted-owner failure in this sandbox. Full final revised suite is not claimed green."
},
{
"kind": "superseded_preparation",
"result": "Initial integer-only float profile superseded before frozen candidate; final admits finite fractional binary64 values."
},
{
"kind": "outside-sandbox-baseline",
"result": "Unmodified actual source reproduces TypeError for companion-lane stub missing auth_args; all other custody failures disappear outside sandbox. Exact baseline log retained. Revised test accepts auth_args and verifies identity while preserving each-lane denial/backend refusal test."
}
],
"limits": [
"No policy or Go global algorithm change",
"No reinterpretation of prior served approvals",
"Large Python integers outside safe binary64 interval fail closed",
"No native proof or rollout",
"Independent review remains required"
],
"revision": "r3-existing-test-stub-contract",
"supersedes_receipt_sha256": "7776fd99177e97a3dfc167e8207d1cdeedecc7bb62ed3f9a53c379946dceb49d",
"baseline_verification": {
"unchanged_original_code_sha256": "0ec4c3f7304d68abd06a0d08dc459e4f1f7269da0e4258549662be9e57b39fd3",
"same_sandbox_environment": true,
"observed_tmp_owner_uid": 65534,
"executing_uid": 1000,
"tests_requested": 16,
"same_failures_reproduced": 16,
"per_test": [
{
"test": "tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage]",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
},
{
"test": "tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner",
"result": "failed",
"observed_failure": "exec owner path has an untrusted owner"
}
],
"baseline_observation_path": "/tmp/secrets-canonical-baseline-sixteen-observation.json",
"baseline_observation_sha256": "652badb08358ae90ea01d7777bd9c26b8778d38b1dc78b97f1fb4ee36dfd40ab",
"baseline_log_path": "/tmp/secrets-canonical-baseline-sixteen-tests.txt",
"baseline_log_sha256": "fa88af7b7c14780c63a7609d9da329688a53146aecb98a3b2b1ec736d6baf2c6",
"staged_full_log_path": "/tmp/secrets-canonical-go-final-tests.txt",
"staged_full_log_sha256": "67339d0b2045081f4df67c61e2388df507413057f417d9dd2d77da207fc0bb3e",
"full_final_revised_suite_green": true
},
"full_final_source_suite": {
"exit_code": 0,
"environment": "outside filesystem sandbox with explicit approved escalation",
"collected": 637,
"passed": 636,
"skipped": 1,
"log_path": "/tmp/secrets-canonical-v3-full-suite.txt",
"log_sha256": "b563940c02dfa8b8509dacb48ece3f017c2c841690cda37f0a51e6c7ba2203e0",
"collection_log_path": "/tmp/secrets-canonical-v3-collection.txt",
"collection_log_sha256": "40689fd881e4b16fffcf693b54e05fa169073e7abd884b44642a252d0236bf77",
"baseline_stub_failure_log_path": "/tmp/secrets-canonical-baseline-unsandboxed.txt",
"baseline_stub_failure_log_sha256": "92fd5edb071e1b74a0971642226e532b8f2edc9284b411339335f370c3816f1b",
"baseline_stub_exit_code": 1,
"canonical_code_sha256_unchanged_from_r1": "d58e5fd01ca2470a5d11a6b4f7e9d262a0c1565637e07b35466faa30e4991d14",
"native_estate_changes": false
}
}

View file

@ -0,0 +1,10 @@
{
"schema": "bounded-source-preparation/v1",
"record_id": "SECRETS-CANONICAL-20261011-R1",
"driver": "SECRETS-WP-0013-T01",
"status": "completed",
"scope": "Local canonical digest consumer repair and source tests only",
"formal_owner_assent": false,
"native_execution": false,
"workplan_task_status_change": false
}

View file

@ -9,9 +9,11 @@ from __future__ import annotations
import copy
import hashlib
import json
import math
import re
import time
import uuid
from decimal import Decimal
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
@ -166,6 +168,8 @@ def _parse_time(value: object, name: str) -> datetime:
def _sorted_map(value: object) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
if not all(isinstance(key, str) for key in value):
raise DecisionError("canonical request object keys must be strings")
return {key: _canonical_map_value(value[key]) for key in sorted(value)}
@ -184,7 +188,7 @@ def _subject_ref(value: object) -> dict[str, Any]:
for name in ("type", "tenant"):
if value.get(name):
subject[name] = _required_text(value, name)
if value.get("attributes") is not None:
if value.get("attributes"):
subject["attributes"] = _sorted_map(value.get("attributes"))
return subject
@ -196,7 +200,7 @@ def _resource_ref(value: object) -> dict[str, Any]:
for name in ("type", "system", "tenant"):
if value.get(name):
resource[name] = _required_text(value, name)
if value.get("attributes") is not None:
if value.get("attributes"):
resource["attributes"] = _sorted_map(value.get("attributes"))
return resource
@ -221,6 +225,8 @@ def canonical_check_request(request: object) -> dict[str, Any]:
)
if request.get("policy_version"):
canonical["policy_version"] = _required_text(request, "policy_version")
if request.get("purpose"):
canonical["purpose"] = _required_text(request, "purpose")
return canonical
@ -248,12 +254,61 @@ def digest_material(request: object) -> dict[str, Any]:
return material
def _go_json_encode(value: Any) -> str:
"""Go encoding/json wire profile, including decoded binary64 numbers.
Integers outside the exactly representable binary64-safe interval fail
closed instead of silently hashing Go's rounded HTTP-decoded value.
"""
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
try:
value.encode("utf-8", errors="strict")
except UnicodeEncodeError as error:
raise DecisionError("canonical request requires valid Unicode scalars") from error
encoded = json.dumps(value, ensure_ascii=False)
for character, escaped in (("<", "\\u003c"), (">", "\\u003e"),
("&", "\\u0026"), ("\u2028", "\\u2028"),
("\u2029", "\\u2029")):
encoded = encoded.replace(character, escaped)
return encoded
if isinstance(value, int):
if abs(value) > 2**53 - 1:
raise DecisionError("canonical request integer exceeds safe binary64 interval")
return str(value)
if isinstance(value, float):
if not math.isfinite(value):
raise DecisionError("canonical request number must be finite")
if value == 0:
return "-0" if math.copysign(1, value) < 0 else "0"
# Both runtimes use shortest round-tripping binary64 digits. Go uses
# fixed notation for [1e-6, 1e21), scientific notation otherwise.
text = repr(value)
magnitude = abs(value)
if 1e-6 <= magnitude < 1e21:
fixed = format(Decimal(text), "f")
return fixed.rstrip("0").rstrip(".") if "." in fixed else fixed
mantissa, exponent = text.lower().split("e")
if mantissa.endswith(".0"):
mantissa = mantissa[:-2]
number = int(exponent)
return mantissa + "e" + ("+" if number >= 0 else "-") + str(abs(number))
if isinstance(value, list):
return "[" + ",".join(_go_json_encode(item) for item in value) + "]"
if isinstance(value, dict):
if not all(isinstance(key, str) for key in value):
raise DecisionError("canonical request object keys must be strings")
return "{" + ",".join(_go_json_encode(key) + ":" + _go_json_encode(item)
for key, item in value.items()) + "}"
raise DecisionError("canonical request contains an unsupported JSON value")
def request_digest(request: object) -> str:
canonical = digest_material(request)
encoded = json.dumps(
canonical, ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
return "sha256:" + hashlib.sha256(encoded).hexdigest()
encoded = _go_json_encode(digest_material(request))
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
#: The context key an approval claim travels on, per flex-auth's

View file

@ -152,7 +152,8 @@ def test_each_lane_is_gated_separately_before_backend(bound, monkeypatch, tmp_pa
table = {entry.id: entry, lane.id: lane}
monkeypatch.setattr(cli, "get_entry", lambda _dir, cid: table[cid])
gated = []
def gate(cfg, e, action, evidence, *, fields=()):
def gate(cfg, e, action, evidence, *, fields=(), auth_args=None):
assert auth_args is args
gated.append((e.id, action, fields))
if e.id == "test-worker":
raise DeliveryError("companion lane denied")
@ -161,8 +162,9 @@ def test_each_lane_is_gated_separately_before_backend(bound, monkeypatch, tmp_pa
monkeypatch.setattr(cli, "require_delivery_state", lambda *a: None)
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend after a lane refusal"))
command = data["delivery_config"]["exec_owner"]["command"]
args = SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env")
with pytest.raises(DeliveryError, match="companion lane denied"):
cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env"))
cli.cmd_exec(_cfg(tmp_path), args)
assert gated == [("test-lane", "exec", ("api_token",)), ("test-worker", "exec", ("worker_token",))]

View file

@ -0,0 +1,109 @@
"""Real Access Engine oracle, source-only; no synthetic reimplementation."""
import copy
import json
import os
from pathlib import Path
import subprocess
import pytest
from secrets_engine.authorization import request_digest, approval_binding_digest
from secrets_engine.errors import DecisionError
from secrets_engine.execution_contract import catalog_target_digest
from tests.test_action_authorization import _request
@pytest.fixture(scope="session")
def go_oracle(tmp_path_factory):
access = Path(os.environ.get("ACCESS_ENGINE_SOURCE", "/home/worsch/access-engine"))
directory = tmp_path_factory.mktemp("go-canonical")
source = directory / "main.go"
source.write_text('''package main
import ("encoding/json"; "os"; "github.com/netkingdom/access-engine/pkg/api")
func main() {
var request api.CheckRequest
if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil { panic(err) }
json.NewEncoder(os.Stdout).Encode([]string{api.RequestDigest(request), api.ApprovalBindingDigest(request)})
}
''')
binary = directory / "oracle"
subprocess.run(["go", "build", "-o", str(binary), str(source)], cwd=access, check=True, timeout=120)
return binary
def _oracle(binary, request):
result = subprocess.run([str(binary)], input=json.dumps(request, ensure_ascii=False, allow_nan=False),
text=True, capture_output=True, check=True, timeout=5)
return json.loads(result.stdout)
@pytest.mark.parametrize("purpose", ["café<&>", "漢字😀", "separator\u2028paragraph\u2029", 'quoted"\\\n\t', "\\u003c", "ascii"])
def test_real_go_unicode_and_approval_join(go_oracle, purpose):
request = _request()
request["context"]["purpose"] = purpose
request["context"]["nested"] = {"ключ<&>": [purpose, None, True, False]}
expected = _oracle(go_oracle, request)
assert [request_digest(request), approval_binding_digest(request)] == expected
before = request_digest(request)
request["context"]["approval"] = {"id": "approval:é<&>", "binding": {"pdp_digest": before}}
expected = _oracle(go_oracle, request)
assert [request_digest(request), approval_binding_digest(request)] == expected
assert expected[1] == before and expected[0] != before
@pytest.mark.parametrize("number", [0, 1, -1, 1.0, 0.0, -0.0, 1.5, 1e-7, 1e-6, 1.2e-6, 1e20, 1e21, 1e23, 5e-324, 1.7976931348623157e308, 1000000000000000, 2**53-1, -(2**53-1)])
def test_numeric_admitted_profile(go_oracle, number):
request = _request()
request["context"]["number"] = {"nested": [number]}
assert request_digest(request) == _oracle(go_oracle, request)[0]
@pytest.mark.parametrize("number", [float("nan"), float("inf"), -float("inf"), 2**53, 2**53+1, 10**100])
def test_numeric_outside_profile_fails_closed(number):
request = _request()
request["context"]["number"] = number
with pytest.raises(DecisionError):
request_digest(request)
with pytest.raises(DecisionError):
approval_binding_digest(request)
def test_empty_optional_maps_and_approval_only(go_oracle):
request = _request()
request["subject"]["attributes"] = {}
request["resource"]["attributes"] = {}
request["context"] = {"approval": {"id": "test"}}
assert [request_digest(request), approval_binding_digest(request)] == _oracle(go_oracle, request)
request["purpose"] = "readback"
assert [request_digest(request), approval_binding_digest(request)] == _oracle(go_oracle, request)
def test_unicode_fingerprint_is_distinct_and_unchanged(go_oracle):
request = _request()
target = request["context"]["catalog_target"]
target["org"] = "café<&>"
import hashlib
expected = "sha256:" + hashlib.sha256(json.dumps(target, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
assert catalog_target_digest(target) == expected
assert request_digest(request) == _oracle(go_oracle, request)[0]
def test_invalid_unicode_fails_closed():
request = _request()
request["context"]["purpose"] = "bad\ud800"
with pytest.raises(DecisionError):
request_digest(request)
def test_binary64_samples_against_real_go(go_oracle):
import random
import struct
import math
randomizer = random.Random(20261011)
for _ in range(256):
number = struct.unpack(">d", randomizer.getrandbits(64).to_bytes(8, "big"))[0]
if not math.isfinite(number):
continue
request = _request()
request["context"]["number"] = number
assert request_digest(request) == _oracle(go_oracle, request)[0], repr(number)

View file

@ -94,3 +94,14 @@ and confirmed revocation/cleanup. No applied CCR38 replay or broad default flip.
Native metadata proof does not certify provision/exec delivery or all T06 scope.
2026-10-11 source revision v2: qualified T03 dependencies point to the owning design and Platform execution-entitlement task. WP13 owns the missing task-scoped execution seam; WP8-T06 retains production adoption and its full workflow proof. Source publication and fixture checks do not imply native acceptance. Original v1 stage remains unchanged.
2026-10-11 completed bounded source preparation within T01: repaired the actual
Python/Go canonical request digest mismatch for valid Unicode/HTML purpose and
nested strings, optional maps and decoded numeric formatting. Full request and
approval-free request bindings retain the opaque sha256 prefix; catalogue,
claim-binding and body fingerprints remain distinct and unchanged. Real Access
Go-oracle cases and unchanged canonical/replay/approval/descriptor fixtures pass.
This is source preparation awaiting independent technical review/publication,
not a formal owner assent, policy deployment or native execution milestone. T01
remains progress; T03 and SECRETS-WP-0008-T06 remain open. Detailed limitations
and retained failed test observations are in the source preparation receipt.