Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
7.4 KiB
Python
201 lines
7.4 KiB
Python
"""F.8 security hardening: bcrypt + legacy SHA-256 upgrade-on-verify.
|
|
|
|
Ensures:
|
|
|
|
* Fresh keys created via ``create_key`` are stored as bcrypt hashes
|
|
(``$2`` prefix). No new SHA-256 hashes can land.
|
|
* Keys whose storage file carries legacy SHA-256 hashes still validate
|
|
(no customer lock-out), but the moment they validate once they are
|
|
re-hashed with bcrypt and persisted — so the file on disk
|
|
progressively drifts to bcrypt-only.
|
|
* Legacy-hash verification uses ``hmac.compare_digest`` (constant-time)
|
|
rather than ``==`` to avoid a timing oracle on legacy entries.
|
|
* Corrupt or truncated bcrypt hashes don't crash the verifier — they
|
|
return False so the caller emits a uniform 401.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from core.api_keys import APIKey, APIKeyManager
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_storage():
|
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
|
|
storage_path = f.name
|
|
yield storage_path
|
|
Path(storage_path).unlink(missing_ok=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(temp_storage):
|
|
return APIKeyManager(storage_path=temp_storage)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fresh keys always bcrypt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFreshKeysAreBcrypt:
|
|
@pytest.mark.unit
|
|
def test_created_key_stores_bcrypt_hash(self, manager):
|
|
result = manager.create_key(project_id="*", scope="read")
|
|
key = manager.keys[result["key_id"]]
|
|
assert APIKeyManager._is_bcrypt_hash(key.key_hash)
|
|
|
|
@pytest.mark.unit
|
|
def test_created_key_hash_is_not_sha256(self, manager):
|
|
plain = "cmp_test_plaintext_not_sha256"
|
|
# Grab the output of _hash_key directly and compare against what a
|
|
# SHA-256 would produce — they must differ.
|
|
bcrypt_hash = manager._hash_key(plain)
|
|
assert bcrypt_hash.startswith("$2")
|
|
assert bcrypt_hash != hashlib.sha256(plain.encode()).hexdigest()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy SHA-256 validation + upgrade
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _install_legacy_key(manager: APIKeyManager, raw_key: str, *, scope: str = "read") -> str:
|
|
"""Inject a legacy SHA-256-hashed key directly into the manager.
|
|
|
|
Simulates a keys.json file that was written before F.8 landed.
|
|
Returns the key_id.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
key_id = "legacy-" + uuid.uuid4().hex[:8]
|
|
legacy_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
manager.keys[key_id] = APIKey(
|
|
key_id=key_id,
|
|
key_hash=legacy_hash,
|
|
project_id="*",
|
|
scope=scope,
|
|
created_at=datetime.now().isoformat(),
|
|
)
|
|
manager._save_keys()
|
|
return key_id
|
|
|
|
|
|
class TestLegacySha256Validation:
|
|
@pytest.mark.unit
|
|
def test_legacy_sha256_hash_still_validates(self, manager):
|
|
raw = "cmp_legacy_customer_key_1234567890"
|
|
_install_legacy_key(manager, raw)
|
|
|
|
assert manager.validate_key(raw, project_id="anything", required_scope="read") is not None
|
|
|
|
@pytest.mark.unit
|
|
def test_legacy_hash_rejects_wrong_key(self, manager):
|
|
_install_legacy_key(manager, "cmp_real_one_1234")
|
|
assert manager.validate_key("cmp_wrong", project_id="x", required_scope="read") is None
|
|
|
|
@pytest.mark.unit
|
|
def test_verify_uses_constant_time_on_legacy_path(self, manager):
|
|
"""Sanity check: _verify_key returns the same type regardless of
|
|
how many leading characters of the SHA-256 hex happen to match."""
|
|
raw = "cmp_constant_time_check_key"
|
|
_install_legacy_key(manager, raw)
|
|
legacy_hash = hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
# Flip the very last byte of the hash — must still be rejected.
|
|
tampered = legacy_hash[:-1] + ("0" if legacy_hash[-1] != "0" else "1")
|
|
assert manager._verify_key(raw, tampered) is False
|
|
# Real hash still passes.
|
|
assert manager._verify_key(raw, legacy_hash) is True
|
|
|
|
|
|
class TestLegacyHashUpgradeOnVerify:
|
|
@pytest.mark.unit
|
|
def test_validate_key_upgrades_legacy_hash_to_bcrypt(self, manager):
|
|
raw = "cmp_upgrade_target_1234567890"
|
|
key_id = _install_legacy_key(manager, raw)
|
|
|
|
# Before validation: legacy SHA-256 hash.
|
|
assert not APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
|
|
|
|
# Validate → upgrade path runs.
|
|
assert manager.validate_key(raw, project_id="*", required_scope="read") == key_id
|
|
|
|
# After validation: bcrypt.
|
|
assert APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
|
|
|
|
@pytest.mark.unit
|
|
def test_get_key_by_token_also_upgrades(self, manager):
|
|
raw = "cmp_get_by_token_upgrade_key"
|
|
key_id = _install_legacy_key(manager, raw)
|
|
|
|
assert manager.get_key_by_token(raw) is not None
|
|
assert APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
|
|
|
|
@pytest.mark.unit
|
|
def test_upgrade_is_persisted_to_disk(self, manager, temp_storage):
|
|
raw = "cmp_persistent_upgrade_key"
|
|
key_id = _install_legacy_key(manager, raw)
|
|
|
|
manager.validate_key(raw, project_id="*", required_scope="read")
|
|
|
|
on_disk = json.loads(Path(temp_storage).read_text())
|
|
assert APIKeyManager._is_bcrypt_hash(on_disk[key_id]["key_hash"])
|
|
|
|
@pytest.mark.unit
|
|
def test_upgrade_keeps_key_valid_after(self, manager):
|
|
raw = "cmp_double_validate_key"
|
|
_install_legacy_key(manager, raw)
|
|
|
|
# First call upgrades.
|
|
assert manager.validate_key(raw, project_id="*", required_scope="read") is not None
|
|
# Second call uses the bcrypt path.
|
|
assert manager.validate_key(raw, project_id="*", required_scope="read") is not None
|
|
|
|
@pytest.mark.unit
|
|
def test_upgrade_does_not_touch_bcrypt_keys(self, manager):
|
|
"""Keys that are already bcrypt-hashed must not be re-hashed every
|
|
verify (bcrypt with fresh salt would invalidate later calls)."""
|
|
result = manager.create_key(project_id="*", scope="read")
|
|
raw = result["key"] # raw plaintext key (create_key returns ``key``)
|
|
before = manager.keys[result["key_id"]].key_hash
|
|
|
|
manager.validate_key(raw, project_id="*", required_scope="read")
|
|
|
|
after = manager.keys[result["key_id"]].key_hash
|
|
assert before == after
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Robustness: corrupt / truncated bcrypt hashes must not crash
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCorruptBcryptHandling:
|
|
@pytest.mark.unit
|
|
def test_truncated_bcrypt_returns_false(self, manager):
|
|
truncated = "$2b$12$truncated"
|
|
assert manager._verify_key("any", truncated) is False
|
|
|
|
@pytest.mark.unit
|
|
def test_non_bcrypt_non_matching_returns_false(self, manager):
|
|
assert manager._verify_key("any", "garbage-not-a-hash") is False
|
|
|
|
@pytest.mark.unit
|
|
def test_is_bcrypt_hash_classifier(self):
|
|
# Positive cases.
|
|
assert APIKeyManager._is_bcrypt_hash("$2b$12$abc.def")
|
|
assert APIKeyManager._is_bcrypt_hash("$2a$10$x")
|
|
assert APIKeyManager._is_bcrypt_hash("$2y$12$foo")
|
|
# Negative cases: SHA-256 hex and garbage.
|
|
assert not APIKeyManager._is_bcrypt_hash("a" * 64)
|
|
assert not APIKeyManager._is_bcrypt_hash("")
|
|
assert not APIKeyManager._is_bcrypt_hash("not-a-hash")
|