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
|