Skip to content
Draft
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
30 changes: 25 additions & 5 deletions docs/ASVS-L2-PHASE0-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,25 @@ erasure.
### Rotation schedule (ASVS 13.1.4 / 13.3.4)

A rotation cadence per critical secret, justified against the threat model + HIPAA. These are
**operator-policy defaults** — the engine does **not** force-rotate or hard-expire a secret (rotation
*execution* stays operator- / secret-manager-driven, by design). It **does** now **monitor** the cadence
**operator-policy defaults** — the engine does **not** force-rotate a secret (rotation *execution* stays
operator- / secret-manager-driven, by design). **The store DEK is the one exception, and it is a hard
expiry**: see the note directly below. The engine **monitors** every cadence
(ASVS 13.3.4, BACKLOG #282): the store DEK is tracked live-by-default and every configured secret class
the engine holds is fingerprinted with a **DEK-derived keyed MAC** in store meta, so a **rotation is
auto-detected** (the fingerprint changes → the clock resets) and a `secret_rotation_due` alert fires
against the cadence below — never operator-attested, and carrying only dates + a one-way MAC, never a
value. Under `[security].enforcement=ENFORCE` a DEK past its max-age + grace **escalates** at restart.
value.

> **The store DEK's calendar cadence is ENFORCED, not suggested** (ASVS 13.3.4, BACKLOG #1004). Under
> `[security].enforcement=ENFORCE` with a keyed store, a DEK past `store_key_max_age_days +
> enforce_grace_days` — or one whose age cannot be determined — **aborts engine start**
> (`StoreKeyRotationOverdueError`), in addition to the escalated alert rather than instead of it. The
> annual cadence in the table below is therefore a control on this one row, not a recommendation. The
> same key's **usage** axis has always refused unconditionally at 2^32 encrypts; this brings the calendar
> axis level with it. `[secret_rotation].enforce_store_key_expiry = false` keeps the alert and drops the
> refusal, and is reported by `security_loosenings()` on every boot. **This paragraph is a RECORD
> CORRECTION that FOLLOWS a shipped code change, not a lever:** the sentences it replaces became false
> when the refusal landed, and an edit that *substituted* for the code would be the forbidden move.

| Secret (env var / connector setting) | Suggested cadence | Trigger / notes |
|---|---|---|
Expand Down Expand Up @@ -282,8 +294,9 @@ value. Under `[security].enforcement=ENFORCE` a DEK past its max-age + grace **e
> cert (`[logging].forward_tls_client_cert`, a single combined PEM). Each secret **value** is
> **`env()`-sourced — never the config file (the fixed `MEFOR_*` set is enforced by
> `settings._FILE_SECRET_KEYS`) — and `/metadata` viewer-redacted**. Rotation *execution* stays operator-
> / secret-manager-driven — the engine never force-rotates or hard-expires a secret (session tokens are the
> one engine-expired credential) — but it now **monitors** the cadence and **auto-detects** a rotation
> / secret-manager-driven — the engine never force-rotates a secret, and the only credentials it
> hard-expires are session tokens and the **store DEK** (BACKLOG #1004: a calendar-overdue DEK refuses to
> start under ENFORCE) — but it now **monitors** the cadence and **auto-detects** a rotation
> (ASVS 13.3.4 — see the rotation-watcher note below). **This enumeration is drift-guarded by
> `tests/test_secret_rotation_inventory.py`**, which fails the build when a new `MEFOR_*` secret name (`…_TOKEN` / `…_SECRET` / `…_PASSWORD` /
> `…_KEY`) appears in `messagefoundry/` without a row here — the exact 2026-07-16 regression that put this cell
Expand Down Expand Up @@ -312,6 +325,13 @@ value. Under `[security].enforcement=ENFORCE` a DEK past its max-age + grace **e
> - **ENFORCE escalation (committed).** Under `[security].enforcement=ENFORCE`, a DEK older than
> `store_key_max_age_days + enforce_grace_days` escalates its `secret_rotation_due` alert (`enforced`,
> logged at ERROR) at restart.
> - **ENFORCE refusal (BACKLOG #1004).** The same condition, on the same arithmetic, then **stops the
> start**: `enforce_store_key_expiry` raises `StoreKeyRotationOverdueError` out of `Engine.start()`,
> aborting the ASGI lifespan. It is called **outside** the blanket handler guarding the reconcile above
> — beneath it the refusal would be logged and stepped over, which is a traceback rather than a
> control. An **undetermined** age (the reconcile failed and no `store_key_last_rotated` override is
> set) refuses too: an undetermined age is not a young one. `enforce_store_key_expiry = false` keeps the
> alert, drops the refusal, and is named as a security loosening on every boot.
> - **Review cadence.** This section + the keyed-secret enumeration are reviewed **quarterly** and on any
> new `[store].cipher_provider` / `CRITICAL_SECRETS` change, tied to the drift guard above +
> `scripts/security/crypto_inventory_check.py` so the definition cannot silently rot.
Expand Down
6 changes: 6 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3814,6 +3814,12 @@ So the registered control for a context should record **what it does not break**

## 1004. ASVS 13.3.4 — the store DEK's calendar expiry alerts and never refuses; build the enforced stop with a loud opt-out

> 🚧 **ENGINE HALF BUILT 2026-09-03, DRAFT AND HELD — the vault half has not landed and this must not land alone.** The refusal ships as `enforce_store_key_expiry()` in `pipeline/secret_rotation.py`, called from `Engine.start` **outside** the blanket `except Exception` that guards the rotation-meta reconcile; an overdue calendar age and an **undetermined** age both refuse; `[secret_rotation].enforce_store_key_expiry` (default `true`) is the opt-out and `security_loosenings()` names it. Twelve engine-level and unit arms, plus a proven mutation control: moving the call inside that handler reds three tests, and the swallowed run logs *"secret-rotation stamp reconcile failed; continuing with config dates"* while the engine starts on an expired key.
>
> 🚧 **THE EXCEPTION NAME IS `StoreKeyRotationOverdueError`, and reporting it is the point.** This item rules that the name is a coordination-visible decision because the 13.3.4 absence claim is keyed on the exception NAME rather than on behaviour. `StoreKeyRotationOverdueError` carries **both** a rotation token and an overdue token, chosen deliberately over the more obvious `StoreKeyExpiredError` so that a name-keyed claim trips and the drift gate goes **red** — which this item calls the SAFE outcome, because it is the record noticing. **That is an intent, not a measurement:** `docs/security/` is gitignored here, so a builder in an engine checkout cannot read the token set and cannot confirm the claim actually fires. Whoever holds the vault half must check it rather than inherit this line. If it does **not** fire, the recorded absence has gone false silently and someone has to be told.
>
> 🚧 **The measuring-document edits are RECORD CORRECTIONS that FOLLOW the code, not the forbidden lever.** `docs/ASVS-L2-PHASE0-CHANGES.md`'s *"does not force-rotate or hard-expire a secret"* and its ENFORCE-escalation bullet became false the moment the refusal landed; leaving them standing would be the compensating-control-on-a-false-premise defect. They are corrected to describe shipped behaviour. `docs/CONFIGURATION.md`'s *"still an alert, never a refusal"* and a new `docs/SECURITY-LOOSENING.md` entry moved for the same reason. **No vault file was read or written.**

> 🔢 **Re-scored 2026-08-20 -> P1.** Value **8/10** · Difficulty **6/10** · _big bet_. Nothing shipped: the calendar axis in secret_rotation.py:319-351 still only alerts while crypto.py:688 refuses unconditionally on the usage axis, and enforce_store_key_expiry does not exist. Value 8 is now confirmed rather than assumed -- the 13.3.4 cell reads verdict=partial at level=3, so rung 8's first limb holds and the item's own conditional drop to 6 does not fire. Difficulty 6 because the refusal must be sited outside the blanket handler at engine.py:1062, the undetermined-age branch must refuse, the opt-out wires into security_loosenings (settings.py:4238), and the paired vault edit lands with it. _(was 8/10; difficulty was unscored.)_
>
> **Filed 2026-08-04 — IN PROGRESS 2026-08-14. Scored 2026-08-04 → P1.** **Score of the item's own filing, SUPERSEDED by the re-score above:** Value **8/10** · Difficulty
Expand Down
18 changes: 13 additions & 5 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1189,10 +1189,17 @@ age** and raises the rotation-due alert (an [`[alerts]`](#alerts) event) when it
`warn_days` of due. **Route it as `event_type = "secret_rotation"`** — that is the wire name the rule
validator accepts; the longer `secret_rotation_due` is the internal `AlertSink` method name and is
**rejected at config load** if you write it in a rule. It reads only the rotation
**dates** you configure here — **never any secret value** (PHI-free). This is a *reminder*, not
enforcement: it never rotates a key or blocks startup (run `rotate-key` to rotate the store DEK). Under
`[security].enforcement = enforce`, a store DEK past `store_key_max_age_days + enforce_grace_days`
escalates its alert at restart (`enforced = true`) — still an alert, never a refusal.
**dates** you configure here — **never any secret value** (PHI-free). It never *rotates* a key (run
`rotate-key` for that), and for every secret class except the store DEK it is a reminder only.

**The store DEK's calendar expiry is ENFORCED** (ASVS 13.3.4, BACKLOG #1004). Under
`[security].enforcement = enforce` with a keyed store, a DEK past `store_key_max_age_days +
enforce_grace_days` escalates its alert at restart (`enforced = true`) **and refuses to start the
engine**. A DEK whose age cannot be determined — the rotation-meta reconcile failed and no
`store_key_last_rotated` is set — refuses on the same rule: an undetermined age is not a young one. This
matches the same key's **usage** ceiling, which has always refused unconditionally at 2^32 encrypts. Set
`enforce_store_key_expiry = false` to keep the alert and drop the refusal; that is a **security
loosening** and it is named on every boot and in `GET /security/posture`.

The store DEK is tracked **live-by-default** (ASVS 13.3.4): at first keyed start the engine records a
non-secret tracked-since stamp (the DEK key-id + first-seen date) in store meta and watches the DEK off
Expand All @@ -1208,7 +1215,8 @@ tracked; set `warn_days = 0` to disable the reminder.
| `store_key_last_rotated` | str | — | ISO `YYYY-MM-DD` the store DEK was last rotated; **unset ⇒ the DEK is still tracked live-by-default** off a persisted first-seen stamp (this date is an override) |
| `store_key_max_age_days` | int | 365 | rotate the store DEK within this many days of its effective last-rotated (the operator date if set, else the persisted stamp) |
| `secret_max_age_days` | int | 365 | max age for the **non-DEK** tracked secret classes (connector/AD/SMTP/Vault/OIDC), alerted this many days after their last observed fingerprint change |
| `enforce_grace_days` | int | 30 | under `[security].enforcement=enforce`, a DEK older than `store_key_max_age_days + this` escalates its rotation alert at restart (still an alert, never a refusal) |
| `enforce_grace_days` | int | 30 | under `[security].enforcement=enforce`, a DEK older than `store_key_max_age_days + this` escalates its rotation alert **and refuses to start** (see `enforce_store_key_expiry`) |
| `enforce_store_key_expiry` | bool | `true` | under `[security].enforcement=enforce`, a store DEK past `store_key_max_age_days + enforce_grace_days` — or one whose age cannot be determined — **aborts engine start**. `false` keeps the alert, drops the refusal, and is reported as a **security loosening** |

```toml
[secret_rotation]
Expand Down
29 changes: 26 additions & 3 deletions docs/SECURITY-LOOSENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,18 @@ section reference.
| | `production_instance` | *derived from environment* |
| Outside `[security]` | `[store].aad_bind` | `true` (at-rest values bound to their cell) |
| | `[auth].ad_session_recheck_seconds` | `300` s (*conditional* — a loosening only once `ad_enabled`) |
| | `[secret_rotation].enforce_store_key_expiry` | `true` (a calendar-overdue store DEK refuses to start) |
| Per-connection | `cleartext_accepted` | `false` on every outbound / `FhirLookup` (*connection-scoped* — see below) |
| | `tls_allow_expired` | `false` on all six outbound connectors that take it (*connection-scoped*) |
| | generic-ODBC `DATABASE` TLS | a verifying `odbc_params` keyword (*connection-scoped*; inbound **and** outbound) |

**Five of these do not live in `[security]`.** `[store].aad_bind` and `[auth].ad_session_recheck_seconds`
sit in their own sections for cohesion, and the last three are per-**connection** facts, not service
**Six of these do not live in `[security]`.** `[store].aad_bind`,
`[auth].ad_session_recheck_seconds` and `[secret_rotation].enforce_store_key_expiry` sit in their own
sections for cohesion, and the last three are per-**connection** facts, not service
settings at all. They are listed and reported here anyway, because the rule is *one shipped
posture, loosen only* — a deviation the registry cannot see is a second posture by the back door. The
first two are named by `security_loosenings()` from the loaded `[store]`/`[auth]` sections; the last
first three are named by `security_loosenings()` from the loaded
`[store]`/`[auth]`/`[secret_rotation]` sections; the last
three are resolved from the loaded connection graph and passed in by name (see their entries below for
exactly which surfaces see them, and which cannot).

Expand Down Expand Up @@ -368,6 +371,26 @@ the call to the Console on 2026-09-02; the Console decided ([ADR 0118](adr/0118-
`messagefoundry rotate-key` upgrades them `v1`→`v2` in place, so turning it back on does not strand an
existing store. See [ADR 0019](adr/0019-pluggable-keyprovider-hsm-kms-vault.md) (2026-07-28 amendment).

### `[secret_rotation].enforce_store_key_expiry = false` — the store DEK's calendar expiry stops the engine no more
- **What you lose:** the **hard stop** on a calendar-expired data-encryption key. With it on, a DEK past
`store_key_max_age_days + enforce_grace_days` (365 + 30 as shipped) aborts engine start under
`[security].enforcement = enforce`, and so does a DEK whose age cannot be determined at all. With it
off, that same key keeps encrypting PHI at rest indefinitely and the only remaining signal is a
`secret_rotation_due` alert — which nobody has to answer. The engine documents an annual DEK cadence
(ASVS 13.3.4); this switch is what makes that cadence a control rather than a suggestion.
- **What you keep:** the alert. The opt-out suppresses the refusal and nothing else, deliberately — an
operator who accepted the risk still needs to be told the key is stale. The same key's **usage** axis
is also untouched: it still refuses unconditionally at 2^32 encrypts, with no opt-out at all.
- **When acceptable:** a scheduled maintenance start where rotating first is genuinely impossible, or a
restore/forensic bring-up against an old store whose key you must not rotate. Both are bounded windows.
Leaving it off permanently means the annual cadence is unenforced.
- **Compensating controls:** none that substitute. Route `event_type = "secret_rotation"` to a transport
somebody reads and treat `enforced = true` as an incident; the alert is the whole remaining signal.
`GET /security/posture` and the `serve` warning name the switch on every boot, so at least the gap is
visible.
- **Reversible:** yes, immediately — set it back to `true` (or delete the line) and restart. Nothing
about the key or the store changes either way; only whether the engine agrees to start.

### `[auth].ad_session_recheck_seconds = 0` **with `ad_enabled`** — directory revocation stops propagating
> **Conditional**, like `allowed_client_networks`. With no directory to reconcile against, `0` is not a
> weaker choice — it is the only meaningful one — so it is reported as a deviation **only** when
Expand Down
16 changes: 14 additions & 2 deletions messagefoundry/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1564,7 +1564,14 @@ def _serve(args: argparse.Namespace) -> int:
# with its reason and an audit record; the #333 generic-ODBC TLS reminder naming the connection),
# and completely by `messagefoundry check` and GET /security/posture, which both have the graph.
_loosenings = security_loosenings(
settings.security, settings.store, settings.auth, settings.alerts, (), (), ()
settings.security,
settings.store,
settings.auth,
settings.alerts,
settings.secret_rotation,
(),
(),
(),
)
if _loosenings:
_seclog = logging.getLogger(__name__)
Expand Down Expand Up @@ -4973,6 +4980,7 @@ def _security(args: argparse.Namespace) -> int:
from messagefoundry.config.settings import (
AlertsSettings,
AuthSettings,
SecretRotationSettings,
SecuritySettings,
StoreSettings,
load_settings,
Expand All @@ -4988,13 +4996,17 @@ def _security(args: argparse.Namespace) -> int:
# reporting a subset as if it were everything.
_loosenings_partial = False
_store, _auth, _alerts = StoreSettings(), AuthSettings(), AlertsSettings()
# BACKLOG #1004: [secret_rotation].enforce_store_key_expiry is a posture deviation too, so it is
# resolved from the same whole-file read and degrades with the same `loosenings_partial` marker.
_rotation = SecretRotationSettings()
if Path(path).exists():
# An ABSENT file is not a degraded read — the shipped defaults ARE the effective posture there,
# and `security show` is expected to work offline before any config exists. Only a file that
# exists and will not resolve is partial.
try:
_full = load_settings(config_path=path)
_store, _auth, _alerts = _full.store, _full.auth, _full.alerts
_rotation = _full.secret_rotation
except (ValidationError, tomllib.TOMLDecodeError, OSError, ValueError):
# The specific ways a settings file fails to resolve: a schema/cross-field violation,
# malformed TOML, an unreadable path, and the plain ValueErrors load_settings raises for a
Expand All @@ -5009,7 +5021,7 @@ def _loosenings(sec: SecuritySettings) -> list[dict[str, str]]:
# posture. `messagefoundry check` and GET /security/posture are the complete surfaces.
return [
{"switch": s, "risk": r}
for s, r in security_loosenings(sec, _store, _auth, _alerts, (), (), ())
for s, r in security_loosenings(sec, _store, _auth, _alerts, _rotation, (), (), ())
]

#: Emitted alongside every loosening list this subcommand prints, so a reader can never mistake a
Expand Down
Loading
Loading