Skip to content

FLIP-PT-004: authenticated AES-GCM payload encryption + per-trust keys - #845

Draft
atriaybagur wants to merge 8 commits into
developfrom
claude/aes-gcm-envelope-per-trust-keys
Draft

FLIP-PT-004: authenticated AES-GCM payload encryption + per-trust keys#845
atriaybagur wants to merge 8 commits into
developfrom
claude/aes-gcm-envelope-per-trust-keys

Conversation

@atriaybagur

Copy link
Copy Markdown
Member

Description

Cross-trust payloads were encrypted with AES-CBC and no authentication, so ciphertexts were malleable — a tampered payload decrypted to attacker-influenced plaintext rather than failing. That matters most for the hub→trust task payload, which carries the cohort SQL a trust then executes against OMOP.

This replaces the scheme with AES-256-GCM (AEAD) in a small key-id'd envelope, in flip-api, trust-api, imaging-api and data-access-api:

  • Tampering now raises InvalidTag. The envelope's kid is bound into the authentication tag, so it cannot be relabelled either.
  • Wire format: base64 of {"v":1,"kid","iv","ct"} — 12-byte GCM nonce, and a fixed algorithm rather than a negotiated one (no algorithm-confusion surface).
  • Keys resolve through a small keyring, so a service can hold several at once. This is what makes rotation and per-trust keys possible without every participant changing over at the same moment — the single-key, version-less scheme made rotation a flag day, which is why it never happened.

Per-trust keys. With one shared key, a single compromised trust yields the key for the whole federation. So:

  • register_trust mints a per-trust key and kid (trust-<id>), returned in the kit as trust_aes_key / trust_aes_kid.
  • submit_cohort_query encrypts project_id per destination trust — a single ciphertext reused across trusts could not be bound to a per-trust key.
  • Trusts without their own key keep using the shared key, so they can be moved over one at a time.

Wiring the kit distributor to write TRUST_AES_KID / TRUST_AES_KEY_BASE64 into the kit file is the remaining task before per-trust keys can be switched on — see docs/aes-payload-keys.md.

Roll-out (no coordination, no key change)

decrypt() also accepts the old AES-CBC format, so a hub and a trust on different builds still understand each other while hosts are upgraded — on-prem trusts cannot all be restarted at once. encrypt() is GCM-only, so no new unauthenticated ciphertext is written.

Nothing is stored encrypted at rest (task payloads are held as plaintext in the hub DB and encrypted at dispatch; encrypted_project_id and encrypted_password are in-flight only), so there is no data to migrate and no key changeAES_KEY_BASE64 is unchanged and a trust operator edits no configuration.

  1. Deploy hub and each trust, in any order and at any pace.
  2. When every host is upgraded, set AES_ACCEPT_LEGACY_CBC=false and confirm nothing breaks — that proves no peer still sends CBC.
  3. Delete the shim (marked DELETE AFTER ROLL-OUT in each module) in a follow-up PR.

One caveat during the upgrade: the hub marks a task IN_PROGRESS at dispatch, so a task collected by a peer that cannot read it stays IN_PROGRESS — reset any such rows to PENDING afterwards.

Linked Issues

Refs #429

Checklist

  • Follows the project's coding conventions and style guide
  • Updates documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Type of Change

  • Non-breaking change (fix or new feature that would not break existing functionality).
  • Breaking change (fix or new feature that would cause existing functionality to change).
  • New tests added to cover the changes.
  • In-line docstrings updated.
  • Documentation updated, tested make -C docs/ docs.

Non-breaking because of the compatibility shim — mixed-build hub/trust pairs interoperate. It becomes breaking the moment the shim is removed, hence the staged roll-out above.

Testing

I did the following tests to verify my changes:

  • Quick tests passed locally by running make unit-test.
  • Integration tests pass (if applicable) by running make integration-test.

49 unit tests green across the four services (ruff + mypy clean on every changed file):

Service Tests
flip-api 40 — encryption suite, register_trust CLI kit shape, submit_cohort_query
trust-api / imaging-api / data-access-api 11 each — shared encryption suite

