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

@ -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