Skip to content

fix(server): let the signed account id decide, not the cache (WALM-681) - #983

Merged
nikola0x0 merged 1 commit into
devfrom
nikolale/walm-681-signed-account-id-is-ignored-when-the-delegate-key-cache
Sep 25, 2026
Merged

nikola0x0 merged 1 commit into
devfrom
nikolale/walm-681-signed-account-id-is-ignored-when-the-delegate-key-cache

Conversation

@nikola0x0

@nikola0x0 nikola0x0 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes WALM-681. Triage of the 22 Sep static security review (WALM-679); MW-02 confirmed against source.

What was wrong

verify_signature covers x-account-id in the canonical message, so a caller states unforgeably which account a request is for. resolve_account then ignored it whenever the delegate-key cache hit.

delegate_key_cache.public_key is the primary key (001_init.sql), so the table holds one account per key: whichever resolved last. A delegate key may be registered on several accounts. With the row warm on A, a request signed for B hit Strategy 1, re-verified the key against A — which still authorizes it, so the check passed — and returned A. The signed account id was only consulted on a miss, in Strategy 2. Write routes take auth.owner and auth.account_id from that result, so a caller saving B's data stored it under A.

Not an unauthenticated takeover: the signature must verify and the cached account must still authorize the key. But it is the wrong account, chosen by cache order rather than by what the caller signed.

The fix

Treat a mismatched row as a miss. Strategy 2 then verifies the requested account directly and overwrites the row, so an account switch costs one delegate verify, which the in-memory verify cache absorbs inside its TTL.

Requests carrying no signed account id are untouched — legacy discovery stays a separate path — and revocation and fail-closed behaviour are unchanged.

Behaviour change callers can see

A request signed for an account the delegate key is not registered on now gets 401. Before, if the key's cache row was warm on some other account, the request silently ran as that cached account. Nothing legitimate depends on the old behaviour, but a misconfigured client that was "working" by accident will start failing loudly, which is the point.

Why not the composite key

Repartitioning on (public_key, account_id) would hold both mappings at once, but it needs migration 023 plus a window where old and new code run against the same table. This is the smaller change on an auth path. The composite key stays open as a follow-up and nothing here forecloses it.

Verification

5 new tests covering the account switch in both directions, exact (non-prefix) comparison, and the unsigned legacy case. 95 auth tests pass.

routes::oauth::tests::fetch_oauth_code_reads_without_consuming fails locally — it fails identically on an unmodified tree, since it needs a live Postgres.

`verify_signature` covers `x-account-id` in the canonical message, so a
caller states unforgeably which account a request is for. `resolve_account`
then ignored it whenever the delegate-key cache hit.

`delegate_key_cache.public_key` is the primary key, so the table holds one
account per key: whichever resolved last. A delegate key may be registered
on several accounts. With the row warm on A, a request signed for B hit
Strategy 1, re-verified the key against A (which still authorizes it, so
the check passed), and returned A. The signed account id was only consulted
on a miss, in Strategy 2. Write routes take `auth.owner` and
`auth.account_id` from that result, so a caller saving B's data stored it
under A.

Not an unauthenticated takeover — the signature must verify and the cached
account must still authorize the key — but it is the wrong account, chosen
by cache order rather than by what the caller signed.

Treat a mismatched row as a miss. Strategy 2 then verifies the requested
account directly and overwrites the row, so an account switch costs one
delegate verify, which the in-memory verify cache absorbs inside its TTL.
Requests carrying no signed account id are untouched: legacy discovery
stays a separate path, and revocation and fail-closed behaviour are
unchanged.

Kept the cache keyed on the public key rather than repartitioning on
(public_key, account_id), which would hold both mappings at once but needs
a migration and a mixed-version window. This is the smaller change on an
auth path; the composite key stays open as a follow-up.

Verified: 5 new tests covering the account switch in both directions, exact
(non-prefix) comparison, and the unsigned legacy case. 95 auth tests pass;
`routes::oauth::tests::fetch_oauth_code_reads_without_consuming` fails here
and on an unmodified tree alike — it needs a live Postgres.

@harrymove-ctrl harrymove-ctrl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Head fixes cross-account cache collision where a delegate key registered across multiple accounts could serve stale/mismatched cached account mappings instead of the signed x-account-id. All checks pass.

@nikola0x0
nikola0x0 merged commit ffa0df9 into dev Sep 25, 2026
18 checks passed
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.

2 participants