Coverage of the security properties specifically:

  • Round-trip via keyring and via explicit key.
  • Tampered ciphertext → InvalidTag (bit-flip on ct).
  • kid swap → InvalidTag (the AAD binding).
  • Unknown kidKeyError; unsupported vValueError.
  • Per-trust key selected automatically when configured; hub encrypts to a named trust; kid_for_trust falls back when a trust has no key yet.
  • Shim: a payload built exactly as an un-upgraded peer would build it still decrypts; rejected once AES_ACCEPT_LEGACY_CBC=false; and turning the flag off does not disturb GCM.

I ran the affected suites rather than each service's full suite — worth a complete make test per service in CI before this leaves draft.

Additional Notes

  • Draft pending: a second reviewer on the envelope format and the shim's decrypt path (this is crypto touching four services), a full per-service make test in CI, and DCO sign-off under a human author — the commit is currently signed off by the automation identity.
  • Reviewers may want to confirm the format-detection choice in _parse_envelope: CBC ciphertext is indistinguishable from random so it never parses as a JSON object carrying kid, which is why the GCM envelope needed no version prefix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU


Generated by Claude Code

…eys (FLIP-PT-004)

Cross-trust payloads were encrypted with AES-CBC and no authentication, so
ciphertexts were malleable: a tampered payload decrypted to attacker-influenced
plaintext rather than failing. That matters most for the hub->trust task payload,
which carries the cohort SQL a trust then executes against OMOP.

Replace it with AES-256-GCM (AEAD) in a small key-id'd envelope, in flip-api,
trust-api, imaging-api and data-access-api:

- Tampering now raises InvalidTag. The envelope's kid is bound into the
  authentication tag, so it cannot be relabelled either.
- Wire format: base64 of {"v":1,"kid","iv","ct"} — 12-byte GCM nonce, and a
  fixed algorithm rather than a negotiated one (no algorithm-confusion surface).
- Keys resolve through a keyring, so a service can hold several at once. This is
  what makes rotation and per-trust keys possible without every participant
  changing over at the same moment.

Per-trust keys (blast radius: one shared key means one compromised trust yields
the key for the whole federation):

- register_trust mints a per-trust key and kid (trust-<id>), returned in the kit
  as trust_aes_key / trust_aes_kid.
- submit_cohort_query encrypts project_id per destination trust; a single
  ciphertext reused across trusts could not be bound to a per-trust key.
- Trusts without their own key keep using the shared key, so they can be moved
  over one at a time.

Roll-out: decrypt() also accepts the old AES-CBC format so a hub and a trust on
different builds still understand each other while hosts are upgraded (on-prem
trusts cannot all be restarted at once). encrypt() is GCM-only, so no new
unauthenticated ciphertext is written, and nothing is stored encrypted at rest —
task payloads are held as plaintext and encrypted at dispatch — so there is no
data to migrate and no key change. Once every host is upgraded, set
AES_ACCEPT_LEGACY_CBC=false to confirm nothing still sends CBC, then delete the
shim; it is marked for removal in each module.

Wiring the kit distributor to write TRUST_AES_KID / TRUST_AES_KEY_BASE64 is the
remaining task before per-trust keys can be switched on; see
docs/aes-payload-keys.md.

Tests, ruff and mypy green across all four services.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

claude added 2 commits July 30, 2026 04:34
Finding IDs belong in commit messages and PR titles, where they link a change to
its tracker entry. In source they date the code to one review and mean nothing to
a reader outside the organisation, on an Apache-2.0 repo.

Every reference carried a real explanation alongside it; only the identifier is
removed. The four `encryption.py` module docstrings still say why GCM replaced CBC,
`generate_trust_key` still says why a symmetric key has no hash form, and the
`submit_cohort_query` comment still points at `docs/aes-payload-keys.md` — which is
the durable reference the ID was standing in for.

Also covers one pre-existing reference in `test_s3_client.py` that predates this
branch.

`docs/aes-payload-keys.md` keeps its reference: it is documentation, not source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
…fields

`RegisteredTrust` gained required `trust_aes_key` / `trust_aes_kid` fields earlier
on this branch, but this test's `_registered()` helper was not updated. It was
failing with a TypeError, and mypy was flagging the same call — so the branch was
red before this commit.

While pinning it: `ICreatedTrust` deliberately does not carry the AES key. This
endpoint backs the admin UI, which has no kit file to write it into, so a trust
registered here keeps encrypting under the shared kid until an operator provisions
its own key on both sides. That asymmetry with the CLI path is now asserted rather
than merely true, so the key cannot start appearing in an HTTP response unnoticed.

Verified: 1346 flip-api unit + step-function tests, 55 trust-api, 261 imaging-api,
156 data-access-api; ruff and mypy clean across all four. The remaining failures are
the pre-existing `test_mfa_gate` and an imaging download test that assumes a non-root
user; the integration errors are Docker-gated and unavailable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
…paths

`register_trust` mints a per-trust payload-encryption key, and until now neither
path handed it over. The admin UI's `ICreatedTrust` dropped it from the response,
and the kit distributor never learned to write it — a gap `docs/aes-payload-keys.md`
flagged as "the remaining task before per-trust keys can be switched on". So the key
was generated and discarded on every registration, and per-trust keys could not
actually be turned on for anyone.

The hub stores it nowhere. Unlike the api key, a symmetric key the hub must also
hold cannot be reduced to a hash, so the value produced at registration is the only
copy that will ever exist — dropping it means re-registering the trust to get
another.

Both paths now deliver it:

- `ICreatedTrust` carries `trust_aes_key` / `trust_aes_kid`, and the kit modal
  includes `TRUST_AES_KEY_BASE64` / `TRUST_AES_KID` in its copy-all block, so the
  admin pastes seven credential lines instead of five.
- `trust_kit_lib` treats them as credential keys, so `register-trust KIT=<CODE>`
  writes them into `trust/.env.<CODE>.<env>` on a new registration and leaves them
  untouched on the idempotent skip path — the same write-once discipline the api key
  already has, and load-bearing here in a way it is not for a hashable credential.

