Skip to content

fix(encrypted_maps): harden derived key material caching#401

Open
marc0olo wants to merge 6 commits into
mainfrom
fix/encrypted-maps-key-cache-hardening
Open

fix(encrypted_maps): harden derived key material caching#401
marc0olo wants to merge 6 commits into
mainfrom
fix/encrypted-maps-key-cache-hardening

Conversation

@marc0olo

@marc0olo marc0olo commented Jun 16, 2026

Copy link
Copy Markdown
Member

Context

A Zeropath security review flagged that the EncryptedMaps frontend SDK persisted per-map derived key material (a non-extractable WebCrypto CryptoKey) in IndexedDB indefinitely, in a fixed store keyed only by [mapOwner, mapName].

Security assessment

The finding is valid, with nuance:

  • The cached CryptoKey is non-extractable, so an attacker cannot exfiltrate the raw key bytes (crypto.subtle.exportKey throws). Storing a non-extractable key in IndexedDB is itself the WebCrypto-recommended pattern.
  • But the risk is real: any same-origin code (XSS, malicious extension, shared browser profile) can use the persisted handle in place to decrypt and exfiltrate plaintext, with no authenticated session. The exposure is data compromise, not key theft.
  • Two distinct problems:
    1. At-rest persistence with no expiry / no clear on logout — persisted indefinitely, extending the window for later/offline compromise. (This is the core finding.)
    2. A fixed, cross-identity store — because entries were keyed only by [mapOwner, mapName] in one shared store, after an identity switch on the same origin a different principal could be served key material cached by a prior one.

Approach

Key material is cached, but identity lifecycle stays the consumer's responsibility — the library does not introspect the agent's identity. (An earlier revision routed the caller principal through the client interface to auto-scope the key; that was reverted because it broke the canister-mirror interface and introduced a TOCTOU race between the principal read for the key and the identity used for the fetch.)

Changes

  • In-memory caching by default (InMemoryDerivedKeyMaterialCache) — never touches disk, discarded on reload. Persistence to IndexedDB (IndexedDbDerivedKeyMaterialCache) is now an explicit opt-in via the constructor.
  • Cross-identity isolation is a property of the cache, owned by the consumer who knows the identity:
    • in-memory default → use a fresh EncryptedMaps instance per identity (or clearCache() on switch);
    • IndexedDB → give the store a per-identity namespace (e.g. include the principal in the DB name).
  • EncryptedMaps.clearCache() — strongly recommended on logout / identity change to drop the still-usable decryption capability.
  • Configurable caching strategy via new EncryptedMaps(client, { cache }). The EncryptedMapsClient interface is unchanged (still a pure canister mirror).
  • Bump @icp-sdk/core to ^5.4.0.

⚠️ Breaking change

EncryptedMaps no longer persists key material by default (one extra key derivation per map per page load unless opting into IndexedDB). No interface changes — existing custom EncryptedMapsClient implementations keep working.

Upgrading from ≤0.4.0

Versions 0.1.00.4.0 persisted derived key material to idb-keyval's default store. The new cache uses a dedicated store, so legacy entries are neither used nor cleared by this version. The CHANGELOG includes a surgical one-time cleanup snippet that deletes only legacy-shaped entries ([string, Uint8Array] -> CryptoKey) from the default store, leaving other app data untouched.

Tests

  • 11 tests in cache.test.ts: in-memory & IndexedDB round-trip, non-extractable key survives persistence (exportKey still rejects), cache hit/miss, owner distinction, prefix-collision safety, separate-instance isolation, IndexedDB namespace isolation, and clearCache() re-fetch.
  • Full suite green incl. canister integration: 45/45 on @icp-sdk/core 5.4.0, on local + CI (Linux & macOS). tsc, vite build, lint, and prettier-check clean.

Reviewer notes (advisory, not blocking)

  • Cache-key collisions are impossible: the key is ${mapOwner}|${hex(mapName)} — principal text is base32 ([a-z2-7-]) and bytesToHex is [0-9a-f], so the | delimiter can never appear inside a field; the key is injective.
  • Cross-identity isolation is a documented consumer contract (fresh instance / per-identity namespace + clearCache() on logout), not enforced by identity introspection — deliberately, to keep the canister-client interface stable and avoid a TOCTOU race.

Note for maintainers

package.json still reads 0.4.0 while the CHANGELOG targets 0.5.0 - Unreleased; the breaking change above should land under 0.5.0 per the release flow.

🤖 Generated with Claude Code

Derived key material for EncryptedMaps was persisted to IndexedDB
indefinitely and keyed only by [mapOwner, mapName]. A same-origin
attacker could use a persisted (non-extractable) key handle to decrypt
without an authenticated session, and after an identity switch on the
same origin a different principal could be served key material cached
by a prior one.

