Skip to content

Commit 67a4151

Browse files
vvillait88claude
andcommitted
feat: add hash_operator_token helper
Canonical sha256 hex digest for plaintext operator tokens. Merchants hash them before storing in DB columns and before comparing against persisted hashes, so plaintext tokens never land in durable storage. Tests lock 6 fixtures with hardcoded digests as the cross-language contract with the Node sibling at @agent-score/commerce. Parametrized so multiple drifts surface independently rather than short-circuiting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f0337e commit 67a4151

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

agentscore_commerce/identity/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
shipping_state_allowed,
3636
)
3737
from agentscore_commerce.identity.signer import extract_x402_signer
38+
from agentscore_commerce.identity.tokens import hash_operator_token
3839
from agentscore_commerce.identity.types import (
3940
AgentIdentity,
4041
AgentMemoryHint,
@@ -138,6 +139,7 @@ def _load_asgi_middleware() -> tuple[Any, Any]:
138139
"denial_reason_to_body",
139140
"extract_x402_signer",
140141
"generate_ucp_signing_key",
142+
"hash_operator_token",
141143
"is_fixable_denial",
142144
"mpp_payment_handler",
143145
"run_gate_with_enforcement",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Operator-token hashing.
2+
3+
Plaintext operator tokens (``opc_...``) never persist on disk. Merchants hash
4+
them before storing in DB columns and before comparing against persisted hashes.
5+
This helper exposes the canonical hash so every consumer agrees on the shape.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import hashlib
11+
12+
13+
def hash_operator_token(plaintext: str) -> str:
14+
"""sha256 hex digest of a plaintext operator token.
15+
16+
Use at every persistence boundary (INSERT) AND every comparison boundary
17+
(SELECT WHERE operator_token_id = ...) so plaintext tokens never land in
18+
durable storage.
19+
"""
20+
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()

tests/test_tokens.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for ``agentscore_commerce.identity.tokens.hash_operator_token``.
2+
3+
The expected digests below are hardcoded — locked as the cross-language
4+
contract with the Node sibling at ``node-commerce/tests/identity/tokens.test.ts``.
5+
Both files reference the same fixture inputs and the same expected output bytes.
6+
A drift in either language (algorithm swap, encoding change, accidental truncation)
7+
fails that language's test against the locked digest.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pytest
13+
14+
from agentscore_commerce.identity import hash_operator_token
15+
16+
# Cross-language fixture inputs + expected digests. The digests are
17+
# sha256(<input>.encode("utf-8")).hexdigest() computed once and locked here so
18+
# the Python and Node sibling tests assert against identical bytes.
19+
#
20+
# Parametrized so each fixture gets its own test invocation: if multiple
21+
# fixtures drift simultaneously, every failure is reported (a for-loop inside
22+
# one test would short-circuit on the first failure).
23+
_FIXTURES = [
24+
("opc_test", "97c30e2a512b5968772c2930705bdafff4831d672556dce26c92b83f7e58508d"),
25+
("opc_cross_lang_fixture", "96690dd2659bc1e33227e943d5f8a526c7c95a0ede5775a1573abab6578ca8ec"),
26+
("opc_anything", "e6ba517ac96ee39190c4d703b2d968fec96e87827374e56095a2f443d870730d"),
27+
("opc_42", "731985dd676ea0702b3e6f6cbb107eaf467319e2801e6f953f08cbcc7dd71684"),
28+
# Non-ASCII fixture — UTF-8 encoding of "é" is 0xC3 0xA9; locks the encoding
29+
# contract so a future implementation that drops the explicit "utf-8" arg
30+
# still produces the same bytes.
31+
("opc_é", "c1dba11d60cbfc1264d115e07a74a0355b6a66ded4ee3f930024a1733ba6942f"),
32+
# Empty-string sha256 is a canonical value documented in many specs; locking
33+
# it here catches an implementation that silently rejects or transforms "".
34+
("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
35+
]
36+
37+
38+
@pytest.mark.parametrize(("plaintext", "expected"), _FIXTURES, ids=[repr(p) for p, _ in _FIXTURES])
39+
def test_known_digest_locked(plaintext: str, expected: str) -> None:
40+
"""Each fixture input maps to the locked cross-language digest."""
41+
assert hash_operator_token(plaintext) == expected
42+
43+
44+
def test_output_is_64_char_lowercase_hex() -> None:
45+
"""sha256 hex digests are always 64 characters of lowercase hex."""
46+
out = hash_operator_token("opc_anything")
47+
assert len(out) == 64
48+
assert out == out.lower()
49+
assert all(c in "0123456789abcdef" for c in out)
50+
51+
52+
def test_deterministic_across_calls() -> None:
53+
"""Same input always yields the same digest (no salt, no nonce)."""
54+
assert hash_operator_token("opc_42") == hash_operator_token("opc_42")
55+
56+
57+
def test_distinct_inputs_distinct_outputs() -> None:
58+
"""Different plaintexts produce different digests."""
59+
assert hash_operator_token("opc_a") != hash_operator_token("opc_b")

0 commit comments

Comments
 (0)