Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 종류별 `<table>` + `<caption>` + `<th scope>`, 외부 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).
Expand Down
165 changes: 165 additions & 0 deletions backend/app/spec/waiver_record.py
Original file line number Diff line number Diff line change
@@ -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": <deep copy>, "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
Comment on lines +27 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Research grounding is incomplete

The signing feature adds standards citations but no academic paper PDF or redistribution assessment. Repository governance requires this grounding for substantive features.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"""

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")
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Ambiguous values share valid signatures

default=str gives distinct waiver values identical signed bytes. A numeric object can become an authenticated string without invalidating verification.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Reserved metadata bypasses waiver integrity

When a waiver contains _meta, sign_waiver excludes its value from the signature. Attackers can alter that field while verification still succeeds.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

).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": <deep copy of 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", "")))
Comment on lines +163 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Metadata type changes evade verification

verify_waiver_signature coerces metadata with str. Attackers can replace a signed string with an equal-looking non-string while verification still succeeds.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

170 changes: 170 additions & 0 deletions backend/tests/test_waiver_record.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading