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_key → base64(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:
- Open the web app (e.g. a deployment served from NotePod), log in, and set/enter a security key so it is cached.
- Open DevTools → Console on the app origin.
- 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)
- 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>
- 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
** In this screenshot, the security key ANUSII is a test key set for testing purposes.
Context
Where has the issue been observed:
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:
Describe the Bug
On the web build, the security key that
KeyManagercaches viaflutter_secure_storageis 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'slocalStorage). This defeats the package's "trust no one" guarantee for the security key on web.Root cause:
secureStorage(insolidpod/lib/src/solid/constants/common.dart) is constructed without aWebOptions.wrapKey. With no wrap key,flutter_secure_storage_webencrypts each value with AES-256-GCM but then stores the raw AES key, unwrapped, in the samelocalStoragenext 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
localStorageends up holding:FlutterSecureStorage→ base64 of the raw AES-256-GCM keyFlutterSecureStorage._solid_security_key→base64(iv12) + "." + base64(ciphertext‖GCMtag)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:
localStorage:Expected Behaviour
A same-origin snapshot of
localStorageshould 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:WebOptions.wrapKey(+wrapKeyIv) so the storage AES key is wrapped rather than stored raw; orlocalStorageat all (require re-entry per session on web), keeping it only in memory; andScreenshots
** In this screenshot, the security key
ANUSIIis a test key set for testing purposes.Context
Where has the issue been observed:
Notes:
flutter_secure_storage_web2.1.x (AES-GCM 256, key stored raw when nowrapKeyis configured) andsolidpodconstants/common.dartsecureStorage.App Version:
Flutter Version:
Closing Criteria
Checklist for closing the issue:
make prepmake qtestlocalStorage(either awrapKeyis configured, or the security key is not cached on web).localStoragealone can no longer decrypt the security key (repro above fails).