Skip to content
Merged
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
97 changes: 96 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ on:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*" # pre-releases: v0.1.0-rc1, …
# The web console is SEPARATELY VERSIONED (its own __version__, changelog and PyPI cadence —
# docs/WEBCONSOLE-PACKAGE.md), so it gets its own tag namespace rather than riding an engine tag.
# It lives in THIS workflow rather than its own file because the PyPI Trusted Publisher for
# messagefoundry-webconsole is registered against `release.yml`; a separate file would not match it.
- "webconsole-v[0-9]+.[0-9]+.[0-9]+"
- "webconsole-v[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:

# Deny-all at the workflow level; each job below requests only the scopes it uses (least privilege).
Expand All @@ -55,7 +61,9 @@ jobs:
# was already flipped to `==` at the cutover; release.yml was missed.
#
# `==` is also the safe form now: a fork, or the retired private vault, still cannot release.
if: github.repository == 'MEFORORG/MessageFoundry'
# NOT on a console tag: the console has its own job below and its own version root, so an
# engine release built from `webconsole-v*` would ship the engine at the console's version.
if: github.repository == 'MEFORORG/MessageFoundry' && !startsWith(github.ref_name, 'webconsole-')
runs-on: ubuntu-latest
permissions:
contents: write # create the release + upload its assets
Expand Down Expand Up @@ -309,6 +317,93 @@ jobs:
skip-existing: true
attestations: true

release-webconsole:
# Build + publish the SEPARATE `messagefoundry-webconsole` distribution (the browser ops console,
# packaging/messagefoundry-webconsole/). Unlike release-harness this is NOT lockstep with the engine:
# the console has its own __version__ root, changelog and PyPI cadence (docs/WEBCONSOLE-PACKAGE.md),
# so it fires on its OWN `webconsole-v*` tag and has NO `needs: release` — an engine release must not
# drag the console along, and a console release must not wait on one.
#
# ASVS 15.2.4 (dependency confusion). `messagefoundry-webconsole` is registered on PyPI as a PENDING
# Trusted Publisher against this workflow, which grants permission to publish but does NOT reserve the
# name: until a distribution is actually uploaded the name is claimable by anyone, and our own docs
# reference it. The FIRST successful run of this job creates the project and closes that exposure.
# Until then docs must point at the source-tree install (pinned by tests/test_install_instruction_provenance.py).
if: github.repository == 'MEFORORG/MessageFoundry' && startsWith(github.ref_name, 'webconsole-')
runs-on: ubuntu-latest
permissions:
contents: write # gh release upload (attach the console wheel)
id-token: write # PyPI Trusted Publishing (OIDC) — no API token anywhere
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"

- name: Build the console wheel (wheel-only — the package tree is force-included from the repo root)
run: |
python -m pip install --upgrade pip build
# Wheel-only for the same reason as the harness: messagefoundry_webconsole/ lives OUTSIDE this
# project dir and is pulled in via force-include, so an sdist would not be self-contained.
python -m build --wheel ./packaging/messagefoundry-webconsole --outdir webconsole-dist
ls -l webconsole-dist/

