What changed, and why it matters
This commit fixes how Stack Wallet restores saved cryptocurrency node settings from backups. Previously, the restore code assumed fields like SSL, Tor, and primary-node flags were stored in specific formats (sometimes text, sometimes true/false) and could misread or drop values, potentially restoring a node with the wrong security/privacy settings. The patch introduces a single, tested helper that consistently interprets those fields across old and new backup formats, and also preserves a previously-missing 'node API secret' field during restore.
Review and merge after confirming the new unit tests pass. Consider auditing other backup/restore deserialization paths for similar bool-as-string handling issues, and validate that legacy backup files with mixed field formats restore expected node security settings.
Security signals we found
Backup restore logic previously mixed string and bool field representations, risking misinterpretation of security-relevant flags (SSL/Tor/primary/trusted).
Inline casts such as `nodeData['isFailover'] as bool` could throw or default incorrectly if backup data contained stringified booleans.
The new factory normalizes bool/string parsing and adds fallbacks, reducing the chance that privacy/security node settings are silently changed during restore.
Previously missing `nodeApiSecret` is now preserved/restored, which could affect RPC authentication state after restore.
No explicit security claim, CVE, or researcher attribution is present in the commit or supplied references.
Evidence from the diff
The change centralizes backup deserialization in a new NodeModel.fromStackBackup() factory. It handles bool-or-string fields (useSSL, enabled, isFailover, isDown, trusted, torEnabled, clearnetEnabled/clearEnabled/plainEnabled, forceNoTor, isPrimary), applies sensible fallbacks, and accepts a legacyPrimaryNodeIds set to recover primary-node status from older backups that lack an explicit isPrimary field. The restore helper now uses this factory in both the ‘pre-existing node revert’ path and the normal restore path, replacing inline casts and fragile ‘== “false”’ checks. A unit test verifies round-trip behavior for current and legacy backup maps.
Changed components
lib/models/node_model.dartlib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.darttest/models/node_model_backup_test.dartInspect captured patch +112 / −32
diff --git a/lib/models/node_model.dart b/lib/models/node_model.dart
index 5386cae..85c02c4 100644
--- a/lib/models/node_model.dart
+++ b/lib/models/node_model.dart
@@ -69,6 +69,54 @@ class NodeModel {
this.nodeApiSecret,
});
+ factory NodeModel.fromStackBackup(
+ Map<String, dynamic> map, {
+ Set<String>? legacyPrimaryNodeIds,
+ }) {
+ final id = map['id'] as String;
+ return NodeModel(
+ host: map['host'] as String,
+ port: map['port'] as int,
+ name: map['name'] as String,
+ id: id,
+ useSSL: _backupBool(map['useSSL'], fallback: true),
+ loginName: map['loginName'] as String?,
+ enabled: _backupBool(map['enabled'], fallback: true),
+ coinName: map['coinName'] as String,
+ isFailover: _backupBool(map['isFailover'], fallback: false),
+ isDown: _backupBool(map['isDown'], fallback: false),
+ trusted: _nullableBackupBool(map['trusted']),
+ torEnabled: _backupBool(map['torEnabled'], fallback: true),
+ clearnetEnabled: _backupBool(
+ map['clearEnabled'] ?? map['plainEnabled'],
+ fallback: true,
+ ),
+ forceNoTor: _backupBool(map['forceNoTor'], fallback: false),
+ isPrimary: _backupBool(
+ map['isPrimary'],
+ fallback: legacyPrimaryNodeIds?.contains(id) ?? false,
+ ),
+ nodeApiSecret: map['nodeApiSecret'] as String?,
+ );
+ }
+
+ static bool _backupBool(Object? value, {required bool fallback}) =>
+ _nullableBackupBool(value) ?? fallback;
+
+ static bool? _nullableBackupBool(Object? value) {
+ if (value is bool) {
+ return value;
+ }
+ if (value is String) {
+ return switch (value.trim().toLowerCase()) {
+ 'true' => true,
+ 'false' => false,
+ _ => null,
+ };
+ }
+ return null;
+ }
+
NodeModel copyWith({
String? host,
int? port,
diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
index cb4ec42..b131db8 100644
--- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
+++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
@@ -1028,7 +1028,7 @@ abstract class SWB {
}
}
} else {
- final Map<String, dynamic> preNodeMap = {};
+ final Map<String, Map<String, dynamic>> preNodeMap = {};
for (final nodeData in nodes) {
preNodeMap[nodeData['id'] as String] = nodeData as Map<String, dynamic>;
}
@@ -1039,19 +1039,7 @@ abstract class SWB {
// node existed before restore attempt
// revert to pre restore node
await nodeService.save(
- node.copyWith(
- host: nodeData['host'] as String,
- port: nodeData['port'] as int,
- name: nodeData['name'] as String,
- useSSL: nodeData['useSSL'] == "false" ? false : true,
- enabled: nodeData['enabled'] == "false" ? false : true,
- coinName: nodeData['coinName'] as String,
- loginName: nodeData['loginName'] as String?,
- isFailover: nodeData['isFailover'] as bool,
- isDown: nodeData['isDown'] as bool,
- trusted: nodeData['trusted'] as bool?,
- isPrimary: nodeData["isPrimary"] as bool? ?? false,
- ),
+ NodeModel.fromStackBackup({...nodeData, 'id': node.id}),
nodeData['password'] as String?,
true,
);
@@ -1258,25 +1246,10 @@ abstract class SWB {
.toSet();
for (final node in nodes) {
- final id = node['id'] as String;
+ final nodeData = Map<String, dynamic>.from(node as Map);
await nodeService.save(
- NodeModel(
- host: node['host'] as String,
- port: node['port'] as int,
- name: node['name'] as String,
- id: id,
- useSSL: node['useSSL'] == "false" ? false : true,
- enabled: node['enabled'] == "false" ? false : true,
- coinName: node['coinName'] as String,
- loginName: node['loginName'] as String?,
- isFailover: node['isFailover'] as bool,
- isDown: node['isDown'] as bool,
- torEnabled: node['torEnabled'] as bool? ?? true,
- clearnetEnabled: node['plainEnabled'] as bool? ?? true,
- isPrimary:
- node["isPrimary"] as bool? ?? primaryIds?.contains(id) ?? false,
- ),
- node["password"] as String?,
+ NodeModel.fromStackBackup(nodeData, legacyPrimaryNodeIds: primaryIds),
+ nodeData["password"] as String?,
true,
);
}
diff --git a/test/models/node_model_backup_test.dart b/test/models/node_model_backup_test.dart
new file mode 100644
index 0000000..d0259c1
--- /dev/null
+++ b/test/models/node_model_backup_test.dart
@@ -0,0 +1,59 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/node_model.dart';
+
+void main() {
+ test('restores current and legacy node backup fields', () {
+ final source = NodeModel(
+ host: 'node.example.com',
+ port: 50002,
+ name: 'Node',
+ id: 'current',
+ useSSL: false,
+ loginName: 'user',
+ enabled: false,
+ coinName: 'bitcoin',
+ isFailover: true,
+ isDown: false,
+ trusted: false,
+ torEnabled: false,
+ clearnetEnabled: false,
+ forceNoTor: true,
+ isPrimary: false,
+ nodeApiSecret: 'current-secret',
+ );
+ expect(NodeModel.fromStackBackup(source.toMap()).toMap(), source.toMap());
+
+ final legacyMap = {
+ ...source.toMap(),
+ 'id': 'legacy',
+ 'useSSL': 'false',
+ 'enabled': 'false',
+ 'isFailover': 'true',
+ 'trusted': 'true',
+ 'torEnabled': 'false',
+ 'plainEnabled': 'false',
+ 'forceNoTor': 'true',
+ 'nodeApiSecret': 'legacy-secret',
+ };
+ legacyMap.remove('clearEnabled');
+ legacyMap.remove('isPrimary');
+ final legacy = NodeModel.fromStackBackup(
+ legacyMap,
+ legacyPrimaryNodeIds: {'legacy'},
+ );
+ expect(
+ (
+ legacy.useSSL,
+ legacy.enabled,
+ legacy.isFailover,
+ legacy.trusted,
+ legacy.torEnabled,
+ legacy.clearnetEnabled,
+ legacy.forceNoTor,
+ legacy.isPrimary,
+ legacy.nodeApiSecret,
+ ),
+ (false, false, true, true, false, false, true, true, 'legacy-secret'),
+ );
+ });
+}
Why this scored 30/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.