Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This repo is the public protocol layer. The private gateway product is in a sepa

## Dev environment

- Python >= 3.9 (tested on 3.10, 3.11, 3.12)
- Python >= 3.10 (tested on 3.10, 3.11, 3.12; the pyproject floor)
- `pip install -e ".[dev]"` for editable + dev deps
- Core deps: `pynacl`, `base58`. Keep the dep footprint small.

Expand Down
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# Changelog

## Unreleased

### Fixed / Security (audit 2026-07-10; pending review before release)
- **Expiry fail-open on Python < 3.11 (authority).** `verify_delegation`, `is_expired`, and `sub_delegate` parsed `expiresAt` with `datetime.fromisoformat`, which did not accept a trailing `Z` until 3.11, and swallowed the resulting error so the expiry check silently no-opped on the declared minimum interpreter. Added `_time.parse_iso_utc` (correct on 3.9+) and made an unparseable expiry fail closed (treated as expired), not open.
- **Canonical-byte divergence from RFC 8785 / the TS SDK (cross-language signatures).** `canonicalize` and `canonicalize_jcs` serialized floats with Python `repr`/`json.dumps` (e.g. `1e21`, `1e-07`, `1e-06`) and sorted object keys by code point. Both now use `_es_number` (ECMAScript `Number::toString`, validated byte-identical to Node over 20k values) and a UTF-16 code-unit key sort, matching the TS reference on floats and astral-plane keys. The JCS non-container fallback also now sets `ensure_ascii=False`.
- **action_ref naive-timestamp divergence.** `compute_action_ref` assumed UTC for offsetless timestamps while the TS reference parses them as local time; it now rejects naive timestamps (spec 4.1 requires an explicit `Z`/offset) and formats the year with explicit zero-padding.

## 2.8.0 (2026-07-10)

### Added
- **`compute_action_ref(agent_id, action_type, scope_required, timestamp)`** (`action_ref.py`): the native APS action_ref of draft-pidlisnyi-aps-03 section 4.1, SHA-256 over the strict RFC 8785 canonicalization of `{agentId, actionType, scopeRequired, timestamp}` with NFC per scope string and a Unicode code-point sort of the scope list on a copy. **Cross-language byte parity with the TS SDK (npm v3.3.0) and the Go implementation**, pinned by the shared vectors in `tests/cross_impl/actionref-canonical-vectors.json` (4 of 4 byte-identical hex). Distinct from `compute_attribution_action_ref` (attribution preimage with nonce and params); that function is untouched.

## 2.6.0 (unreleased)
## 2.7.0 (2026-07-04)

- Release/version bump only: synced the package version and the description's cross-language parity line to the current TS SDK. No functional or byte-level changes to the protocol primitives (README.md, pyproject.toml).

## 2.6.0 (2026-07-04)

### Added
- **`trace_beneficiary(receipt, delegations, beneficiary_map)`** (`attribution.py`) and **`verify_action_receipt(receipt, agent_public_key)`** (`delegation.py`): parity with the TypeScript beneficiary-verified-honesty change. `verified` is a real cryptographic check (the receipt signature verifies at the chain tail via `verify_action_receipt`, and every delegation in the lineage verifies via `verify_delegation`), not a lookup; a new `resolved` field carries the lookup-only semantics (lineage maps to known records and a known beneficiary, no cryptographic claim). The reported lineage is deterministic (valid-first, then `delegationId`) with the tail hop tied to `receipt.delegationId`. A forged or tampered chain reports `resolved` true but `verified` false. Reuses the existing Ed25519 verifiers; no crypto reimplemented.
Expand Down
2 changes: 1 addition & 1 deletion src/agent_passport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
Docs: https://agent-passport.org/llms-full.txt
"""

__version__ = "2.6.0"
__version__ = "2.8.0"

# Crypto
from .crypto import generate_key_pair, sign, verify, public_key_from_private
Expand Down
31 changes: 31 additions & 0 deletions src/agent_passport/_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright (c) 2026 Tymofii Pidlisnyi
# SPDX-License-Identifier: Apache-2.0
"""UTC timestamp parsing that is correct on Python 3.9+ (matches the TS SDK).

