Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions lib/src/solid/constants/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
124 changes: 118 additions & 6 deletions lib/src/solid/utils/key_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

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.

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 comment is not particularly clear to me. Does the key here refer to the non-extractable AES-GCM key in IndexedDB? If so, does it imply a live same-origin script would still be able to use this AES-GCM key to get the user's security key?

The cache survives reloads, so the user is not forced to re-enter the key.

As a web app user, I actually expect to re-enter my security key after reloading/refreshing.

//
// 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<void> _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<bool> 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;
Expand All @@ -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<String?> 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) {
Expand All @@ -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<void> 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<void> 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 {
Expand Down
49 changes: 49 additions & 0 deletions lib/src/solid/utils/web_secure_key_cache_stub.dart
Original file line number Diff line number Diff line change
@@ -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<void> write(String securityKey) async {}

/// Read the cached security key (web only). Always null on native.

static Future<String?> read() async => null;

/// Remove the cached security key (web only). No-op on native.

static Future<void> delete() async {}

/// Whether a security key is cached (web only). Always false on native.

static Future<bool> has() async => false;
}
Loading
Loading