Skip to content
Merged
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
62 changes: 62 additions & 0 deletions lib/src/solid/constants/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

library;

import 'package:flutter/services.dart' show PlatformException;

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

/// Length limit for long strings for a screen.
Expand Down Expand Up @@ -189,6 +191,22 @@ const String demoWebID =
/// 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.
/// - macOS additionally turns `usesDataProtectionKeychain` off (the plugin
/// defaults it on). The data protection keychain is reachable only by a
/// process carrying a keychain access group, which Developer ID distribution
/// cannot supply: it embeds no provisioning profile, and
/// `keychain-access-groups` is a restricted entitlement without one. Worse,
/// the OS then fails asymmetrically. `SecItemAdd` returns
/// `errSecMissingEntitlement` (-34018), so the caller falls back to plaintext
/// `shared_preferences`, while `SecItemCopyMatching` returns
/// `errSecItemNotFound` (-25300), which reads as "never stored" and triggers
/// no fallback. Secrets are leaked to disk and then lost on read. The
/// file-based (login) keychain needs no entitlement and works sandboxed or
/// not; `accessibility` is a data protection attribute and is ignored there.
///
/// 20260920 tonypioneer Diagnosed against the notarized todopod 1.0.46 DMG,
/// where it left the PKCE `code_verifier` unreadable and made every login
/// fail with `invalid_grant - PKCE verification failed`.
/// - Web: values are AES-GCM-encrypted (256-bit) via the browser's Web Crypto
/// API. The caveat is the encryption key: with the default options the AES
/// key is stored *unwrapped* in the same storage as the ciphertext, so any
Expand All @@ -206,13 +224,57 @@ FlutterSecureStorage secureStorage = const FlutterSecureStorage(
),
mOptions: MacOsOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
usesDataProtectionKeychain: false,
),
// Web only: use sessionStorage instead of localStorage so cached secrets
// (security key, DPoP key, tokens) do not persist beyond the browsing
// session. Ignored on native platforms.
webOptions: WebOptions(useSessionStorage: true),
);

/// Removes [key] from the secure storage, tolerating the one keychain error
/// that does not mean the deletion failed.
///
/// `flutter_secure_storage`'s Darwin `delete()` runs `SecItemDelete` twice —
/// once with `kSecAttrSynchronizable` true, once false — so that an item
/// written by an earlier build is removed whichever way it was stored. An
/// iCloud-synchronizable query needs a keychain access group, which only an
/// embedded provisioning profile can supply, so in a Developer ID build the
/// synchronizable pass always fails with `errSecMissingEntitlement` (-34018).
/// The plugin then reports that status for the whole call unless the
/// non-synchronizable pass actually deleted something:
///
/// ```swift
/// let status = statusSync != errSecItemNotFound ? statusSync : statusNonSync
/// ```
///
/// -34018 therefore reaches us only when the real (non-synchronizable) item
/// was not there — the plugin returns success when it was found and removed.
/// Swallowing it is exactly equivalent to "nothing to delete", which is what
/// `delete()` promises for an absent key. An app that *can* reach the iCloud
/// keychain never produces the status at all, so nothing is hidden there.
///
/// 20260920 tonypioneer Left unhandled this aborted login in the notarized
/// todopod DMG: [writeToSecureStorage] deletes before every write, so
/// `markPodStructureInitialised()` threw straight out of the login flow.

Future<void> deleteFromSecureStorage(String key) async {
try {
await secureStorage.delete(key: key);
} on PlatformException catch (e) {
if (!_isMissingKeychainEntitlement(e)) rethrow;
}
}

/// Whether [e] reports the iOS/macOS keychain "a required entitlement is not
/// present" error (`errSecMissingEntitlement`, OSStatus -34018).
///
/// The Darwin plugin puts the raw OSStatus in `details` and echoes the number
/// in `message`, so both are checked rather than the generic `code` string.

bool _isMissingKeychainEntitlement(PlatformException e) =>
e.details == -34018 || (e.message?.contains('-34018') ?? false);

/// Enum of resource status

enum ResourceStatus {
Expand Down
5 changes: 3 additions & 2 deletions lib/src/solid/utils/authdata_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import 'package:flutter/foundation.dart' show ValueNotifier;
import 'package:solid_auth/solid_auth.dart'
show SolidAuthData, SolidAuthManager, SolidOidcConfig;

import 'package:solidpod/src/solid/constants/common.dart' show secureStorage;
import 'package:solidpod/src/solid/constants/common.dart'
show deleteFromSecureStorage, secureStorage;
import 'package:solidpod/src/solid/utils/misc.dart' show writeToSecureStorage;

/// Global auth state notifier for reactive UI updates.
Expand Down Expand Up @@ -174,7 +175,7 @@ class AuthDataManager {
_webId = null;

if (await secureStorage.containsKey(key: _authDataSecureStorageKey)) {
await secureStorage.delete(key: _authDataSecureStorageKey);
await deleteFromSecureStorage(_authDataSecureStorageKey);
}

authStateNotifier.value = false;
Expand Down
2 changes: 1 addition & 1 deletion lib/src/solid/utils/init_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Future<void> clearPodStructureInitialised() async {
try {
final key = await _getPodInitFlagKey();
if (await secureStorage.containsKey(key: key)) {
await secureStorage.delete(key: key);
await deleteFromSecureStorage(key);
}
} on NotLoggedInException {
// Expected during account-switch flows where the caller logs out before
Expand Down
2 changes: 1 addition & 1 deletion lib/src/solid/utils/key_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class KeyStorage {
try {
if (await secureStorage.containsKey(key: _securityKeySecureStorageKey)) {
try {
await secureStorage.delete(key: _securityKeySecureStorageKey);
await deleteFromSecureStorage(_securityKeySecureStorageKey);
debugPrint(
'KeyStorage => deleteSecurityKey() removed from secure storage',
);
Expand Down
4 changes: 2 additions & 2 deletions lib/src/solid/utils/misc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export 'package:solidpod/src/solid/utils/session.dart';
/// so calling it unconditionally clears any such orphan before we write.

Future<void> writeToSecureStorage(String key, String value) async {
await secureStorage.delete(key: key);
await deleteFromSecureStorage(key);

try {
await secureStorage.write(key: key, value: value);
Expand All @@ -75,7 +75,7 @@ Future<void> writeToSecureStorage(String key, String value) async {
// synchronizable variant), purge once more and retry the write.

if (_isDuplicateKeychainItem(e)) {
await secureStorage.delete(key: key);
await deleteFromSecureStorage(key);
await secureStorage.write(key: key, value: value);
} else {
rethrow;
Expand Down
Loading