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: 2 additions & 0 deletions agentscore_commerce/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
shipping_state_allowed,
)
from agentscore_commerce.identity.signer import extract_x402_signer
from agentscore_commerce.identity.tokens import hash_operator_token
from agentscore_commerce.identity.types import (
AgentIdentity,
AgentMemoryHint,
Expand Down Expand Up @@ -138,6 +139,7 @@ def _load_asgi_middleware() -> tuple[Any, Any]:
"denial_reason_to_body",
"extract_x402_signer",
"generate_ucp_signing_key",
"hash_operator_token",
"is_fixable_denial",
"mpp_payment_handler",
"run_gate_with_enforcement",
Expand Down
20 changes: 20 additions & 0 deletions agentscore_commerce/identity/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Operator-token hashing.

Plaintext operator tokens (``opc_...``) never persist on disk. Merchants hash
them before storing in DB columns and before comparing against persisted hashes.
This helper exposes the canonical hash so every consumer agrees on the shape.
"""

from __future__ import annotations

import hashlib


def hash_operator_token(plaintext: str) -> str:
"""sha256 hex digest of a plaintext operator token.

Use at every persistence boundary (INSERT) AND every comparison boundary
(SELECT WHERE operator_token_id = ...) so plaintext tokens never land in
durable storage.
"""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
59 changes: 59 additions & 0 deletions tests/test_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for ``agentscore_commerce.identity.tokens.hash_operator_token``.

The expected digests below are hardcoded — locked as the cross-language
contract with the Node sibling at ``node-commerce/tests/identity/tokens.test.ts``.
Both files reference the same fixture inputs and the same expected output bytes.
A drift in either language (algorithm swap, encoding change, accidental truncation)
fails that language's test against the locked digest.
"""

from __future__ import annotations

import pytest

from agentscore_commerce.identity import hash_operator_token

# Cross-language fixture inputs + expected digests. The digests are
# sha256(<input>.encode("utf-8")).hexdigest() computed once and locked here so
# the Python and Node sibling tests assert against identical bytes.
#
# Parametrized so each fixture gets its own test invocation: if multiple
# fixtures drift simultaneously, every failure is reported (a for-loop inside
# one test would short-circuit on the first failure).
_FIXTURES = [
("opc_test", "97c30e2a512b5968772c2930705bdafff4831d672556dce26c92b83f7e58508d"),
("opc_cross_lang_fixture", "96690dd2659bc1e33227e943d5f8a526c7c95a0ede5775a1573abab6578ca8ec"),
("opc_anything", "e6ba517ac96ee39190c4d703b2d968fec96e87827374e56095a2f443d870730d"),
("opc_42", "731985dd676ea0702b3e6f6cbb107eaf467319e2801e6f953f08cbcc7dd71684"),
# Non-ASCII fixture — UTF-8 encoding of "é" is 0xC3 0xA9; locks the encoding
# contract so a future implementation that drops the explicit "utf-8" arg
# still produces the same bytes.
("opc_é", "c1dba11d60cbfc1264d115e07a74a0355b6a66ded4ee3f930024a1733ba6942f"),
# Empty-string sha256 is a canonical value documented in many specs; locking
# it here catches an implementation that silently rejects or transforms "".
("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
]


@pytest.mark.parametrize(("plaintext", "expected"), _FIXTURES, ids=[repr(p) for p, _ in _FIXTURES])
def test_known_digest_locked(plaintext: str, expected: str) -> None:
"""Each fixture input maps to the locked cross-language digest."""
assert hash_operator_token(plaintext) == expected


def test_output_is_64_char_lowercase_hex() -> None:
"""sha256 hex digests are always 64 characters of lowercase hex."""
out = hash_operator_token("opc_anything")
assert len(out) == 64
assert out == out.lower()
assert all(c in "0123456789abcdef" for c in out)


def test_deterministic_across_calls() -> None:
"""Same input always yields the same digest (no salt, no nonce)."""
assert hash_operator_token("opc_42") == hash_operator_token("opc_42")


def test_distinct_inputs_distinct_outputs() -> None:
"""Different plaintexts produce different digests."""
assert hash_operator_token("opc_a") != hash_operator_token("opc_b")
Loading