From ca22d5e341ba17f732dccd389ea21b5f8f5c8a08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:01:58 +0900 Subject: [PATCH] feat(spec): signed waiver records for the #947 assessment chain (#947 increment 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.spec.waiver_record โ€” a pure, IO-free function pair that makes a normalization-assessment waiver tamper-evident: - sign_waiver(waiver, *, signer, signed_at, key_id, key) deep-copies the waiver and returns {"waiver", "signature"} where signature.value is an HMAC-SHA256 over the canonical JSON of the waiver with the signer / signed_at / key_id metadata folded in as _meta, so altering the metadata invalidates the signature exactly as altering the body does. - verify_waiver_signature(record, *, key) recomputes and compares with hmac.compare_digest. Body/metadata tamper or wrong key -> False; missing signature or non-hmac-sha256 algo -> ValueError. Canonical form sorts keys at every level (key-order independent). The secret key is caller-supplied and never stored, logged, or echoed back. 20 test cases (round-trip, per-field tamper, wrong key, JSON round-trip, determinism, blank-metadata rejection). mypy clean, interrogate 100%. Cites NIST FIPS 198-1 and RFC 8785; doctoring note updated. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013SeQS8tSee5QVeyGpJ9SaY --- CHANGELOG.md | 1 + backend/app/spec/waiver_record.py | 165 +++++++++++++++++ backend/tests/test_waiver_record.py | 170 ++++++++++++++++++ ...on-and-functional-dependency-assessment.md | 39 +++- 4 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 backend/app/spec/waiver_record.py create mode 100644 backend/tests/test_waiver_record.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a350f561..5eae00c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [BE] ๐Ÿ” **์„œ๋ช…๋œ waiver ๋ ˆ์ฝ”๋“œ (6์ฐจ ์ฆ๋ถ„)**: `app.spec.waiver_record`๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. `sign_waiver(waiver, *, signer, signed_at, key_id, key)`๋Š” waiver ๋ณธ๋ฌธ์„ ๊นŠ์€ ๋ณต์‚ฌํ•œ ๋’ค ์„œ๋ช… ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(`_meta`)๋ฅผ ์ ‘์–ด ๋„ฃ์€ ์ •๊ทœ JSON์— HMAC-SHA256(`WAIVER_SIGNATURE_ALGO = "hmac-sha256"`)์„ ๊ณ„์‚ฐํ•ด `{"waiver", "signature": {"algo","signer","signed_at","key_id","value"}}`๋ฅผ ๋Œ๋ ค์ฃผ๋ฏ€๋กœ, ์„œ๋ช…์žยท์‹œ๊ฐ์„ ๋ฐ”๊ฟ”๋„ ๋ณธ๋ฌธ ๋ณ€์กฐ์™€ ๋™์ผํ•˜๊ฒŒ ์„œ๋ช…์ด ๊นจ์ง‘๋‹ˆ๋‹ค. `verify_waiver_signature(record, *, key)`๋Š” `hmac.compare_digest`๋กœ ์ƒ์ˆ˜ ์‹œ๊ฐ„ ๊ฒ€์ฆํ•˜๋ฉฐ, ๋ณธ๋ฌธยท๋ฉ”ํƒ€ ๋ณ€์กฐ๋‚˜ ์ž˜๋ชป๋œ ํ‚ค๋Š” `False`, ์„œ๋ช… ๋ˆ„๋ฝยท๋น„`hmac-sha256` `algo`๋Š” `ValueError`์ž…๋‹ˆ๋‹ค. ์ •๊ทœํ˜•์€ ๋ชจ๋“  ๋ ˆ๋ฒจ์—์„œ ํ‚ค๋ฅผ ์ •๋ ฌํ•˜๋ฏ€๋กœ ํ‚ค ์ˆœ์„œ๋งŒ ๋‹ค๋ฅธ waiver๋„ ๊ฒ€์ฆ๋ฉ๋‹ˆ๋‹ค. ๋น„๋ฐ€ ํ‚ค๋Š” ํ˜ธ์ถœ์ž๊ฐ€ ๊ณต๊ธ‰ํ•˜๋ฉฐ ์ €์žฅยท๋กœ๊น…ยท๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. DBยท๋„คํŠธ์›ŒํฌยทํŒŒ์ผ์‹œ์Šคํ…œ ์ ‘๊ทผ ์—†๋Š” ์ˆœ์ˆ˜ ํ•จ์ˆ˜์Œ. ํ…Œ์ŠคํŠธ 20์ข…(์™•๋ณตยทํ•„๋“œ๋ณ„ ๋ณ€์กฐยท์ž˜๋ชป๋œ ํ‚คยทJSON ์™•๋ณตยท๊ฒฐ์ •์„ฑ). ์ธ์šฉ: NIST FIPS 198-1, RFC 8785. - [BE] ๐Ÿ”— **์ดํ–‰ ์ข…์†์„ฑ(3NF) ํ‰๊ฐ€ (5์ฐจ ์ฆ๋ถ„)**: `app.spec.transitive_dependency_assessment.assess_transitive_dependencies(snapshot, *, declared_functional_dependencies=None, waivers=None)`๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ์นดํƒˆ๋กœ๊ทธ ๊ทผ๊ฑฐ๋งŒ์œผ๋กœ๋Š” `non_key_reference_cluster`(ํ›„๋ณด ํ‚ค๊ฐ€ ์•„๋‹Œ ๋‹ค์ค‘ FK + ๋น„ํ”„๋ผ์ž„ ์„œ์ˆ  ์ปฌ๋Ÿผ = ์ดํ–‰ ์ข…์†์„ฑ์˜ ๊ตฌ์กฐ์  ์ „์ œ, ๊ทผ๊ฑฐ ๋“ฑ๊ธ‰ `inferred`)๋ฅผ, ํ˜ธ์ถœ์ž๊ฐ€ `{"relation","determinant","dependent"}` ํ˜•ํƒœ๋กœ ๋ช…์‹œํ•œ ํ•จ์ˆ˜ ์ข…์†์„ฑ์œผ๋กœ๋Š” `transitive_dependency_via_declared_fd`(๋น„์Šˆํผํ‚ค ๊ฒฐ์ •์ž โ†’ ๋น„ํ”„๋ผ์ž„ ์ข…์†์ž = ์‹ค์ œ 3NF ์œ„๋ฐ˜, ๊ทผ๊ฑฐ ๋“ฑ๊ธ‰ `declared`)์™€ ์ง์„ ์ด๋ฃจ๋Š” `candidate_3nf_split` ์ œ์•ˆ(`proposed`, ์ž๋™ ์ ์šฉ ์—†์Œ)์„ ํƒ์ง€ํ•ฉ๋‹ˆ๋‹ค. ์ปฌ๋Ÿผ๋ช…์œผ๋กœ ์ข…์†์„ฑ์„ ์ถ”๋ก ํ•˜์ง€ ์•Š์œผ๋ฉฐ, ํ•ด์„ ๋ถˆ๊ฐ€ํ•œ ๋ช…์‹œ FD๋Š” `unresolved_declared_fds`์— ์‚ฌ์œ ์™€ ํ•จ๊ป˜ ๋ณด๊ณ ํ•ฉ๋‹ˆ๋‹ค. ์ˆœ์ˆ˜ ํ•จ์ˆ˜ยทDDL/IO ์—†์Œ. ๊ณจ๋“  ํ”ฝ์Šค์ฒ˜ 13์ข…. - [BE] ๐Ÿ“„ **ํ‰๊ฐ€ ๋ฆฌํฌํŠธ HTML ๋ทฐ (4์ฐจ ์ฆ๋ถ„)**: ๋‘ ์Šคํ‚ค๋งˆ ํ’ˆ์งˆ ํ‰๊ฐ€ ์—”๋“œํฌ์ธํŠธ์— `?format=html`์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. `app.spec.assessment_html.render_assessment_html`์ด ์ •๊ทœํ™”ยทhot-partition ๋ฆฌํฌํŠธ๋ฅผ ์ ‘๊ทผ์„ฑ ์žˆ๋Š” ์ •ํ™•๊ฐ’ HTML ํ‘œ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค โ€” ๋ชจ๋“  ์…€์€ `html.escape(quote=True)`๋กœ ์ด์Šค์ผ€์ดํ”„, ์ƒํƒœ๋Š” ์ƒ‰์ƒ์ด ์•„๋‹Œ ํ…์ŠคํŠธ ๋ผ๋ฒจ(`[declared]`, `risk: review`), finding ์ข…๋ฅ˜๋ณ„ `` + `
` + `
`, ์™ธ๋ถ€ CSS/JSยท์Šคํฌ๋ฆฝํŠธ ์—†์Œ. ๋ฏธ์กฐํšŒ/๋ฏธ์ธ๊ฐ€ ์‹œ์—” `format`๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ uniform JSON not-found๋ฅผ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. ์•…์„ฑ relation ์ด๋ฆ„ ์ด์Šค์ผ€์ดํ”„ ๋“ฑ ํ…Œ์ŠคํŠธ ํฌํ•จ. - [BE] ๐Ÿ”ฅ **Hot-partitionยท์„ฑ์žฅ ํ‰๊ฐ€ (3์ฐจ ์ฆ๋ถ„)**: `app.spec.hot_partition_assessment` ๋ถ„์„๊ธฐ์™€ `GET /api/snapshots/{uuid}/hot-partition-assessment` ์ฝ๊ธฐ ์ „์šฉ ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ์นดํƒˆ๋กœ๊ทธ ๊ทผ๊ฑฐ(์„ ์–ธ ํ‚คยท์ปฌ๋Ÿผ ํƒ€์ž…/๊ธฐ๋ณธ๊ฐ’ยทPostgreSQL ํŒŒํ‹ฐ์…˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ)์™€ ์„ ํƒ์  ๋ช…์‹œ capacity profile๋งŒ ์‚ฌ์šฉํ•˜๋ฉฐ ๋ผ์ด๋ธŒ ์›Œํฌ๋กœ๋“œ๋ฅผ ๊ฐ€์ •ํ•˜์ง€ ์•Š๊ณ  ๋ฐ์ดํ„ฐ๋ฅผ ํ‘œ๋ณธํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. Append-heavy ํ…Œ์ด๋ธ”ยท๋ฌดํ•œ ๋ณด์กดยท๋‹จ์กฐ ์ฆ๊ฐ€ ํ‚ค hot-pageยทํŒŒํ‹ฐ์…˜ ํ‚ค๊ฐ€ UNIQUE์— ๋น ์ง„ ๊ฒฝ์šฐยทwrite/read ํŽธ์ค‘ ์ถ•์„ ๊ทผ๊ฑฐ ๋“ฑ๊ธ‰(`observed`/`declared`/`inferred`/`proposed`)๊ณผ ํ•จ๊ป˜ ํƒ์ง€ํ•˜๊ณ , capacity profile์ด ์žˆ๊ฑฐ๋‚˜ ์นดํƒˆ๋กœ๊ทธ๋กœ ์„ ์–ธ๋œ ์‹ ํ˜ธ์ผ ๋•Œ๋งŒ ๊ตฌ์ฒด ์กฐ์น˜๋ฅผ `proposed`๋กœ ์Šน๊ฒฉํ•ฉ๋‹ˆ๋‹ค. DDLยท์“ฐ๊ธฐ ์—†์Œ. ๊ณจ๋“  ํ”ฝ์Šค์ฒ˜ 10์ข… + ๋ฆฌํฌํŠธ/์—”๋“œํฌ์ธํŠธ ํ…Œ์ŠคํŠธ. EXPLAIN pruning ํ”ฝ์Šค์ฒ˜๋Š” ํ›„์† ์ฆ๋ถ„(#947). diff --git a/backend/app/spec/waiver_record.py b/backend/app/spec/waiver_record.py new file mode 100644 index 00000000..1d4cdac5 --- /dev/null +++ b/backend/app/spec/waiver_record.py @@ -0,0 +1,165 @@ +"""Tamper-evident signing for normalization-assessment waiver records. + +The assessment modules in this package (:mod:`app.spec.normalization_assessment` +and :mod:`app.spec.transitive_dependency_assessment`) accept caller-supplied +*waivers*: small records that say "this finding is a deliberate, reviewed +exception, not a defect". Today those waivers are trusted as-is. For an audit +trail an enterprise buyer can rely on, a waiver needs to be **tamper-evident**: +a reviewer signs it once, and anyone can later check that neither the waiver +body nor the "who signed it / when / with which key" metadata was altered +afterwards. + +This module does exactly that and nothing more: + +* :func:`sign_waiver` takes a waiver ``dict`` plus the signer identity, an + ISO-8601 timestamp, a key id, and the secret key bytes. It returns a new + record ``{"waiver": , "signature": {...}}`` whose ``signature`` + carries an HMAC-SHA256 over the canonical JSON of the waiver *with the + signature metadata folded in*, so changing the signer or the timestamp + invalidates the signature just as changing the waiver body would. +* :func:`verify_waiver_signature` recomputes that HMAC from ``record["waiver"]`` + and ``record["signature"]`` and compares it in constant time. + +The secret key never leaves the caller: this module neither stores it, logs +it, nor puts it (or any plaintext derived from it) into the returned record. +It is a pure function pair with no database, network, or filesystem access. + +References (APA 7th): + +* National Institute of Standards and Technology. (2008). *The keyed-hash + message authentication code (HMAC)* (FIPS PUB 198-1). + https://doi.org/10.6028/NIST.FIPS.198-1 +* Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization + Scheme (JCS)* (RFC 8785). RFC Editor. + https://doi.org/10.17487/RFC8785 +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from copy import deepcopy +from typing import Any + +WAIVER_SIGNATURE_ALGO = "hmac-sha256" +"""Identifier stored in every signature; the only algorithm this module accepts.""" + +_META_FIELDS = ("signer", "signed_at", "key_id") + + +def _canonical(waiver: dict[str, Any]) -> bytes: + """Return a deterministic byte string for ``waiver``. + + Keys are sorted at every level and separators are tight, so two dicts that + are equal as Python objects produce identical bytes regardless of the order + their keys were inserted. ``default=str`` lets values such as ``datetime`` + or ``Decimal`` serialize instead of raising; the same Python value always + stringifies the same way, which is all a signature needs. + """ + + return json.dumps( + waiver, sort_keys=True, separators=(",", ":"), default=str + ).encode("utf-8") + + +def _require_non_empty_str(value: object, field: str) -> str: + """Return ``value`` unchanged, or raise :class:`ValueError` naming ``field``.""" + + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _expected_value(waiver: dict[str, Any], meta: dict[str, str], key: bytes) -> str: + """Compute the HMAC-SHA256 hex digest over the waiver plus its signature meta.""" + + return hmac.new( + key, _canonical({**waiver, "_meta": meta}), hashlib.sha256 + ).hexdigest() + + +def sign_waiver( + waiver: dict[str, Any], + *, + signer: str, + signed_at: str, + key_id: str, + key: bytes, +) -> dict[str, Any]: + """Return a signed, tamper-evident copy of ``waiver``. + + Args: + waiver: The waiver body to sign. It is deep-copied into the result, so + the caller's dict is never mutated and later edits to it do not + affect the signed record. + signer: Who approved the waiver (a person or system identity). Required, + non-empty. + signed_at: When it was approved, as an ISO-8601 string. Required, + non-empty; this module records it verbatim and does not parse it. + key_id: Which signing key was used, so a verifier can pick the right + secret without trial and error. Required, non-empty. + key: The secret key bytes for the HMAC. Required, non-empty. Never + stored, logged, or echoed back in the result. + + Returns: + ``{"waiver": , "signature": {"algo", "signer", + "signed_at", "key_id", "value"}}`` where ``value`` is the HMAC-SHA256 + hex digest binding the waiver body to the three metadata fields. + + Raises: + ValueError: If ``signer``, ``signed_at``, or ``key_id`` is not a + non-empty string, or if ``key`` is empty / not ``bytes``. + """ + + meta = { + "signer": _require_non_empty_str(signer, "signer"), + "signed_at": _require_non_empty_str(signed_at, "signed_at"), + "key_id": _require_non_empty_str(key_id, "key_id"), + } + if not isinstance(key, (bytes, bytearray)) or not key: + raise ValueError("key must be non-empty bytes") + + return { + "waiver": deepcopy(waiver), + "signature": { + "algo": WAIVER_SIGNATURE_ALGO, + **meta, + "value": _expected_value(waiver, meta, bytes(key)), + }, + } + + +def verify_waiver_signature(record: dict[str, Any], *, key: bytes) -> bool: + """Return ``True`` iff ``record``'s signature matches its waiver body. + + Recomputes the HMAC-SHA256 from ``record["waiver"]`` and the ``signer`` / + ``signed_at`` / ``key_id`` inside ``record["signature"]``, then compares it + to the stored ``value`` with :func:`hmac.compare_digest` (constant time). + Any change to the waiver body or to a signature metadata field makes this + return ``False``; a wrong ``key`` also returns ``False``. + + Args: + record: A record produced by :func:`sign_waiver` (or one claiming to + be). Must have a ``waiver`` dict and a ``signature`` dict whose + ``algo`` is :data:`WAIVER_SIGNATURE_ALGO`. + key: The secret key bytes to verify against. + + Raises: + ValueError: If ``record`` is missing ``waiver`` or ``signature``, if + either is not a dict, or if the signature's ``algo`` is not + :data:`WAIVER_SIGNATURE_ALGO`. + """ + + if not isinstance(record, dict) or "signature" not in record: + raise ValueError("record must contain a 'signature'") + waiver = record.get("waiver") + signature = record["signature"] + if not isinstance(waiver, dict) or not isinstance(signature, dict): + raise ValueError("record 'waiver' and 'signature' must both be objects") + if signature.get("algo") != WAIVER_SIGNATURE_ALGO: + raise ValueError(f"unsupported signature algo: {signature.get('algo')!r}") + + meta = {field: str(signature.get(field, "")) for field in _META_FIELDS} + expected = _expected_value(waiver, meta, bytes(key)) + return hmac.compare_digest(expected, str(signature.get("value", ""))) diff --git a/backend/tests/test_waiver_record.py b/backend/tests/test_waiver_record.py new file mode 100644 index 00000000..3108fb8f --- /dev/null +++ b/backend/tests/test_waiver_record.py @@ -0,0 +1,170 @@ +"""Tests for :mod:`app.spec.waiver_record` โ€” signed, tamper-evident waivers.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from app.spec.waiver_record import ( + WAIVER_SIGNATURE_ALGO, + sign_waiver, + verify_waiver_signature, +) + +_KEY = b"unit-test-secret-key-0123456789ab" +_OTHER_KEY = b"a-different-secret-key-0123456789" + + +def _waiver() -> dict[str, Any]: + """Return a representative waiver body (matches the assessment-module shape).""" + + return { + "scope": {"relation": "sales.invoice_line", "kind": "candidate_3nf_split"}, + "owner": "data-architecture-guild", + "reason": "denormalized on purpose for the reporting read model", + "review_date": "2026-09-01", + "expiry": "2027-03-01", + } + + +def _sign(waiver: dict[str, Any] | None = None) -> dict[str, Any]: + """Sign ``waiver`` (or the default) with fixed metadata for reuse in tests.""" + + return sign_waiver( + waiver if waiver is not None else _waiver(), + signer="reviewer@example.test", + signed_at="2026-09-02T10:00:00Z", + key_id="waiver-key-2026-09", + key=_KEY, + ) + + +def test_round_trip_verifies_true() -> None: + """A freshly signed record verifies against the same key.""" + + record = _sign() + assert record["signature"]["algo"] == WAIVER_SIGNATURE_ALGO + assert verify_waiver_signature(record, key=_KEY) is True + + +def test_signing_does_not_mutate_caller_waiver() -> None: + """The caller's dict is deep-copied, not referenced, by the signed record.""" + + original = _waiver() + record = _sign(original) + original["reason"] = "changed after signing" + assert record["waiver"]["reason"] == "denormalized on purpose for the reporting read model" + assert verify_waiver_signature(record, key=_KEY) is True + + +@pytest.mark.parametrize("field", ["owner", "reason", "review_date", "expiry"]) +def test_tampering_a_waiver_field_fails_verification(field: str) -> None: + """Editing any waiver body field after signing is detected.""" + + record = _sign() + record["waiver"][field] = "tampered" + assert verify_waiver_signature(record, key=_KEY) is False + + +def test_tampering_nested_scope_fails_verification() -> None: + """Editing a nested waiver value is detected too.""" + + record = _sign() + record["waiver"]["scope"]["relation"] = "sales.something_else" + assert verify_waiver_signature(record, key=_KEY) is False + + +@pytest.mark.parametrize("field", ["signer", "signed_at", "key_id"]) +def test_tampering_signature_metadata_fails_verification(field: str) -> None: + """Changing who/when/which-key without re-signing is detected.""" + + record = _sign() + record["signature"][field] = "tampered" + assert verify_waiver_signature(record, key=_KEY) is False + + +def test_tampering_signature_value_fails_verification() -> None: + """A doctored HMAC digest does not verify.""" + + record = _sign() + record["signature"]["value"] = "0" * 64 + assert verify_waiver_signature(record, key=_KEY) is False + + +def test_wrong_key_fails_verification() -> None: + """Verification with a different secret key returns False, not an error.""" + + record = _sign() + assert verify_waiver_signature(record, key=_OTHER_KEY) is False + + +def test_missing_signature_raises_value_error() -> None: + """A record without a signature is a programming error, not a False.""" + + with pytest.raises(ValueError, match="signature"): + verify_waiver_signature({"waiver": _waiver()}, key=_KEY) + + +def test_unsupported_algo_raises_value_error() -> None: + """Only HMAC-SHA256 is accepted; anything else is rejected loudly.""" + + record = _sign() + record["signature"]["algo"] = "hmac-sha1" + with pytest.raises(ValueError, match="algo"): + verify_waiver_signature(record, key=_KEY) + + +@pytest.mark.parametrize("bad", ["", None, 0]) +def test_blank_metadata_is_rejected_at_signing(bad: object) -> None: + """signer / signed_at / key_id must each be a non-empty string.""" + + for field in ("signer", "signed_at", "key_id"): + kwargs: dict[str, Any] = { + "signer": "s", + "signed_at": "t", + "key_id": "k", + "key": _KEY, + } + kwargs[field] = bad + with pytest.raises(ValueError, match=field): + sign_waiver(_waiver(), **kwargs) + + +def test_empty_key_is_rejected_at_signing() -> None: + """An empty signing key is refused.""" + + with pytest.raises(ValueError, match="key"): + sign_waiver( + _waiver(), + signer="s", + signed_at="t", + key_id="k", + key=b"", + ) + + +def test_canonical_form_is_key_order_independent() -> None: + """Two waivers equal as dicts but built in different key order verify alike.""" + + a = {"alpha": 1, "beta": {"x": 1, "y": 2}} + b = {"beta": {"y": 2, "x": 1}, "alpha": 1} + record_a = _sign(a) + # Swap in the differently-ordered but equal body; signature must still hold. + record_a["waiver"] = b + assert verify_waiver_signature(record_a, key=_KEY) is True + + +def test_record_survives_json_round_trip() -> None: + """Serializing and reloading the record does not break verification.""" + + record = _sign() + reloaded = json.loads(json.dumps(record)) + assert verify_waiver_signature(reloaded, key=_KEY) is True + + +def test_signing_is_deterministic() -> None: + """Signing the same inputs twice yields the same digest.""" + + assert _sign()["signature"]["value"] == _sign()["signature"]["value"] diff --git a/docs/doctoring/normalization-and-functional-dependency-assessment.md b/docs/doctoring/normalization-and-functional-dependency-assessment.md index 0aa75993..276cbb4b 100644 --- a/docs/doctoring/normalization-and-functional-dependency-assessment.md +++ b/docs/doctoring/normalization-and-functional-dependency-assessment.md @@ -145,6 +145,34 @@ Waivers use the same `scope` shape as `assess_normalization`. Row-level FD discovery from table data stays out of scope (it needs profiling, which belongs in a separate service). +## Signed waiver records โ€” landed + +`app.spec.waiver_record` makes a waiver **tamper-evident** so an auditor can +trust one without re-reviewing it. It is a pure function pair with no +database, network, or filesystem access. + +- `sign_waiver(waiver, *, signer, signed_at, key_id, key)` returns + `{"waiver": , "signature": {"algo", "signer", "signed_at", + "key_id", "value"}}`. `value` is an HMAC-SHA256 (`algo` is the constant + `WAIVER_SIGNATURE_ALGO = "hmac-sha256"`) over the canonical JSON of the + waiver **with the signature metadata folded in** as `_meta`, so altering + the signer or the timestamp invalidates the signature exactly as altering + the waiver body does. The caller's dict is deep-copied, never mutated. +- `verify_waiver_signature(record, *, key)` recomputes that HMAC from + `record["waiver"]` and the `signer` / `signed_at` / `key_id` in + `record["signature"]` and compares it with `hmac.compare_digest` + (constant time). Any edit to the body or the metadata, or a wrong key, + returns `False`; a missing signature or a non-`hmac-sha256` `algo` raises + `ValueError`. +- Canonical form sorts keys at every level, so a waiver rebuilt in a + different key order still verifies. The secret `key` is supplied by the + caller and is never stored, logged, or echoed into the record. + +What remains deferred: **persisting** these signed records (with owner, +review date, scope, expiry) alongside the assessment run, and key +rotation / `key_id` resolution โ€” that is storage-layer work tracked with +the persisted-assessment-run item below. + ## Deferred (later bounded increments on #947) - **Row-level functional-dependency discovery** โ€” profiling a sample of @@ -154,7 +182,9 @@ belongs in a separate service). non-color-only HTML rendering, and the `assessment_run` / `capacity_profile` / `partition_candidate` / `remediation_action` records persisted with tool/commit provenance. -- **Persisted, signed waiver records** with owner, review date, scope, expiry. +- **Persisted signed waiver records** โ€” the signing / verification core has + landed (`app.spec.waiver_record`, above); storing the signed records with + owner, review date, scope, and expiry is the remaining storage-layer step. ## Rust boundary decision โ€” DEFERRED @@ -191,3 +221,10 @@ https://doi.org/10.1007/978-1-4842-5540-7 Lucchesi, C. L., & Osborn, S. L. (1978). Candidate keys for relations. *Journal of Computer and System Sciences, 17*(2), 270-279. https://doi.org/10.1016/0022-0000(78)90009-0 + +National Institute of Standards and Technology. (2008). *The keyed-hash +message authentication code (HMAC)* (FIPS PUB 198-1). +https://doi.org/10.6028/NIST.FIPS.198-1 + +Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme +(JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785