Skip to content

feat(security)!: no data label may allow a cleartext hop (ADR 0153) + the shipped-posture default flips - #38

Merged
wshallwshall merged 9 commits into
mainfrom
posture
Jul 29, 2026
Merged

feat(security)!: no data label may allow a cleartext hop (ADR 0153) + the shipped-posture default flips#38
wshallwshall merged 9 commits into
mainfrom
posture

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Implements ADR 0153 (Accepted 2026-07-25, previously NOT yet built) and finishes the posture-default lane.

⚠️ Do not auto-merge. This carries a breaking config change and an engine-seam bump. Two owner decisions are listed at the bottom.

ADR 0153

insecure_hop_disposition no longer reads the data label at all — verified, its signature is now (enforcing, is_loopback_hop, hop_attested, cleartext_accepted). No data label can permit a cleartext hop. Per-destination cleartext_accepted/cleartext_reason land as WARN + audited, kept deliberately distinct from tls_hop_attested.

No TLS default was flipped — verified (weakened_tls: bool = False throughout), and pinned by test_no_transport_factory_flips_its_tls_default. That was explicitly sized and rejected upstream because it hard-fails 14/14 inbound MLLP listeners.

Beyond the ADR's letter, the build also closed transports/smart.py — the last connection-scoped cell still reading the raw unclamped MEFOR_ALLOW_INSECURE_TLS — and extended the declaration to FhirLookup read connections, which the ADR did not enumerate.

Posture defaults (one posture, loosen only)

[store].aad_bind False→True; [auth].ad_session_recheck_seconds 0→300, with the cross-field refusal keyed on model_fields_set so the shipped default is inert without AD but an explicitly typed value still refuses — a naive flip would have failed startup on every non-AD deployment.

security_loosenings() now takes (sec, store, auth, cleartext_hops), all required: an optional parameter is a detector that can silently fail to fire, and a deviation the registry cannot see is a second posture by the back door.

⚠️ Owner decisions before merge

  1. Breaking config change. [logging].forward_hop_attested = true without a reason now refuses. Clean in this repo (0 occurrences in samples/, harness/, ide/, docs) — but the estate config repo could not be grepped from this worktree. Check it before merging.
  2. Engine UI seam 14 → 15 (SecurityPosture.loosenings_scope). The webconsole seam list and golden snapshot are updated here, but a console wheel built from an older tag will refuse to start against this engine — that is the handshake working as designed, and it makes this a lockstep release.

Known gaps, stated rather than buried

  • tls_hop_attested still has no authoring surface on a connection — deliberately not wired, since that would add a silent-ALLOW per-connection loosening needing its own ratification. Docs that told operators to use it are corrected; recorded as owed.
  • The registry still does not cover 8 other [store]/[auth] switches; they are enumerated as explicit exemptions in a floor test rather than left implicit.
  • SQL Server and Postgres legs do not run locally. The aad_bind flip changes what open_store writes on every backend — those CI legs are the real test.

Verification

ruff clean · ruff format clean · mypy 21 errors, exactly the pre-existing set, 0 new · pytest 9105 passed, 816 skipped, 1 failed.

That one failure is test_anon_parity, which is not from this branch (git diff --name-only -- messagefoundry/anon tee returns 0 files) and was fixed independently in #35, now merged.

ADR 0092's precedence arm 3 was `not is_phi -> ALLOW`: an instance whose
environment file declared `data_class = "synthetic"` crossed EVERY cleartext
transport hop, silently, with no warning and no audit record. `data_class` is
authored in the same file as the hosts it governs, by the same hand — a typo in
it was indistinguishable from a deliberate declaration, and its blast radius was
every transport hop in the product.

`insecure_hop_disposition` loses `is_phi`, and `audited_opt_out` goes with it.
The precedence is now: loopback -> ALLOW, `hop_attested` -> ALLOW,
`cleartext_accepted` -> WARN, not `enforcing` -> WARN, else REFUSE. Only an arm
that returned ALLOW was deleted, so ADR 0092 decision 5 (no-loosen) holds BY
CONSTRUCTION — asserted over the whole old input space, not merely claimed.

