diff --git a/backend/packages/harness/deerflow/auth/__init__.py b/backend/packages/harness/deerflow/auth/__init__.py new file mode 100644 index 00000000..392d4b29 --- /dev/null +++ b/backend/packages/harness/deerflow/auth/__init__.py @@ -0,0 +1,13 @@ +"""Auth primitives shared by the headless API (Stage 1). + +Lives in the ``deerflow`` (harness) layer because both the persistence +hot path (``ApiKeyRepository.get_active_by_hash``) and the app-layer +mint endpoint need token generation/hashing, and the harness boundary +forbids ``deerflow`` importing ``app``. +""" + +from __future__ import annotations + +from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix + +__all__ = ["GeneratedKey", "generate_api_key", "hash_api_key", "split_prefix"] diff --git a/backend/packages/harness/deerflow/auth/tokens.py b/backend/packages/harness/deerflow/auth/tokens.py new file mode 100644 index 00000000..efa976aa --- /dev/null +++ b/backend/packages/harness/deerflow/auth/tokens.py @@ -0,0 +1,51 @@ +"""API key generation, hashing, and prefix extraction (Stage 1 PR1). + +Format is irreversible once business systems integrate (spec D5): +``dfk_live_<24>`` / ``dfk_test_<24>``. The public ``key_prefix`` is the +first 16 chars (``dfk_live_`` + 7 random) and is stored UNIQUE for audit +logging; the DB only ever stores ``sha256(plaintext)`` hex, never the +plaintext. +""" + +from __future__ import annotations + +import hashlib +import secrets +from dataclasses import dataclass +from typing import Literal + +_PREFIX_LEN = 16 +# token_urlsafe(18) yields ceil(18 * 4 / 3) = 24 url-safe chars. +_RANDOM_BYTES = 18 + + +@dataclass(frozen=True) +class GeneratedKey: + """A freshly minted key. ``plaintext`` is returned to the caller + exactly once; only ``prefix`` + ``key_hash`` are persisted.""" + + plaintext: str + prefix: str + key_hash: str + + +def hash_api_key(plaintext: str) -> str: + """Return the sha-256 hex digest of a plaintext token.""" + return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + + +def split_prefix(plaintext: str) -> str: + """Return the public, loggable prefix (first 16 chars) of a token.""" + return plaintext[:_PREFIX_LEN] + + +def generate_api_key(env: Literal["live", "test"]) -> GeneratedKey: + """Generate a new API key for the given environment. + + Raises ``ValueError`` for any env other than ``"live"`` / ``"test"``. + """ + if env not in ("live", "test"): + raise ValueError(f"env must be 'live' or 'test', got {env!r}") + random_part = secrets.token_urlsafe(_RANDOM_BYTES) + plaintext = f"dfk_{env}_{random_part}" + return GeneratedKey(plaintext=plaintext, prefix=split_prefix(plaintext), key_hash=hash_api_key(plaintext)) diff --git a/backend/tests/test_tokens.py b/backend/tests/test_tokens.py new file mode 100644 index 00000000..025897d6 --- /dev/null +++ b/backend/tests/test_tokens.py @@ -0,0 +1,56 @@ +"""Tests for deerflow.auth.tokens (Stage 1 PR1). + +API key 格式 / 哈希 / prefix 截取。格式锁定 dfk_{live,test}_<24>, +prefix = 前 16 字符(含 dfk_live_),sha256 hex 存储(spec D5)。 +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix + + +def test_generate_live_key_shape(): + key = generate_api_key("live") + assert isinstance(key, GeneratedKey) + assert key.plaintext.startswith("dfk_live_") + # dfk_live_ (9) + token_urlsafe(18) (24) = 33 chars + assert len(key.plaintext) == 33 + assert key.prefix == key.plaintext[:16] + assert len(key.prefix) == 16 + assert key.key_hash == hashlib.sha256(key.plaintext.encode("utf-8")).hexdigest() + assert len(key.key_hash) == 64 + + +def test_generate_test_key_prefix_env(): + key = generate_api_key("test") + assert key.plaintext.startswith("dfk_test_") + assert key.prefix.startswith("dfk_test_") + + +def test_generate_rejects_bad_env(): + with pytest.raises(ValueError): + generate_api_key("prod") # type: ignore[arg-type] + + +def test_two_keys_are_unique(): + a = generate_api_key("live") + b = generate_api_key("live") + assert a.plaintext != b.plaintext + assert a.key_hash != b.key_hash + + +def test_hash_is_deterministic_and_not_reversible(): + plaintext = "dfk_live_abcdefghijklmnopqrstuvwx" + h1 = hash_api_key(plaintext) + h2 = hash_api_key(plaintext) + assert h1 == h2 + assert h1 != plaintext + assert len(h1) == 64 + + +def test_split_prefix_takes_first_16(): + assert split_prefix("dfk_live_abcdefghijklmnop") == "dfk_live_abcdefg"