What changed, and why it matters
This commit hardens how Stack Wallet stores the desktop password 'key blob' on disk. It prevents creating a new password blob when one already exists, verifies writes actually landed, makes password changes more atomic, and lets the app recover when the stored version number doesn't match the blob. The changes reduce the risk of ending up with a corrupt or mismatched password state that could lock a user out or leave an old password usable unexpectedly.
Review and merge after confirming tests pass. Consider whether _putAndVerify's read-after-write semantics are safe under concurrent access, and verify that StorageCryptoHandler.fromExisting's IncorrectPassphraseOrVersion/VersionError exceptions are the only expected failure modes in the version-probe loop. No immediate incident response is indicated by the diff alone.
Security signals we found
Prevention of keyBlob overwrite during new password setup
Write verification helper (_putAndVerify) to detect silent persistence failures
Rethrow of storage errors instead of swallowing them
Version probing to recover from mismatched blob/version metadata
Atomic-ish password change with old blob remaining usable if new blob write fails
Direct blob authentication in changePassphrase instead of relying on cached handler
New test coverage for failure and recovery scenarios
Evidence from the diff
The patch refactors lib/utilities/desktop_password_service.dart to make Desktop Password Service (DPS) persistence more robust. Key changes: (1) initFromNew now refuses to overwrite an existing keyBlob. (2) Writes use a new _putAndVerify helper that re-reads and compares the persisted value, tolerating a reported error only if verification succeeds. (3) _put and _get now rethrow after logging, so callers can detect failures. (4) initFromExisting and changePassphrase authenticate the blob against a set of supported versions (versionHint plus all versions down to 1), handling version metadata drift. (5) changePassphrase now authenticates the old passphrase directly against the stored blob rather than relying on the in-memory _handler, and only updates _handler after a verified write. (6) Added compaction and upgrade helpers with error logging. A comprehensive test suite was added covering new-password persistence, failed setup, atomic password change, failed automatic upgrade recovery, and interrupted upgrade state recovery.
Changed components
lib/utilities/desktop_password_service.darttest/utilities/desktop_password_service_test.dartInspect captured patch +400 / −21
diff --git a/lib/utilities/desktop_password_service.dart b/lib/utilities/desktop_password_service.dart
index 1649a5d..aab071e 100644
--- a/lib/utilities/desktop_password_service.dart
+++ b/lib/utilities/desktop_password_service.dart
@@ -60,13 +60,23 @@ class DPS {
}
try {
- _handler = await StorageCryptoHandler.fromNewPassphrase(
+ if (await _get(key: _kKeyBlobKey) != null) {
+ throw Exception(
+ "DPS: attempted to overwrite an existing keyBlob with a new one",
+ );
+ }
+
+ final handler = await StorageCryptoHandler.fromNewPassphrase(
passphrase,
kLatestBlobVersion,
);
+ final keyBlob = await handler.getKeyBlob();
- await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob());
+ // The blob is the password-exists commit marker. Store its version first
+ // so a failed blob write leaves a safe, retryable version-only state.
await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ await _putAndVerify(key: _kKeyBlobKey, value: keyBlob);
+ _handler = handler;
} catch (e, s) {
Logging.instance.e(
"${_getMessageFromException(e)}\n$s",
@@ -89,21 +99,36 @@ class DPS {
if (keyBlob == null) {
throw Exception(
- "DPS: failed to find keyBlob while attempting to initialize with existing passphrase",
+ "DPS: failed to find keyBlob while attempting to initialize with"
+ " existing passphrase",
);
}
- final blobVersion = await _getStoredKeyBlobVersion();
- _handler = await StorageCryptoHandler.fromExisting(
+ final versionHint = await _getStoredKeyBlobVersion();
+ final authenticated = await _authenticateKeyBlob(
passphrase,
keyBlob,
- blobVersion,
+ versionHint,
);
- if (blobVersion < kLatestBlobVersion) {
- // update blob
- await _handler!.resetPassphrase(passphrase, kLatestBlobVersion);
- await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob());
- await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ _handler = authenticated.handler;
+
+ if (authenticated.version < kLatestBlobVersion) {
+ await _tryUpgradeKeyBlob(
+ passphrase: passphrase,
+ keyBlob: keyBlob,
+ version: authenticated.version,
+ );
+ } else if (versionHint != authenticated.version) {
+ try {
+ await _updateStoredKeyBlobVersion(authenticated.version);
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: failed to repair key blob version metadata",
+ error: e,
+ stackTrace: s,
+ );
+ }
}
+ await _tryCompactPasswordStorage();
} catch (e, s) {
Logging.instance.e(
"${_getMessageFromException(e)}\n$s",
@@ -122,8 +147,8 @@ class DPS {
// no passphrase key blob found so any passphrase is technically bad
return false;
}
- final blobVersion = await _getStoredKeyBlobVersion();
- await StorageCryptoHandler.fromExisting(passphrase, keyBlob, blobVersion);
+ final versionHint = await _getStoredKeyBlobVersion();
+ await _authenticateKeyBlob(passphrase, keyBlob, versionHint);
// existing passphrase matches key blob
return true;
} catch (e, s) {
@@ -142,6 +167,10 @@ class DPS {
String passphraseNew,
) async {
try {
+ if (_handler == null) {
+ return false;
+ }
+
final keyBlob = await _get(key: _kKeyBlobKey);
if (keyBlob == null) {
@@ -149,14 +178,22 @@ class DPS {
return false;
}
- if (!(await verifyPassphrase(passphraseOld))) {
- return false;
- }
+ final versionHint = await _getStoredKeyBlobVersion();
+ final authenticated = await _authenticateKeyBlob(
+ passphraseOld,
+ keyBlob,
+ versionHint,
+ );
+ final newHandler = authenticated.handler;
+ await newHandler.resetPassphrase(passphraseNew, kLatestBlobVersion);
+ final newBlob = await newHandler.getKeyBlob();
- final blobVersion = await _getStoredKeyBlobVersion();
- await _handler!.resetPassphrase(passphraseNew, blobVersion);
- await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob());
- await _updateStoredKeyBlobVersion(blobVersion);
+ // The version may be temporarily ahead if the blob write fails. Readers
+ // probe supported versions, so the old blob remains usable and retryable.
+ await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ await _putAndVerify(key: _kKeyBlobKey, value: newBlob);
+ _handler = newHandler;
+ await _tryCompactPasswordStorage();
// successfully updated passphrase
return true;
@@ -181,7 +218,118 @@ class DPS {
}
Future<void> _updateStoredKeyBlobVersion(int version) async {
- await _put(key: _kKeyBlobVersionKey, value: version.toString());
+ await _putAndVerify(key: _kKeyBlobVersionKey, value: version.toString());
+ }
+
+ Future<({StorageCryptoHandler handler, int version})> _authenticateKeyBlob(
+ String passphrase,
+ String keyBlob,
+ int versionHint,
+ ) async {
+ Object? lastError;
+ StackTrace? lastStackTrace;
+ final versions = <int>{versionHint};
+ for (int version = kLatestBlobVersion; version >= 1; version--) {
+ versions.add(version);
+ }
+
+ for (final version in versions) {
+ try {
+ return (
+ handler: await StorageCryptoHandler.fromExisting(
+ passphrase,
+ keyBlob,
+ version,
+ ),
+ version: version,
+ );
+ } on IncorrectPassphraseOrVersion catch (e, s) {
+ lastError = e;
+ lastStackTrace = s;
+ } on VersionError catch (e, s) {
+ lastError = e;
+ lastStackTrace = s;
+ }
+ }
+
+ Error.throwWithStackTrace(lastError!, lastStackTrace!);
+ }
+
+ Future<void> _tryUpgradeKeyBlob({
+ required String passphrase,
+ required String keyBlob,
+ required int version,
+ }) async {
+ try {
+ final upgradedHandler = await StorageCryptoHandler.fromExisting(
+ passphrase,
+ keyBlob,
+ version,
+ );
+ await upgradedHandler.resetPassphrase(passphrase, kLatestBlobVersion);
+ final upgradedBlob = await upgradedHandler.getKeyBlob();
+
+ await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ await _putAndVerify(key: _kKeyBlobKey, value: upgradedBlob);
+ _handler = upgradedHandler;
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: key blob upgrade failed; continuing with authenticated version",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+
+ Future<void> _tryCompactPasswordStorage() async {
+ Box<String>? box;
+ try {
+ box = await DB.instance.hive.openBox<String>(kBoxNameDesktopData);
+ await box.compact();
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: failed to compact desktop password storage",
+ error: e,
+ stackTrace: s,
+ );
+ } finally {
+ try {
+ await box?.close();
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: failed to close desktop password storage after compaction",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+ }
+
+ Future<void> _putAndVerify({
+ required String key,
+ required String value,
+ }) async {
+ try {
+ await _put(key: key, value: value);
+ } catch (e, s) {
+ try {
+ if (await _get(key: key) == value) {
+ Logging.instance.w(
+ "DPS: put($key) reported an error but persisted data was verified",
+ error: e,
+ stackTrace: s,
+ );
+ return;
+ }
+ } catch (_) {
+ // Preserve the original write error below.
+ }
+ Error.throwWithStackTrace(e, s);
+ }
+
+ if (await _get(key: key) != value) {
+ throw Exception("DPS: persisted value verification failed for $key");
+ }
}
Future<void> _put({required String key, required String value}) async {
@@ -191,6 +339,7 @@ class DPS {
await box.put(key, value);
} catch (e, s) {
Logging.instance.f("DPS failed put($key): ", error: e, stackTrace: s);
+ rethrow;
} finally {
await box?.close();
}
@@ -204,6 +353,7 @@ class DPS {
value = box.get(key);
} catch (e, s) {
Logging.instance.f("DPS failed get($key): ", error: e, stackTrace: s);
+ rethrow;
} finally {
await box?.close();
}
diff --git a/test/utilities/desktop_password_service_test.dart b/test/utilities/desktop_password_service_test.dart
new file mode 100644
index 0000000..0947f2c
--- /dev/null
+++ b/test/utilities/desktop_password_service_test.dart
@@ -0,0 +1,229 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hive_ce/hive.dart' show Box;
+import 'package:stack_wallet_backup/secure_storage.dart';
+import 'package:stackwallet/db/hive/db.dart';
+import 'package:stackwallet/utilities/desktop_password_service.dart';
+
+const _blobKey = "swbKeyBlobKeyStringID";
+const _versionKey = "swbKeyBlobVersionKeyStringID";
+
+void main() {
+ late Directory tempDirectory;
+
+ setUp(() async {
+ await DB.instance.hive.close();
+ tempDirectory = await Directory.systemTemp.createTemp("dps_test_");
+ DB.instance.hive.init(tempDirectory.path);
+ });
+
+ tearDown(() async {
+ await DB.instance.hive.close();
+ await tempDirectory.delete(recursive: true);
+ });
+
+ test("new password persists in the legacy-compatible format", () async {
+ const passphrase = "correct horse battery staple";
+ final service = DPS();
+ await service.initFromNew(passphrase);
+
+ final stored = await _readStoredCredentials();
+ expect(stored.keys, {_blobKey, _versionKey});
+ expect(stored.version, kLatestBlobVersion.toString());
+ await StorageCryptoHandler.fromExisting(
+ passphrase,
+ stored.blob!,
+ int.parse(stored.version!),
+ );
+
+ final restarted = DPS();
+ await restarted.initFromExisting(passphrase);
+ expect(await restarted.verifyPassphrase(passphrase), isTrue);
+ });
+
+ test("failed setup does not install an in-memory handler", () async {
+ final service = DPS();
+ final initialization = service.initFromNew("new password");
+ final blockingBox = await _openIncompatibleBox();
+ try {
+ await expectLater(initialization, throwsA(anything));
+ expect(() => service.handler, throwsException);
+ } finally {
+ await blockingBox.close();
+ }
+
+ await service.initFromNew("new password");
+ expect(await service.verifyPassphrase("new password"), isTrue);
+ });
+
+ test("password change is atomic from the service's perspective", () async {
+ const field = "wallet secret";
+ const plaintext = "seed material";
+ final service = DPS();
+ await service.initFromNew("old password");
+ final ciphertext = await service.handler.encryptValue(field, plaintext);
+ final originalBlob = (await _readStoredCredentials()).blob!;
+ expect(await _desktopDataFileContains(tempDirectory, originalBlob), isTrue);
+
+ final failedChange = service.changePassphrase(
+ "old password",
+ "failed password",
+ );
+ final blockingBox = await _openIncompatibleBox();
+ try {
+ expect(await failedChange, isFalse);
+ } finally {
+ await blockingBox.close();
+ }
+
+ expect((await _readStoredCredentials()).blob, originalBlob);
+ expect(await service.verifyPassphrase("old password"), isTrue);
+
+ final compactionBlocker = Directory(
+ _desktopDataPath(tempDirectory, "hivec"),
+ );
+ await compactionBlocker.create();
+ try {
+ expect(
+ await service.changePassphrase("old password", "new password"),
+ isTrue,
+ );
+ } finally {
+ await compactionBlocker.delete();
+ }
+ final stored = await _readStoredCredentials();
+ expect(stored.blob, isNot(originalBlob));
+ expect(stored.version, kLatestBlobVersion.toString());
+ expect(await _desktopDataFileContains(tempDirectory, originalBlob), isTrue);
+
+ final restarted = DPS();
+ expect(await restarted.verifyPassphrase("old password"), isFalse);
+ await restarted.initFromExisting("new password");
+ expect(await restarted.handler.decryptValue(field, ciphertext), plaintext);
+ expect(
+ await _desktopDataFileContains(tempDirectory, originalBlob),
+ isFalse,
+ );
+ });
+
+ test("failed automatic upgrade stays usable and retries", () async {
+ const passphrase = "legacy password";
+ const field = "wallet secret";
+ const plaintext = "seed material";
+ final oldHandler = await StorageCryptoHandler.fromNewPassphrase(
+ passphrase,
+ 1,
+ );
+ final oldBlob = await oldHandler.getKeyBlob();
+ final ciphertext = await oldHandler.encryptValue(field, plaintext);
+ await _writeStoredCredentials(blob: oldBlob, version: 1);
+
+ final firstLogin = DPS();
+ final initialization = firstLogin.initFromExisting(passphrase);
+ final blockingBox = await _openIncompatibleBox();
+ try {
+ await initialization;
+ expect(
+ await firstLogin.handler.decryptValue(field, ciphertext),
+ plaintext,
+ );
+ } finally {
+ await blockingBox.close();
+ }
+
+ var stored = await _readStoredCredentials();
+ expect(stored.blob, oldBlob);
+ expect(stored.version, "1");
+
+ final retriedLogin = DPS();
+ await retriedLogin.initFromExisting(passphrase);
+ stored = await _readStoredCredentials();
+ expect(stored.blob, isNot(oldBlob));
+ expect(stored.version, kLatestBlobVersion.toString());
+ expect(
+ await retriedLogin.handler.decryptValue(field, ciphertext),
+ plaintext,
+ );
+ expect(await _desktopDataFileContains(tempDirectory, oldBlob), isFalse);
+
+ final restarted = DPS();
+ await restarted.initFromExisting(passphrase);
+ expect(await restarted.handler.decryptValue(field, ciphertext), plaintext);
+ });
+
+ test("interrupted upgrade states recover and finish at latest", () async {
+ const passphrase = "legacy password";
+
+ final latestHandler = await StorageCryptoHandler.fromNewPassphrase(
+ passphrase,
+ kLatestBlobVersion,
+ );
+ final latestBlob = await latestHandler.getKeyBlob();
+ await _writeStoredCredentials(blob: latestBlob);
+ await DPS().initFromExisting(passphrase);
+ var stored = await _readStoredCredentials();
+ expect(stored.blob, latestBlob);
+ expect(stored.version, kLatestBlobVersion.toString());
+
+ await DB.instance.hive.deleteBoxFromDisk(kBoxNameDesktopData);
+ final oldHandler = await StorageCryptoHandler.fromNewPassphrase(
+ passphrase,
+ 1,
+ );
+ final oldBlob = await oldHandler.getKeyBlob();
+ await _writeStoredCredentials(blob: oldBlob, version: kLatestBlobVersion);
+ await DPS().initFromExisting(passphrase);
+ stored = await _readStoredCredentials();
+ expect(stored.blob, isNot(oldBlob));
+ expect(stored.version, kLatestBlobVersion.toString());
+ });
+}
+
+Future<({String? blob, String? version, Set<dynamic> keys})>
+_readStoredCredentials() async {
+ final box = await DB.instance.hive.openBox<String>(kBoxNameDesktopData);
+ final result = (
+ blob: box.get(_blobKey),
+ version: box.get(_versionKey),
+ keys: box.keys.toSet(),
+ );
+ await box.close();
+ return result;
+}
+
+Future<void> _writeStoredCredentials({
+ required String blob,
+ int? version,
+}) async {
+ final box = await DB.instance.hive.openBox<String>(kBoxNameDesktopData);
+ await box.put(_blobKey, blob);
+ if (version != null) {
+ await box.put(_versionKey, version.toString());
+ }
+ await box.close();
+}
+
+Future<Box<Object?>> _openIncompatibleBox() async {
+ final deadline = DateTime.now().add(const Duration(seconds: 5));
+ while (true) {
+ try {
+ return await DB.instance.hive.openBox<Object?>(kBoxNameDesktopData);
+ } catch (_) {
+ if (DateTime.now().isAfter(deadline)) {
+ rethrow;
+ }
+ await Future<void>.delayed(const Duration(milliseconds: 10));
+ }
+ }
+}
+
+String _desktopDataPath(Directory directory, String extension) =>
+ "${directory.path}${Platform.pathSeparator}"
+ "${kBoxNameDesktopData.toLowerCase()}.$extension";
+
+Future<bool> _desktopDataFileContains(Directory directory, String value) async {
+ final bytes = await File(_desktopDataPath(directory, "hive")).readAsBytes();
+ return latin1.decode(bytes).contains(value);
+}
Why this scored 57/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.