The honest escape (decision 2) is a new `Destination` pair, `cleartext_accepted`
+ `cleartext_reason`, load-validated three ways and authored either code-first on
`outbound()` or as TOP-LEVEL `connections.toml` keys. It is deliberately SEPARATE
from `tls_hop_attested`, with the opposite claim — attestation says the hop IS
secure by means the engine cannot see (ALLOW, silent); acceptance says it is NOT,
and we accept that (WARN, logged + audited at every construction). Merging them
would leave the audit trail unable to tell a proxy-terminated hop from plaintext
PHI on a flat network. For `Tcp()`/`X12()`, which have no TLS support at all, the
declaration is permanent and structural (BACKLOG #311).

NO TLS default is flipped (decision 3): all four factories keep `tls = False`.

Three implementation questions the ADR left open, resolved and recorded in its
Build notes:

* `rest._shipped_strict_disposition`'s no-loosen floor is re-keyed on
  `cleartext_accepted` rather than on the global escape. A naive port would have
  floored arm 3's WARN straight back to REFUSE, making the declaration INERT for
  REST/SOAP/FHIR/DICOMweb — the largest cleartext-egress family and the only one
  where it is a genuine escape. Side effect, intended: MEFOR_ALLOW_INSECURE_TLS
  can no longer relax a non-enforcing HTTP cleartext hop. That is a TIGHTENING,
  and it stops the blunt env var being alive on HTTP while dead on raw TCP.
* the two out-of-scope DELEGATING callers — `phi_read_hop_disposition` and
  `settings.forward_hop_disposition` — restate the deleted `not is_phi -> ALLOW`
  arm explicitly instead of inheriting it. Both are non-connections with nowhere
  to carry a declaration; refusing them would create a deviation the loosening
  registry cannot express. The scope limit is now a written decision at the one
  place it applies, not an emergent property of a signature change.
* the declaration reaches the CREDENTIAL hops (Digest, OAuth2/SMART token
  endpoints, the forward-proxy credential, SOAP WS-Security / body secrets), not
  only the body hops. Leaving credentials with no honest declaration would push
  an operator toward writing a FALSE `tls_hop_attested` — the precise defect this
  ADR exists to remove. SMTP AUTH over cleartext stays refused OUTRIGHT.

Also here, because they cannot be separated from the above:

* the flag-implies-reason rule is retro-fitted to `tls_hop_attested` (decision 2)
  and deliberately reaches `[logging].forward_hop_attested`, which shares the
  validator and whose reason docs/PHI.md already described as mandatory;
* the attestation-audit branches drop their `posture.is_phi` conjunct — with the
  authority no longer reading the label, gating the audit on it would silence the
  record for exactly the hops that newly depend on an attestation to cross;
* the acceptance audit marker is lower-case on purpose: the PHI redaction filter
  collapses adjacent ALL-CAPS tokens to `[redacted]`, which would have scrubbed
  the marker out of the very record it exists to make findable;
* `security_loosenings()` gains a required `cleartext_hops` parameter and reports
  the declaring connections by name, alongside the in-flight posture lane's
  required `store`/`auth` parameters and its `aad_bind` /
  `ad_session_recheck_seconds` entries. `[store].aad_bind` now defaults true and
  `[auth].ad_session_recheck_seconds` 300 (ADR 0148 GIVEN 1 — the hardened path
  is the shipped path); the AD cross-field refusal is re-keyed on
  `model_fields_set` so an EXPLICIT value without `ad_enabled` still refuses while
  the inert shipped default does not fail startup on a non-AD box.
* `messagefoundry check` gains a `cleartext-accepted` surface listing the WHOLE
  accepted set (the ADR's stated mitigation for broad declaration), and
  `security show` now emits its `loosenings_scope` marker rather than reporting a
  settings-only list as if it were the whole posture.

`data_class`, `HopPosture.is_phi` and `MEFOR_ALLOW_INSECURE_TLS` are all retained;
the cleartext-hop authority simply stops reading them.
…ture lane

The precedence table is the anchor, so it is rewritten from a 2**5 product over
the old inputs to a 2**4 one over the new arms — plus two guards the old suite
had no equivalent of:

* a SIGNATURE pin. Asserting the arms alone would not catch a re-added
  `is_phi=False -> ALLOW` behind a defaulted parameter, because every existing
  call site would keep passing. `inspect.signature` is the detector that CAN fail.
* a STRICTLY-STRICTER property test over the whole OLD 32-row input space,
  against a verbatim copy of the pre-0153 precedence kept in the test file. ADR
  0153's central claim — "removing an ALLOW arm can only turn a crossing into a
  WARN or a REFUSE" — was previously a sentence in a document; it is now a check.

Every test that asserted the deleted arm is inverted rather than deleted, and
says in its own body what it used to assert and why the answer moved: a synthetic
instance now REFUSES on raw TCP/X12/MLLP/DIMSE/FTP, on the whole HTTP family, on
the FhirLookup read path, at `messagefoundry check`, and at serve. The
`allowed_with_escape` transport tests are re-keyed onto the per-connection
declaration, because MEFOR_ALLOW_INSECURE_TLS no longer relaxes any cleartext hop.

New coverage for the acceptance pair: the three load-validation rules on both
halves; a Destination-ONLY pin (a later "symmetry" refactor must not add a dead
inbound knob that reads like a working control); connections.toml/code-first
desugar parity asserted on the DISPOSITION, not just the field; the WARN-plus-
dedicated-audit-record at every construction; and a test that the acceptance and
the attestation are DISTINGUISHABLE in the trail, which is the one distinction
keeping them separate fields exists to preserve. Plus a pin that no transport
factory flips its `tls` default (ADR 0153 decision 3's emphatic non-goal, which
would otherwise rot silently).

For the posture lane, tests/test_security_posture_defaults.py covers both flipped
defaults and — deliberately — the REGISTRY around them:

* the AD cross-field refusal is exercised through `load_settings`, not the
  constructor. Constructing AuthSettings(...) marks every passed field as set, so
  a constructor-only test cannot distinguish the shipped default from an
  explicitly-typed 300 — the entire distinction the guard turns on. The negative
  case uses the SAME value as the default, so it can only pass if the check keys
  on `model_fields_set`.
* a COMPLETENESS FLOOR: every boolean `[security]` switch at its insecure value
  must be named by `security_loosenings()`, with an explicit exemption set that
  states why each exempt switch is not a loosening. Nothing asserted this before;
  under "one posture, loosen only" a registry with no floor is the shape that
  lets a later switch be added at an insecure value with nothing reporting it.
* `GET /security/posture` on the real surface for all three deviations, including
  the connection-scoped one read live off the running graph.
…the deviations

ADR 0153 flips to BUILT and gains a Build notes section carrying the three
implementation questions it left open, the answers taken, and the visibility
surfaces the owner required — including the two surfaces that CANNOT see a
per-connection declaration (`security show`, which never loads the graph, and the
serve-time warning, which fires before it is loaded) and what covers them instead.
Recording those is the point: a scope limit nobody wrote down is indistinguishable
from an oversight.

ADR 0092 gains a prominent amendment banner at the top rather than an edit to its
decision text. Its precedence is now historically wrong but its structure, its
loopback carve-out, its attestation and its two-layer gating all still stand, so
the record of what was decided in July stays intact and the banner says exactly
which two arms moved.

ADR 0019 (`aad_bind`) and ADR 0079 (`ad_session_recheck_seconds`) get dated
amendments for their default flips, each stating why the flip is safe (dual-read
and rotate-key upgrade for the first; the reconciler's own LDAP-client
precondition for the second), what it costs (CRYPTO-1's byte-identity now
describes the opt-out, not the default), and why the AD cross-field refusal had to
be re-keyed rather than removed. ADR 0019's superseded amendment keeps its text
and gains a pointer.

docs/SECURITY-LOOSENING.md gains all three deviations in the register's existing
shape (what you lose / when acceptable / compensating controls), plus a standards
row each. The `cleartext_accepted` entry is written to be read by someone about to
set it: it says plainly what crosses the wire, distinguishes it from
`tls_hop_attested` in the operator's own terms, states that Tcp()/X12() have no
migration target, and enumerates both where it IS reported and where it is not.

The remaining drift is corrected where the code moved under it: CONFIGURATION.md's
cleartext-egress note (which still described the synthetic carve-out), PHI.md's
§2/§3/§8 cell-binding prose and its `[logging]` forwarder gate (which now explains
why that cell still reads the data label when the transport cells do not),
CONNECTIONS.md (a new section documenting the declaration, both authoring
surfaces, the permanent-vs-transitional distinction, and the EMAIL row), SECURITY.md
and ASVS-L2-PHASE0-CHANGES.md's quoted defaults, and the ADR index status line.
…TLS, and must be visible wherever it is honoured

Four adversarial reviews of the ADR 0153 build converged on the same two defects.

**`cleartext_accepted` had been threaded into `refuse_verify_off`.** A `verify_tls=false`
hop is encrypted-but-unauthenticated, not cleartext, so ADR 0153 — scoped to "the
cleartext transport-hop decision only" — does not govern it. It broke in both directions
at once: an enforcing hop that REFUSED before now CROSSED on a declaration (a loosening
ADR 0092 decision 5 forbids), while the clamped MEFOR_ALLOW_INSECURE_TLS escape silently
stopped relaxing a non-enforcing one — a behaviour change to an escape that
docs/SECURITY-LOOSENING.md asserts is unaffected. It also attached an operator's written
"this peer cannot do TLS" reason to a peer that plainly does, and split the HTTP family
from MLLP/FTPS on the same question. The cell now keeps its pre-0153 clamped escape
(`weakened_tls=True` in `_shipped_strict_disposition`) and the declaration does not reach
it; the only 0153 change that still does is the deleted `not is_phi` ALLOW arm, which can
only tighten.

**A `FhirLookup` read connection honoured the declaration with no way to author it and no
way to see it.** `FhirLookupExecutor` read the pair off its spec settings, but it was not
a `FhirLookup()` parameter and `accepted_cleartext_hops` walked only `registry.outbound`.
So a cleartext PHI-READ hop could cross on a hand-written settings key that skipped the
mandatory-reason rule and appeared in none of `messagefoundry check`,
`security_loosenings()` or `GET /security/posture` — exactly the deviation-the-registry-
cannot-see the governing rule forbids. The pair is now a factory parameter,
coherence-checked at that one surface, and the shared reader walks `registry.fhir_lookups`
too (entries named `fhir_lookup:<name>`, a separate namespace).

Also:

* the SMART token endpoint still read the RAW, unclamped `insecure_tls_allowed()`, so one
  process-wide env var put a signed `client_assertion` on cleartext http even on an
  enforcing PHI instance, invisible to every loosening surface. Routed through
  `refuse_cleartext_credential_hop` like its OAuth2 sibling — a tightening, and the last
  connection-scoped cleartext cell where the variable was still alive;
* the acceptance record named no connection, though `cell` is a static family label and
  the HTTP-family message carries a host with no port — so with two destinations to one
  host it could not be traced back to the declaration that produced it. The name is now
  threaded to every cell that can know it, and mirrored (`cleartext_connection`) into the
  deep settings-driven seams alongside the pair;
* `GET /security/posture` reported an empty cleartext list with NO marker when the engine
  has no loaded graph. It now carries `loosenings_scope`, the same discipline
  `security show` follows. Engine UI seam 14 -> 15 (v14 shipped in v0.3.2, so it is bumped
  rather than corrected in place);
* `security show`'s bare `except Exception` narrowed to the four ways a settings file
  actually fails to resolve, and a MISSING file no longer reports itself as a degraded
  read — the shipped defaults ARE the effective posture there;
* `security_loosenings()`'s docstring claimed "every security-relevant switch". It now
  states its real scope — every `[security]` switch plus three enumerated deviations
  outside it — and names the `[store]`/`[auth]` switches it does not yet reach.
…e wiring had no detector

The scope corrections, each as a test that can fail:

* `verify_tls=false` keeps the clamped global escape and is NOT relaxed by
  `cleartext_accepted` in any posture (the loosening this branch had introduced);
* the three `..._allowed_with_escape` cleartext tests are renamed `..._allowed_when_accepted`
  — their bodies stopped exercising the escape, and a name that asserts a guarantee the body
  no longer checks is worse than no test;
* the SMART token endpoint refuses under enforce WITH the env var set, and crosses on the
  per-connection declaration; `token_provider_from_settings` reads the mirrored pair;
* a declared `FhirLookup` is named by `accepted_cleartext_hops`, and its factory enforces
  all three flag/reason coherence rules;
* the acceptance record names the declaring connection (parametrised over every raw cell).

Four surfaces had production wiring with no end-to-end proof, verified by mutation —
each of these reds when its production line is disabled:

* `create_managed_app`'s `app.state.auth_settings` stash. The existing route test sets the
  attribute by hand, so it could not fail if the stash regressed and the auth deviation
  went unreported. Driven through the real lifespan (mutation-checked: disabling the stash
  reds it);
* `_dest_config`'s settings MIRROR — the only path a graph-authored declaration takes to
  the Digest / OAuth2 / SMART / forward-proxy seams. Its own tests hand the settings dict
  in, and the desugaring test asserts only the typed `Destination` fields;
* `security show`'s `loosenings_scope` / `loosenings_partial` markers, which had zero test
  references while being the stated mitigation for that command's blindness;
* rule 3 of the attestation retro-fit reaching `[logging].forward_hop_attested`, previously
  only claimed in a sibling test's docstring.

`test_toml_rejects_the_pair_under_settings` now drives the LOADER instead of asserting a
factory signature — the signature check would still pass if `_build_spec` ever grew a
pass-through for unknown keys, which is the drift it exists to catch.

The `[security]` completeness floor gains a `[store]`/`[auth]` sibling with an explicit,
reasoned exemption set. It does not close the gap (extending the registry over those
switches is separate work) but it makes the gap a written decision: a NEW bool in either
section reds the test until it is either reported or exempted with a reason.
…escribing controls that do not exist

ADR 0153 said two different things about `rest._shipped_strict_disposition` — the
*Explicitly out of scope* table (a ratified scope decision) said "unchanged", Build note 1
seventy lines later said it had been re-keyed. The table now records what actually
happened, and Build note 1 is split: the floor IS re-keyed for the cleartext cells, and
the `verify_tls=false` cell it also serves is genuinely unchanged. Build note 3's claim
that the declaration reached "the OAuth2/SMART token endpoints" was false for SMART until
this branch made it true, and says so. A fourth build note records the `FhirLookup`
authoring surface. The Consequences bullet offering `[security].enforcement = warn` as the
alternative to a declaration is corrected: it works for the raw transports only — the HTTP
family's no-loosen floor turns that WARN back into a REFUSE.

`docs/CONNECTIONS.md` presented "exactly three ways such a hop crosses" and listed
`tls_hop_attested` as one. There is no authoring surface for it on a connection — no
factory takes it, it is not a `connections.toml` key — so an operator following that row
gets a hard load error, and the only row that loads is `cleartext_accepted`. They would
then declare "this hop is NOT secure" about a hop that is, inverting the one distinction
ADR 0153 says the audit trail exists to preserve. The table now lists the routes that
work, and the attestation is described as what it is: present in the engine, unreachable
from config, owed. Also: `Http()` removed from the "declare it here" transport list (it is
an inbound listener with no hop to declare), `Ftp()` qualified to the anonymous case, and
`FhirLookup()` added.

`docs/SECURITY-LOOSENING.md`: the `verify_tls=false` claim ("weakened-TLS refusals are
unaffected") is true again now the code matches it, and is made explicit; the audit-record
bullet says what is actually emitted (a WARNING log line naming the connection, cell, host
and reason — not a tamper-evident audit row, which pure `config/` code cannot write); the
`aad_bind` keyless bullet no longer claims the registry stays silent, because it does not;
the register gains a scope paragraph naming the `[store]`/`[auth]` switches it does not
cover, so the gap is visible rather than implied.

`docs/CONFIGURATION.md`: `[logging].forward_hop_attested_reason` is now MANDATORY with the
flag (the retro-fitted rule reaches that sibling), not "only valid with". ADR 0092's
amendment banner records that the escape survives for the weakened-TLS cells too, not only
the six non-connection ones.
PR #38 flipped [store].aad_bind on, making mfenc:v2 the default at-rest
format. The v2 marker inserts an alg field ahead of the key id
(mfenc:v2:<alg>:<key_id>:<b64> vs mfenc:v1:<key_id>:<b64>), so ten
rotation assertions that hand-rolled `split(":", 3)[2] == active_key_id`
were reading "a256gcm" where they expected the fingerprint.

The product is correct: Cipher.active_marker_prefix already generalises
the stores' rotation scans for v2. This is a test-only fix. Each site now
calls that shipped accessor instead of re-deriving the layout beside it,
which is version-agnostic (it survives a future v3) and strictly stronger
than what it replaces: it pins marker + version + alg + key id + the
trailing ':' together, where the old form checked one field of an
arbitrary colon-split and never pinned the marker or the version at all.

Two shapes, because the writer differs between the groups:

- The two rotate-key CLI tests are the ones actually red in CI. Their
  writer is built inside open_store as build_store_cipher(settings), i.e.
  write_v2=aad_bind, and no object the test held had that posture. The
  local cipher now mirrors it via make_cipher(key_b, write_v2=
  settings.aad_bind), reused for the verify handle and the assertion.
  Reading the prefix off the plain make_cipher(key_b) would still have
  failed - that yields the v1 prefix.

- The other eight sites construct AesGcmCipher(k2, retired_keys=[k1])
  explicitly, so they bypass build_store_cipher, still write v1, and pass
  today. Hoisting that inline cipher into a local and reading its own
  prefix is a robustness/parity upgrade, not a red-to-green fix; it also
  makes them correct under the MEFOR_TEST_FORCE_AAD_BIND=1 sweep, where
  today they would read "a256gcm".

Comments naming the v1 layout are updated so they no longer state a
format the writer may not produce. No assertion was weakened or dropped:
the neighbouring startswith(MARKER_PREFIX), the NULL-detail check, and
the deliberate keyless-plaintext negatives are untouched.

Verification: ruff check + ruff format --check clean over tests; both
suites still collect (147 Postgres + 149 SQL Server); full suite 9117
passed, 816 skipped.

Not verified locally, stated plainly: the Postgres and SQL Server legs
skip without a server (296 skipped, zero executed here), so no leg is
claimed to pass - CI is the real test. The marker arithmetic the fix
turns on was proven server-free against the real cipher: a v2 blob's
split(":", 3)[2] is "a256gcm" (reproducing the CI failure), it does not
start with the write_v2=False prefix (the trap), it does start with the
mirrored-posture prefix, and a v1 blob satisfies both the old and the new
form (so the eight green sites do not flip).
… v1-layout site

Review round on f9de129. Three findings survived verification against the code;
the rest of the critique was checked and found mistaken or already handled.

1. Independence, not strength (the one substantive finding). Four of the ten
   converted sites drew their ONLY proof that rotation moved anything from
   `active_marker_prefix` — the very property the stores' rotation predicate is
   built from (`active_like = f"{cipher.active_marker_prefix}%"` in
   postgres.py, sqlserver.py and store.py). The old `split(":", 3)[2] ==
   active_key_id` form drew its expectation from a DIFFERENT property, so the
   two cross-checked. Concretely: were `active_marker_prefix` to regress
   over-broad, `NOT LIKE` would select zero rows, rotation would silently
   no-op, and `startswith` would pass vacuously — while the neighbouring
   round-trips stay green because the handle still carries the retired key.

   Restored by pairing each prefix check with `_fingerprint(k1) not in <value>`
   — derived from the fingerprint, not the prefix, so it is independent again.
   Sites: test_response_rotation_and_purge, test_reencrypt_skips_null_response_
   detail, test_reencrypt_rotates_summary_and_metadata,
   test_reencrypt_rotates_error_lasterror_detail.

   Deliberately NOT the reviewer's suggested `reencrypt_to_active() == N`: N is
   not derivable from the test's own setup at three of the four, it cannot be
   checked locally, and guessing it wrong turns green legs red. The other six
   sites already carry an independent proof (`== 6`, `== 2`, and the CLI tests'
   key_b-alone verify handle) and are left alone.

2. An over-claim in f9de129's own message and comments: the new form was
   described as pinning "marker + version + alg + key id". For the eight
   AesGcmCipher(...) sites the cipher is write_v2=False, so the prefix is
   `mfenc:v1:<kid>:` and there IS no alg segment. Comments reworded to say
   marker + version + key id, plus the alg on v2.

3. A stale section header: the H4 block still read "route through the SAME
   store cipher (mfenc:v1)". With [store].aad_bind defaulting True the engine's
   at-rest format is v2, so naming a version there misdescribes the product.
   The version is dropped rather than swapped — the format follows the setting.
   (test_postgres_store.py:1128 is correctly left naming v1: its `c2 =
   make_cipher(k2, [k1])` genuinely writes v1.)

Plus an 11th instance of the original defect that the `split(":", 3)` grep
could not match, because it hand-builds the same assumption by CONCATENATION
instead of parsing it:

    tests/test_transform_state.py
    -   new_id = make_cipher(new).active_key_id
    -   assert at_rest.startswith(f"{PREFIX}{new_id}:")
    +   assert at_rest.startswith(rotating.active_marker_prefix)

`f"{PREFIX}{new_id}:"` IS the v1 branch of active_marker_prefix, open-coded, in
a rotation test — the same intent as the nine rotation sites already converted.
It is green in CI today only because its writer is an explicit v1 cipher.

Unlike the SQL Server and Postgres legs, this one RUNS locally, so it is the
only part of either round with direct execution evidence. Proved both ways:
under MEFOR_TEST_FORCE_AAD_BIND=1 the old form fails with exactly the CI
signature ("mfenc:v2:a256gcm:<kid>:..." vs "mfenc:v1:<kid>:") and the new form
passes; both pass in the default posture.

Verification: ruff check + ruff format --check clean over tests (531 files);
collection unchanged (147 Postgres + 149 SQL Server = 296, 23 transform-state).
The added assertions were proved server-free against the real cipher: across
4,800 fresh tokens per key pair, in BOTH v1 and v2, the retired fingerprint
never appears in an active-key value (so the addition cannot flake), while an
un-rotated value both fails the prefix check and still contains it — including
under a simulated over-broad-prefix regression, where only the new check fires.

Not verified locally, stated plainly: the SQL Server and Postgres legs skip
without a server (296 collected, 0 executed). No claim is made that they pass;
CI is the only real test for those nine sites.

Known, pre-existing, out of scope: under the MEFOR_TEST_FORCE_AAD_BIND sweep
test_state_value_encrypted_at_rest_and_read_back still fails on a v1-only
`startswith(PREFIX)` "is it encrypted at all" check, as do ~14 similar siblings
in the two server suites. Untouched here, and no CI job sets that flag.
@wshallwshall
wshallwshall merged commit 3ecceb0 into main Jul 29, 2026
34 checks passed
@wshallwshall
wshallwshall deleted the posture branch July 29, 2026 13:46
wshallwshall added a commit that referenced this pull request Jul 29, 2026
…x, and pin the release toolchain (#48)

* test(crypto): sweep the "encrypted at all" at-rest assertions off the frozen v1 prefix

`[store].aad_bind` now defaults True (#38), so the default at-rest format is mfenc:v2. 41 test
assertions anchored on `PREFIX` ("mfenc:v1:") whose actual claim is "this value is enciphered at
rest at all" — a v1-only spelling that a v2 value silently fails. They move to the version-agnostic
`MARKER_PREFIX` ("mfenc:"), which is the same anchor the store's own find-all/migration `mfenc:%`
LIKE patterns and `is_encrypted()` already use.

Evidence, not assertion. Under the ASVS correctness net `MEFOR_TEST_FORCE_AAD_BIND=1` (conftest
forces every AesGcmCipher to the v2 writer), the swept files went 9 failed -> 1 failed. The one
remaining failure is `test_store_aad_binding::test_v1_rows_still_read_under_aad_bind`, a site this
commit deliberately does NOT touch: it fails on both branches because forcing v2 destroys its v1
premise, which is exactly what makes it the liveness receipt that the flag is live.

DELIBERATELY LEFT as v1 (CRYPTO-1 / ADR 0019 coverage a sweep would DELETE), each now carrying a
comment saying so — their absence is what made this dangerous to review:
  - the frozen-writer set (test_store_encryption: the _v1_blob oracle, the byte-identity gate,
    test_default_writer_is_v1_not_v2)
  - premise assertions whose point is "the row really IS v1" so a dual-read/no-rotation proof
    cannot go vacuous (test_v2_active_decrypts_v1_without_rotation, test_store_aad_binding,
    test_keyprovider x2, test_keyprovider_vault)
  - field-POSITION pins, where "mfenc:<fp>:" is a string no writer emits, so widening makes the
    positive unsatisfiable and the negative unfailable (test_key_id_is_a_fingerprint_not_zero,
    the legacy key_id='0' fixture)
  - undecryptable-payload injections (test_batch_claim_fifo, test_claim_fifo_heads): the marker
    version must be one the cipher DISPATCHES on, or the poison takes the unknown-version branch
    instead of the base64 failure the test is about

Three checks got strictly stronger rather than merely wider:
  - the rotation assertion now anchors on `cipher.active_marker_prefix`, keeping the "re-encrypted
    under the ACTIVE key" half of the proof that a bare MARKER_PREFIX would have dropped
  - three NEGATIVE leak/plaintext checks (`not startswith` / `not in`) now exclude EVERY marker
    version; as v1-only they would have passed silently on a leaked or already-encrypted v2 value
  - test_ack_sent_store's manual decrypt now passes the cell AAD the store wrote with. Pre-existing
    and previously MASKED by the v1 assertion failing first; unmasked once the sweep let the test
    reach it. Harmless on v1 (that reader ignores the caller's aad by design).

Comments naming "v1"/"the v1 marker" at swept sites were updated so they no longer lie.

Verification (SQLite leg, from the worktree): 9135 passed, 816 skipped, 0 failed; ruff check +
ruff format --check clean. Non-vacuity proven by injecting one regression (AesGcmCipher.encrypt
returns plaintext): two swept assertions FAIL on a plaintext at-rest value and pass without it.

* ci(supply-chain): pin the release-path toolchain + guard the pins against silent rot

Five installs on the release path resolved whatever PyPI served at tag time (Scorecard
PinnedDependenciesID; ADR 0034 §3). The `release` job holds contents/id-token/attestations: write
and `release-harness` holds contents/id-token: write, so these ran with the OIDC identity that
signs and publishes.

The sharp one is `sigstore`: its step carries no `if:`, so it runs on every tag AND every dispatch,
and the very next command signs the wheel, sdist, SBOM and VEX with that identity.

  release.yml  sigstore                -> ==4.4.0
  release.yml  pip + build (x2: engine and harness jobs)  -> ==26.1.2 / ==1.5.0
  release.yml  cyclonedx-bom           -> ~=7.3.1
  release.yml  packaging (x2)          -> ==$PKG_PIN, derived from constraints.lock
  security.yml pip + cyclonedx-bom     -> ==26.1.2 / ~=7.3.1   (keeps it the true SBOM twin)

Three judgement calls, none of them the obvious choice:

1. sigstore 4.4.0, NOT the newer 4.5.0. .github/dependabot.yml sets a 5-day supply-chain cooldown
   whose stated purpose is dodging a package compromised shortly after publish; 4.5.0 is <48h old.
   Hard-pinning the SIGNING toolchain to an artifact fresher than the repo's own routine-update
   policy allows would invert that policy at the highest-privilege point in the pipeline. 4.4.0 has
   aged 23 days. Re-evaluate once 4.5.0 clears the window.

2. `packaging` is DERIVED from constraints.lock, not hardcoded. It IS a DEP-1 dependency
   (requirements.lock + constraints.lock both pin it at 26.2), so a literal would drift on the next
   Dependabot bump. Same run-time-read pattern as quality-advisory.yml's ruff pin, but FAIL-CLOSED
   instead of falling back to an unpinned fetch — a soft fallback on the release path defeats the
   pin exactly where it matters. Both installs also move ABOVE their `GITHUB_REF_TYPE = tag` guard
   so a workflow_dispatch dry-run exercises them; they were the only two of the five a dispatch
   could not reach, and an install that never runs before the tag cannot be validated before it.

3. ~=7.3.1 rather than ~=7.3 for cyclonedx-bom: the looser form floats the whole 7.x minor range,
   and a 7.4 could change the JSON shape scripts/security/sbom_finalize.py parses — which exits
   non-zero and FAILS the release. ~=7.3.1 still takes patch fixes.

This is residual-risk reduction, NOT an alert closure: ADR 0034 §3 shows from this repo's own data
that exactly-pinned tools (bandit==1.9.4, zizmor==1.5.2) are still flagged while --require-hashes
installs are not. Pinning `sigstore` also pins the TOP only — its ~30 transitive deps still float
at signing time. Closing it needs the hashed release-tools lock (ADR 0034 option B), which is an
owner call and is deliberately NOT built here.

GUARD: a pin nothing watches rots back to unpinned, and Dependabot has no updater for an inline
`pip install X==Y` in a workflow (its `uv` ecosystem reads only pyproject.toml + uv.lock), so both
a stale pin and a DELETED pin are invisible. tests/test_ci_venv_pinning.py gains two tests: a
BLANKET scan of every pip-install target in release.yml (so a NEW unpinned install added tomorrow
fails too — the case a fixed name list cannot see), plus a non-vacuity table asserting each tool is
still installed and still pinned at EVERY occurrence.

Guard proven by injecting one regression at a time rather than trusting a green run:
  un-pin sigstore        -> 2 failed (blanket scan + the named backstop)
  DELETE the install     -> 1 failed (only the backstop can see this, as designed)
  un-pin 1 of 2 `build`  -> 3 failed (proves every occurrence is checked, not just the first)
  restored               -> 12 passed
Both workflows re-parsed as YAML. 192 passed across every test that reads .github/workflows.

Line-neutral where ADR 0034 requires it: the scanner re-fires a dismissed alert under a NEW number
when a line moves, so each pin was made in place. Not done: security.yml's `pip uv` (line 55) and
`pip-audit` (81) stay unpinned — the DEP-1 job is contents: read and outside the release path.

* test(crypto): finish the cell-AAD sweep and correct the rationale the sweep recorded

Follow-up to 09767bf. The sweep widened the "encrypted at rest" assertions correctly, but
review found five things it got wrong or left half-done.

1. Five more bare `decrypt()` calls on cell-bound columns (the defect the sweep fixed once,
   in test_ack_sent_store). Each fails closed on an mfenc:v2 value and was only invisible
   because the v1 prefix assertion above it failed first:
     - test_store_encryption   x3, queue.last_error   (store.py binds cell_aad("queue","last_error", <row id>))
     - test_sqlserver_store    x1, response.body      (sqlserver.py:4326)
     - test_postgres_store     x1, response.body      (postgres.py:3668)
   The SS/PG pair skip without a live DB, so they surface only in CI. Both now read
   destination_name back from the row rather than rebuilding the "\x1fack:" sentinel.

2. Seven new comments asserted a causal link that does not exist: "the at-rest format follows
   [store].aad_bind (v2 by default), so a v1-only prefix would fail here". aad_bind is read in
   exactly one place -- base.py:1736, make_cipher(..., write_v2=settings.aad_bind), reachable
   only via build_cipher/open_store. Every one of these tests builds its own cipher, and
   make_cipher's write_v2 parameter still defaults False, so they observe mfenc:v1: in a normal
   run and the setting has no influence on them. Reworded to the true reason: the marker version
   is the cipher's business, not the assertion's.

3. Two poison-injection comments named the wrong failure mechanism. Measured:
     'mfenc:v1:not-base64-$$$' -> ValueError: Nonce must be between 8 and 128 bytes
     'mfenc:not-base64-$$$'    -> CipherError: unknown at-rest marker version
   `_parse` splits on the second colon, which is absent, so the blob is empty and b64decode("")
   succeeds -- the raise comes out of AESGCM, not the base64 path. The decision to keep these
   two at v1 was right; only the stated mechanism was wrong.

4. test_store_encryption's M9 section claimed "WRITES v1 BY DEFAULT" / "the shipping default
   never emits a v2 marker" in the same file where the sweep added six "v2 by default" comments.
   ADR 0148 falsified the prose, not the tests: they pin make_cipher's PARAMETER default (still
   False), while the shipped store writes v2 via build_cipher. Both defaults now named separately.

5. test_bytes_per_message_amplification documented the at-rest shape as mfenc:v1:<key_id>:<b64>.

Receipt (real numbers, run from this worktree). Default mode over the 11 edited modules:
166 passed, 351 skipped. Forced-v2 leg (MEFOR_TEST_FORCE_AAD_BIND=1) over all 60 test modules
that import store.crypto -- the scanned set, not a 12-file window: 10 failed, 1159 passed,
359 skipped, down from 13 failed. The 3 that stopped failing are exactly the last_error sites
in (1). All 10 remaining are deliberate-v1 sites that MUST red under a forced v2 writer:
the four CRYPTO-1 frozen-writer pins, test_active_marker_prefix_v1_and_v2,
test_key_id_is_a_fingerprint_not_zero, and the four v1-premise dual-read tests.

No messagefoundry/ change. ruff check + ruff format --check clean over tests/.

* ci(supply-chain): make the pin guard actually reject a non-pin, and finish the ADR 0034 rows

Follow-up to 9bf7178. The pins were right; the guard protecting them was not, and two of the
four ADR 0034 rows were only half-applied.

THE GUARD ACCEPTED `>=` AS A PIN. `_VERSION_OPS` listed ("==", "~=", ">=", "<=", "!="), so
`pip install "sigstore>=4.4.0"` — which resolves whatever PyPI serves at tag time, byte-for-byte
the exposure ADR 0034:190 calls "the highest residual in the group … runs with the OIDC identity
used to publish" — passed green under a test named test_release_toolchain_pin_is_present, and its
own failure text never fired. Split into _PIN_OPS ("==", "~=") for the pin decision and _SPEC_OPS
(every PEP 440 operator) for matching a token to a package NAME, so an unpinned `sigstore>=…` is
reported as unpinned rather than as a missing step.

Three more holes in the same scan:
  - `"/" not in target` exempted every URL and git+ target — the one class that cannot carry a pin
    at all. `pip install https://…/evil.whl` inserted before the Sigstore step scored ZERO failures.
    Remote is now tested FIRST and always reported; only local path shapes are exempt.
  - `line.split(" install ", 1)[1]` disagreed with the `\bpip\s+install\b` regex that selected the
    line, so `pip\tinstall` raised IndexError instead of asserting. Both now anchor on one regex,
    which also picks up `pip3 install` and `pip --quiet install`.
  - The vacuity floor said >= 6 while the file has 8 pip installs — two steps could be deleted
    before the scan noticed. Floor is now the real count.

ADR 0034 ROWS FINISHED:
  - The harness `packaging` install got the pin but not the venv. ADR 0034:192 prescribes both, and
    the venv is the half carrying the risk: it landed in release-harness's MAIN interpreter, and the
    steps after it attach the wheel to the release and publish to PyPI. Now /tmp/harnesssmoke,
    mirroring the engine job's /tmp/relsmoke.
  - The two SBOM installs had drifted apart, which quietly retired ADR 0034:205's pre-tag validation
    route ("run security.yml's sbom job … the install command there is byte-identical"). Nothing in
    PR CI executes release.yml, so that dispatch was its only dry-run proxy. Both lines are now the
    same command and a test keeps them that way.

The register itself said "Recommended hardening — identified, NOT done" over four rows this branch
had already done, and called them "an owner decision, not a drive-by". Rewritten with per-row status
and the reason the owner gate is retired for them (they are PR-visible now). What is NOT closed is
stated plainly: these pin only the top of each install, sigstore's ~30 transitive deps still float,
and §3's own data (bandit==1.9.4 pinned and still flagged) says no pin moves the Scorecard finding —
option B remains the only thing that does, and remains an owner call.

New guards: security.yml's unpinned installs are a REGISTERED set (pip/uv/pip-audit — contents:read,
schedule-only, nothing anyone installs) so a new one there still reds; the SBOM twin-identity check;
and a PR-time canary that constraints.lock still carries exactly one `packaging==` line, because
release.yml derives that pin at run time and `exit 1`s without it — on the tag push.

RECEIPTS. Guard mutation-tested against a TEMP COPY of the workflows (repo never modified), one
regression at a time — control 0 failures, no crashes:
  sigstore == -> >=                     2   blanket_scan + named_backstop
  build == -> >= (1 of the 2 installs)  2   blanket_scan + named_backstop[build]
  sigstore install deleted              2   blanket_scan + named_backstop
  new unpinned named install            1   blanket_scan
  new https://…whl install              1   blanket_scan   (scored 0 before this commit)
  new git+ install                      1   blanket_scan
  packaging pin -> bare                 2   blanket_scan + named_backstop[packaging]
  TAB / pip3 / flag-before-subcommand   2   each (was an IndexError crash)
  SBOM twin drift                       1   sbom_twin
  security.yml gains an unpinned target 1   security_registry
  --upgrade-deps / lost --require-hashes 2 / 1
  constraints.lock loses packaging==    1   packaging_canary

Both workflows re-parse as YAML; permissions, triggers and job lists are byte-identical to
origin/main (checked, not assumed). 195 passed / 10 skipped across all 14 test modules that read
.github/. No messagefoundry/ change.

* ci(release): pin the console job's toolchain, which the new guard caught

The console publish job landed while this branch was open and installs pip,
build and packaging unpinned. The guard added earlier on this branch rejects
exactly that, so updating the branch against main turned all three test legs
red -- the guard catching a real regression written after it was, which is the
best evidence it works that this branch could have produced.

pip and build take the same explicit pins the sibling build jobs use. packaging
is derived from constraints.lock the way relsmoke and harnesssmoke already do,
so one lock bump moves every release-path packaging install together instead of
letting them drift apart.

Also adds the check the pin guard structurally cannot make. Getting here, an
edit put a literal 0x01 byte in release.yml -- a sed backreference written into
a non-raw Python string -- and the file became unparseable YAML that GitHub
Actions could never have run. The pin guard passed green through it, because it
greps lines and never parses the document. So the file is now verified by
parsing it, and its permissions blocks and triggers are compared against
origin/main as parsed structures rather than eyeballed: jobs release,
release-webconsole, release-harness; permissions identical; triggers identical.

Verification: pin guard 15 passed; full suite 9331 passed, 818 skipped, 1
failed. That failure is tests/test_gate_installed_parity.py, which is
PRE-EXISTING and unrelated -- it reproduces identically on clean main, this
branch touches neither the gate source nor that test, and the test is
LOCAL-MACHINE only (it skips on CI, where no installed gate exists). It reports
that this workstation's installed ~/.claude/hooks copy is older than the source
that PR #36 merged; re-running install-gate.ps1 is the fix, and that is
deliberately an owner action because it activates the EnterWorktree rule for
every session on the box.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant