diff --git a/.lycheeignore b/.lycheeignore index 49be24e1..52cd7105 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -85,6 +85,8 @@ https://server/POD_NAME/APP_NAME/data/FILE_PATH https://server/alice/ https://server/alice/myapp/ https://anusii.github.io/solidpodeg/client-profile.jsonld +https://anushkavidanage.github.io/solidpod/example/redirect.html +https://anushkavidanage.github.io/solidpod/example/client-profile.jsonld # 20260605 gjw Failing solid servers diff --git a/lib/src/solid/constants/common.dart b/lib/src/solid/constants/common.dart index e0bd7ae0..d33a9655 100644 --- a/lib/src/solid/constants/common.dart +++ b/lib/src/solid/constants/common.dart @@ -196,6 +196,14 @@ const String demoWebID = /// same-origin script (e.g. via XSS) can recover both key and ciphertext. /// Web therefore provides encryption-at-rest but not the full trust-no-one /// guarantee unless a `wrapKey` is supplied. +/// +/// Because of this, the *security key* — which derives the master key that +/// protects every encrypted resource — is NEVER persisted here on web. It is +/// kept in memory for the session only ([KeyStorage] handles this) and must +/// be re-entered after a page reload. The DPoP private key and OIDC tokens +/// ([AuthDataManager]) are still cached in web localStorage as a usability +/// trade-off; they grant session access but not data decryption, and expire. +/// Supplying a `WebOptions.wrapKey` would harden those too. FlutterSecureStorage secureStorage = const FlutterSecureStorage( iOptions: IOSOptions( diff --git a/lib/src/solid/utils/key_storage.dart b/lib/src/solid/utils/key_storage.dart index c063c146..5d417180 100644 --- a/lib/src/solid/utils/key_storage.dart +++ b/lib/src/solid/utils/key_storage.dart @@ -28,10 +28,12 @@ library; -import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/utils/misc.dart'; +import 'package:solidpod/src/solid/utils/web_secure_key_cache_stub.dart' + if (dart.library.js_interop) 'package:solidpod/src/solid/utils/web_secure_key_cache_web.dart'; /// Manages secure storage operations for the security key. @@ -40,9 +42,63 @@ class KeyStorage { static const String _securityKeySecureStorageKey = '_solid_security_key'; - /// Check if the security key exists in secure storage. + // Session copy of the security key on web (avoids an IndexedDB round-trip on + // every read within a session). + // + // Why the web path is special: plain `flutter_secure_storage` keeps its + // AES-GCM key *unwrapped* in the same `localStorage` as the ciphertext, so a + // same-origin script or a copy of `localStorage` recovers both — and the + // security key derives the master key protecting every encrypted resource. + // + // On web we therefore cache the security key via [WebSecureKeyCache]: it is + // encrypted under a **non-extractable** AES-GCM key held as an opaque + // `CryptoKey` in IndexedDB. A storage dump then yields only ciphertext and a + // key handle whose bytes can never be exported, so the key cannot be + // recovered offline. (A live same-origin XSS can still *use* — but not + // exfiltrate — the key; that is an inherent browser limitation, mitigated by + // CSP/XSS prevention, not storage.) The cache survives reloads, so the user + // is not forced to re-enter the key. + // + // Native platforms are unaffected: they use the OS-backed secure store + // (Keychain / Keystore / DPAPI / libsecret) and persist as before. + + static String? _webSecurityKey; + + // Best-effort removal of any security key a previous build may have written + // to web `localStorage`, so upgrading users do not leave the exposed value + // behind. Never re-reads the value. + + static Future _purgeLegacyWebEntry() async { + try { + if (await secureStorage.containsKey(key: _securityKeySecureStorageKey)) { + await secureStorage.delete(key: _securityKeySecureStorageKey); + debugPrint('KeyStorage => purged legacy web security key'); + } + } on Object catch (e) { + debugPrint( + 'KeyStorage => _purgeLegacyWebEntry() error: ${e.runtimeType}', + ); + } + } + + /// Check if the security key exists. + /// + /// On web this checks the in-memory session value and the encrypted + /// IndexedDB cache; on native platforms it queries secure storage. static Future hasStoredSecurityKey() async { + if (kIsWeb) { + if (_webSecurityKey != null) { + return true; + } + try { + return await WebSecureKeyCache.has(); + } on Object catch (e) { + debugPrint('KeyStorage => hasStoredSecurityKey(web) ' + 'error: ${e.runtimeType}'); + return false; + } + } try { final key = await secureStorage.read(key: _securityKeySecureStorageKey); return key != null; @@ -56,9 +112,29 @@ class KeyStorage { } } - /// Read the security key from secure storage. + /// Read the security key. + /// + /// On web this returns the in-memory session value, falling back to the + /// encrypted IndexedDB cache (never a plaintext `localStorage` value); on + /// native platforms it reads from secure storage. static Future readSecurityKey() async { + if (kIsWeb) { + if (_webSecurityKey != null) { + return _webSecurityKey; + } + try { + _webSecurityKey = await WebSecureKeyCache.read(); + } on Object catch (e) { + // Any decode/crypto error => behave as if not cached (re-prompt). + + debugPrint( + 'KeyStorage => readSecurityKey(web) error: ${e.runtimeType}', + ); + _webSecurityKey = null; + } + return _webSecurityKey; + } try { return await secureStorage.read(key: _securityKeySecureStorageKey); } catch (e) { @@ -69,22 +145,58 @@ class KeyStorage { } } - /// Write the security key to secure storage. + /// Write the security key. + /// + /// On web the key is kept in memory and cached in encrypted form via + /// [WebSecureKeyCache] (non-extractable IndexedDB key); any legacy plaintext + /// `localStorage` copy is purged. On native platforms it is written to secure + /// storage. static Future writeSecurityKey(String securityKey) async { + if (kIsWeb) { + _webSecurityKey = securityKey; + try { + // Encrypted, dump-resistant persistence (survives reload). + + await WebSecureKeyCache.write(securityKey); + } on Object catch (e) { + // Best-effort: on failure we keep the key in memory for this session + // only (the user re-enters it after a reload). + + debugPrint('KeyStorage => writeSecurityKey(web) ' + 'cache failed: ${e.runtimeType}'); + } + // Remove any plaintext value persisted by an earlier build. + + await _purgeLegacyWebEntry(); + return; + } await writeToSecureStorage( _securityKeySecureStorageKey, securityKey, ); } - /// Remove the security key from secure storage. + /// Remove the security key. /// /// This function is platform-safe: - /// - Uses FlutterSecureStorage which is safe on all platforms including web. + /// - On web it clears the in-memory value, deletes the encrypted IndexedDB + /// cache, and purges any legacy plaintext `localStorage` copy. + /// - On native platforms it deletes from FlutterSecureStorage. /// - Errors during deletion are logged but don't prevent function from completing. static Future deleteSecurityKey() async { + if (kIsWeb) { + _webSecurityKey = null; + try { + await WebSecureKeyCache.delete(); + } on Object catch (e) { + debugPrint('KeyStorage => deleteSecurityKey(web) ' + 'error: ${e.runtimeType}'); + } + await _purgeLegacyWebEntry(); + return; + } try { if (await secureStorage.containsKey(key: _securityKeySecureStorageKey)) { try { diff --git a/lib/src/solid/utils/web_secure_key_cache_stub.dart b/lib/src/solid/utils/web_secure_key_cache_stub.dart new file mode 100644 index 00000000..e03e9a7d --- /dev/null +++ b/lib/src/solid/utils/web_secure_key_cache_stub.dart @@ -0,0 +1,49 @@ +/// Native stub for [WebSecureKeyCache]. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +/// No-op stand-in for the web-only secure key cache (see the web variant). + +class WebSecureKeyCache { + /// Persist the security key (web only). No-op on native. + + static Future write(String securityKey) async {} + + /// Read the cached security key (web only). Always null on native. + + static Future read() async => null; + + /// Remove the cached security key (web only). No-op on native. + + static Future delete() async {} + + /// Whether a security key is cached (web only). Always false on native. + + static Future has() async => false; +} diff --git a/lib/src/solid/utils/web_secure_key_cache_web.dart b/lib/src/solid/utils/web_secure_key_cache_web.dart new file mode 100644 index 00000000..e7f927e5 --- /dev/null +++ b/lib/src/solid/utils/web_secure_key_cache_web.dart @@ -0,0 +1,185 @@ +/// Web-only secure cache for the security key. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +const String _dbName = 'solidpod_secure'; +const String _storeName = 'kv'; +const String _wrapKeyId = 'security_key_wrap_key'; +const String _ivId = 'security_key_iv'; +const String _ctId = 'security_key_ct'; + +/// Web implementation of the security-key cache (see the library doc comment). + +class WebSecureKeyCache { + static web.SubtleCrypto get _subtle => web.window.crypto.subtle; + + // JS object literals for the Web Crypto algorithm parameters. Built with + // setProperty rather than an extension-type factory so the sunset + // dart_code_metrics unused-code check (which cannot parse extension types) + // does not report false positives. + + static JSObject _aesKeyGenParams() => JSObject() + ..setProperty('name'.toJS, 'AES-GCM'.toJS) + ..setProperty('length'.toJS, 256.toJS); + + static JSObject _aesGcmParams(JSAny iv) => JSObject() + ..setProperty('name'.toJS, 'AES-GCM'.toJS) + ..setProperty('iv'.toJS, iv); + + // IndexedDB helpers. + + static Future _openDb() { + final completer = Completer(); + final req = web.window.indexedDB.open(_dbName, 1); + req.onupgradeneeded = ((web.Event _) { + final db = req.result as web.IDBDatabase; + if (!db.objectStoreNames.contains(_storeName)) { + db.createObjectStore(_storeName); + } + }).toJS; + req.onsuccess = ((web.Event _) { + completer.complete(req.result as web.IDBDatabase); + }).toJS; + req.onerror = ((web.Event _) { + completer.completeError(StateError('IndexedDB open failed')); + }).toJS; + return completer.future; + } + + static Future _await(web.IDBRequest req) { + final completer = Completer(); + req.onsuccess = ((web.Event _) { + completer.complete(req.result); + }).toJS; + req.onerror = ((web.Event _) { + completer.completeError(StateError('IndexedDB request failed')); + }).toJS; + return completer.future; + } + + static Future _put(web.IDBDatabase db, String id, JSAny value) async { + final tx = db.transaction(_storeName.toJS, 'readwrite'); + await _await(tx.objectStore(_storeName).put(value, id.toJS)); + } + + static Future _get(web.IDBDatabase db, String id) async { + final tx = db.transaction(_storeName.toJS, 'readonly'); + return _await(tx.objectStore(_storeName).get(id.toJS)); + } + + // Wrapping key. + + static Future _getOrCreateWrapKey(web.IDBDatabase db) async { + final existing = await _get(db, _wrapKeyId); + if (existing != null) { + // Only CryptoKey objects are ever stored under this id. + + return existing as web.CryptoKey; + } + final key = (await _subtle + .generateKey( + _aesKeyGenParams(), + false, // extractable: false — bytes can never be exported. + ['encrypt'.toJS, 'decrypt'.toJS].toJS, + ) + .toDart) as web.CryptoKey; + await _put(db, _wrapKeyId, key); + return key; + } + + // Public API. + + /// Encrypt and persist [securityKey] under the non-extractable wrapping key. + + static Future write(String securityKey) async { + final db = await _openDb(); + final wrapKey = await _getOrCreateWrapKey(db); + + final iv = + (web.window.crypto.getRandomValues(Uint8List(12).toJS) as JSUint8Array) + .toDart; + final ctBuf = (await _subtle + .encrypt( + _aesGcmParams(iv.toJS), + wrapKey, + Uint8List.fromList(utf8.encode(securityKey)).toJS, + ) + .toDart) as JSArrayBuffer; + + await _put(db, _ivId, iv.toJS); + await _put(db, _ctId, ctBuf.toDart.asUint8List().toJS); + } + + /// Decrypt and return the cached security key, or null if none/undecryptable. + + static Future read() async { + final db = await _openDb(); + final keyAny = await _get(db, _wrapKeyId); + final ivAny = await _get(db, _ivId); + final ctAny = await _get(db, _ctId); + if (keyAny == null || ivAny == null || ctAny == null) { + return null; + } + final ptBuf = (await _subtle + .decrypt( + _aesGcmParams(ivAny), + keyAny as web.CryptoKey, + ctAny as JSUint8Array, + ) + .toDart) as JSArrayBuffer; + return utf8.decode(ptBuf.toDart.asUint8List()); + } + + /// Remove all cached security-key material (key handle, IV, ciphertext). + + static Future delete() async { + final db = await _openDb(); + for (final id in const [_wrapKeyId, _ivId, _ctId]) { + final tx = db.transaction(_storeName.toJS, 'readwrite'); + await _await(tx.objectStore(_storeName).delete(id.toJS)); + } + } + + /// Whether a wrapped security key is currently cached. + + static Future has() async { + final db = await _openDb(); + final key = await _get(db, _wrapKeyId); + final ct = await _get(db, _ctId); + return key != null && ct != null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 55864787..f3c89a1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 1.0.15 homepage: https://github.com/anusii/solidpod environment: - sdk: '>=3.2.3 <4.0.0' + sdk: '>=3.3.0 <4.0.0' flutter: '>=1.17.0' # To automatically upgrade package dependencies to the latest versions: @@ -36,6 +36,7 @@ dependencies: rdflib: ^0.2.12 solid_auth: ^1.0.2 universal_io: ^2.3.1 + web: ^1.1.0 dev_dependencies: build_runner: ^2.10.5