Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
63b6d0e
add cryptopgraphy package
anushkavidanage Jun 9, 2026
b5ffc55
change pubspec to point to local
anushkavidanage Jun 9, 2026
20e5af2
add Argon2id specific predicates
anushkavidanage Jun 9, 2026
cb9d6f6
salt and key version predicates
anushkavidanage Jun 9, 2026
266f923
add new key generation and verification methods
anushkavidanage Jun 9, 2026
b69ae0f
add new keys to pods
anushkavidanage Jun 9, 2026
1632e9c
key migration from old to new
anushkavidanage Jun 9, 2026
5140832
security key verification exception for solidui
anushkavidanage Jun 9, 2026
e54e081
update pubspec
anushkavidanage Jun 9, 2026
7d99f7f
Merge branch 'dev' of https://github.com/anusii/solidpod into av/655_…
anushkavidanage Jun 9, 2026
e2d8eb4
remove secret information from debugPrint
anushkavidanage Jun 9, 2026
13fd5ca
remove info from debugPrint
anushkavidanage Jun 9, 2026
705120d
flutter secure storage add options
anushkavidanage Jun 9, 2026
31748cc
Merge branch 'dev' of https://github.com/anusii/solidpod into av/fix_…
anushkavidanage Jun 9, 2026
9e70c86
token refresh fix
anushkavidanage Jun 10, 2026
1efcbe1
point to local branch
anushkavidanage Jun 10, 2026
1a8fd2b
Merge branch 'dev' of https://github.com/anusii/solidpod into av/655_…
anushkavidanage Jun 10, 2026
32cbcc4
Merge branch 'av/655_use_kdf_in_key_generation' of https://github.com…
anushkavidanage Jun 10, 2026
d7d52fe
dart format
anushkavidanage Jun 10, 2026
01b492b
Add trailing commas
anushkavidanage Jun 10, 2026
dcb6f39
Merge branch 'av/fix_flutter_secure_storage_security_issue' of https:…
anushkavidanage Jun 10, 2026
d075441
point to solid_auth git branch
anushkavidanage Jun 10, 2026
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: 1 addition & 1 deletion example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependency_overrides:
solidui:
git:
url: https://github.com/anusii/solidui.git
ref: dev
ref: av/655_use_kdf_in_key_generation

dev_dependencies:
dependency_validator: ^5.0.4
Expand Down
1 change: 1 addition & 0 deletions lib/solidpod.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export 'src/solid/utils/exceptions.dart'
ResourceNotDecryptableException,
ResourceNotExistException,
SecurityKeyNotAvailableException,
SecurityKeyVerificationException,
SolidAuthCancelledException;

/// Includes common TTL conversion functions such as parseTTLMap.
Expand Down
35 changes: 32 additions & 3 deletions lib/src/solid/constants/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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';
Expand Down Expand Up @@ -173,9 +175,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

Expand Down
6 changes: 6 additions & 0 deletions lib/src/solid/constants/solid_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
26 changes: 23 additions & 3 deletions lib/src/solid/utils/authdata_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<SolidAuthData?> _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 {
Expand Down
14 changes: 14 additions & 0 deletions lib/src/solid/utils/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 when a notification cannot be delivered because the recipient's Pod
/// is not ready — either their WebID does not exist, they have not set up the
/// app, or their notification folder has not yet been created.
Expand Down
171 changes: 151 additions & 20 deletions lib/src/solid/utils/key_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<int> 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<int> 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);
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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<String> 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);
}

Expand Down
Loading
Loading