- name: Smoke-check the console wheel (version == the console's OWN __version__ == tag)
run: |
built=$(python -c "import glob,re; print(re.search(r'messagefoundry_webconsole-([^-]+)-', glob.glob('webconsole-dist/*.whl')[0]).group(1))")
echo "console wheel version: $built"
# Compared against the CONSOLE's version root, never the engine's — that is the whole point of
# the separate tag namespace. PEP 440 comparison because hatchling normalises the filename
# (tag webconsole-v0.3.0-rc1 -> 0.3.0rc1), so a string compare could never match a pre-release.
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
want="${GITHUB_REF_NAME#webconsole-v}"
python -m pip install --quiet packaging
python - "$built" "$want" <<'PYVER'
import sys
from packaging.version import InvalidVersion, Version

built, want = sys.argv[1], sys.argv[2]
try:
b, w = Version(built), Version(want)
except InvalidVersion as exc:
raise SystemExit(f"::error::unparseable version ({exc}) — built={built!r} tag={want!r}")
if b != w:
raise SystemExit(
f"::error::console wheel version {built} != tag {want} (normalised {b} != {w})"
)
print(f"console version matches tag: {b}")
PYVER
fi

- name: Attach the console wheel to the GitHub release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${GITHUB_REF_NAME}" webconsole-dist/*.whl --clobber

- name: Upload console artifact (dry-run / always)
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: webconsole-artifacts
path: webconsole-dist/
if-no-files-found: ignore

# GATED like the harness: no-op until the owner sets repo variable PUBLISH_WEBCONSOLE=true. The
# build + version-check above still run on every console tag, so the publish path is exercised
# before it is armed. Flipping the variable is what actually claims the PyPI name.
- name: Publish messagefoundry-webconsole to PyPI (Trusted Publishing / OIDC)
if: ${{ startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_WEBCONSOLE == 'true' }}
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
packages-dir: webconsole-dist/
skip-existing: true
attestations: true

release-harness:
# Build + publish the SEPARATE `messagefoundry-harness` distribution (the synthetic test/load/failover
# harness, packaging/messagefoundry-harness/) in lockstep with the engine — same version, same tag.
Expand Down
2 changes: 1 addition & 1 deletion docs/ASVS-L2-PHASE0-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Update it whenever a crypto dependency, algorithm, or key source changes.
| Store-at-rest cipher — in-process (`[store].cipher_provider = aesgcm`, default) | AES-256-GCM (`mfenc:v1:<key_id>:…`, additive AAD-bound `mfenc:v2`) in-process keyring cipher. **The shipped default is key-required, NOT keyless:** every built-in environment (`dev`/`staging`/`prod`) derives `data_class=phi` ([ADR 0148](../adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1), and a PHI instance **refuses to start keyless** in *any* environment (the `serve` gate in [`__main__.py`](../../messagefoundry/__main__.py); `[store].require_encryption` forces the refusal even for a synthetic instance). The identity/plaintext cipher runs only on a **synthetic / non-PHI** instance (`data_class ≠ phi`, e.g. CI) or via the loud, audited `[store].allow_unencrypted_phi` opt-out (a second `[security].allow_unencrypted_phi_under_strict_enforcement` ack under strict enforcement). Covers the `_CIPHER_COLUMNS` set — `messages.raw`/`.summary`/`.metadata`/`.error`, `queue.payload`/`.last_error`, `message_events.detail`, `users.totp_secret`, `connection_event.reason`, `alert_instance.reason` (`summary`/`metadata` carry ingest-derived MRN/patient-name PHI); WebAuthn COSE public keys are deliberately excluded (verification material, not a secret). **Usage scope:** this DEK is the confidentiality key for the at-rest store columns above **only** — message bodies + ingest-derived MRN/patient-name PHI; it is **not** a transport, signing, or token key, is never used to verify a signature, and never leaves the engine host. | **Keyring:** active key `MEFOR_STORE_ENCRYPTION_KEY` (or DPAPI-protected `MEFOR_STORE_ENCRYPTION_KEY_FILE`) + decrypt-only `MEFOR_STORE_ENCRYPTION_KEYS_RETIRED`; `key_id` = SHA-256 fingerprint | **See key-management policy below** |
| Vault Transit KeyProvider (opt-in, [ADR 0019](../adr/0019-pluggable-keyprovider-hsm-kms-vault.md)) | Envelope-unwraps the store DEK: Vault Transit `decrypt_data` returns the base64 32-byte DEK, decrypting the wrapped DEK (`vault:v1:…`) against a **non-extractable KEK held inside Vault**; only the wrapped DEK sits at rest. **Fail-closed** — any missing config or Transit/transport failure raises `KeyProviderError` and `serve` refuses to start (never degrades to the identity/plaintext cipher); key material is never logged (only the exception type). `hvac` in [`store/keyprovider_vault.py`](../../messagefoundry/store/keyprovider_vault.py). **Usage scope:** the Vault KEK **only** unwraps the store DEK inside Vault — it never encrypts store data directly and never leaves Vault; the DEK it yields protects exactly the at-rest store columns above, and no transport / signing / token material. | Env-sourced: `MEFOR_STORE_VAULT_ADDR` / `MEFOR_STORE_VAULT_TOKEN` / `MEFOR_STORE_VAULT_TRANSIT_KEY` / `MEFOR_STORE_VAULT_WRAPPED_DEK`; `hvac` behind the optional `[vault]` extra | **OFF by default** — `[store].key_provider` defaults to `auto` (env-then-DPAPI key sourcing for the in-process `aesgcm` cipher), not `vault` |
| Store-at-rest cipher — Transit mode (`[store].cipher_provider = vault_transit`, opt-in, [ADR 0138](../adr/0138-transit-bulk-crypto-provider-dek-out-of-engine-heap-for-asvs-13-3-3-demand-gated.md)) | Bulk at-rest AEAD encrypt/decrypt runs **inside** Vault/OpenBao Transit — each value is stored as the `mfenc:v3:` marker + Transit's own `vault:v1:…` ciphertext, and the audit-chain MAC is computed **inside** Transit via `generate_hmac` (forgery-**resistant**, not keyless SHA-256). `TransitCipher` in [`store/crypto_transit.py`](../../messagefoundry/store/crypto_transit.py) imports **none** of the six stdlib crypto modules — every primitive is delegated over the Vault HTTP seam, so the plaintext DEK **never enters engine heap** (ASVS 13.3.3 isolated security module; 13.3.1's L3 hardware clause still wants the vault HSM-sealed). ASVS 11.3.3 cell-binding rides for free — `cell_aad(table, column, *pk)` is forwarded as Transit `associated_data`. **Fail-closed** — missing config or an unreachable/unknown Transit key raises `KeyProviderError` and `serve` refuses to start (never degrades to plaintext); a per-op Transit failure raises `CipherError`, surfacing only the exception **type**, never key material or PHI. **Usage scope:** the named Transit keys encrypt/decrypt **only** the at-rest store columns above (data key) and compute **only** the audit-chain MAC (audit key); they are Vault-resident, non-exportable, and protect no transport / signing / token material. | Env-named Transit keys `MEFOR_STORE_TRANSIT_KEY` (data) + optional `MEFOR_STORE_TRANSIT_AUDIT_KEY` (audit MAC; unset ⇒ reuses the data key), reached via `MEFOR_STORE_VAULT_ADDR` / `MEFOR_STORE_VAULT_TOKEN`; `hvac` behind the optional `[vault]` extra | **OFF by default** — `[store].cipher_provider` defaults to `aesgcm`; the key material stays inside Vault, so **roll the Transit key version in Vault** (prior versions still decrypt existing `mfenc:v3` rows) |
| Audit chain | Row-hash chain (tamper-evident): **keyless SHA-256** in the default keyless posture, upgraded to **HMAC-SHA256** — keyed on an HKDF-SHA256-derived (`mefor/audit-chain/v1`) subkey of the store DEK — only when a store key is configured (#190), or on an isolated-module Transit MAC under `cipher_provider=vault_transit` (ADR 0138); both modes hash identical canonical bytes, so keyless deployments and legacy rows still verify. The digest primitive is `audit_row_hash` in `store/store.py` (`hashlib` + `hmac`), shared verbatim by all three backends. **Verification is constant-time and full-walk (ASVS 11.2.4):** `hmac.compare_digest` over `audit_mac_bytes` on every row MAC *and* on the external-anchor head, in `store/store.py`, `store/postgres.py` and `store/sqlserver.py` — the walk never returns early, so verify duration is a function of chain length, not of where a forgery sits (the first divergent row id is still named in the operator-facing result) | `audit_log.row_hash` | Append-only; verified by `messagefoundry audit-verify` |
| Audit chain | Row-hash chain (tamper-evident): **keyless SHA-256** in the default keyless posture, upgraded to **HMAC-SHA256** — keyed on an HKDF-SHA256-derived (`mefor/audit-chain/v1`) subkey of the store DEK — only when a store key is configured (#190), or on an isolated-module Transit MAC under `cipher_provider=vault_transit` (ADR 0138); both modes hash identical canonical bytes, so keyless deployments and legacy rows still verify. The digest primitive is `audit_row_hash` in `store/store.py` (`hashlib` + `hmac`), shared verbatim by all three backends. **Usage scope:** the HKDF-derived subkey (and, under `vault_transit`, the named Transit audit key) authenticates the **audit row chain only** — an integrity/tamper-evidence key, never a confidentiality key. It is domain-separated from the store DEK it is derived from (`mefor/audit-chain/v1`), decrypts nothing, protects no message body, and is not transport, signing, or token material. **Verification is constant-time and full-walk (ASVS 11.2.4):** `hmac.compare_digest` over `audit_mac_bytes` on every row MAC *and* on the external-anchor head, in `store/store.py`, `store/postgres.py` and `store/sqlserver.py` — the walk never returns early, so verify duration is a function of chain length, not of where a forgery sits (the first divergent row id is still named in the operator-facing result) | `audit_log.row_hash` | Append-only; verified by `messagefoundry audit-verify` |
| Config fingerprint ([ADR 0041](../adr/0041-load-path-attestation-and-change-attribution.md)) | SHA-256 content digest of a loaded config bundle — path-relative Merkle fold over every loaded file (`*.py` incl `_*.py`, `connections.toml`, `codesets/*`, `environments/*.toml`); `hashlib` in `config/fingerprint.py` | Recorded in the `config_reload` audit detail (not stored as a secret) | Recomputed per reload/startup; binds reviewed-commit → loaded-bytes (integrity/attribution, not confidentiality) |
| Engine wheel attestation ([ADR 0041](../adr/0041-load-path-attestation-and-change-attribution.md) D3) | SHA-256 over each **loaded** first-party `messagefoundry` module file, compared to the installed wheel's `*.dist-info/RECORD` baseline (a base64 `sha256=` manifest already in the wheel); `hashlib` in `integrity.py` | Drift recorded in the hash-chained `startup_integrity` audit row (not a secret); RECORD baseline read from site-packages metadata | Recomputed at startup + on demand; in-place-tamper tripwire (integrity, not confidentiality). Alert-only by default; `[integrity].fail_closed_on_drift` refuses to start on drift; no-op on an editable install |
| Outbound message signing (opt-in) | Detached JWS (RFC 7515) — RS256/PS256 (RSA) or ES256 (ECDSA P-256), SHA-256; `cryptography` in `transports/signing.py` (ASVS 4.1.5, [ADR 0018](../adr/0018-per-message-signatures-accepted-risk.md)) | Operator-supplied PEM **private** signing key per connection (inline via `env()` or a PEM file path; encrypted-key passphrase via `env()`); the **public** key is shared with the partner out-of-band. **Usage scope:** this private key **only** signs this connection's outbound per-message JWS — a message-**authenticity/integrity** key in transit; it is never used for at-rest encryption or session/token material, and the partner holds only the matching **public** verification half | **OFF by default**; per-connection opt-in. `kid` carried in the JWS header so key rotation / a managed provider ([ADR 0019](../adr/0019-pluggable-keyprovider-hsm-kms-vault.md)) slots in without a wire change |
Expand Down
Loading
Loading