diff --git a/backend/api/content_checksum_tool.py b/backend/api/content_checksum_tool.py new file mode 100644 index 000000000..c6a081df4 --- /dev/null +++ b/backend/api/content_checksum_tool.py @@ -0,0 +1,137 @@ +"""Bounded cryptographic checksums for exact UTF-8 text content. + +This module owns the checksum algorithm allowlist and registers the tool with +Naruon's existing deterministic tool catalog. Its digests compare content +bytes; they are not proof of sender identity or message authenticity. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable +from typing import Any + +from api.tools import ToolInfo, registry + +MAX_CONTENT_BYTES = 1_048_576 +SECURITY_NOTE = ( + "Use this digest to compare exact content bytes; it does not authenticate " + "the sender or replace a MAC/signature." +) + + +class ContentChecksumError(ValueError): + """Expected checksum validation failure with a stable machine error code.""" + + def __init__(self, message: str, *, error_code: str) -> None: + """Initialize a customer-safe validation failure and deterministic code.""" + super().__init__(message) + self.error_code = error_code + + +def _sha256(payload: bytes) -> str: + """Return the SHA-256 hexadecimal digest for ``payload``.""" + return hashlib.sha256(payload).hexdigest() + + +def _sha3_256(payload: bytes) -> str: + """Return the SHA-3-256 hexadecimal digest for ``payload``.""" + return hashlib.sha3_256(payload).hexdigest() + + +def _blake2b_256(payload: bytes) -> str: + """Return the 256-bit BLAKE2b hexadecimal digest for ``payload``.""" + return hashlib.blake2b(payload, digest_size=32).hexdigest() + + +_HASHERS: dict[str, Callable[[bytes], str]] = { + "sha256": _sha256, + "sha3_256": _sha3_256, + "blake2b_256": _blake2b_256, +} + +_ALGORITHM_ALIASES = { + "sha-256": "sha256", + "sha3-256": "sha3_256", + "sha-3-256": "sha3_256", + "blake2b-256": "blake2b_256", +} + + +def _normalize_algorithm_code(value: str) -> str: + """Map documented human-facing labels to the bounded canonical codes.""" + normalized = value.lower() + return _ALGORITHM_ALIASES.get(normalized, normalized) + + +async def content_checksum_handler(params: dict[str, Any]) -> dict[str, Any]: + """Hash exact UTF-8 bytes with an allowlisted modern checksum algorithm. + + The input is never Unicode-normalized, so the digest compares the exact + byte representation Naruon received. Inputs larger than one MiB after + UTF-8 encoding are rejected before hashing. Canonical algorithm codes and + the documented SHA/BLAKE2 display labels resolve to the same bounded + allowlist; legacy or out-of-contract algorithms still fail closed. + + Args: + params: Validated tool parameters containing ``text`` and ``algorithm``. + + Returns: + A deterministic checksum receipt with the canonical algorithm code, + digest, byte length, encoding, and an authenticity warning. + + Raises: + ContentChecksumError: If the algorithm is not allowlisted, the text + cannot be represented as valid UTF-8, or the encoded content exceeds + one MiB. Each expected failure carries a stable machine-readable + ``error_code``. + """ + text = params["text"] + algorithm = _normalize_algorithm_code(params["algorithm"]) + if algorithm not in _HASHERS: + raise ContentChecksumError( + "Unsupported checksum algorithm; choose sha256, sha3_256, or blake2b_256", + error_code="unsupported_checksum_algorithm", + ) + + try: + payload = text.encode("utf-8") + except UnicodeEncodeError as exc: + raise ContentChecksumError( + "Content must contain valid Unicode scalar values", + error_code="content_checksum_invalid_utf8", + ) from exc + if len(payload) > MAX_CONTENT_BYTES: + raise ContentChecksumError( + f"Content exceeds {MAX_CONTENT_BYTES} UTF-8 bytes", + error_code="content_checksum_payload_too_large", + ) + + return { + "algorithm_code": algorithm, + "digest_hex": _HASHERS[algorithm](payload), + "byte_length": len(payload), + "encoding_code": "utf-8", + "security_note": SECURITY_NOTE, + } + + +def register_content_checksum_tool() -> None: + """Register the checksum generator once in Naruon's built-in tool catalog.""" + if registry.get("content_checksum_generator") is not None: + return + + registry.register( + ToolInfo( + code="content_checksum_generator", + name="Content checksum generator", + description=( + "Compare exact UTF-8 content using SHA-256, SHA-3-256, or " + "BLAKE2b-256. Choose an algorithm, then compare the returned " + "digest with the expected value." + ), + category="유틸리티", + parameters={"text": "string", "algorithm": "string"}, + ), + content_checksum_handler, + ) \ No newline at end of file diff --git a/backend/api/tools.py b/backend/api/tools.py index bd15abfac..94766d740 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -20,7 +20,7 @@ ) from services.llm_provider_urls import build_pinned_https_async_client from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, model_serializer router = APIRouter(prefix="/api", tags=["tools"]) logger = logging.getLogger(__name__) @@ -125,9 +125,24 @@ class ExecuteRequest(BaseModel): class ExecuteResponse(BaseModel): + """Stable public envelope returned by tool execution endpoints.""" + status: str = Field(..., description="실행 상태 (예: success, failed)") result: Any = Field(..., description="실행 결과 데이터") message: Optional[str] = Field(default=None, description="결과 메시지") + error_code: Optional[str] = Field( + default=None, description="예상된 실패의 안정적인 기계 판독 오류 코드" + ) + + @model_serializer(mode="wrap") + def _serialize_response( + self, handler: SerializerFunctionWrapHandler + ) -> dict[str, Any]: + """Omit an absent error code while preserving legacy null result fields.""" + payload = handler(self) + if self.error_code is None: + payload.pop("error_code", None) + return payload class ToolRegistry: @@ -706,6 +721,8 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]: "합니다", } ) + + def _normalize_analysis_text(value: str) -> str: """Normalize user text for deterministic, multilingual rule matching.""" if len(value) > ANALYSIS_TEXT_MAX_CHARS: @@ -769,7 +786,6 @@ async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]: ) - @router.get("/tools", response_model=list[ToolInfo]) def get_tools() -> list[ToolInfo]: """ @@ -889,4 +905,5 @@ async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse: status="failed", result=None, message=_safe_tool_failure_message(e), + error_code=getattr(e, "error_code", None), ) diff --git a/backend/main.py b/backend/main.py index 51b054dbf..6a854e869 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,6 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from api.auth import get_auth_context, preload_oidc_jwks +from api.content_checksum_tool import register_content_checksum_tool from api.search import router as search_router from api.llm import router as llm_router from api.calendar import router as calendar_router @@ -42,6 +43,8 @@ from services.reply_sla_scheduler import ReplySlaScheduler from prometheus_fastapi_instrumentator import Instrumentator +register_content_checksum_tool() + imap_worker = ImapSyncWorker() pop3_worker = Pop3SyncWorker() reply_sla_scheduler = ReplySlaScheduler() diff --git a/backend/tests/test_content_checksum_api.py b/backend/tests/test_content_checksum_api.py new file mode 100644 index 000000000..eefbd4a21 --- /dev/null +++ b/backend/tests/test_content_checksum_api.py @@ -0,0 +1,165 @@ +"""Public API contract tests for the bounded content-checksum tool.""" + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import time + +from fastapi.testclient import TestClient + +os.environ.setdefault("AUTH_SESSION_HMAC_SECRET", secrets.token_urlsafe(48)) + +from main import app + + +EXPECTED_SHA256_ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + + +def _base64url_encode(raw: bytes) -> str: + """Encode one JWT segment without padding.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _signed_session_token() -> str: + """Create a real short-lived HMAC session accepted by the private tools API.""" + now = int(time.time()) + header_segment = _base64url_encode( + json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode( + "utf-8" + ) + ) + payload_segment = _base64url_encode( + json.dumps( + { + "ver": 1, + "iss": "naruon-control-plane", + "aud": "naruon-api", + "sub": "checksum-contract-user", + "role": "member", + "org": "checksum-contract-org", + "groups": [], + "workspace": "workspace-checksum-contract-org", + "iat": now, + "exp": now + 300, + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ) + signing_input = f"{header_segment}.{payload_segment}" + signature = hmac.new( + os.environ["AUTH_SESSION_HMAC_SECRET"].encode("utf-8"), + signing_input.encode("ascii"), + hashlib.sha256, + ).digest() + return f"{signing_input}.{_base64url_encode(signature)}" + + +def _tampered_session_token() -> str: + """Return a structurally valid session token with a deliberately forged signature.""" + token = _signed_session_token() + header_segment, payload_segment, signature_segment = token.split(".") + signature_padding = "=" * (-len(signature_segment) % 4) + signature = bytearray( + base64.urlsafe_b64decode(signature_segment + signature_padding) + ) + signature[0] ^= 0x01 + return f"{header_segment}.{payload_segment}.{_base64url_encode(bytes(signature))}" + + +def test_content_checksum_api_executes_authenticated_request() -> None: + """Startup registration and the authenticated execute route must work together.""" + with TestClient(app) as client: + response = client.post( + "/api/tools/content_checksum_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "abc", "algorithm": "sha256"}}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "success" + assert payload["result"]["digest_hex"] == EXPECTED_SHA256_ABC + assert payload["result"]["byte_length"] == 3 + assert payload["message"] == "Execution successful" + + +def test_content_checksum_api_accepts_advertised_sha256_label() -> None: + """The catalog's human-facing SHA-256 label must execute and return the canonical code.""" + with TestClient(app) as client: + response = client.post( + "/api/tools/content_checksum_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "abc", "algorithm": "SHA-256"}}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "success" + assert payload["result"]["algorithm_code"] == "sha256" + assert payload["result"]["digest_hex"] == EXPECTED_SHA256_ABC + + +def test_content_checksum_api_rejects_unauthenticated_request() -> None: + """The checksum execute route must retain the generic tools auth boundary.""" + with TestClient(app) as client: + response = client.post( + "/api/tools/content_checksum_generator/execute", + json={"parameters": {"text": "abc", "algorithm": "sha256"}}, + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} + + +def test_content_checksum_api_rejects_forged_signed_session() -> None: + """The checksum route must reject a structurally valid token with a forged signature.""" + with TestClient(app) as client: + response = client.post( + "/api/tools/content_checksum_generator/execute", + headers={"Authorization": f"Bearer {_tampered_session_token()}"}, + json={"parameters": {"text": "abc", "algorithm": "sha256"}}, + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} + + +def test_content_checksum_api_maps_invalid_algorithm_to_execute_failure() -> None: + """Invalid tool input must expose a stable machine-readable failure code.""" + with TestClient(app) as client: + response = client.post( + "/api/tools/content_checksum_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "abc", "algorithm": "md5"}}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "failed" + assert payload["result"] is None + assert payload["error_code"] == "unsupported_checksum_algorithm" + assert "Unsupported checksum algorithm" in payload["message"] + + +def test_content_checksum_api_maps_invalid_utf8_to_execute_failure() -> None: + """Escaped lone surrogates must reach the handler and fail with a stable code.""" + raw_body = b'{"parameters":{"text":"\\ud800","algorithm":"sha256"}}' + with TestClient(app) as client: + response = client.post( + "/api/tools/content_checksum_generator/execute", + headers={ + "Authorization": f"Bearer {_signed_session_token()}", + "Content-Type": "application/json", + }, + content=raw_body, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "failed" + assert payload["result"] is None + assert payload["error_code"] == "content_checksum_invalid_utf8" \ No newline at end of file diff --git a/backend/tests/test_content_checksum_tool.py b/backend/tests/test_content_checksum_tool.py new file mode 100644 index 000000000..91531025f --- /dev/null +++ b/backend/tests/test_content_checksum_tool.py @@ -0,0 +1,216 @@ +"""Regression tests for Naruon's bounded content-checksum tool.""" + +import hashlib + +import pytest + +from api.content_checksum_tool import ( + ContentChecksumError, + register_content_checksum_tool, +) +from api.tools import registry +from main import app + + +SECURITY_NOTE = ( + "Use this digest to compare exact content bytes; it does not authenticate " + "the sender or replace a MAC/signature." +) + + +def test_application_bootstrap_registers_content_checksum_tool() -> None: + """Loading the FastAPI application must expose the built-in checksum tool.""" + assert app is not None + assert registry.get("content_checksum_generator") is not None + + +def test_content_checksum_registration_is_idempotent() -> None: + """Repeated startup registration must preserve the existing catalog entry.""" + original = registry.get("content_checksum_generator") + + assert original is not None + register_content_checksum_tool() + + assert registry.get("content_checksum_generator") is original + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("algorithm", "expected_digest"), + [ + ( + "sha256", + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ( + "sha3_256", + "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532", + ), + ( + "blake2b_256", + "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319", + ), + ], +) +async def test_content_checksum_generator_matches_published_vectors( + algorithm: str, + expected_digest: str, +) -> None: + """The catalog tool must return stable standards-based digests for exact bytes.""" + tool = registry.get("content_checksum_generator") + + assert tool is not None + assert tool.parameters == {"text": "string", "algorithm": "string"} + + result = await registry.invoke_tool( + "content_checksum_generator", + {"text": "abc", "algorithm": algorithm}, + ) + + assert result == { + "algorithm_code": algorithm, + "digest_hex": expected_digest, + "byte_length": 3, + "encoding_code": "utf-8", + "security_note": SECURITY_NOTE, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("algorithm", "expected_code"), + [ + ("SHA-256", "sha256"), + ("SHA-3-256", "sha3_256"), + ("SHA3-256", "sha3_256"), + ("BLAKE2b-256", "blake2b_256"), + ], +) +async def test_content_checksum_generator_accepts_advertised_algorithm_labels( + algorithm: str, + expected_code: str, +) -> None: + """Human-facing algorithm labels in the catalog must execute as canonical codes.""" + result = await registry.invoke_tool( + "content_checksum_generator", + {"text": "abc", "algorithm": algorithm}, + ) + + assert result["algorithm_code"] == expected_code + + +@pytest.mark.asyncio +async def test_content_checksum_generator_hashes_empty_sha256_input() -> None: + """Empty UTF-8 content is valid input and must produce the published SHA-256 digest.""" + result = await registry.invoke_tool( + "content_checksum_generator", + {"text": "", "algorithm": "sha256"}, + ) + + assert result == { + "algorithm_code": "sha256", + "digest_hex": ( + "e3b0c44298fc1c149afbf4c8996fb924" + "27ae41e4649b934ca495991b7852b855" + ), + "byte_length": 0, + "encoding_code": "utf-8", + "security_note": SECURITY_NOTE, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("algorithm", ["sha256", "sha3_256", "blake2b_256"]) +async def test_content_checksum_generator_matches_incremental_utf8_chunk_reference( + algorithm: str, +) -> None: + """One-shot tool output must match byte chunks split inside UTF-8 code points.""" + payload = "Naruon 이메일 증거🙂\nsecond chunk".encode("utf-8") + multibyte_start = payload.index("이".encode("utf-8")) + emoji_start = payload.index("🙂".encode("utf-8")) + chunks = [ + payload[: multibyte_start + 1], + payload[multibyte_start + 1 : emoji_start + 2], + payload[emoji_start + 2 :], + ] + text = b"".join(chunks).decode("utf-8") + if algorithm == "blake2b_256": + reference = hashlib.blake2b(digest_size=32) + else: + reference = hashlib.new(algorithm) + for chunk in chunks: + reference.update(chunk) + + result = await registry.invoke_tool( + "content_checksum_generator", + {"text": text, "algorithm": algorithm}, + ) + + assert result["digest_hex"] == reference.hexdigest() + assert result["byte_length"] == len(payload) + + +@pytest.mark.asyncio +async def test_content_checksum_generator_hashes_exact_utf8_without_normalizing() -> ( + None +): + """Canonically equivalent Unicode strings must remain distinct exact-byte inputs.""" + composed = await registry.invoke_tool( + "content_checksum_generator", + {"text": "é", "algorithm": "sha256"}, + ) + decomposed = await registry.invoke_tool( + "content_checksum_generator", + {"text": "e\u0301", "algorithm": "sha256"}, + ) + + assert composed["byte_length"] == 2 + assert composed["digest_hex"] == ( + "4a99557e4033c3539de2eb65472017cad5f9557f7a0625a09f1c3f6e2ba69c4c" + ) + assert decomposed["byte_length"] == 3 + assert decomposed["digest_hex"] == ( + "bf12767b0f2a56b2190075bae8169f656e3ce8d6357d4aff184bc6c7ea48f9f6" + ) + assert composed["digest_hex"] != decomposed["digest_hex"] + + +@pytest.mark.asyncio +async def test_content_checksum_generator_rejects_invalid_utf8_scalar_input() -> None: + """A lone surrogate must fail with the checksum tool's stable validation code.""" + with pytest.raises(ContentChecksumError) as exc_info: + await registry.invoke_tool( + "content_checksum_generator", + {"text": "\ud800", "algorithm": "sha256"}, + ) + + assert exc_info.value.error_code == "content_checksum_invalid_utf8" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("algorithm", ["sha1", "md5", "sha512", "blake2b", ""]) +async def test_content_checksum_generator_rejects_unapproved_algorithm_names( + algorithm: str, +) -> None: + """Legacy or out-of-contract algorithm names must fail closed.""" + with pytest.raises(ValueError, match="Unsupported checksum algorithm"): + await registry.invoke_tool( + "content_checksum_generator", + {"text": "abc", "algorithm": algorithm}, + ) + + +@pytest.mark.asyncio +async def test_content_checksum_generator_enforces_utf8_byte_limit() -> None: + """The one-mebibyte boundary is measured after UTF-8 encoding.""" + accepted = await registry.invoke_tool( + "content_checksum_generator", + {"text": "a" * 1_048_576, "algorithm": "sha256"}, + ) + assert accepted["byte_length"] == 1_048_576 + + with pytest.raises(ValueError, match="Content exceeds 1048576 UTF-8 bytes"): + await registry.invoke_tool( + "content_checksum_generator", + {"text": "é" * 524_289, "algorithm": "sha256"}, + ) \ No newline at end of file diff --git a/docs/adr/0007-bounded-content-checksum-surface.md b/docs/adr/0007-bounded-content-checksum-surface.md new file mode 100644 index 000000000..893a3f6bc --- /dev/null +++ b/docs/adr/0007-bounded-content-checksum-surface.md @@ -0,0 +1,58 @@ +# ADR-0007: Bound the customer content-checksum algorithm surface + +**Status:** Proposed + +**Date:** 2026-08-15 + +**Decision owner:** Naruon maintainers + +**Capability maturity:** deterministic tool contract proposed on an unmerged branch; production authority requires protected-branch integration and verification + +**Scope:** Naruon's customer-facing deterministic content-checksum utility only. This ADR does not define sender authentication, signatures, password hashing, key derivation, or external artifact-signing policy. + +**Related issue:** #1247 + +## Context + +A checksum utility is useful for comparing exported email/document text, audit evidence, and workflow payloads, but an unconstrained generic hash selector creates two avoidable product risks. First, legacy algorithms such as MD5 or SHA-1 can be misread as recommended security controls. Second, digest output can be mistaken for proof of who produced a value even though an unkeyed hash authenticates neither sender nor origin. + +Naruon's deterministic tool registry already supplies a stable execution boundary. The smallest defensible slice is therefore an explicit modern algorithm allowlist with bounded exact-byte input and a machine-visible authenticity warning, rather than a wrapper over arbitrary `hashlib` names. + +A later review exposed a presentation mismatch: the catalog described standard human-facing names such as `SHA-256`, but the handler accepted only internal codes such as `sha256`. A user entering the advertised label could therefore receive an unsupported-algorithm failure even though they had selected the intended algorithm. The repair treats only the documented display spellings as aliases of the same three algorithms; it does not expand the cryptographic surface. + +## Decision + +1. The normal customer surface contains exactly SHA-256, SHA3-256, and BLAKE2b-256. Canonical response codes remain `sha256`, `sha3_256`, and `blake2b_256`. +2. Input accepts those canonical codes case-insensitively plus the documented display spellings `SHA-256`, `SHA3-256`/`SHA-3-256`, and `BLAKE2b-256`; each display spelling is normalized to the corresponding canonical response code. +3. MD5, SHA-1, SHA-512, generic BLAKE2b, unknown names, and unlisted aliases fail closed. No free-form `hashlib` name guessing is performed. +4. Text is encoded as UTF-8 exactly as supplied and is not Unicode-normalized before hashing. +5. One invocation accepts at most 1,048,576 encoded bytes so the generic tool endpoint cannot become an unbounded hashing sink. +6. The result records the canonical algorithm code, hexadecimal digest, encoded byte length, encoding, and an explicit warning that the digest does not authenticate a sender or replace a MAC/signature. +7. The implementation uses the Python standard library and remains deterministic and independent of model judgment or LLM credentials. +8. A future legacy compatibility mode requires a separate reviewed decision with an explicit non-security acknowledgement; this ADR does not authorize one. + +## Consequences + +- Buyers can enter the algorithm spelling shown by the catalog without weakening the underlying allowlist. +- API consumers receive one stable canonical algorithm code regardless of the accepted display spelling. +- Buyers can compare exact content evidence without being steered toward a legacy digest. +- Canonically equivalent Unicode strings may intentionally produce different digests when their UTF-8 byte sequences differ; this is correct for exact-byte evidence. +- Callers that need origin authenticity must select an authenticated construction outside this tool. +- Algorithm expansion is a product/security decision rather than a free-form runtime option and requires tests plus standards review. + +## Verification + +The implementation contract requires stable vectors for all three algorithms, exact UTF-8 behavior, equivalence with incremental hashing of the same UTF-8 byte sequence across chunk boundaries, standard display-label to canonical-code normalization, rejection of legacy/out-of-contract names, byte-boundary tests, idempotent registry startup, authenticated API coverage for at least one advertised label, 100% owned production statement/branch coverage where exposed, and current-head security/review gates before protected integration. + +This ADR remains Proposed while the implementation is outside protected `develop`. It may be marked Accepted only after the decision and its exact implementation are normally integrated under the live protected-branch contract; a Draft PR or passing branch-local test suite is not acceptance authority. + +Standards status and APA 7 references are maintained in [`docs/doctoring/content-checksum-generator.md`](../doctoring/content-checksum-generator.md). + +## Alternatives rejected + +- **Expose every `hashlib` algorithm:** transfers a cryptographic policy decision to callers and makes legacy options look supported. +- **Reject the catalog's documented display spellings:** leaves the product UI/API contract internally inconsistent and forces users to infer undocumented implementation codes. +- **Accept arbitrary punctuation/alias variants:** turns a bounded product contract back into compatibility guessing; only explicitly documented spellings are normalized. +- **Default to SHA-1 or MD5 for interoperability:** creates a new normal-surface dependency on algorithms Naruon should not recommend for security-labelled use. +- **Normalize Unicode before hashing:** destroys the exact byte-level comparison contract and can make different source evidence converge silently. +- **Describe a digest as authentication:** an unkeyed checksum does not establish sender or provenance identity. \ No newline at end of file diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d461fff6..ba301df44 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,6 +12,7 @@ govern implementation. | [ADR-0001](0001-topic-measurement-authority.md) | Naruon-local policy for consuming structural topic measurement, never a keyword/label heuristic | Accepted | `ACCEPTED-NARUON-POLICY`; no runtime promotion | | [ADR-0002](0002-fitted-topic-artifact-consumption.md) | Conditionally consume only a versioned fitted topic artifact through a fail-closed adapter | Proposed | Target `PLANNED`; runtime `BLOCKED-UPSTREAM` | | [ADR-0003](0003-separate-topic-measurement-from-agenda-generation.md) | Keep statistical measurement separate from agenda generation | Proposed | Target and future capability `PLANNED`; no implementation authorization | +| [ADR-0007](0007-bounded-content-checksum-surface.md) | Bound the customer checksum surface to modern allowlisted algorithms and exact UTF-8 evidence | Proposed | Deterministic tool contract pending protected integration and verification | | [ADR-0004](0004-status-weighted-calendar-conflicts.md) | Evaluate CalDAV VEVENT overlaps by occupying status; cancelled does not occupy | Accepted | `ACCEPTED-NARUON-POLICY`; advisory evaluate API only | The complete topic-intelligence requirements, architecture, contract, UML, @@ -27,4 +28,4 @@ Create or update an ADR when a Naruon change adopts or declines an external serv Every implementing PR must keep the corresponding source, tests, doctoring, architecture/operability contract, and CHANGELOG maturity truthful. An active PR, accepted local policy, or proposed target must not be described as protected- -branch implementation before it is integrated and independently verified. +branch implementation before it is integrated and independently verified. \ No newline at end of file diff --git a/docs/doctoring/content-checksum-generator.md b/docs/doctoring/content-checksum-generator.md new file mode 100644 index 000000000..c56b32c91 --- /dev/null +++ b/docs/doctoring/content-checksum-generator.md @@ -0,0 +1,64 @@ +# Content checksum generator — standards and product boundary + +**Status:** active PR candidate; not shipped until merged through protected `develop`. + +**Traceability:** bounded checksum slice of issue #1247, *Build an auditable data-hygiene utility suite*. + +## Product contract + +Naruon's `content_checksum_generator` compares the exact UTF-8 byte sequence supplied by the caller. It deliberately does **not** Unicode-normalize input before hashing, because normalization would change the byte-level evidence being compared. The deterministic tool accepts at most 1,048,576 UTF-8 bytes per invocation and exposes only these normal-surface algorithms: + +- canonical `sha256`, with documented display spelling `SHA-256`; +- canonical `sha3_256`, with documented display spellings `SHA3-256` and `SHA-3-256`; +- canonical `blake2b_256`, with documented display spelling `BLAKE2b-256`. + +Input matching for these canonical/display spellings is case-insensitive, and output always returns the canonical code. The mapping is an explicit presentation compatibility boundary, not free-form `hashlib` alias resolution. SHA-512, generic BLAKE2b, MD5, SHA-1 and other unlisted names remain outside the normal surface. + +MD5 and SHA-1 are intentionally absent from the normal surface. NIST states that SHA-1 is being transitioned out for applying cryptographic protection by December 31, 2030; Naruon therefore does not introduce SHA-1 as a new customer-facing checksum choice. No legacy-compatibility checksum mode is part of this slice. + +A returned digest is an equality/integrity fingerprint for the exact supplied bytes. It does not authenticate a sender, prove provenance, or replace a keyed MAC or digital signature. Customer-facing output carries that warning with every result so the next action is explicit: compare the digest with an independently obtained expected digest when checking content equality; use an authenticated construction when sender or origin authenticity matters. + +## Catalog/API label mismatch finding and repair + +Generated hash-tool provenance PR #1739 carried a review finding that human-facing algorithm names could be rejected by the execution boundary. Fresh inspection confirmed the same narrower mismatch on the canonical checksum owner: the catalog description advertised `SHA-256`, `SHA-3-256`, and `BLAKE2b-256`, while the handler accepted only internal codes. + +The repair is deliberately bounded. Regression `d23b6f82d61de219ceb6fc0eec7781d810a580f4` requires documented display labels to resolve to canonical codes. Production fix `e1a0d464fcfcecca7e97819978fd183b995f6b7d` adds only the explicit label map and canonicalization. API regression `d0edb517759e3aeaff115e5f449b0f23d1b90cc6` proves `SHA-256` through the authenticated execute route. MD5, SHA-1, SHA-512, generic BLAKE2b, unknown names, and unlisted aliases still fail closed. + +The broader generated-PR review finding that the protected Tools page fabricated placeholder `test_value` parameters is owned by the canonical utility-console form lane #1505, which replaces placeholder execution with real user-entered parameter values. This checksum owner does not copy or parallel-write that frontend. Final buyer-visible acceptance therefore requires the utility-console owner path and this checksum owner to coexist on the integrated tree before browser/UI completion can be claimed. + +## Standards status reviewed 2026-09-10 + +The official NIST publication page still lists FIPS 180-4 (2015) as the final Secure Hash Standard. NIST's March 7, 2023 Crypto Publication Review Board decision says FIPS 180-4 will be revised, including removal of the SHA-1 specification, but that decision is a revision plan rather than a replacement final standard. The official NIST/CSRC publication page still lists FIPS 202 (2015) as the final SHA-3 standard and carries a planning note that NIST decided to update it. NIST's March 12, 2025 decision says FIPS 202 will be updated and SP 800-185 revised through the normal draft/public-comment process. A fresh primary-source review on 2026-09-10 found no successor final publication, so Naruon continues to cite the existing final standards while separately recording the announced revisions. RFC 7693 remains the RFC Editor publication describing BLAKE2. + +The implementation uses Python's standard-library `hashlib` bindings only; this slice adds no external cryptographic dependency and no model-mediated decision path. Deterministic checksum behavior therefore remains independent of LLM judgment and credentials. + +## Research grounding + +Two primary peer-reviewed cryptography papers are directly relevant to the non-SHA-2 choices in this bounded surface: + +- Bertoni, Daemen, Peeters, and Van Assche (2008) prove the indifferentiability properties of the sponge construction that underpins Keccak/SHA-3. That work supports treating SHA-3 as a distinct, standardized sponge-based hash construction rather than an alias for SHA-2. +- Aumasson, Neves, Wilcox-O'Hearn, and Winnerlein (2013) introduce BLAKE2 and describe BLAKE2b as the 64-bit-oriented variant, including its software-performance and security design goals. That primary design paper is the research basis for exposing BLAKE2b only under an explicit 256-bit output identifier rather than as an ambiguous generic `blake2` option. + +No paper PDF is committed in this slice because redistribution permission for the publisher versions was not established from the primary publication records during this review. The citations and DOI links below are therefore the auditable research traceability; this avoids assuming redistribution rights merely because a paper can be viewed online. + +## Acceptance evidence + +The regression contract covers published/stable `abc` digest vectors for all three algorithms, exact-byte distinction between canonically equivalent Unicode strings, **equivalence between the one-shot tool result and incremental hashing of the identical multilingual UTF-8 byte sequence across chunk boundaries for all three allowed algorithms**, documented display-label normalization to canonical codes, rejection of legacy/out-of-contract names, rejection of text that cannot be represented as valid UTF-8 Unicode scalar values, the one-MiB UTF-8 boundary, authenticated API execution for an advertised label, and idempotent application registration. The chunk-equivalence regression is evidence about digest invariance for identical bytes; it does not introduce or claim a streaming public API. Protected-branch integration still requires exact-current-head CI, security, **100% owned production statement/branch coverage where exposed as required by [ADR-0007](../adr/0007-bounded-content-checksum-surface.md)**, independent review gates, and integration with #1505's real parameter-entry UI before the capability may be described as shipped. + +## References (APA 7th) + +Aumasson, J.-P., Neves, S., Wilcox-O'Hearn, Z., & Winnerlein, C. (2013). BLAKE2: Simpler, smaller, fast as MD5. In *Applied cryptography and network security* (Lecture Notes in Computer Science, Vol. 7954, pp. 119–135). Springer. https://doi.org/10.1007/978-3-642-38980-1_8 + +Bertoni, G., Daemen, J., Peeters, M., & Van Assche, G. (2008). On the indifferentiability of the sponge construction. In *Advances in cryptology – EUROCRYPT 2008* (Lecture Notes in Computer Science, Vol. 4965, pp. 181–197). Springer. https://doi.org/10.1007/978-3-540-78967-3_11 + +National Institute of Standards and Technology. (2015). *Secure hash standard (SHS)* (FIPS PUB 180-4). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.180-4 + +National Institute of Standards and Technology. (2015). *SHA-3 standard: Permutation-based hash and extendable-output functions* (FIPS PUB 202). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.202 + +National Institute of Standards and Technology. (2022, December 15). *NIST transitioning away from SHA-1 for all applications* (updated February 3, 2025). https://www.nist.gov/news-events/news/2022/12/nist-transitioning-away-sha-1-all-applications + +National Institute of Standards and Technology. (2023, March 7). *Decision to revise FIPS 180-4, Secure Hash Standard (SHS)* (updated February 3, 2025). https://www.nist.gov/news-events/news/2023/03/decision-revise-fips-180-4-secure-hash-standard-shs + +National Institute of Standards and Technology. (2025, March 12). *Decision to update FIPS 202 and revise SP 800-185*. Computer Security Resource Center. https://csrc.nist.gov/News/2025/decision-to-update-fips-202-and-revise-sp-800-185 + +Saarinen, M.-J. O., & Aumasson, J.-P. (2015). *The BLAKE2 cryptographic hash and message authentication code (MAC)* (RFC 7693). RFC Editor. https://doi.org/10.17487/RFC7693 \ No newline at end of file diff --git a/docs/operations/content-checksum-generator.md b/docs/operations/content-checksum-generator.md new file mode 100644 index 000000000..1b4c6ef29 --- /dev/null +++ b/docs/operations/content-checksum-generator.md @@ -0,0 +1,47 @@ +# Content checksum generator + +**Availability:** this capability is not shipped until its implementation passes protected-`develop` integration and review gates. + +Use `content_checksum_generator` when a customer needs to determine whether two pieces of text have the same exact UTF-8 byte representation. It is appropriate for export verification, evidence comparison, and reproducible workflow receipts. It is not sender authentication and it does not replace a MAC or digital signature. + +## Decide which algorithm to use + +- Choose `sha256` for the broadest SHA-2 interoperability. +- Choose `sha3_256` when the receiving workflow explicitly uses SHA3-256. +- Choose `blake2b_256` when both sides agree on BLAKE2b with a 256-bit digest. + +Do not substitute MD5, SHA-1, aliases, or differently sized BLAKE2 outputs; the tool rejects them rather than guessing what the caller intended. + +## Execute + +Use the canonical generic tool-execution contract implemented by [`backend/api/tools.py`](../../backend/api/tools.py): + +- **Method and route:** `POST /api/tools/content_checksum_generator/execute`. +- **Authentication prerequisite:** send `Authorization: Bearer ` with a bearer token accepted by Naruon's [`get_auth_context`](../../backend/api/auth.py). The tools router is mounted with that private-API dependency in [`backend/main.py`](../../backend/main.py); unauthenticated requests are not part of the supported contract. +- **Content type:** `application/json`. +- **Request envelope:** place tool inputs under the required `parameters` object; do not send `text` or `algorithm` at the top level. + +```json +{ + "parameters": { + "text": "content to compare", + "algorithm": "sha256" + } +} +``` + +On successful execution, the endpoint returns the generic `ExecuteResponse` envelope with `status: "success"`; its `result` contains `algorithm_code`, `digest_hex`, `byte_length`, `encoding_code`, and `security_note`. Input is limited to 1,048,576 bytes **after** UTF-8 encoding. Canonically equivalent Unicode text can produce different digests when its byte sequences differ because Naruon does not normalize the source before hashing. + +## Take the next action + +1. Obtain the expected digest through an independent trusted channel or from the system that produced the reference artifact. +2. Confirm the algorithm identifiers are identical on both sides. +3. Compare the complete hexadecimal digests exactly. +4. If they differ, treat the contents as non-identical and investigate source encoding/content provenance; do not truncate or approximately compare the digest. +5. If the business decision depends on who created or authorized the content, use the relevant authenticated provenance/signature workflow instead of interpreting checksum equality as identity proof. + +## Failure handling + +An unsupported algorithm or oversized payload fails closed with a deterministic `ExecuteResponse` whose `status` is `"failed"` and whose message is a bounded validation error. Operators should change the requested algorithm to an allowlisted value or split/restructure the calling workflow; they should not bypass the limit or add a legacy digest solely to make a failed request pass. + +Architecture decision: [`ADR-0007`](../adr/0007-bounded-content-checksum-surface.md). Standards and APA 7 references: [`docs/doctoring/content-checksum-generator.md`](../doctoring/content-checksum-generator.md).