Skip to content

SECURITY: Cached security key is recoverable from localStorage #701

Description

@tonypioneer

Describe the Bug

On the web build, the security key that KeyManager caches via flutter_secure_storage is recoverable by anyone with same-origin access to the page (a browser profile on the user's machine, an XSS payload, or a copy of the origin's localStorage). This defeats the package's "trust no one" guarantee for the security key on web.

Root cause: secureStorage (in solidpod/lib/src/solid/constants/common.dart) is constructed without a WebOptions.wrapKey. With no wrap key, flutter_secure_storage_web encrypts each value with AES-256-GCM but then stores the raw AES key, unwrapped, in the same localStorage next to the ciphertext. The file's own doc-comment already acknowledges this ("with the default options … the AES key is stored unwrapped in the same localStorage, so any same-origin script (e.g. via XSS) can recover both key and ciphertext"), but the web build still ships without a wrap key.

Concretely, the origin's localStorage ends up holding:

  • FlutterSecureStorage → base64 of the raw AES-256-GCM key
  • FlutterSecureStorage._solid_security_keybase64(iv12) + "." + base64(ciphertext‖GCMtag)
  • (and likewise the DPoP private key / OIDC tokens written by AuthDataManager)

Because both the key and the ciphertext are present and same-origin readable, the plaintext security key can be reconstructed offline. Once the security key is known, the master key follows (sha256 for v1, or Argon2id + the stored salt for v2), so all encrypted resources in the POD become decryptable — the key-derivation strength (including v2's Argon2id) is bypassed entirely.

To Reproduce

Steps to reproduce the behavior:

  1. Open the web app (e.g. a deployment served from NotePod), log in, and set/enter a security key so it is cached.
  2. Open DevTools → Console on the app origin.
  3. Dump localStorage:
# In the DevTools console:
copy(JSON.stringify(Object.keys(localStorage)
  .reduce((o,k)=>(o[k]=localStorage.getItem(k),o),{})))
# Save the clipboard to ls.json. Note both entries are present:
#   "FlutterSecureStorage"                      (the raw AES key, base64)
#   "FlutterSecureStorage._solid_security_key"  (iv.ciphertext, base64)
  1. Decrypt the security key offline with nothing but that dump — the AES key is right there:
$ python - <<'PY'
import base64, json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
ls = json.load(open("ls.json"))
prefix = next(k[:-len("._solid_security_key")] for k in ls if k.endswith("._solid_security_key"))
aes = base64.b64decode(ls[prefix])                       # unwrapped AES key
iv_b64, ct_b64 = ls[prefix + "._solid_security_key"].split(".")
sk = AESGCM(aes).decrypt(base64.b64decode(iv_b64), base64.b64decode(ct_b64), None)
print("recovered security key:", sk.decode())
PY
recovered security key: <the user's plaintext security key>
  1. See that the plaintext security key is recovered from client storage alone, with no brute force and no server access.

Expected Behaviour

A same-origin snapshot of localStorage should not be sufficient to recover the security key. At minimum, the web build should not persist the decryption key in plaintext beside the ciphertext. Options:

  • Supply a WebOptions.wrapKey (+ wrapKeyIv) so the storage AES key is wrapped rather than stored raw; or
  • Do not cache the security key in web localStorage at all (require re-entry per session on web), keeping it only in memory; and
  • Clearly document that the web platform provides encryption-at-rest only, not the trust-no-one guarantee, so downstream apps deploying to web make an informed choice.

Screenshots

Image

** In this screenshot, the security key ANUSII is a test key set for testing purposes.

Context

Where has the issue been observed:

  • Android
  • Chrome
  • iOS
  • Linux
  • macOS
  • Web
  • Windows

Notes:

  • Platform-specific. Native desktop/mobile builds are not affected: macOS/iOS use the data-protection Keychain, Android the Keystore, Windows DPAPI, Linux libsecret — none of which expose the key beside the ciphertext.
  • Verified against flutter_secure_storage_web 2.1.x (AES-GCM 256, key stored raw when no wrapKey is configured) and solidpod constants/common.dart secureStorage.

App Version:
Flutter Version:

Closing Criteria

Checklist for closing the issue:

  • No errors from make prep
  • All tests pass make qtest
  • Web build no longer persists the storage AES key in plaintext in localStorage (either a wrapKey is configured, or the security key is not cached on web).
  • A dump of the origin's localStorage alone can no longer decrypt the security key (repro above fails).
  • Security note added documenting the web platform's at-rest-only guarantee.
  • Regression test covering the web storage path.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions