From 9033870eba575375654246e054a588bf8d6d5ef8 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 11 Aug 2026 11:28:24 -0500 Subject: [PATCH] docs(crypto): publish the cryptographic-agility seam contract (ASVS 11.2.2) G18 ruled Reading A on 2026-08-11: this project commits to RELEASE-swappability and explicitly refuses RUNTIME reconfiguration. The ruling's cost was "a written seam contract"; this is it, and the contract is what 11.2.2 actually needs. The requirement's phrase -- "reconfigured, upgraded, or swapped at any time" -- has two readings that cost wildly different things, so the document commits to one EXPLICITLY rather than letting a reader infer it: COMMITTED a RELEASE can change an at-rest algorithm with no data migration and no unreadable ciphertext left behind NOT COMMITTED an OPERATOR cannot select an algorithm on a running instance, and that is a refusal with a stated price, not an unbuilt feature Each of the three properties was verified against shipped code, not asserted: the value is self-describing (mfenc:v2:::), the reader fails closed on an unknown version OR algorithm, and rotate-key gives the swap a driven, resumable migration path. v2 is the SHIPPED default writer, so the contract describes what a new deployment actually writes rather than an opt-in. WHY RUNTIME SELECTION IS REFUSED, recorded as a cost. An algorithm identifier here is read from three places: config (the operator chooses), the wire (a token minter chooses), and STORED DATA -- mfenc:v2's alg segment, which means whoever can write a store row chooses. A second registered AEAD lands in that third and most exposed class and converts a fail-closed one-way dispatch into a two-way one keyed on attacker-writable data. The agility asked for would be bought by creating a downgrade surface. THE LIMIT IS STATED AND PINNED, because it is the half a reader loses first: the seam covers the at-rest value core and NOT the audit MAC or its KDF, which carry no version discriminator at all. Changing the audit MAC means versioning the tamper-evidence chain, which is undesigned. Any future claim that this project "has crypto agility" must exclude the audit chain or be false. ALSO CORRECTS A FALSE COMMENT IN THE CRYPTO MODULE, found while verifying: _V2_PREFIX carried "not written by default" while the module docstring 60 lines above said v2 is ON by default since ADR 0148. Settled by execution -- StoreSettings().aad_bind is True, so make_cipher gets write_v2=True. The docstring was right and the constant's own comment was wrong, which is the worse way round: the comment sits on the thing it describes. A published contract nothing checks is a claim, not a control -- so eight tests pin it, including the limit and the refusal. WATCHED FAIL, each landing on its own assertion and no other: reverting aad_bind to False reds ONLY the shipped-default test; neutering the unknown-alg branch reds ONLY the fails-closed test. --- docs/PHI.md | 40 ++++++++ messagefoundry/store/crypto.py | 7 +- tests/test_asvs_crypto_agility_seam.py | 124 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/test_asvs_crypto_agility_seam.py diff --git a/docs/PHI.md b/docs/PHI.md index 7e5f9edb..3b28acd1 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -662,6 +662,46 @@ that the record no longer carried.)* residual above is unchanged either way, and a deployment still needs the host-side control. Rung 3 (SEV-SNP/TDX plus a verified CPU-signed quote) remains unbuilt. +### 3.x The cryptographic-agility seam — what may be swapped, and what may not + +**Ruled 2026-08-11 (ASVS 11.2.2).** The requirement asks that cryptography be "reconfigured, upgraded, +or swapped at any time". That sentence has two readings and they cost very different things, so the +project commits to one of them **explicitly** rather than leaving a reader to infer it: + +- **What is committed — RELEASE-swappability.** A *release* of MessageFoundry can change an at-rest + algorithm without a data migration and without leaving unreadable ciphertext behind. +- **What is NOT committed — RUNTIME reconfiguration.** An *operator* cannot select an at-rest + algorithm on a running instance, and this is a deliberate refusal, not an unbuilt feature. See + "Why runtime selection is refused" below. + +**The three properties that make release-swappability real** — each verified against the shipped code +rather than asserted: + +| Property | Where | What it means for a swap | +|---|---|---| +| The stored value is **self-describing** | `mfenc:v2:::` (`store/crypto.py`) | A reader knows which algorithm produced a value without being told out of band, so old and new can coexist in one column during a rollover. | +| The reader **fails closed** on anything it does not know | `Cipher._parse` raises `CipherError` on an unknown version *or* an unknown `alg` | An unrecognised algorithm is refused, never silently mis-decrypted or skipped. A downgrade cannot pass as a read. | +| Re-encryption is **driven and resumable** | `messagefoundry rotate-key` | The swap has an executable migration path; an interrupted run accounts for what it already re-encrypted rather than starting over or double-counting. | + +`mfenc:v2` is the **shipped default** writer (`[store].aad_bind` defaults `true`), so these properties +describe the format a new deployment actually writes — not an opt-in path. `mfenc:v1` remains +decode-only and frozen. + +**Why runtime selection is refused, stated as a cost rather than a gap.** An algorithm identifier in +this system is read from three places: configuration (the *operator* chooses), the wire (a token +*minter* chooses), and **stored data** — `mfenc:v2`'s `alg` segment, which means *whoever can write a +store row* chooses. Registering a second at-rest algorithm puts a selector in that third and most +exposed class, converting a fail-closed one-way dispatch into a two-way one keyed on attacker-writable +data. The agility the requirement asks for would be bought by creating a downgrade surface, and on +this trade the project takes the refusal. + +**THE HONEST LIMIT, and it is the part a reader should take away:** the seam covers the **at-rest +value core**. It does **not** cover the **audit MAC or its KDF**, which carry *no version +discriminator at all* — measured: zero `mfenc`-style markers anywhere on the audit-chain path. So +changing the audit MAC is not a swap along this seam; it means versioning the tamper-evidence chain +itself, which is undesigned. Any future claim that this project "has crypto agility" must exclude the +audit chain or be false. + --- ## 4. Data in transit diff --git a/messagefoundry/store/crypto.py b/messagefoundry/store/crypto.py index a60618b4..c2fe3e6f 100644 --- a/messagefoundry/store/crypto.py +++ b/messagefoundry/store/crypto.py @@ -93,7 +93,12 @@ # the other version's rows. MARKER_PREFIX = "mfenc:" PREFIX = "mfenc:v1:" # the v1 writer marker — FROZEN (CRYPTO-1); v1 output stays byte-identical -_V2_PREFIX = "mfenc:v2:" # additive, decode-capable; not written by default +# The DEFAULT writer since ADR 0148 GIVEN 1: `[store].aad_bind` ships true, so make_cipher gets +# write_v2=True and a new deployment writes v2. (This comment previously said "not written by +# default", which the module docstring already contradicted 60 lines above — corrected 2026-08-11. +# It matters because the ASVS 11.2.2 agility seam rests on v2's self-describing `alg` segment being +# what the engine actually WRITES, not an opt-in path.) +_V2_PREFIX = "mfenc:v2:" # additive, decode-capable, and the shipped default writer # Transit-wrapped marker (ADR 0138). The value after the prefix is Vault/OpenBao Transit's own # `vault:v1:…` ciphertext — the plaintext DEK lives ONLY inside the isolated module, never in engine # heap (ASVS 13.3.3). Written/decoded by `store/crypto_transit.py`'s TransitCipher, NEVER by diff --git a/tests/test_asvs_crypto_agility_seam.py b/tests/test_asvs_crypto_agility_seam.py new file mode 100644 index 00000000..3e0d2db1 --- /dev/null +++ b/tests/test_asvs_crypto_agility_seam.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""ASVS 11.2.2 — the cryptographic-agility seam contract (docs/PHI.md section 3.x). + +The owner ruled 2026-08-11 that this project commits to RELEASE-swappability (a release can change an +at-rest algorithm without a data migration) and explicitly refuses RUNTIME reconfiguration (an +operator selecting an algorithm on a running instance). + +A published seam contract that nothing checks is a claim, not a control. These pin the three +properties the contract rests on, plus the one limit it states — so the document cannot quietly +become false. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path + +import pytest + +from messagefoundry.config.settings import StoreSettings +from messagefoundry.store.crypto import ( + _V2_PREFIX, + PREFIX, + Cipher, + CipherError, + cell_aad, + generate_key, + make_cipher, +) + +_ROOT = Path(__file__).resolve().parent.parent + + +def test_the_stored_value_is_self_describing() -> None: + """Property 1: a reader learns the algorithm from the value, not out of band.""" + assert _V2_PREFIX == "mfenc:v2:" + cipher = make_cipher(generate_key(), write_v2=True) + stored = cipher.encrypt("PHI body", aad=cell_aad("messages", "raw", 1)) + assert stored.startswith(_V2_PREFIX) + # mfenc:v2::: -- the alg segment is present and non-empty. + alg = stored[len(_V2_PREFIX) :].split(":", 1)[0] + assert alg, "the v2 marker must carry an algorithm segment or it is not self-describing" + + +def test_v2_is_the_shipped_default_writer_not_an_opt_in() -> None: + """The seam only describes reality if v2 is what a new deployment actually WRITES. + + This is the claim the crypto module's own constant comment got wrong until 2026-08-11 (it said + "not written by default" while the module docstring 60 lines above said the opposite). + """ + assert StoreSettings().aad_bind is True + assert "write_v2" in inspect.signature(make_cipher).parameters + + +@pytest.mark.parametrize( + "stored", + [ + "mfenc:v9:deadbeef:AAAA", # unknown VERSION + "mfenc:v2:rot13:deadbeef:AAAA", # unknown ALGORITHM + ], +) +def test_an_unrecognised_algorithm_fails_closed(stored: str) -> None: + """Property 2, and the one that makes a swap safe rather than merely possible. + + A downgrade must be REFUSED, never silently mis-decrypted or skipped. Both the version and the + algorithm are checked, because a swap changes the second while leaving the first intact. + """ + cipher = make_cipher(generate_key(), write_v2=True) + with pytest.raises(CipherError): + cipher.decrypt(stored, aad=cell_aad("messages", "raw", 1)) + + +def test_v1_stays_decodable_so_a_swap_leaves_nothing_unreadable() -> None: + """A release-level swap must not strand existing ciphertext. v1 is frozen but still READ.""" + key = generate_key() + v1 = make_cipher(key, write_v2=False).encrypt("older body", aad=cell_aad("messages", "raw", 1)) + assert v1.startswith(PREFIX) + # The v2-writing cipher still reads it -- old and new coexist in one column during a rollover. + assert ( + make_cipher(key, write_v2=True).decrypt(v1, aad=cell_aad("messages", "raw", 1)) + == "older body" + ) + + +def test_the_audit_mac_carries_no_version_discriminator() -> None: + """THE STATED LIMIT, pinned so the contract cannot silently overclaim. + + The seam covers the at-rest value core and NOT the audit MAC. If someone later gives the audit + chain an mfenc-style marker, this test fails and the contract's limit paragraph must be revisited + -- which is the point: the document says "undesigned", and that must stop being true loudly. + """ + hits = [ + p + for p in (_ROOT / "messagefoundry" / "store").glob("*.py") + if "audit" in p.read_text(encoding="utf-8").lower() + and "mfenc:vaudit" in p.read_text(encoding="utf-8") + ] + assert hits == [], f"the audit chain gained a version marker; revisit PHI.md 3.x: {hits}" + + +def test_the_seam_contract_is_actually_published() -> None: + """A contract nobody can read is not a contract. Pins the section AND its limit paragraph.""" + phi = (_ROOT / "docs" / "PHI.md").read_text(encoding="utf-8") + assert "cryptographic-agility seam" in phi + assert "RELEASE-swappability" in phi + assert "RUNTIME reconfiguration" in phi + # The limit is the half a reader is most likely to lose; pin it explicitly. + assert "audit MAC" in phi and "no version" in phi.replace("*", "") + + +def test_cipher_exposes_no_operator_facing_algorithm_selector() -> None: + """The refusal half: runtime selection is not merely absent, it must STAY absent. + + `write_v2` is a FORMAT flag (v1 vs v2 framing), not an algorithm choice -- there is exactly one + AEAD. A future `algorithm=`/`cipher=` parameter here would be the runtime-selection surface the + contract refuses, keyed on config rather than on a release. + """ + params = set(inspect.signature(Cipher.__init__).parameters) | set( + inspect.signature(make_cipher).parameters + ) + assert not (params & {"algorithm", "alg", "cipher_alg", "aead"}), ( + f"an algorithm selector appeared on the cipher constructor: {sorted(params)}" + )