- Cache derived key material in memory by default
  (InMemoryDerivedKeyMaterialCache); persistence to IndexedDB
  (IndexedDbDerivedKeyMaterialCache) is now an explicit opt-in.
- Scope cache entries to the authenticated caller's principal, closing
  the cross-identity cache-hit hole. EncryptedMapsClient gains
  getCallerPrincipal().
- Add EncryptedMaps.clearCache() for logout / identity change.
- Make caching strategy configurable via the constructor.
- Bump @icp-sdk/core to ^5.4.0.

BREAKING CHANGE: EncryptedMaps no longer persists key material by
default, and EncryptedMapsClient requires getCallerPrincipal().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marc0olo marc0olo requested a review from a team as a code owner June 16, 2026 11:32
@marc0olo marc0olo requested a review from Copilot June 16, 2026 11:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens EncryptedMaps derived key material caching to reduce at-rest exposure and prevent cross-identity cache hits on the same origin, addressing the security review findings while keeping persistence available as an explicit opt-in.

Changes:

  • Replaces implicit IndexedDB persistence with an explicit caching strategy (InMemory… default; IndexedDb… opt-in) and adds EncryptedMaps.clearCache().
  • Scopes derived-key cache entries to the authenticated caller via a new EncryptedMapsClient.getCallerPrincipal() requirement.
  • Updates dependencies to @icp-sdk/core@^5.4.0 and adds tests covering cache behavior and caller scoping.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pnpm-lock.yaml Locks @icp-sdk/core to 5.4.0 (and transitive updates).
frontend/ic_vetkeys/src/encrypted_maps/index.ts Introduces pluggable cache strategy, caller-scoped cache keys, and clearCache().
frontend/ic_vetkeys/src/encrypted_maps/encrypted_maps_canister.ts Implements getCallerPrincipal() in the default client via the agent.
frontend/ic_vetkeys/src/encrypted_maps/cache.ts Adds cache strategy interface plus in-memory and IndexedDB implementations.
frontend/ic_vetkeys/src/encrypted_maps/cache.test.ts Adds unit tests for cache round-trips, caller scoping, and clearCache().
frontend/ic_vetkeys/package.json Bumps @icp-sdk/core dependency range to ^5.4.0.
frontend/ic_vetkeys/CHANGELOG.md Documents the caching strategy changes, security implications, and breaking API updates.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread frontend/ic_vetkeys/CHANGELOG.md
marc0olo and others added 2 commits June 16, 2026 13:44
…type-assertion

The in-memory cache methods wrapped synchronous Map operations in async
functions with no await (require-await), and a test used an unnecessary
type assertion. Return promises explicitly and narrow with a guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Versions <=0.4.0 persisted derived key material to idb-keyval's default
store. The new cache uses a dedicated store, so legacy entries are
neither used nor cleared on upgrade. Document a surgical one-time
cleanup snippet so upgraders can remove the residual at-rest capability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

Comment thread frontend/ic_vetkeys/CHANGELOG.md Outdated
Comment thread frontend/ic_vetkeys/src/encrypted_maps/cache.ts Outdated
Comment thread frontend/ic_vetkeys/src/encrypted_maps/index.ts Outdated
marc0olo and others added 2 commits June 16, 2026 14:24
- Rename EncryptedMapsClient.getCallerPrincipal -> get_caller_principal
  to match the interface's snake_case canister-mirroring convention. A
  holistic camelCase migration of the client interfaces is deferred to a
  dedicated follow-up targeting the same unreleased 0.5.0.
- Tighten the legacy IndexedDB cleanup snippet to match the exact legacy
  key shape [string, Uint8Array] before deleting, avoiding any chance of
  removing unrelated array-keyed CryptoKey entries.
- Use {@link CryptoKey} instead of {@link !CryptoKey} for doc consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…caller introspection

Per review, the library should not reach back through the canister-client
interface to introspect the caller's identity for cache scoping. Identity
lifecycle is the consumer's responsibility, and that introspection was also
the source of a TOCTOU race (the principal read for the cache key could differ
from the identity used for the fetch).

- Revert the `get_caller_principal()` addition to `EncryptedMapsClient`; the
  interface is once again a pure canister mirror (no breaking change for custom
  client implementers).
- Cache key is `[mapOwner, mapName]` again. Cross-identity isolation is now a
  property of the cache: a per-identity in-memory instance (the default), or a
  per-identity-namespaced IndexedDb store.
- Document the isolation contract on EncryptedMaps, clearCache(), and both
  cache implementations. Update tests to cover owner distinction, instance
  isolation, and IndexedDb namespace isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

Comment thread frontend/ic_vetkeys/src/encrypted_maps/cache.ts Outdated
The per-identity namespace hint used an inline code span containing a
template literal's backticks, which renders incorrectly in Markdown/TypeDoc.
Move it to a fenced code block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

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