Step 1 of the rollout (adding the key to the hub's `AES_TRUST_KEYS`) stays manual;
the doc now says so, and says why recording the value at registration matters.

Tests pin the property that actually bites: that the key survives to a place the
operator can read it. The kit-lib suite asserts it is written on registration and
preserved on the skip path, the modal spec asserts it reaches the copy-all block, and
the endpoint test asserts it reaches the response.

While adding those, one existing assertion needed anchoring. `content.count(
"AES_KEY_BASE64=")` was counting a substring, and `AES_KEY_BASE64` is a suffix of
`TRUST_AES_KEY_BASE64` — so the new credential silently inflated the count and the
duplicate-detection assertion stopped meaning anything. Replaced with an exact-key
line count.

Note for anyone running these: `scripts/tests/*.py` are standalone harnesses that
print ✅/❌ and exit non-zero via `main()`. Under `pytest` the test functions run but
never assert, so failures print and the run still reports green — run them as
`python scripts/tests/<file>.py`.

Verified: 1346 flip-api unit + step-function tests, 1080 flip-ui unit tests, and all
six scripts harnesses (PASS=58 FAIL=0 for trust_kit_lib); ruff, mypy and eslint clean.
The one failure is the pre-existing `test_mfa_gate`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
Keeping the admin-UI registration flow means the kit docs have to describe it, and
two statements about it were wrong.

`register-trust KIT=<CODE>` was documented as filling "BOTH the Kit credentials AND
the Hub-shared block in one step", which the Makefile's own comment on
`sync-trust-kit` contradicts: the script runs inside the flip-api container, so
`_hub_shared_from_env()` can only emit the `HUB_SHARED_ENV_KEYS` present in that
task's environment. The deploy-side rest — image tags, registries, NLB subdomain,
FL server port, kit dates — comes from the admin's workstation, which is why sync is
required on prod rather than a rotation-only convenience. Documented as step 3, with
what breaks if it is skipped.

The UI path was described as replaced by the CLI ("the old paste 5 UI lines"). It is
a supported alternative, the count is now seven lines rather than five, and the
`docs/source/deploy-flip/` on-prem guide already lists `POST /admin/trusts` as a way
to satisfy the hub-side prerequisite. Written up as its own paragraph, including the
part that is easy to get wrong: on the UI path sync supplies the *whole* Hub-shared
block, not just the deploy-side remainder, because nothing hub-side was written into
the kit.

Also corrected the Kit-credentials table row. "Hub keeps only the hash" holds for the
api key but not for `TRUST_AES_KEY_BASE64` — the hub retains nothing for that one, so
a lost value costs a re-registration.

`AGENTS.md` regenerated from `CLAUDE.md` with the documented sed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
…ring at startup

Two gaps found while tracing what happens to the AES key after registration.

**The task-dispatch path was still on the shared key.** `GET /tasks/pending` called
`encrypt(task.payload)` with no kid, so the main hub->trust channel used the shared
key even for a trust that had its own. Every task in a batch belongs to the polling
trust and `trust` is already in scope, so the kid resolves once per batch and the
whole batch goes under it. Cohort submission already did this; now the two agree.

`fl_service.start_training` stays on the shared key, and now says why: it hands one
ciphertext to the FL server, which fans it out to every participating client, so
encrypting under one trust's key would make it undecryptable for the others.
Narrowing that needs a per-client payload from the FL server, not a different kid at
the call site. Trust-internal encryption needs no change — on a trust,
`_default_kid()` already resolves to that trust's own kid.

**A half-configured rollout was invisible.** The trust's kit and the hub's
`AES_TRUST_KEYS` are provisioned separately with nothing joining them up, and the two
directions disagree asymmetrically when only one is done: trust->hub raises `KeyError:
No key registered for kid ...`, but hub->trust silently falls back to the shared key
and keeps working, so the isolation is absent with no symptom.

`trusts_services/services/trust_key_config.py` audits what the hub can actually see
and logs it during startup: coverage (how many trusts have their own key) at info,
and at error the two operator mistakes that produce a silent fallback — a kid
matching no registered trust (typo, or a trust since deleted; the key loads but is
never selected, which at runtime looks exactly like "no key yet"), and a key that is
not a valid AES length. It also forces the lazy `AES_TRUST_KEYS` parse, so a
malformed value fails at boot instead of on the first request that encrypts. The
audit is diagnostics, so a DB error during it is logged and does not stop the app.

What the hub still cannot know is whether a trust has `TRUST_AES_KID` set in its kit
— that lives on the trust host. The orphan-kid check is the closest hub-side proxy,
and the doc says so rather than implying the check is complete.

`registered_kids()` exposes the keyring's shape (kid -> key length) so the audit can
report and length-check without ever holding key material.

Verified: 1356 flip-api unit + step-function tests; ruff and mypy clean. The
dispatch test was checked against a reverted `encrypt(task.payload)` to confirm it
actually fails, rather than passing vacuously. The one failure is the pre-existing
`test_mfa_gate`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
…ecryption

Delivering the key (8172c09) also wrote `TRUST_AES_KID` live into every new kit.
That flips the trust onto its own key immediately, while the hub still knows nothing
about it — and there is a trust->hub encrypted path that then fails:

    imaging-api creates an XNAT user and encrypts its password
      -> services/users.py:171, under the trust's own kid
    the hub decrypts it to email the credentials
      -> private_services/imaging_notifications.py:93
      -> KeyError: No key registered for kid 'trust-<uuid>'

The KeyError lands in a broad `except Exception` that logs and moves on, so the user
is created on XNAT and simply never receives their password. The only trace is one
log line — the exact silent-failure shape the startup audit was added to surface,
introduced by the commit before it.

The kit now ships `TRUST_AES_KEY_BASE64` live and `TRUST_AES_KID` commented, so the
key is delivered (the problem 8172c09 set out to fix) without changing behaviour.
Uncommenting it is the deliberate act that switches the trust over. The admin UI's
copy-all block matches, and says why the line is commented.

`upsert_commented` writes a commented key while preserving its value — distinct from
`comment_out`, which discards the value, and from `upsert`, which would make it live.
It leaves an existing *live* line alone, so a re-registration cannot silently switch
off a key an operator has already enabled.

There is no safe partial state here, which the doc now spells out with both failure
modes and the order to avoid them: hub's `AES_TRUST_KEYS` plus a hub restart first
(the keyring is memoised for the process lifetime), then uncomment on the trust and
restart it. Enabling the trust first breaks the password path above; enabling the hub
first makes `kid_for_trust()` select a kid the trust has not loaded, so task payloads
stop decrypting.

Verified: 1356 flip-api unit + step-function tests, 1080 flip-ui unit tests, all six
scripts harnesses (trust_kit_lib now PASS=61); ruff, mypy and eslint clean. Two new
kit-lib assertions cover the inert write and the operator-override case. The one
failure is the pre-existing `test_mfa_gate`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UY3k5789vkwUEL613TKxjU
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
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.

3 participants