Align authorization request digests with Go canonical wire profile
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
d5ff4c7421
commit
3ffb6a138e
7 changed files with 399 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue