From 63b6d0e1d125ae989538f79f2530ab04a2875b34 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:09:04 +1000 Subject: [PATCH 01/17] add cryptopgraphy package --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index 045180eb..65f11c95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: archive: ^4.0.8 crypto: ^3.0.7 + cryptography_plus: ^3.0.0 encrypter_plus: ^5.1.0 fast_rsa: ^3.8.6 flutter_secure_storage: ^10.0.0 From b5ffc5569bf823f572e2d3555ff5193bb80b32b0 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:09:22 +1000 Subject: [PATCH 02/17] change pubspec to point to local --- example/pubspec.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 65417e82..d8fcc127 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,10 +23,10 @@ dependency_overrides: solidpod: path: .. solidui: - # path: ../../solidui - git: - url: https://github.com/anusii/solidui.git - ref: dev + path: ../../solidui + # git: + # url: https://github.com/anusii/solidui.git + # ref: dev dev_dependencies: dependency_validator: ^5.0.4 From 20e5af2fde74fdf82a1ef2f3a0322f5a4ddf20dd Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:10:10 +1000 Subject: [PATCH 03/17] add Argon2id specific predicates --- lib/src/solid/constants/common.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/solid/constants/common.dart b/lib/src/solid/constants/common.dart index bdb21e51..a8108416 100644 --- a/lib/src/solid/constants/common.dart +++ b/lib/src/solid/constants/common.dart @@ -78,6 +78,8 @@ const String titlePred = 'title'; const String prvKeyPred = 'prvKey'; const String pubKeyPred = 'pubKey'; const String encKeyPred = 'encKey'; // verification key of the master key +const String saltPred = 'salt'; // salt for the key-derivation function +const String keyVersionPred = 'keyVersion'; // key-derivation scheme version const String pathPred = 'path'; const String accessListPred = 'accessList'; const String filePathListPred = 'filePathList'; From cb9d6f6e739ec6b304273deb7c7da7e0775f0cad Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:10:36 +1000 Subject: [PATCH 04/17] salt and key version predicates --- lib/src/solid/constants/solid_constants.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/src/solid/constants/solid_constants.dart b/lib/src/solid/constants/solid_constants.dart index 1636a81f..10863bfb 100644 --- a/lib/src/solid/constants/solid_constants.dart +++ b/lib/src/solid/constants/solid_constants.dart @@ -182,6 +182,12 @@ class _Predicates { /// Encryption key predicate String get encryptionKey => common.encKeyPred; + /// Salt predicate (for the key-derivation function) + String get salt => common.saltPred; + + /// Key-derivation scheme version predicate + String get keyVersion => common.keyVersionPred; + /// Path predicate String get path => common.pathPred; From 266f923f1a94df973870abd8ba6d3310877d0f41 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:12:47 +1000 Subject: [PATCH 05/17] add new key generation and verification methods --- lib/src/solid/utils/key_helper.dart | 171 ++++++++++++++++++++++++---- 1 file changed, 151 insertions(+), 20 deletions(-) diff --git a/lib/src/solid/utils/key_helper.dart b/lib/src/solid/utils/key_helper.dart index a9666267..d03b7038 100644 --- a/lib/src/solid/utils/key_helper.dart +++ b/lib/src/solid/utils/key_helper.dart @@ -29,8 +29,11 @@ library; import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; +import 'package:crypto/crypto.dart' hide Hmac; +import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:encrypter_plus/encrypter_plus.dart'; import 'package:fast_rsa/fast_rsa.dart' as fast_rsa; import 'package:pointycastle/asymmetric/api.dart'; @@ -59,18 +62,112 @@ String getUniqueStrWebId(String webId) { return uniqueStr; } -/// Derive the master key from the security key -Key genMasterKey(String securityKey) => Key.fromUtf8( +/// The current key-derivation scheme version. +/// +/// Version 1 (legacy): master key = `sha256(securityKey)`, verification key = +/// `sha224(securityKey)` — no salt, no work factor (see [genLegacyMasterKey] +/// and [genLegacyVerificationKey]). +/// +/// Version 2: a single salted Argon2id run is HKDF-expanded into two +/// domain-separated outputs — the AES master key and the verification value +/// (see [deriveKeys]). The version is stored alongside the keys in +/// `encryption/enc-keys.ttl` so existing PODs can be detected and migrated. +const int kdfVersion = 2; + +/// Generate random salt +List generateSalt() { + final random = Random.secure(); + return List.generate(16, (_) => random.nextInt(256)); +} + +/// Derive the master key and verification value from the security key. +/// +/// Runs Argon2id once (the expensive, salted step) to obtain a master secret, +/// then HKDF-expands it into two domain-separated outputs: the AES-256 master +/// key and the verification value stored on the POD. Because the two outputs +/// use distinct `info` labels, the stored verification value reveals nothing +/// about the master key, and brute-forcing it costs a full Argon2id run per +/// guess. +Future<({Key masterKey, String verificationKey})> deriveKeys( + String securityKey, + List salt, +) async { + final argon2 = Argon2id( + parallelism: 4, + memory: 10000, // 10,000 x 1kB = 10 MB + iterations: 1, // raise memory instead of iterations for better security + hashLength: 32, // 32 bytes = AES-256 + ); + + final masterSecret = await argon2.deriveKeyFromPassword( + password: securityKey, + nonce: salt, + ); + + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + + // The salt is reused as the HKDF nonce (this implementation requires a + // non-empty nonce). Domain separation between the two outputs comes from + // the distinct `info` labels, not the nonce. + final mk = await hkdf.deriveKey( + secretKey: masterSecret, + nonce: salt, + info: utf8.encode('solidpod/v2/master-key'), + ); + final vk = await hkdf.deriveKey( + secretKey: masterSecret, + nonce: salt, + info: utf8.encode('solidpod/v2/verification'), + ); + + return ( + // Full 32 bytes => true 256-bit AES key. + masterKey: Key(Uint8List.fromList(await mk.extractBytes())), + verificationKey: base64.encode(await vk.extractBytes()), + ); +} + +/// Derive the master key from the security key using the legacy (version 1) +/// scheme: plain `sha256` truncated to 32 hex chars. +/// +/// Retained only to decrypt keys on PODs created before the version 2 scheme +/// so they can be migrated. Do NOT use for new keys. +Key genLegacyMasterKey(String securityKey) => Key.fromUtf8( sha256.convert(utf8.encode(securityKey)).toString().substring(0, 32), ); -/// Derive the verification key from the security key -String genVerificationKey(String securityKey) => +/// Derive the verification key from the security key using the legacy +/// (version 1) scheme: plain `sha224` truncated to 32 hex chars. +/// +/// Retained only to verify the security key on legacy PODs before migrating +/// them. Do NOT use for new keys. +String genLegacyVerificationKey(String securityKey) => sha224.convert(utf8.encode(securityKey)).toString().substring(0, 32); -/// Verify the security key +/// Constant-time comparison of two strings. +/// +/// Avoids leaking the length of a matching prefix via short-circuit timing +/// (security finding H1). Returns false immediately only on a length +/// mismatch, which is not secret here (hash/verification values have a fixed +/// length). +bool constantTimeEquals(String a, String b) { + if (a.length != b.length) { + return false; + } + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diff == 0; +} + +/// Verify the security key against a legacy (version 1) verification key. +/// +/// This is part of the public API surface. Version 2 verification requires the +/// stored salt and is performed inside `KeyManager`; this helper covers the +/// legacy scheme only. bool verifySecurityKey(String securityKey, String verificationKey) => - verificationKey == genVerificationKey(securityKey); + constantTimeEquals(verificationKey, genLegacyVerificationKey(securityKey)); /// Create a random individual/session key Key genRandIndividualKey() => Key.fromSecureRandom(32); @@ -96,8 +193,18 @@ String decryptPrivateKey(String encPrivateKey, Key masterKey, IV iv) => String getPredicateUrl(String pred, {Namespace? ns}) => (ns ?? solidTermsNS.ns).withAttr(pred).value; -/// Read file `encryption/enc-keys.ttl' to get verification key and encrypted private key -Future<({String verificationKey, PrvKeyRecord record})> readEncKeyFile() async { +/// Read file `encryption/enc-keys.ttl' to get verification key and encrypted +/// private key, together with the key-derivation salt and scheme version. +/// +/// PODs created before the version 2 scheme have neither a salt nor a version +/// triple; for those `saltB64` is null and `version` defaults to 1 (legacy). +Future< + ({ + String verificationKey, + PrvKeyRecord record, + String? saltB64, + int version, + })> readEncKeyFile() async { final encKeyUrl = await getFileUrl(await getEncKeyPath()); final tripleMap = turtleToTripleMap( @@ -122,7 +229,17 @@ Future<({String verificationKey, PrvKeyRecord record})> readEncKeyFile() async { ivBase64: getVal(ivPred) as String, ); - return (verificationKey: verificationKey, record: prvKeyRecord); + // Salt and version are absent on legacy (version 1) PODs. + final saltB64 = getVal(saltPred) as String?; + final versionStr = getVal(keyVersionPred) as String?; + final version = versionStr == null ? 1 : int.parse(versionStr); + + return ( + verificationKey: verificationKey, + record: prvKeyRecord, + saltB64: saltB64, + version: version, + ); } /// Read file `encryption/ind-keys.ttl' to get encrypted individual keys @@ -293,21 +410,35 @@ final _bindNS = { termsNS.prefix: termsNS.ns, }; -/// Generate the content of encKeyFile +/// Generate the content of encKeyFile. +/// +/// When [saltB64] and [version] are provided (version 2+ scheme) they are +/// written as additional triples so the key-derivation salt and scheme version +/// can be recovered on subsequent logins. They are omitted for the legacy +/// scheme to keep the file format backwards compatible. Future genEncKeyTTLStr( String encKeyUrl, String verificationKey, - PrvKeyRecord prvKeyRecord, -) async { - final triples = { - URIRef(encKeyUrl): { - termsNS.ns.withAttr(titlePred): encKeyFileTitle, - solidTermsNS.ns.withAttr(encKeyPred): verificationKey, - solidTermsNS.ns.withAttr(ivPred): prvKeyRecord.ivBase64, - solidTermsNS.ns.withAttr(prvKeyPred): prvKeyRecord.encKeyBase64, - }, + PrvKeyRecord prvKeyRecord, { + String? saltB64, + int? version, +}) async { + final predObjMap = { + termsNS.ns.withAttr(titlePred): encKeyFileTitle, + solidTermsNS.ns.withAttr(encKeyPred): verificationKey, + solidTermsNS.ns.withAttr(ivPred): prvKeyRecord.ivBase64, + solidTermsNS.ns.withAttr(prvKeyPred): prvKeyRecord.encKeyBase64, }; + if (saltB64 != null) { + predObjMap[solidTermsNS.ns.withAttr(saltPred)] = saltB64; + } + if (version != null) { + predObjMap[solidTermsNS.ns.withAttr(keyVersionPred)] = version.toString(); + } + + final triples = {URIRef(encKeyUrl): predObjMap}; + return tripleMapToTurtle(triples, bindNamespaces: _bindNS); } From b69ae0f3ae4bda0b63376eb50527ef2e6f87ee8a Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:12:58 +1000 Subject: [PATCH 06/17] add new keys to pods --- lib/src/solid/utils/key_operations.dart | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/src/solid/utils/key_operations.dart b/lib/src/solid/utils/key_operations.dart index a6acdcee..9c33eb7b 100644 --- a/lib/src/solid/utils/key_operations.dart +++ b/lib/src/solid/utils/key_operations.dart @@ -59,6 +59,14 @@ class KeyOperations { static PrvKeyRecord? _prvKeyRecord; + // The base64 key-derivation salt (null on legacy version 1 PODs). + + static String? _saltB64; + + // The key-derivation scheme version (1 = legacy). + + static int? _keyVersion; + /// Clear all cached key data. static void clear() { @@ -67,6 +75,8 @@ class KeyOperations { _verificationKey = null; _pubKey = null; _prvKeyRecord = null; + _saltB64 = null; + _keyVersion = null; } /// Load verification key and encrypted private key. @@ -90,6 +100,8 @@ class KeyOperations { final r = await readEncKeyFile(); _verificationKey = r.verificationKey; _prvKeyRecord = r.record; + _saltB64 = r.saltB64; + _keyVersion = r.version; } catch (e) { debugPrint('KeyOperations => loadEncryptionKey() error: $e'); rethrow; @@ -100,8 +112,10 @@ class KeyOperations { static Future saveEncryptionKey( String verificationKey, - PrvKeyRecord prvKeyRecord, - ) async { + PrvKeyRecord prvKeyRecord, { + String? saltB64, + int? version, + }) async { _encKeyUrl ??= await getFileUrl(await getEncKeyPath()); await createResource( @@ -110,8 +124,15 @@ class KeyOperations { _encKeyUrl!, verificationKey, prvKeyRecord, + saltB64: saltB64, + version: version, ), ); + + // Keep the cache consistent with what was just persisted. + + _saltB64 = saltB64; + _keyVersion = version; } /// Load the public key. @@ -139,6 +160,14 @@ class KeyOperations { static String? getVerificationKey() => _verificationKey; + /// Get the cached base64 key-derivation salt (null on legacy PODs). + + static String? getSalt() => _saltB64; + + /// Get the cached key-derivation scheme version (null if not yet loaded). + + static int? getKeyVersion() => _keyVersion; + /// Get the cached public key. static String? getPublicKey() => _pubKey; From 1632e9cc90cfae52e3d080c89fd1981514a69058 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:13:21 +1000 Subject: [PATCH 07/17] key migration from old to new --- lib/src/solid/utils/key_manager.dart | 234 ++++++++++++++++++++------- 1 file changed, 173 insertions(+), 61 deletions(-) diff --git a/lib/src/solid/utils/key_manager.dart b/lib/src/solid/utils/key_manager.dart index 1f503254..e101fa09 100644 --- a/lib/src/solid/utils/key_manager.dart +++ b/lib/src/solid/utils/key_manager.dart @@ -8,8 +8,12 @@ /// /// Some terminology used in this class are defined as follows: /// - security key: the string user provides to unlock encrypted data in PODs -/// - master key: the sha256 of the security key -/// - verification key: the sha224 of the security key +/// - master key: the AES key derived from the security key. Version 2 derives +/// it via Argon2id + HKDF using a stored salt; legacy (version 1) PODs used +/// plain sha256 (see [deriveKeys] / [genLegacyMasterKey]). +/// - verification key: a value derived from the security key and stored on the +/// POD to check the key is correct. Version 2 derives it via the same +/// Argon2id run (HKDF, separate domain); legacy used plain sha224. /// - individual key: the AES key used to encrypt an individual file /// - public/private key pair: the RSA key pair for data sharing. /// @@ -41,11 +45,14 @@ library; +import 'dart:convert' show base64; + import 'package:flutter/foundation.dart' show debugPrint; import 'package:encrypter_plus/encrypter_plus.dart'; import 'package:solidpod/src/solid/utils/authdata_manager.dart'; +import 'package:solidpod/src/solid/utils/exceptions.dart'; import 'package:solidpod/src/solid/utils/individual_key_manager.dart'; import 'package:solidpod/src/solid/utils/key_helper.dart'; import 'package:solidpod/src/solid/utils/key_operations.dart'; @@ -76,6 +83,10 @@ class KeyManager { static Key? _masterKey; + // Random salt + + static List? _salt; + /// Remove stored security key and set all cached private members to null. static Future clear() async { @@ -88,6 +99,7 @@ class KeyManager { _securityKey = null; _masterKey = null; + _salt = null; // Clear all sub-managers. @@ -133,8 +145,10 @@ class KeyManager { // NOTE: Do NOT save to local storage yet - must save to server first. _securityKey = securityKey; - _masterKey = genMasterKey(_securityKey!); - final verificationKey = genVerificationKey(_securityKey!); + _salt = generateSalt(); + final keys = await deriveKeys(_securityKey!, _salt!); + _masterKey = keys.masterKey; + final verificationKey = keys.verificationKey; // Set the public-private key pair. @@ -150,7 +164,12 @@ class KeyManager { // Save encKeyFile, indKeyFile, and pubKeyFile (on server) FIRST. // This ensures server has the verification key before we save locally. - await KeyOperations.saveEncryptionKey(verificationKey, prvKeyRecord); + await KeyOperations.saveEncryptionKey( + verificationKey, + prvKeyRecord, + saltB64: base64.encode(_salt!), + version: kdfVersion, + ); await IndividualKeyManager.saveIndividualKeys(null); await KeyOperations.savePublicKey(pubKey); @@ -185,17 +204,148 @@ class KeyManager { throw Exception('You must first set the security key!'); } - if (!verifySecurityKey(_securityKey!, await getVerificationKey())) { + try { + _masterKey = await _resolveMasterKey(_securityKey!); + } on SecurityKeyVerificationException { + // Only forget the stored key on a genuine mismatch, not on transient + // errors (network, missing file). await forgetSecurityKey(); - throw Exception('Unable to verify the security key!'); + rethrow; } - - _masterKey = genMasterKey(_securityKey!); } return _masterKey!; } + /// Verify [securityKey] against the verification value stored on the POD and + /// return the derived master key. + /// + /// For version 2 PODs the master key is derived with Argon2id + HKDF using + /// the stored salt. For legacy (version 1) PODs the old sha256 master key is + /// returned. Throws [SecurityKeyVerificationException] when the key is wrong. + /// + /// Does NOT migrate; callers that want migration use [_resolveMasterKey]. + + static Future<({Key masterKey, int version})> _verifyAndDerive( + String securityKey, + ) async { + await KeyOperations.loadEncryptionKey(); + final version = KeyOperations.getKeyVersion() ?? 1; + final storedVerification = await getVerificationKey(); + + if (version >= 2) { + final saltB64 = KeyOperations.getSalt(); + if (saltB64 == null) { + throw Exception( + 'Missing key-derivation salt for a version $version POD!', + ); + } + final keys = await deriveKeys(securityKey, base64.decode(saltB64)); + if (!constantTimeEquals(storedVerification, keys.verificationKey)) { + throw SecurityKeyVerificationException( + 'Unable to verify the security key!', + ); + } + _salt = base64.decode(saltB64); + return (masterKey: keys.masterKey, version: version); + } + + // Legacy (version 1): verify with the old sha224 scheme. + + if (!verifySecurityKey(securityKey, storedVerification)) { + throw SecurityKeyVerificationException( + 'Unable to verify the security key!', + ); + } + return (masterKey: genLegacyMasterKey(securityKey), version: 1); + } + + /// Verify [securityKey] and return the master key, migrating legacy PODs to + /// the current scheme ([kdfVersion]) on the first successful login. + + static Future _resolveMasterKey(String securityKey) async { + final r = await _verifyAndDerive(securityKey); + if (r.version < kdfVersion) { + return _migrateToV2(securityKey, r.masterKey); + } + return r.masterKey; + } + + /// Re-derive version 2 keys for [targetSecurityKey] with a fresh salt and + /// re-encrypt all key material currently protected by [oldMasterKey]. + /// + /// Re-encrypts the private key (in enc-keys.ttl) and all individual keys (in + /// ind-keys.ttl) under the new master key, and persists the new verification + /// value + salt + version on the server. Data files are NOT touched: each is + /// encrypted under its own individual key, only the master-key encryption of + /// those keys changes. Returns the new master key and salt; does NOT write + /// the security key to local storage. + + static Future<({Key masterKey, List salt})> _rekeyToV2( + Key oldMasterKey, + String targetSecurityKey, + ) async { + // Load and decrypt existing key material under the old master key. + + await KeyOperations.loadEncryptionKey(); + await IndividualKeyManager.loadIndividualKeys(); + + final prvKeyRecord = KeyOperations.getPrivateKeyRecord(); + assert(prvKeyRecord != null); + prvKeyRecord!.key ??= decryptPrivateKey( + prvKeyRecord.encKeyBase64, + oldMasterKey, + IV.fromBase64(prvKeyRecord.ivBase64), + ); + + // Derive new version 2 keys with a fresh salt. + + final newSalt = generateSalt(); + final keys = await deriveKeys(targetSecurityKey, newSalt); + final newMasterKey = keys.masterKey; + + // Re-encrypt the private key under the new master key. + + final iv = genRandIV(); + prvKeyRecord.ivBase64 = iv.base64; + prvKeyRecord.encKeyBase64 = encryptPrivateKey( + prvKeyRecord.key!, + newMasterKey, + iv, + ); + + await KeyOperations.saveEncryptionKey( + keys.verificationKey, + prvKeyRecord, + saltB64: base64.encode(newSalt), + version: kdfVersion, + ); + KeyOperations.setVerificationKey(keys.verificationKey); + KeyOperations.setPrivateKeyRecord(prvKeyRecord); + + // Re-encrypt all individual keys under the new master key. + + await IndividualKeyManager.reEncryptIndividualKeys( + oldMasterKey, + newMasterKey, + ); + + return (masterKey: newMasterKey, salt: newSalt); + } + + /// Re-key a legacy POD to the current scheme ([kdfVersion]) without changing + /// the security key. Returns the new master key. + + static Future _migrateToV2( + String securityKey, + Key oldMasterKey, + ) async { + debugPrint('KeyManager => migrating POD keys to version $kdfVersion'); + final r = await _rekeyToV2(oldMasterKey, securityKey); + _salt = r.salt; + return r.masterKey; + } + /// Get the verification key. /// /// Throws an exception if user is not logged in. @@ -231,12 +381,10 @@ class KeyManager { return false; } - final verificationKey = await getVerificationKey(); + // Verifying a version 2 key requires deriving it (Argon2id), so cache the + // resulting master key. Migrates legacy PODs on first successful login. - if (!verifySecurityKey(_securityKey!, verificationKey)) { - await forgetSecurityKey(); - return false; - } + _masterKey ??= await _resolveMasterKey(_securityKey!); return true; } catch (e) { @@ -258,12 +406,11 @@ class KeyManager { return; } - if (!verifySecurityKey(securityKey, await getVerificationKey())) { - throw Exception('Unable to verify the provided security key!'); - } + // Verify the key and derive the master key (migrating legacy PODs on the + // way). Throws [SecurityKeyVerificationException] if the key is wrong. + _masterKey = await _resolveMasterKey(securityKey); _securityKey = securityKey; - _masterKey = genMasterKey(_securityKey!); await KeyStorage.writeSecurityKey(_securityKey!); } @@ -324,58 +471,23 @@ class KeyManager { String currentSecurityKey, String newSecurityKey, ) async { - if (!verifySecurityKey(currentSecurityKey, await getVerificationKey())) { - throw Exception('Unable to verify the current security key!'); - } - assert(newSecurityKey.trim().isNotEmpty); assert(newSecurityKey != currentSecurityKey); - _securityKey = currentSecurityKey; - final oldMasterKey = genMasterKey(_securityKey!); - _masterKey = oldMasterKey; + // Verify the current key and derive its (possibly legacy) master key. + // Throws [SecurityKeyVerificationException] if the current key is wrong. - // Load and decrypt keys using old master key. + final old = await _verifyAndDerive(currentSecurityKey); - await KeyOperations.loadEncryptionKey(); - await IndividualKeyManager.loadIndividualKeys(); - - // Decrypt private key. + // Re-key all material to version 2 under the new security key. - var prvKeyRecord = KeyOperations.getPrivateKeyRecord(); - assert(prvKeyRecord != null); - prvKeyRecord!.key ??= await getPrivateKey(); + final r = await _rekeyToV2(old.masterKey, newSecurityKey); - // Set the new security key, master key, and verification key. + // Commit the new in-memory state and persist the security key locally. _securityKey = newSecurityKey; - _masterKey = genMasterKey(_securityKey!); - final newVerificationKey = genVerificationKey(_securityKey!); - - // Encrypt the private key using the new master key (and new IV). - - final iv = genRandIV(); - prvKeyRecord.ivBase64 = iv.base64; - prvKeyRecord.encKeyBase64 = encryptPrivateKey( - prvKeyRecord.key!, - _masterKey!, - iv, - ); - - // Save re-encrypted encryption keys. - - await KeyOperations.saveEncryptionKey(newVerificationKey, prvKeyRecord); - KeyOperations.setVerificationKey(newVerificationKey); - KeyOperations.setPrivateKeyRecord(prvKeyRecord); - - // Re-encrypt individual keys with new master key. - - await IndividualKeyManager.reEncryptIndividualKeys( - oldMasterKey, - _masterKey!, - ); - - // Save security key to local secure storage. + _masterKey = r.masterKey; + _salt = r.salt; await KeyStorage.writeSecurityKey(_securityKey!); } From 5140832af12a500ea7e44724f16535f9c44b35b5 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 10:13:40 +1000 Subject: [PATCH 08/17] security key verification exception for solidui --- lib/solidpod.dart | 1 + lib/src/solid/utils/exceptions.dart | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/solidpod.dart b/lib/solidpod.dart index 42f0d5e0..b77947cd 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -100,6 +100,7 @@ export 'src/solid/utils/exceptions.dart' ResourceNotDecryptableException, ResourceNotExistException, SecurityKeyNotAvailableException, + SecurityKeyVerificationException, SolidAuthCancelledException; /// Includes common TTL conversion functions such as parseTTLMap. diff --git a/lib/src/solid/utils/exceptions.dart b/lib/src/solid/utils/exceptions.dart index 3726f66b..1db9a181 100644 --- a/lib/src/solid/utils/exceptions.dart +++ b/lib/src/solid/utils/exceptions.dart @@ -82,6 +82,20 @@ class SecurityKeyNotAvailableException implements Exception { String toString() => 'SecurityKeyNotAvailableException: $message'; } +/// Thrown when a provided security key fails verification against the +/// verification value stored on the POD (i.e. the key is wrong). Distinct from +/// transient errors (network, missing file) so callers can decide to forget +/// the stored security key only on a genuine mismatch. + +class SecurityKeyVerificationException implements Exception { + final String message; + + SecurityKeyVerificationException(this.message); + + @override + String toString() => 'SecurityKeyVerificationException: $message'; +} + /// Thrown by [solidAuthenticate] when the in-flight authentication is aborted /// by [cancelSolidAuthenticate]. Callers can catch this to distinguish a /// deliberate cancellation from a genuine authentication failure such as a From e54e0813b55b05dd88a90eb483f44f76c52d9b6b Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 11:53:47 +1000 Subject: [PATCH 09/17] update pubspec --- example/pubspec.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index d8fcc127..51c92bd8 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,10 +23,10 @@ dependency_overrides: solidpod: path: .. solidui: - path: ../../solidui - # git: - # url: https://github.com/anusii/solidui.git - # ref: dev + # path: ../../solidui + git: + url: https://github.com/anusii/solidui.git + ref: av/655_use_kdf_in_key_generation dev_dependencies: dependency_validator: ^5.0.4 From e2d8eb43a81646a4a830e9c393ad97cad1a92e16 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 15:40:18 +1000 Subject: [PATCH 10/17] remove secret information from debugPrint --- lib/src/solid/utils/key_operations.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/solid/utils/key_operations.dart b/lib/src/solid/utils/key_operations.dart index 9c33eb7b..e653bd86 100644 --- a/lib/src/solid/utils/key_operations.dart +++ b/lib/src/solid/utils/key_operations.dart @@ -103,7 +103,9 @@ class KeyOperations { _saltB64 = r.saltB64; _keyVersion = r.version; } catch (e) { - debugPrint('KeyOperations => loadEncryptionKey() error: $e'); + // Log only the exception type, never `$e`: this path handles encryption + // key material (finding M1). + debugPrint('KeyOperations => loadEncryptionKey() error: ${e.runtimeType}'); rethrow; } } From 13fd5ca84f3ab7aabb08309f53e6b53525d2f9fb Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 15:40:39 +1000 Subject: [PATCH 11/17] remove info from debugPrint --- lib/src/solid/utils/key_storage.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/src/solid/utils/key_storage.dart b/lib/src/solid/utils/key_storage.dart index 189873af..10de28d3 100644 --- a/lib/src/solid/utils/key_storage.dart +++ b/lib/src/solid/utils/key_storage.dart @@ -47,7 +47,9 @@ class KeyStorage { final key = await secureStorage.read(key: _securityKeySecureStorageKey); return key != null; } catch (e) { - debugPrint('KeyStorage => hasStoredSecurityKey() error: $e'); + // Log only the exception type, never `$e`: this path handles the raw + // security key and a lower-level error could echo it (finding M1). + debugPrint('KeyStorage => hasStoredSecurityKey() error: ${e.runtimeType}'); return false; } } @@ -58,7 +60,7 @@ class KeyStorage { try { return await secureStorage.read(key: _securityKeySecureStorageKey); } catch (e) { - debugPrint('KeyStorage => readSecurityKey() error: $e'); + debugPrint('KeyStorage => readSecurityKey() error: ${e.runtimeType}'); return null; } } @@ -85,12 +87,15 @@ class KeyStorage { ); } on Object catch (e) { debugPrint( - 'KeyStorage => deleteSecurityKey() deletion failed (non-critical): $e', + 'KeyStorage => deleteSecurityKey() deletion failed ' + '(non-critical): ${e.runtimeType}', ); } } } on Object catch (e) { - debugPrint('KeyStorage => deleteSecurityKey() unexpected error: $e'); + debugPrint( + 'KeyStorage => deleteSecurityKey() unexpected error: ${e.runtimeType}', + ); } } } From 705120d1137631feb878a5e01d5cd19712d7adcf Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Tue, 9 Jun 2026 16:15:47 +1000 Subject: [PATCH 12/17] flutter secure storage add options --- lib/src/solid/constants/common.dart | 33 +++++++++++++++++++++++++--- lib/src/solid/utils/key_manager.dart | 21 +++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/lib/src/solid/constants/common.dart b/lib/src/solid/constants/common.dart index a8108416..fae5da23 100644 --- a/lib/src/solid/constants/common.dart +++ b/lib/src/solid/constants/common.dart @@ -174,9 +174,36 @@ const String demoWebID = 'https://pods.solidcommunity.au/john-doe/profile/card#me'; /// Initialize a constant instance of FlutterSecureStorage for secure data storage. -/// This instance provides encrypted storage to securely store key-value pairs. - -FlutterSecureStorage secureStorage = const FlutterSecureStorage(); +/// This instance provides encrypted storage to securely store key-value pairs +/// (the security key, and the DPoP private key + OIDC tokens via +/// [AuthDataManager]). +/// +/// Platform notes (flutter_secure_storage v10): +/// - Android: the default already uses AES-GCM with RSA-OAEP key wrapping +/// (the old `encryptedSharedPreferences` flag is deprecated and ignored), so +/// no Android options are needed. +/// - iOS/macOS: pin the keychain accessibility to `first_unlock_this_device`. +/// This keeps items reachable for background token refresh (after the first +/// unlock following a reboot) while ensuring the device-bound secrets are +/// NOT migrated to a new device via encrypted backups / iCloud. Losing the +/// DPoP key on device migration simply forces a re-login, which is expected +/// since the OIDC client is registered dynamically per session anyway. +/// - Web: values ARE encrypted at rest — AES-GCM (256-bit) via the browser's +/// Web Crypto API, stored in localStorage. The caveat is the encryption key: +/// with the default options (no `wrapKey` in `WebOptions`, which we don't +/// set) 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. +/// Web therefore provides encryption-at-rest but not the full trust-no-one +/// guarantee unless a `wrapKey` is supplied. + +FlutterSecureStorage secureStorage = const FlutterSecureStorage( + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + mOptions: MacOsOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), +); /// Enum of resource status diff --git a/lib/src/solid/utils/key_manager.dart b/lib/src/solid/utils/key_manager.dart index e101fa09..b42ea5ff 100644 --- a/lib/src/solid/utils/key_manager.dart +++ b/lib/src/solid/utils/key_manager.dart @@ -111,7 +111,12 @@ class KeyManager { 'KeyManager => clear() completed - all sensitive data cleared', ); } on Object catch (e) { - debugPrint('KeyManager => clear() error during clearing: $e'); + // Log only the exception type, never `$e`: this class handles the raw + // security key and master key, and a lower-level error could echo them + // (finding M1). + debugPrint( + 'KeyManager => clear() error during clearing: ${e.runtimeType}', + ); // Fallback: force-clear all memory state anyway. @@ -124,7 +129,8 @@ class KeyManager { debugPrint('KeyManager => clear() fallback memory clear succeeded'); } catch (fallbackError) { debugPrint( - 'KeyManager => clear() fallback also failed: $fallbackError', + 'KeyManager => clear() fallback also failed: ' + '${fallbackError.runtimeType}', ); } } @@ -184,7 +190,7 @@ class KeyManager { await KeyStorage.writeSecurityKey(_securityKey!); } catch (e) { - debugPrint('KeyManager => initPodKeys() error: $e'); + debugPrint('KeyManager => initPodKeys() error: ${e.runtimeType}'); // Clear memory state on failure to prevent inconsistent state. @@ -388,7 +394,7 @@ class KeyManager { return true; } catch (e) { - debugPrint('KeyManager => hasSecurityKey() error: $e'); + debugPrint('KeyManager => hasSecurityKey() error: ${e.runtimeType}'); // If verification key file doesn't exist, this will throw. // In that case, the key in storage is orphaned and should be removed. @@ -445,7 +451,9 @@ class KeyManager { 'KeyManager => forgetSecurityKey() cleared all sensitive data from memory', ); } on Object catch (e) { - debugPrint('KeyManager => forgetSecurityKey() unexpected error: $e'); + debugPrint( + 'KeyManager => forgetSecurityKey() unexpected error: ${e.runtimeType}', + ); // Fallback: null out everything anyway. @@ -459,7 +467,8 @@ class KeyManager { ); } catch (fallbackError) { debugPrint( - 'KeyManager => forgetSecurityKey() fallback also failed: $fallbackError', + 'KeyManager => forgetSecurityKey() fallback also failed: ' + '${fallbackError.runtimeType}', ); } } From 9e70c86c23abe16970acddf98bdbade758eb1e7e Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 10 Jun 2026 10:57:38 +1000 Subject: [PATCH 13/17] token refresh fix --- lib/src/solid/utils/authdata_manager.dart | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/src/solid/utils/authdata_manager.dart b/lib/src/solid/utils/authdata_manager.dart index 9a76ce46..81d8321f 100644 --- a/lib/src/solid/utils/authdata_manager.dart +++ b/lib/src/solid/utils/authdata_manager.dart @@ -197,14 +197,34 @@ class AuthDataManager { /// Exposes the live [SolidAuthManager] for DPoP proof generation and logout. static SolidAuthManager? getAuthManager() => _authManager; - /// Returns [SolidAuthData] from [manager], refreshing the token if expired. + /// Buffer before actual token expiry within which we proactively refresh. + /// + /// Refreshing slightly early avoids sending a token that expires mid-flight + /// and guards against minor client/server clock skew that would otherwise + /// produce a 401 on an apparently-valid token. + static const Duration _refreshBuffer = Duration(minutes: 2); + + /// Returns [SolidAuthData] from [manager], refreshing the token if it is + /// expired or about to expire. + /// + /// We do not rely solely on `package:oidc`'s background refresh timer: that + /// timer is a foreground Dart [Timer] scheduled shortly before expiry and is + /// suspended while the app is backgrounded, so a token can be stale by the + /// time the user resumes. Checking the real expiry here (and refreshing + /// within [_refreshBuffer]) ensures every resource request uses a live token. static Future _getRefreshedAuthData( SolidAuthManager manager, ) async { try { var authData = manager.currentAuthData; - if (authData != null && authData.isExpired) { - authData = await manager.refreshToken(); + + final needsRefresh = authData == null || + DateTime.now().add(_refreshBuffer).isAfter(authData.expiresAt); + + if (needsRefresh) { + // Fall back to the existing (possibly stale) data if the refresh + // returns null, e.g. when no refresh token is available. + authData = await manager.refreshToken() ?? authData; } return authData; } on Object { From 1efcbe12d869c3fd82ebd5a6e7762e5db000609d Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 10 Jun 2026 10:57:47 +1000 Subject: [PATCH 14/17] point to local branch --- pubspec.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 641776ee..dc6c1e4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,9 @@ dependencies: petitparser: ^6.1.0 pointycastle: ^4.0.0 rdflib: ^0.2.12 - solid_auth: ^1.0.0 + # solid_auth: ^1.0.0 + solid_auth: + path: ../solid_auth universal_io: ^2.3.1 dev_dependencies: From d7d52fe2b5aaad972a85c6532f90a67bf7be8063 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 10 Jun 2026 11:26:56 +1000 Subject: [PATCH 15/17] dart format --- lib/src/solid/utils/key_operations.dart | 3 ++- lib/src/solid/utils/key_storage.dart | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/solid/utils/key_operations.dart b/lib/src/solid/utils/key_operations.dart index e653bd86..9cbac600 100644 --- a/lib/src/solid/utils/key_operations.dart +++ b/lib/src/solid/utils/key_operations.dart @@ -105,7 +105,8 @@ class KeyOperations { } catch (e) { // Log only the exception type, never `$e`: this path handles encryption // key material (finding M1). - debugPrint('KeyOperations => loadEncryptionKey() error: ${e.runtimeType}'); + debugPrint( + 'KeyOperations => loadEncryptionKey() error: ${e.runtimeType}'); rethrow; } } diff --git a/lib/src/solid/utils/key_storage.dart b/lib/src/solid/utils/key_storage.dart index 10de28d3..28612556 100644 --- a/lib/src/solid/utils/key_storage.dart +++ b/lib/src/solid/utils/key_storage.dart @@ -49,7 +49,8 @@ class KeyStorage { } catch (e) { // Log only the exception type, never `$e`: this path handles the raw // security key and a lower-level error could echo it (finding M1). - debugPrint('KeyStorage => hasStoredSecurityKey() error: ${e.runtimeType}'); + debugPrint( + 'KeyStorage => hasStoredSecurityKey() error: ${e.runtimeType}'); return false; } } From 01b492bc8a3deb324da1b3357ea0ad4bd552e782 Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 10 Jun 2026 11:31:17 +1000 Subject: [PATCH 16/17] Add trailing commas --- lib/src/solid/utils/key_operations.dart | 3 ++- lib/src/solid/utils/key_storage.dart | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/src/solid/utils/key_operations.dart b/lib/src/solid/utils/key_operations.dart index 9cbac600..7033335d 100644 --- a/lib/src/solid/utils/key_operations.dart +++ b/lib/src/solid/utils/key_operations.dart @@ -106,7 +106,8 @@ class KeyOperations { // Log only the exception type, never `$e`: this path handles encryption // key material (finding M1). debugPrint( - 'KeyOperations => loadEncryptionKey() error: ${e.runtimeType}'); + 'KeyOperations => loadEncryptionKey() error: ${e.runtimeType}', + ); rethrow; } } diff --git a/lib/src/solid/utils/key_storage.dart b/lib/src/solid/utils/key_storage.dart index 28612556..c063c146 100644 --- a/lib/src/solid/utils/key_storage.dart +++ b/lib/src/solid/utils/key_storage.dart @@ -50,7 +50,8 @@ class KeyStorage { // Log only the exception type, never `$e`: this path handles the raw // security key and a lower-level error could echo it (finding M1). debugPrint( - 'KeyStorage => hasStoredSecurityKey() error: ${e.runtimeType}'); + 'KeyStorage => hasStoredSecurityKey() error: ${e.runtimeType}', + ); return false; } } @@ -61,7 +62,9 @@ class KeyStorage { try { return await secureStorage.read(key: _securityKeySecureStorageKey); } catch (e) { - debugPrint('KeyStorage => readSecurityKey() error: ${e.runtimeType}'); + debugPrint( + 'KeyStorage => readSecurityKey() error: ${e.runtimeType}', + ); return null; } } @@ -69,7 +72,10 @@ class KeyStorage { /// Write the security key to secure storage. static Future writeSecurityKey(String securityKey) async { - await writeToSecureStorage(_securityKeySecureStorageKey, securityKey); + await writeToSecureStorage( + _securityKeySecureStorageKey, + securityKey, + ); } /// Remove the security key from secure storage. From d075441916bcaa889aa1a3a6188a2cb0a9d69ade Mon Sep 17 00:00:00 2001 From: anushkavidanage Date: Wed, 10 Jun 2026 11:47:09 +1000 Subject: [PATCH 17/17] point to solid_auth git branch --- pubspec.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index dc6c1e4b..f22597ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,10 @@ dependencies: rdflib: ^0.2.12 # solid_auth: ^1.0.0 solid_auth: - path: ../solid_auth + # path: ../solid_auth + git: + url: https://github.com/anusii/solid_auth.git + ref: av/347_fix_token_refresh_expiry universal_io: ^2.3.1 dev_dependencies: