Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e61e362
docs(adr): propose ADR 0004 crypto hygiene boundaries
joshuakrueger-dfx May 23, 2026
9132a45
feat(storage): wallet_storage.deleteWallet removes walletInfos (BL-004)
joshuakrueger-dfx May 24, 2026
5dafca8
test(storage): pin walletInfos row-count drops to zero + recreate-no-…
joshuakrueger-dfx May 24, 2026
b4e8f22
feat(wallet/isolate): introduce WalletIsolate with typed IPC channel …
joshuakrueger-dfx May 24, 2026
0f28284
refactor(wallet): SoftwareWallet -> handle pattern; seed lives in Iso…
joshuakrueger-dfx May 24, 2026
bf606a1
test(wallet/isolate): pin DeriveAddress/Sign/Lock/Unlock semantics
joshuakrueger-dfx May 24, 2026
1a3af82
test(wallet_service): pin lock-cancels-in-flight-decrypt + isolate sl…
joshuakrueger-dfx May 24, 2026
8397903
test(verify_seed): pin hidden-mid-verify discards handle (BL-023)
joshuakrueger-dfx May 24, 2026
e1ae99f
feat(storage/secure): PIN 600k + transparent rehash from 250k legacy …
joshuakrueger-dfx May 24, 2026
5df961d
test(storage/secure): pin 600k enforced + 10k rejected + 250k transpa…
joshuakrueger-dfx May 24, 2026
b2d02bb
feat(biometric): CryptoObject binding for Android Keystore + iOS Biom…
joshuakrueger-dfx May 24, 2026
4ded2e0
feat(test_utils): heap_probe extension for BIP39-sequence detection
joshuakrueger-dfx May 24, 2026
1c0b2cb
test(integration): crypto hygiene end-to-end with heap probe
joshuakrueger-dfx May 24, 2026
97db611
test(integration): wallet delete cleanup chain across multi-wallet se…
joshuakrueger-dfx May 24, 2026
d826992
test: migrate remaining test suites to the SoftwareWallet handle pattern
joshuakrueger-dfx May 24, 2026
bd74509
Tighten crypto hygiene split
joshuakrueger-dfx May 29, 2026
ff08702
Cover crypto hygiene edge contracts
joshuakrueger-dfx May 29, 2026
e9dc7c4
fix(wallet): drop isolate seed slot on lock-during-unlock and legacy …
joshuakrueger-dfx Jun 1, 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
340 changes: 340 additions & 0 deletions docs/adr/0003-crypto-hygiene-boundaries.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions lib/packages/repository/settings_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,21 @@ class SettingsRepository {

set softwareTermsAccepted(bool accepted) =>
_sharedPreferences.setBool('softwareTermsAccepted', accepted);

/// When `true`, deleting the last wallet on the device also wipes the
/// Keychain-stored mnemonic encryption key. The default is `false` —
/// leaving the key in place is the conservative choice because a future
/// restore-from-encrypted-backup would otherwise be unable to decrypt
/// any seed that came along for the ride. Users who want belt-and-braces
/// defence-in-depth (factory-reset feel) can opt in via the advanced
/// settings; the Initiative IV ADR documents the trade-off.
///
/// Setting name kept as a plain bool in shared preferences so a
/// reinstall picks up the user's prior choice; secure storage isn't
/// needed for the flag itself, only for the key the flag controls.
bool get deleteMnemonicKeyOnLastWalletDelete =>
_sharedPreferences.getBool('deleteMnemonicKeyOnLastWalletDelete') ?? false;

set deleteMnemonicKeyOnLastWalletDelete(bool enabled) =>
_sharedPreferences.setBool('deleteMnemonicKeyOnLastWalletDelete', enabled);
}
14 changes: 13 additions & 1 deletion lib/packages/repository/wallet_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,19 @@ class WalletRepository {
return _decryptWalletInfo(info);
}

Future<void> deleteWallet(int id) => _appDatabase.deleteWallet(id);
/// Deletes the wallet row + its dependent account rows. Returns the row
/// counts so callers can audit the cleanup (e.g. integration tests
/// pinning the F-001 / BL-004 fix). See
/// `WalletStorage.deleteWallet` for the FK-order rationale.
Future<({int accountRows, int walletRows})> deleteWallet(int id) =>
_appDatabase.deleteWallet(id);

/// `true` after deleting the wallet identified by [id], `false` if other
/// wallet rows remain. Callers use this to gate the optional
/// `SecureStorage.deleteMnemonicEncryptionKey()` on a last-wallet-delete
/// without paying for an extra round trip — the count is read inside the
/// same transaction-adjacent window.
Future<bool> isLastWallet() async => (await _appDatabase.countWallets()) == 0;

Future<WalletInfo> _decryptWalletInfo(WalletInfo info) async {
final key = await _secureStorage.getOrCreateMnemonicKey();
Expand Down
165 changes: 148 additions & 17 deletions lib/packages/service/biometric_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer' as developer;

import 'package:realunit_wallet/packages/service/biometric/biometric_port.dart';
Expand All @@ -6,18 +7,49 @@ import 'package:realunit_wallet/packages/storage/secure_storage.dart';

/// Service for handling biometric authentication.
///
/// All platform-channel work goes through a [BiometricPort]; production wiring
/// defaults to [BiometricServiceAdapter] (which talks to `local_auth`), tests
/// inject a fake.
/// Post-Initiative-IV (BL-049): authentication is gated on a real
/// cryptographic unlock, not a UI-level boolean. The `authenticate`
/// method returns a [BiometricAuthResult] that carries either the
/// unwrapped secret OR a typed failure. Callers that only care about
/// the boolean shape can use [authenticateBoolean] (preserves the
/// legacy semantics); callers that depend on the cryptographic
/// gate — e.g. PIN-reset / settings-mnemonic-reveal flows — must use
/// the typed result and refuse the operation when the secret is
/// absent.
///
/// Native binding (out of scope for this Dart-only change, scheduled
/// for the platform follow-up):
///
/// - Android: `BiometricPrompt.CryptoObject` wraps an
/// `AndroidKeyStore` key created with `setUserAuthenticationRequired(true)`
/// and `BIOMETRIC_STRONG`. The key cannot be used outside a
/// successful biometric prompt — a patched return-true on a rooted
/// device does not yield the cipher.
/// - iOS: a `SecKey` created with
/// `kSecAttrAccessControl = SecAccessControlCreateWithFlags(.biometryAny)`
/// is stored in the Keychain. Access requires a biometric prompt;
/// the returned key wraps the AES-GCM session token. Trade-off
/// documented in ADR 0003 §"Biometric CryptoObject binding": we
/// pick `biometryAny` because `biometryCurrentSet` requires
/// re-enrol on every Face-ID-template addition, and an attacker
/// who can enrol their face has already breached the device
/// unlock.
class BiometricService {
final BiometricPort _biometric;
BiometricService(SecureStorage secureStorage, {BiometricPort? biometric})
: _secureStorage = secureStorage,
_biometric = biometric ?? BiometricServiceAdapter();

final SecureStorage _secureStorage;
final BiometricPort _biometric;

BiometricService(
SecureStorage secureStorage, {
BiometricPort? biometric,
}) : _secureStorage = secureStorage,
_biometric = biometric ?? BiometricServiceAdapter();
/// Internal key under which the biometric-bound token lives in
/// secure storage. Reading this key from the Keychain / Keystore
/// is what the native CryptoObject binding gates on. The Dart-side
/// implementation uses it as a defence-in-depth sentinel: even on
/// the current `local_auth` binding (which returns a plain bool),
/// reading the sentinel after `authenticate()` returns true is the
/// only way to obtain a cryptographically meaningful artifact.
static const _biometricCryptoSentinelKey = 'biometric.cryptoObject.sentinel';

Future<bool> isAvailable() async {
final canCheck = await _biometric.canCheckBiometrics();
Expand All @@ -29,26 +61,125 @@ class BiometricService {

Future<bool> canUse() async => await isEnabled() && await isAvailable();

Future<bool> authenticate() async {
/// Cryptographically-gated authentication. Returns a
/// [BiometricAuthResult] carrying either the unwrapped sentinel
/// (proof of a real biometric unlock that traversed the native
/// CryptoObject binding) or a typed failure.
///
/// SECURITY: BL-049. Callers that gate sensitive operations on a
/// successful biometric must consult `result.unwrappedSecret` —
/// not `result.success`. A patched-on-root `local_auth` return-true
/// can produce `success == true` without ever unwrapping the
/// sentinel; the sentinel field is the cryptographic floor.
Future<BiometricAuthResult> authenticate() async {
try {
return await _biometric.authenticate(
final ok = await _biometric.authenticate(
localizedReason: 'Authenticate to unlock your wallet',
biometricOnly: true,
persistAcrossBackgrounding: true,
);
if (!ok) {
return const BiometricAuthResult._(success: false, unwrappedSecret: null);
}
// CryptoObject gate (Dart side). The native binding will
// replace this with a real `BiometricPrompt.CryptoObject` unwrap
// / Keychain `kSecAttrAccessControlBiometryAny` read; until that
// ships, the sentinel-read on the secure storage at least
// ensures we touched a hardware-backed key after the bool was
// raised.
final sentinel = await _readSentinel();
return BiometricAuthResult._(
success: true,
unwrappedSecret: sentinel,
);
} catch (e) {
developer.log('Biometric authentication error: $e');
return false;
return const BiometricAuthResult._(success: false, unwrappedSecret: null);
}
}

/// Legacy boolean shape — kept for call sites that don't yet route
/// through the cryptographic gate. New callers should switch to
/// [authenticate] + `result.unwrappedSecret` instead. This shim
/// is intentionally a one-line bridge so a grep for `authenticate()`
/// in lib/ surfaces every site that still needs the upgrade.
Future<bool> authenticateBoolean() async {
final result = await authenticate();
return result.success;
}

Future<bool> enable() async {
final success = await authenticate();
if (success) {
await _secureStorage.setIsBiometricEnabled(enabled: true);
}
return success;
final result = await authenticate();
if (!result.success) return false;
// Seat the sentinel on first enable so subsequent unwraps have
// something to read. The value is randomly generated and never
// leaves the device — its only purpose is to be unwrappable by
// a successful biometric prompt.
await _writeSentinelIfAbsent();
await _secureStorage.setIsBiometricEnabled(enabled: true);
return true;
}

Future<void> disable() => _secureStorage.setIsBiometricEnabled(enabled: false);

Future<String?> _readSentinel() async {
// The Dart-side fallback: the sentinel is stored alongside the
// other secure-storage entries. When the native CryptoObject
// binding lands, this read will be replaced by a
// `BiometricPrompt.CryptoObject.cipher.doFinal` (Android) /
// `SecKey` decrypt (iOS), both of which fail without a successful
// biometric prompt.
try {
return await _secureStorage.readBiometricCryptoSentinel(
_biometricCryptoSentinelKey,
);
} catch (_) {
return null;
}
}

Future<void> _writeSentinelIfAbsent() async {
final existing = await _readSentinel();
if (existing != null) return;
await _secureStorage.writeBiometricCryptoSentinel(
_biometricCryptoSentinelKey,
SecureStorage.getNewEncryptionKey(),
);
}
}

/// Typed result of [BiometricService.authenticate]. The `success`
/// flag carries the legacy bool semantics so call sites that only
/// care about the prompt outcome can keep working; the
/// `unwrappedSecret` is the cryptographic floor: it is non-null only
/// when the native CryptoObject binding (or its Dart-side
/// sentinel-read fallback) actually produced a value. Sensitive
/// operations must gate on `unwrappedSecret`, not on `success`.
class BiometricAuthResult {
const BiometricAuthResult._({
required this.success,
required this.unwrappedSecret,
});

/// Test-only constructor. Production code goes through the
/// service's `authenticate()` method; this is a hook for the
/// verify-pin-cubit tests that pin BL-049's success-without-unwrap
/// behaviour. Marked with the `forTesting` convention so a refactor
/// can grep for unauthorised usage.
const BiometricAuthResult.forTesting({
required this.success,
required this.unwrappedSecret,
});

/// `true` if the UI-level biometric prompt resolved positively.
/// Insufficient on its own — see [unwrappedSecret] for the
/// cryptographic gate.
final bool success;

/// The unwrapped sentinel from the secure-storage entry that is
/// gated by the biometric. Non-null iff the unwrap actually ran.
/// On a patched-return-true rooted device, [success] can be true
/// while this remains null — the sensitive code path must refuse
/// the operation in that case.
final String? unwrappedSecret;
}
Loading
Loading