``datetime.fromisoformat`` did not accept a trailing ``Z`` until Python 3.11.
The SDK writes ``Z``-suffixed timestamps and the TS reference issues them, so a
bare ``fromisoformat(ts)`` silently raised ``ValueError`` on the standard form
under the declared minimum interpreter (3.10). Where that error was swallowed,
expiry checks became no-ops (fail open). This helper normalizes ``Z`` to
``+00:00`` first, so a valid timestamp parses on every supported version, and
raises on genuinely malformed input so the caller can fail closed.
"""

from datetime import datetime, timezone


def parse_iso_utc(ts: str) -> datetime:
"""Parse an ISO 8601 timestamp to a timezone-aware UTC datetime.

Accepts a trailing ``Z`` (UTC designator) on Python 3.9+. A naive
(offsetless) timestamp is read as UTC, matching how the SDK writes them.
Raises ``ValueError``/``TypeError`` on malformed input; callers treating an
unparseable expiry as expired keep the check fail-closed.
"""
if not isinstance(ts, str):
raise TypeError(f"timestamp must be str, got {type(ts).__name__}")
normalized = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
dt = datetime.fromisoformat(normalized)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
17 changes: 15 additions & 2 deletions src/agent_passport/action_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,22 @@ def _normalize_timestamp(ts: str) -> str:
except (ValueError, TypeError) as exc:
raise ValueError(f"compute_action_ref: invalid timestamp {ts!r}") from exc
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# Reject naive timestamps. Spec 4.1 requires an explicit UTC designator
# ('Z'), and the TS reference parses an offsetless string as LOCAL time,
# so assuming UTC here would silently produce a different action_ref for
# the same input across implementations. Fail closed on non-conforming
# input instead of guessing a zone.
raise ValueError(
f"compute_action_ref: timestamp {ts!r} must carry an explicit UTC "
"offset or 'Z' (spec 4.1); naive timestamps are non-conforming"
)
dt = dt.astimezone(timezone.utc).replace(microsecond=0)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
# Explicit zero-padded formatting; strftime('%Y') zero-pads platform-
# dependently for years < 1000. APS timestamps are always four-digit years.
return (
f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}"
f"T{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}Z"
)


def _canonicalize_scope_required(scope_required):
Expand Down
68 changes: 57 additions & 11 deletions src/agent_passport/canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,61 @@
- Object keys sorted alphabetically
- null/None values omitted from objects (NOT from arrays)
- No whitespace
- Dates serialized as ISO strings
"""

import json
import re


def _es_number(value: float) -> str:
"""Serialize a finite float exactly like ECMAScript Number::toString.

RFC 8785 section 3.2.2.3 mandates the ECMAScript number-to-string algorithm.
Python's repr() yields the same shortest round-trip DIGITS as ECMAScript (the
shortest decimal that round-trips a double is unique), but Python and
json.dumps format the decimal point / exponent differently (e.g. 1e21 ->
'1000000000000000000000', 1e-7 -> '1e-07', 1e-6 -> '1e-06'), so we reformat
the digits into ECMAScript notation ('1e+21', '1e-7', '0.000001'). Validated
by differential test against Node's JSON.stringify (tests/test_es_number.py).
"""
if value == 0:
return "0" # ECMAScript renders -0 as "0"
sign = ""
if value < 0:
sign, value = "-", -value
r = repr(value)
if "e" in r or "E" in r:
mant, exp_s = re.split("[eE]", r)
exp = int(exp_s)
else:
mant, exp = r, 0
if "." in mant:
int_part, frac_part = mant.split(".")
else:
int_part, frac_part = mant, ""
digits = (int_part + frac_part).lstrip("0")
lsd_pow = exp - len(frac_part) # power of ten of the least-significant digit
trail = len(digits) - len(digits.rstrip("0"))
s = digits.rstrip("0")
k = len(s)
n = lsd_pow + trail + k # value = s * 10^(n-k); 10^(n-1) <= value < 10^n
if k <= n <= 21:
return sign + s + "0" * (n - k)
if 0 < n <= 21:
return sign + s[:n] + "." + s[n:]
if -6 < n <= 0:
return sign + "0." + "0" * (-n) + s
e_out = n - 1
e_str = ("e+" if e_out >= 0 else "e-") + str(abs(e_out))
return sign + (s if k == 1 else s[0] + "." + s[1:]) + e_str


def _canonical_keys(keys):
"""Sort object keys by UTF-16 code units, matching RFC 8785 section 3.2.3
and the TS SDK (Array.prototype.sort compares UTF-16 code units). Python's
default str sort is by code point, which orders astral-plane (>= U+10000)
keys differently from surrogate-pair UTF-16 order."""
return sorted(keys, key=lambda k: k.encode("utf-16-be"))


def canonicalize(obj) -> str:
Expand All @@ -31,10 +82,7 @@ def canonicalize(obj) -> str:
import math
if math.isnan(obj) or math.isinf(obj):
raise ValueError(f"Cannot canonicalize {obj} — NaN/Infinity are not valid JSON per RFC 8259")
# Match TypeScript: JSON.stringify(1.0) produces "1", not "1.0"
if obj == int(obj):
return str(int(obj))
return json.dumps(obj)
return _es_number(obj)
if isinstance(obj, str):
# ensure_ascii=False to match the TypeScript SDK's JSON.stringify,
# which emits raw UTF-8 and does not \u-escape non-ASCII. Without
Expand All @@ -45,7 +93,7 @@ def canonicalize(obj) -> str:
return "[" + ",".join(canonicalize(item) for item in obj) + "]"
if isinstance(obj, dict):
pairs = []
for key in sorted(obj.keys()):
for key in _canonical_keys(obj.keys()):
val = obj[key]
if val is None:
continue
Expand Down Expand Up @@ -78,19 +126,17 @@ def canonicalize_jcs(obj) -> str:
import math
if math.isnan(obj) or math.isinf(obj):
raise ValueError(f"Cannot canonicalize {obj}")
if obj == int(obj):
return str(int(obj))
return json.dumps(obj)
return _es_number(obj)
if isinstance(obj, str):
return json.dumps(obj, ensure_ascii=False)
if isinstance(obj, list):
return "[" + ",".join(canonicalize_jcs(item) for item in obj) + "]"
if isinstance(obj, dict):
# Preserve null values in objects (unlike legacy canonicalize)
pairs = []
for key in sorted(obj.keys()):
for key in _canonical_keys(obj.keys()):
pairs.append(
json.dumps(key, ensure_ascii=False) + ":" + canonicalize_jcs(obj[key])
)
return "{" + ",".join(pairs) + "}"
return json.dumps(obj)
return json.dumps(obj, ensure_ascii=False)
22 changes: 12 additions & 10 deletions src/agent_passport/delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .crypto import sign, verify
from .canonical import canonicalize
from ._time import parse_iso_utc


def create_delegation(
Expand Down Expand Up @@ -81,12 +82,13 @@ def verify_delegation(delegation: dict) -> dict:
expires_at = delegation.get("expiresAt", "")
if expires_at:
try:
exp = datetime.fromisoformat(expires_at)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
expired = exp < datetime.now(timezone.utc)
expired = parse_iso_utc(expires_at) < datetime.now(timezone.utc)
except (ValueError, TypeError):
pass
# Fail closed: an expiresAt that is present but unparseable is
# treated as expired, not ignored. (parse_iso_utc handles the 'Z'
# form the SDK/TS reference emits on Python 3.9+.)
expired = True
errors.append(f"Unparseable expiresAt: {expires_at!r}")
if expired:
errors.append(f"Expired at {expires_at}")

Expand Down Expand Up @@ -176,13 +178,13 @@ def sub_delegate(
# parent's expiresAt when the parent carries one.
parent_expiry = None
parent_expires_at = parent.get("expiresAt")
if isinstance(parent_expires_at, str):
if isinstance(parent_expires_at, str) and parent_expires_at:
try:
parent_expiry = datetime.fromisoformat(parent_expires_at)
if parent_expiry.tzinfo is None:
parent_expiry = parent_expiry.replace(tzinfo=timezone.utc)
parent_expiry = parse_iso_utc(parent_expires_at)
except (ValueError, TypeError):
parent_expiry = None
# Fail closed: a parent whose expiry cannot be parsed is treated as
# already expired, so the child cannot outlive an unknowable bound.
parent_expiry = now
expiry = min(requested_expiry, parent_expiry) if parent_expiry is not None else requested_expiry

delegation = {
Expand Down
15 changes: 9 additions & 6 deletions src/agent_passport/passport.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .crypto import generate_key_pair, sign, verify
from .canonical import canonicalize
from ._time import parse_iso_utc

DEFAULT_EXPIRY_DAYS = 365

Expand Down Expand Up @@ -151,14 +152,16 @@ def update_passport(passport: dict, updates: dict, private_key: str) -> dict:


def is_expired(passport: dict) -> bool:
"""Check if a passport has expired."""
"""Check if a passport has expired.

Fail-closed: a passport whose expiresAt is present but unparseable is
treated as expired. parse_iso_utc accepts the 'Z' form the SDK/TS reference
emits on Python 3.9+ (bare datetime.fromisoformat only learned 'Z' in 3.11).
"""
expires_at = passport.get("expiresAt", "")
if not expires_at:
return False
try:
expiry = datetime.fromisoformat(expires_at)
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return expiry < datetime.now(timezone.utc)
return parse_iso_utc(expires_at) < datetime.now(timezone.utc)
except (ValueError, TypeError):
return False
return True
84 changes: 84 additions & 0 deletions tests/test_audit_fixes_2026_07_10.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright (c) 2026 Tymofii Pidlisnyi
# SPDX-License-Identifier: Apache-2.0
"""Regression tests for the 2026-07-10 audit fixes.

Covers: Z-timestamp expiry parsing + fail-closed, ECMAScript number
serialization, UTF-16 key ordering, and action_ref naive-timestamp rejection.
"""

from datetime import datetime, timedelta, timezone

import pytest

from agent_passport._time import parse_iso_utc
from agent_passport.canonical import _es_number, canonicalize_jcs, canonicalize
from agent_passport.delegation import create_delegation, verify_delegation
from agent_passport.passport import is_expired
from agent_passport.crypto import generate_key_pair
from agent_passport.action_ref import compute_action_ref


def test_parse_iso_utc_accepts_Z():
dt = parse_iso_utc("2026-01-01T00:00:00Z")
assert dt.tzinfo is not None and dt.utcoffset() == timedelta(0)


def test_expired_Z_delegation_is_rejected():
kp = generate_key_pair()
d = create_delegation(kp["publicKey"], "did:aps:recipient", ["scope:x"],
kp["privateKey"], expires_in_days=1)
# Force an already-past Z-form expiry (the TS/SDK standard shape).
d["expiresAt"] = "2000-01-01T00:00:00Z"
d["signature"] = "" # signature is checked separately; we assert the expiry gate
status = verify_delegation(d)
assert status["expired"] is True


def test_unparseable_expiry_fails_closed():
assert is_expired({"expiresAt": "not-a-timestamp"}) is True
kp = generate_key_pair()
d = create_delegation(kp["publicKey"], "did:aps:r", ["s:x"], kp["privateKey"])
d["expiresAt"] = "garbage"
assert verify_delegation(d)["expired"] is True


def test_valid_future_Z_passport_not_expired():
future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
assert is_expired({"expiresAt": future}) is False


# ECMAScript Number::toString table (validated against Node JSON.stringify).
ES_CASES = [
(1e21, "1e+21"), (1.5e21, "1.5e+21"), (1e-6, "0.000001"), (1e-7, "1e-7"),
(1e-8, "1e-8"), (0.1, "0.1"), (100.5, "100.5"), (1e16, "10000000000000000"),
(5e-324, "5e-324"), (1e308, "1e+308"), (-0.0001, "-0.0001"), (6.022e23, "6.022e+23"),
(0.0, "0"), (-0.0, "0"), (1.0, "1"),
]


@pytest.mark.parametrize("value,expected", ES_CASES)
def test_es_number(value, expected):
assert _es_number(value) == expected


def test_utf16_key_order_matches_ts():
# Empty string first, then digits, upper, lower, astral (D834...) before U+FF61.
obj = {"\U0001D306": 2, "": 1, "b": 3, "a": 4, "。": 5, "Z": 6, "10": 7, "2": 8}
out = canonicalize_jcs(obj)
assert out.index('"":') < out.index('"10":') < out.index('"2":') < out.index('"Z":')
assert out.index('"b":') < out.index('"\U0001D306":') < out.index('"。":')


def test_float_canonical_matches_es():
assert canonicalize_jcs({"a": 1e-7, "b": 1e21}) == '{"a":1e-7,"b":1e+21}'
assert canonicalize({"a": 0.000001}) == '{"a":0.000001}'


def test_action_ref_rejects_naive_timestamp():
with pytest.raises(ValueError):
compute_action_ref("did:aps:a", "act", ["s:r"], "2026-01-01T12:00:00")


def test_action_ref_accepts_zoned():
ref = compute_action_ref("did:aps:a", "act", ["s:r"], "2026-01-01T12:00:00Z")
assert len(ref) == 64 and all(c in "0123456789abcdef" for c in ref)
Loading