From 3180ee67cb52113b3cb7a366b0bbb34c03d645dd Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 15 Jul 2026 22:16:32 +1000 Subject: [PATCH 1/7] Add backup functionality --- example/pubspec.yaml | 2 +- lib/solidui.dart | 3 + lib/src/services/solid_backup_service.dart | 740 ++++++++++++++++++ lib/src/widgets/solid_backup_dialog.dart | 491 ++++++++++++ .../solid_scaffold_appbar_builder.dart | 30 + pubspec.yaml | 5 + test/backup_crypto_test.dart | 207 +++++ 7 files changed, 1477 insertions(+), 1 deletion(-) create mode 100644 lib/src/services/solid_backup_service.dart create mode 100644 lib/src/widgets/solid_backup_dialog.dart create mode 100644 test/backup_crypto_test.dart diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ae76255..bf4b904 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: intl: ^0.20.2 markdown_tooltip: ^0.0.10 rdflib: ^0.2.12 - solidpod: ^1.0.8 + solidpod: ^1.0.13 solidui: path: .. universal_io: ^2.3.1 diff --git a/lib/solidui.dart b/lib/solidui.dart index 61c60ce..1e5301c 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -104,6 +104,9 @@ export 'src/widgets/change_key_dialog.dart'; export 'src/widgets/change_password_dialog.dart'; export 'src/widgets/create_account_dialog.dart'; +export 'src/services/solid_backup_service.dart'; +export 'src/widgets/solid_backup_dialog.dart'; + export 'src/utils/snack_bar.dart'; export 'src/widgets/solid_file.dart'; diff --git a/lib/src/services/solid_backup_service.dart b/lib/src/services/solid_backup_service.dart new file mode 100644 index 0000000..6dddd93 --- /dev/null +++ b/lib/src/services/solid_backup_service.dart @@ -0,0 +1,740 @@ +/// Backup and restore of an app's POD data folder as a single portable file. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'dart:convert'; +import 'dart:math' show Random; +import 'dart:typed_data'; + +import 'package:archive/archive.dart' show GZipEncoder, GZipDecoder; +import 'package:crypto/crypto.dart' show Hmac, sha256; +import 'package:encrypter_plus/encrypter_plus.dart' + show AES, AESMode, Encrypted, Encrypter, IV, Key; +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:solidpod/solidpod.dart' + show + SecurityKeyVerificationException, + deleteFile, + getDataDirPath, + getDirUrl, + getResourcesInContainer, + isFileEncrypted, + isUserLoggedIn, + readPod, + verifyAppSecurityKey, + writePod; + +// Format constants. + +/// The magic string that identifies a solidui backup file. Stored (in the +/// clear, once the outer gzip layer is removed) at the top of the header so a +/// wrong or corrupt file can be rejected before any key is requested. + +const String kBackupMagic = 'solidui-backup'; + +/// The backup container format version. Bump this whenever the on-disk layout +/// or the key-derivation / encryption scheme changes in an incompatible way. + +const int kBackupFormatVersion = 1; + +/// The file extension used for solidui backup files. It is a custom, opaque +/// container (gzip-wrapped JSON with an encrypted payload), so a bespoke +/// extension keeps it from being mistaken for a plain archive or document. + +const String kBackupFileExtension = 'solidbak'; + +// Number of PBKDF2-HMAC-SHA256 iterations used to stretch a security key into +// the AES key that protects the backup payload. High enough to make offline +// guessing expensive, low enough to stay responsive on a phone. + +const int _kdfIterations = 150000; + +// Length in bytes of the derived AES key (AES-256) and of the random salt. + +const int _aesKeyLength = 32; +const int _saltLength = 16; +const int _ivLength = 16; + +// Domain-separation labels mixed into the two independent one-way derivations +// so the stored fingerprint can never be used to reconstruct the AES key, and +// vice versa. + +const String _kdfInfoAesKey = 'solidui-backup/v1/aes-key'; +const String _kdfInfoFingerprint = 'solidui-backup/v1/fingerprint'; + +// Public models. + +/// The metadata read from a backup file's header before any decryption. +/// +/// This is what [SolidBackupService.inspect] returns so the UI can decide +/// whether the file even belongs to this application, and later verify a +/// user-supplied security key against [keyFingerprint] — all without touching +/// the (still-encrypted) payload. + +class SolidBackupHeader { + /// Constructor. + + const SolidBackupHeader({ + required this.formatVersion, + required this.appId, + required this.appUrl, + required this.createdAt, + required this.fileCount, + required this.saltBase64, + required this.ivBase64, + required this.keyFingerprint, + required this.payloadBase64, + }); + + /// The backup container format version (see [kBackupFormatVersion]). + + final int formatVersion; + + /// The canonical application identifier (the app's POD folder name), used to + /// verify that a backup was created by the same application even when it + /// originated on a different POD (which changes [appUrl]). + + final String appId; + + /// The full URL of the source app's data folder at export time. Kept for + /// display and provenance; cross-POD verification relies on [appId], which is + /// derived from this URL and is POD-independent. + + final String appUrl; + + /// When the backup was created (ISO-8601, UTC). + + final String createdAt; + + /// The number of data files stored in the backup. + + final int fileCount; + + /// The base64 salt used both to derive the AES key from the original + /// security key and to compute [keyFingerprint]. + + final String saltBase64; + + /// The base64 initialisation vector for the AES-CBC payload. + + final String ivBase64; + + /// A one-way fingerprint of the security key used at export time. It cannot + /// reconstruct the key but uniquely confirms whether a supplied key matches. + + final String keyFingerprint; + + /// The base64 AES-CBC ciphertext of the (JSON) file bundle. + + final String payloadBase64; +} + +/// The outcome of an export operation. + +class SolidBackupExport { + /// Constructor. + + const SolidBackupExport({ + required this.bytes, + required this.suggestedFileName, + required this.fileCount, + }); + + /// The complete, self-contained backup file content (gzip-compressed). + + final Uint8List bytes; + + /// A sensible default file name, e.g. `myapp_backup_20260715_1032.solidbak`. + + final String suggestedFileName; + + /// The number of data files captured. + + final int fileCount; +} + +/// The outcome of an import operation. + +class SolidBackupImport { + /// Constructor. + + const SolidBackupImport({ + required this.restoredCount, + required this.removedCount, + required this.skipped, + }); + + /// The number of files written (re-encrypted) to the destination POD. + + final int restoredCount; + + /// The number of pre-existing data files removed to mirror the backup. + + final int removedCount; + + /// Files that could not be restored, mapped to the reason. Empty on a fully + /// successful import. + + final Map skipped; +} + +/// Thrown when an imported backup was created by a different application. + +class BackupAppMismatchException implements Exception { + /// Constructor. + + BackupAppMismatchException(this.backupAppId, this.currentAppId); + + /// The application identifier recorded in the backup. + + final String backupAppId; + + /// The identifier of the application attempting the import. + + final String currentAppId; + + @override + String toString() => 'BackupAppMismatchException: backup was created by ' + '"$backupAppId" but this application is "$currentAppId"'; +} + +/// Thrown when a file's content is not a well-formed solidui backup. + +class InvalidBackupFileException implements Exception { + /// Constructor. + + InvalidBackupFileException(this.message); + + /// The error message. + + final String message; + + @override + String toString() => 'InvalidBackupFileException: $message'; +} + +// A single backed-up file: its path relative to the app's data folder, the +// decrypted content, and whether it was encrypted at rest on the source POD +// (so it can be written back with the same protection). + +class _BackupEntry { + _BackupEntry({ + required this.path, + required this.content, + required this.encrypted, + }); + + factory _BackupEntry.fromJson(Map json) => _BackupEntry( + path: json['path'] as String, + content: json['content'] as String, + encrypted: json['encrypted'] as bool? ?? true, + ); + + final String path; + final String content; + final bool encrypted; + + Map toJson() => { + 'path': path, + 'content': content, + 'encrypted': encrypted, + }; +} + +// Service. + +/// Exports and imports the current application's POD data folder as a single +/// compressed, encrypted backup file that can be restored onto the same POD or +/// a different one. +/// +/// The design follows five rules drawn from the feature specification: +/// 1. Everything is written to a single, self-contained file. +/// 2. The header records the source application's full URL so an import can +/// confirm the backup belongs to the same application. +/// 3. The header stores a one-way fingerprint of the export security key so +/// a user-supplied key can be verified without ever revealing the key. +/// 4. The payload stays encrypted and the whole file is compressed. +/// 5. Import re-encrypts the data with the destination POD's own security +/// key before uploading, overwriting the data folder's contents. + +class SolidBackupService { + /// Shared singleton instance. + + static final SolidBackupService instance = SolidBackupService._(); + + SolidBackupService._(); + + // Export. + + /// Read and decrypt every file in the current app's data folder and package + /// them into an encrypted, compressed backup. + /// + /// [securityKey] is the current application's security key. It is verified + /// against the app's own encryption keys first (so a wrong key fails fast), + /// then used to derive both the payload's AES key and the stored fingerprint. + /// + /// [now] is injected so callers (and tests) control the timestamp used in the + /// header and suggested file name. + /// + /// Throws [SecurityKeyVerificationException] if [securityKey] is incorrect. + + Future export({ + required String securityKey, + DateTime? now, + }) async { + if (!await isUserLoggedIn()) { + throw Exception('You must be logged in to create a backup.'); + } + + final appId = await _currentAppId(); + final appRootUrl = await getDirUrl(appId); + final dataDirPath = await getDataDirPath(); + final dataDirUrl = await getDirUrl(dataDirPath); + + // Verify the security key against this app's stored verification value. + // This confirms the key is correct before we bind the backup to it, so an + // export never produces a file that only a wrong key could open. + + await verifyAppSecurityKey(appRootUrl, securityKey); + + // Collect every data file (recursively) and read its decrypted content. + + final relativePaths = []; + await _collectFiles(dataDirUrl, '', relativePaths); + + final entries = <_BackupEntry>[]; + for (final relativePath in relativePaths) { + try { + final content = await readPod(relativePath); + final encrypted = await isFileEncrypted(relativePath); + entries.add( + _BackupEntry( + path: relativePath, + content: content, + encrypted: encrypted, + ), + ); + } on Object catch (e) { + // A single unreadable file should not abort the whole backup; skip it + // and carry on so the user still captures everything else. + + debugPrint('[SolidBackupService] skipping "$relativePath": $e'); + } + } + + // Serialise the file bundle, then encrypt it with a key derived from the + // security key and a fresh random salt. + + final bundleJson = jsonEncode({ + 'files': entries.map((e) => e.toJson()).toList(), + }); + + final salt = _randomBytes(_saltLength); + final iv = IV(_randomBytes(_ivLength)); + final aesKey = Key(_deriveKey(securityKey, salt, _kdfInfoAesKey)); + final payloadBase64 = Encrypter(AES(aesKey, mode: AESMode.cbc)) + .encrypt(bundleJson, iv: iv) + .base64; + + final createdAt = (now ?? DateTime.now()).toUtc(); + + final header = { + 'magic': kBackupMagic, + 'formatVersion': kBackupFormatVersion, + 'appId': appId, + 'appUrl': dataDirUrl, + 'createdAt': createdAt.toIso8601String(), + 'fileCount': entries.length, + 'kdf': { + 'algorithm': 'PBKDF2-HMAC-SHA256', + 'iterations': _kdfIterations, + 'salt': base64.encode(salt), + }, + 'cipher': { + 'algorithm': 'AES-256-CBC', + 'iv': iv.base64, + }, + 'keyFingerprint': _fingerprint(securityKey, salt), + 'payload': payloadBase64, + }; + + // Compress the whole document. Import therefore decompresses first (step 1) + // and only then decrypts the payload (step 2). + + final compressed = Uint8List.fromList( + const GZipEncoder().encodeBytes(utf8.encode(jsonEncode(header))), + ); + + return SolidBackupExport( + bytes: compressed, + suggestedFileName: + '${appId}_backup_${_timestamp(createdAt.toLocal())}.$kBackupFileExtension', + fileCount: entries.length, + ); + } + + // Inspect. + + /// Decompress and parse a backup file's header without decrypting anything. + /// + /// Throws [InvalidBackupFileException] if the bytes are not a solidui backup + /// or use an unsupported format version. + + SolidBackupHeader inspect(Uint8List bytes) { + Map header; + try { + final decompressed = const GZipDecoder().decodeBytes(bytes); + header = jsonDecode(utf8.decode(decompressed)) as Map; + } on Object catch (e) { + throw InvalidBackupFileException( + 'The selected file is not a valid solidui backup ($e).', + ); + } + + if (header['magic'] != kBackupMagic) { + throw InvalidBackupFileException( + 'The selected file is not a solidui backup.', + ); + } + + final formatVersion = header['formatVersion'] as int? ?? -1; + if (formatVersion != kBackupFormatVersion) { + throw InvalidBackupFileException( + 'Unsupported backup format version: $formatVersion. This application ' + 'supports version $kBackupFormatVersion.', + ); + } + + final kdf = header['kdf'] as Map?; + final cipher = header['cipher'] as Map?; + if (kdf == null || cipher == null) { + throw InvalidBackupFileException('The backup header is incomplete.'); + } + + return SolidBackupHeader( + formatVersion: formatVersion, + appId: header['appId'] as String? ?? '', + appUrl: header['appUrl'] as String? ?? '', + createdAt: header['createdAt'] as String? ?? '', + fileCount: header['fileCount'] as int? ?? 0, + saltBase64: kdf['salt'] as String? ?? '', + ivBase64: cipher['iv'] as String? ?? '', + keyFingerprint: header['keyFingerprint'] as String? ?? '', + payloadBase64: header['payload'] as String? ?? '', + ); + } + + /// Whether a backup with [header] was created by the application currently + /// running, comparing the POD-independent [SolidBackupHeader.appId]. + + Future isSameApplication(SolidBackupHeader header) async { + final currentAppId = await _currentAppId(); + return header.appId.isNotEmpty && header.appId == currentAppId; + } + + /// Whether [securityKey] matches the key used to create the backup, checked + /// against the one-way [SolidBackupHeader.keyFingerprint]. + + bool verifyOriginalKey(SolidBackupHeader header, String securityKey) { + final salt = base64.decode(header.saltBase64); + return _constantTimeEquals( + header.keyFingerprint, + _fingerprint(securityKey, salt), + ); + } + + // Import. + + /// Restore a backup onto the current POD. + /// + /// Steps, mirroring the specification's import workflow: + /// 1. The file is already decompressed by [inspect] to obtain [header]. + /// 2. Decrypt the payload with [originalKey] (the key used at export time). + /// 3. Re-encrypt each file with [currentKey] (the destination POD's key). + /// 4. Upload the re-encrypted files, overwriting the data folder. + /// + /// [currentKey] is verified against the destination app's own encryption + /// keys; [originalKey] is verified against the backup's fingerprint. When + /// [clearExisting] is true (the default) any pre-existing data files not + /// present in the backup are removed so the folder mirrors the backup. + /// + /// Throws [BackupAppMismatchException] if the backup belongs to another + /// application, and [SecurityKeyVerificationException] if either key fails + /// verification. + + Future import({ + required SolidBackupHeader header, + required String originalKey, + required String currentKey, + bool clearExisting = true, + }) async { + if (!await isUserLoggedIn()) { + throw Exception('You must be logged in to restore a backup.'); + } + + // Reject a backup created by a different application outright. + + final currentAppId = await _currentAppId(); + if (header.appId != currentAppId) { + throw BackupAppMismatchException(header.appId, currentAppId); + } + + // Verify the original key against the fingerprint the backup carries. + + if (!verifyOriginalKey(header, originalKey)) { + throw SecurityKeyVerificationException( + 'The original security key does not match this backup.', + ); + } + + // Verify the current (destination) key against the live app's keys. This + // guarantees the writes below re-encrypt under a key the user actually + // controls, so the restored data is decryptable afterwards. + + final appRootUrl = await getDirUrl(currentAppId); + await verifyAppSecurityKey(appRootUrl, currentKey); + + // Decrypt the payload with the original key. + + final salt = base64.decode(header.saltBase64); + final aesKey = Key(_deriveKey(originalKey, salt, _kdfInfoAesKey)); + final iv = IV.fromBase64(header.ivBase64); + + String bundleJson; + try { + bundleJson = Encrypter(AES(aesKey, mode: AESMode.cbc)) + .decrypt(Encrypted.fromBase64(header.payloadBase64), iv: iv); + } on Object catch (e) { + throw InvalidBackupFileException( + 'Failed to decrypt the backup payload ($e).', + ); + } + + final bundle = jsonDecode(bundleJson) as Map; + final entries = (bundle['files'] as List? ?? const []) + .cast>() + .map(_BackupEntry.fromJson) + .toList(); + + final restorePaths = entries.map((e) => e.path).toSet(); + + // (Overwrite) Remove pre-existing data files that are not part of the + // backup so the folder ends up mirroring the backup exactly. + + var removedCount = 0; + if (clearExisting) { + final dataDirUrl = await getDirUrl(await getDataDirPath()); + final existing = []; + await _collectFiles(dataDirUrl, '', existing); + for (final relativePath in existing) { + if (restorePaths.contains(relativePath)) continue; + try { + // getDirUrl() returns the data folder URL with a trailing slash, so + // appending the data-relative path yields the file's absolute URL + // (getFileUrl() would instead resolve against the POD root). + + await deleteFile(fileUrl: '$dataDirUrl$relativePath'); + removedCount++; + } on Object catch (e) { + debugPrint( + '[SolidBackupService] could not remove "$relativePath": $e', + ); + } + } + } + + // Re-encrypt with the destination key (writePod uses the live + // KeyManager master key) and upload, overwriting existing files. + + final skipped = {}; + var restoredCount = 0; + for (final entry in entries) { + try { + await writePod( + entry.path, + entry.content, + encrypted: entry.encrypted, + overwrite: true, + ); + restoredCount++; + } on Object catch (e) { + skipped[entry.path] = e.toString(); + debugPrint('[SolidBackupService] failed to restore "${entry.path}": $e'); + } + } + + return SolidBackupImport( + restoredCount: restoredCount, + removedCount: removedCount, + skipped: skipped, + ); + } + + // Internal helpers. + + // The canonical app identifier is the first segment of the data directory + // path. It is the same on every POD, which is what lets an import tell + // whether two backups came from the same app. + + Future _currentAppId() async { + final dataDirPath = await getDataDirPath(); + final appId = dataDirPath.split('/').first; + if (appId.isEmpty) { + throw Exception('Unable to determine the current application name.'); + } + return appId; + } + + // Walk a container recursively, appending the data-relative path of every + // file found. ACL and metadata sidecars are skipped: solidpod recreates them + // when writePod restores each file. + + Future _collectFiles( + String dirUrl, + String relativePrefix, + List out, + ) async { + final normalisedDirUrl = dirUrl.endsWith('/') ? dirUrl : '$dirUrl/'; + final listing = await getResourcesInContainer(normalisedDirUrl); + + for (final file in listing.files) { + if (file.endsWith('.acl') || file.endsWith('.meta')) continue; + out.add('$relativePrefix$file'); + } + + for (final subDir in listing.subDirs) { + await _collectFiles( + '$normalisedDirUrl$subDir/', + '$relativePrefix$subDir/', + out, + ); + } + } + + // Cryptographically secure random bytes. + + Uint8List _randomBytes(int length) { + final random = Random.secure(); + return Uint8List.fromList( + List.generate(length, (_) => random.nextInt(256)), + ); + } + + // Derive a 32-byte AES key from a security key and salt using PBKDF2-HMAC- + // SHA256, mixing in [info] so different call sites (key vs fingerprint) never + // produce the same bytes. + + Uint8List _deriveKey(String securityKey, List salt, String info) { + final password = [...utf8.encode(info), ...utf8.encode(securityKey)]; + return _pbkdf2( + password: password, + salt: salt, + iterations: _kdfIterations, + keyLength: _aesKeyLength, + ); + } + + // A one-way fingerprint of the security key: a second, domain-separated + // PBKDF2 derivation. It is expensive to brute-force and cannot be inverted to + // recover the key, yet uniquely confirms a supplied key. + + String _fingerprint(String securityKey, List salt) { + final password = [ + ...utf8.encode(_kdfInfoFingerprint), + ...utf8.encode(securityKey), + ]; + final digest = _pbkdf2( + password: password, + salt: salt, + iterations: _kdfIterations, + keyLength: 32, + ); + return base64.encode(digest); + } + + // A self-contained PBKDF2-HMAC-SHA256 implementation (RFC 2898). Avoids an + // extra dependency: the `crypto` package already provides the HMAC-SHA256 + // primitive it is built on. + + Uint8List _pbkdf2({ + required List password, + required List salt, + required int iterations, + required int keyLength, + }) { + final hmac = Hmac(sha256, password); + const blockSize = 32; // SHA-256 output length in bytes. + final blockCount = (keyLength + blockSize - 1) ~/ blockSize; + final derived = Uint8List(blockCount * blockSize); + + for (var block = 1; block <= blockCount; block++) { + // U1 = HMAC(password, salt || INT_32_BE(block)). + + final blockIndex = Uint8List(4) + ..[0] = (block >> 24) & 0xff + ..[1] = (block >> 16) & 0xff + ..[2] = (block >> 8) & 0xff + ..[3] = block & 0xff; + + var u = hmac.convert([...salt, ...blockIndex]).bytes; + final t = List.from(u); + + for (var i = 1; i < iterations; i++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < blockSize; j++) { + t[j] ^= u[j]; + } + } + + derived.setRange((block - 1) * blockSize, block * blockSize, t); + } + + return Uint8List.sublistView(derived, 0, keyLength); + } + + // A filename-friendly timestamp, YYYYMMDD_HHMM, from a local [DateTime]. + + String _timestamp(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${t.year}${two(t.month)}${two(t.day)}_${two(t.hour)}${two(t.minute)}'; + } + + // Constant-time string comparison so fingerprint verification does not leak + // a matching prefix via timing. + + 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; + } +} diff --git a/lib/src/widgets/solid_backup_dialog.dart b/lib/src/widgets/solid_backup_dialog.dart new file mode 100644 index 0000000..33a7e6f --- /dev/null +++ b/lib/src/widgets/solid_backup_dialog.dart @@ -0,0 +1,491 @@ +/// A dialog for backing up and restoring the current app's POD data folder. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:gap/gap.dart'; +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' + show SecurityKeyVerificationException, isUserLoggedIn; +import 'package:universal_io/io.dart' show File; + +import 'package:solidui/src/services/solid_backup_service.dart'; +import 'package:solidui/src/widgets/secret_text_field.dart'; + +/// A dialog offering two actions for the current application's data folder: +/// +/// * **Export** — read and decrypt every file in the data folder, then save +/// it as a single compressed, encrypted backup file on this device. +/// * **Import** — restore a previously exported backup (from this POD or +/// another), re-encrypting the data with this POD's current security key. +/// +/// The dialog mirrors the layout and wording of the TodoPod Backup feature but +/// works at the level of the whole app data folder rather than a single model. + +class SolidBackupDialog extends StatefulWidget { + /// Constructor. + + const SolidBackupDialog({super.key}); + + /// Show the backup dialog. + + static Future show(BuildContext context) => showDialog( + context: context, + builder: (_) => const SolidBackupDialog(), + ); + + @override + State createState() => _SolidBackupDialogState(); +} + +class _SolidBackupDialogState extends State { + bool _busy = false; + + String? _exportMessage; + bool _exportError = false; + + String? _importMessage; + bool _importError = false; + + void _setExportMessage(String message, {bool error = false}) { + if (!mounted) return; + setState(() { + _exportMessage = message; + _exportError = error; + }); + } + + void _setImportMessage(String message, {bool error = false}) { + if (!mounted) return; + setState(() { + _importMessage = message; + _importError = error; + }); + } + + // Export. + + Future _handleExport() async { + if (!await isUserLoggedIn()) { + _setExportMessage( + 'You must be logged in to create a backup.', + error: true, + ); + return; + } + + // Ask for the current security key. This is the key the backup will be + // encrypted with, and whose fingerprint is stored in the file so it can be + // matched again on import. + + final keys = await _promptForKeys( + title: 'Export Backup', + message: 'Enter your current security key. The backup will be encrypted ' + 'with this key, and you will need it again when restoring.', + fields: const [ + (key: 'securityKey', label: 'Security Key'), + ], + ); + if (keys == null) return; + + setState(() { + _busy = true; + _exportMessage = null; + }); + + try { + final result = await SolidBackupService.instance.export( + securityKey: keys['securityKey']!, + ); + + final savedPath = await _saveFile( + bytes: result.bytes, + fileName: result.suggestedFileName, + ); + + if (savedPath == null) { + // The user cancelled the save dialog. + + _setExportMessage('Backup cancelled.'); + return; + } + + _setExportMessage( + 'Backed up ${result.fileCount} ' + 'file${result.fileCount == 1 ? '' : 's'} to "$savedPath".', + ); + } on SecurityKeyVerificationException { + _setExportMessage('Incorrect security key.', error: true); + } on Object catch (e) { + _setExportMessage('Backup failed: $e', error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + // Save [bytes] to a user-chosen location. On web the picker triggers a + // download; on other platforms it returns a path we write to explicitly + // (the desktop picker does not persist bytes itself). + + Future _saveFile({ + required Uint8List bytes, + required String fileName, + }) async { + final path = await FilePicker.saveFile( + dialogTitle: 'Save backup', + fileName: fileName, + type: FileType.custom, + allowedExtensions: const [kBackupFileExtension], + bytes: bytes, + ); + if (path == null) return null; + if (!kIsWeb) { + await File(path).writeAsBytes(bytes); + } + return path; + } + + // Import. + + Future _handleImport() async { + if (!await isUserLoggedIn()) { + _setImportMessage( + 'You must be logged in to restore a backup.', + error: true, + ); + return; + } + + setState(() { + _busy = true; + _importMessage = null; + }); + + try { + // Pick and read the backup file. + + final picked = await FilePicker.pickFiles( + dialogTitle: 'Select a backup file', + type: FileType.custom, + allowedExtensions: const [kBackupFileExtension], + withData: true, + ); + if (picked == null || picked.files.isEmpty) { + _setImportMessage('Import cancelled.'); + return; + } + + final bytes = picked.files.first.bytes; + if (bytes == null) { + _setImportMessage('Could not read the selected file.', error: true); + return; + } + + // Read the header and confirm the backup belongs to this application + // before asking the user for any keys. + + final header = SolidBackupService.instance.inspect(bytes); + if (!await SolidBackupService.instance.isSameApplication(header)) { + _setImportMessage( + 'This backup was created by a different application ' + '("${header.appId}") and cannot be restored here.', + error: true, + ); + return; + } + + // Same application: ask for the original key (to decrypt the backup) and + // the current key (to re-encrypt for this POD). They may differ when the + // backup came from another POD or the key has since been changed. + + if (!mounted) return; + final keys = await _promptForKeys( + title: 'Import Backup', + message: 'This backup holds ${header.fileCount} ' + 'file${header.fileCount == 1 ? '' : 's'}. Enter the original ' + 'security key it was created with, and the current security key ' + 'for this POD. Restoring overwrites the contents of this app\'s ' + 'data folder.', + fields: const [ + (key: 'originalKey', label: 'Original Security Key'), + (key: 'currentKey', label: 'Current Security Key'), + ], + ); + if (keys == null) { + _setImportMessage('Import cancelled.'); + return; + } + + final result = await SolidBackupService.instance.import( + header: header, + originalKey: keys['originalKey']!, + currentKey: keys['currentKey']!, + ); + + final skippedNote = result.skipped.isEmpty + ? '' + : ' (${result.skipped.length} skipped)'; + _setImportMessage( + 'Restored ${result.restoredCount} ' + 'file${result.restoredCount == 1 ? '' : 's'}$skippedNote.', + error: result.skipped.isNotEmpty, + ); + } on BackupAppMismatchException catch (e) { + _setImportMessage( + 'This backup was created by a different application ' + '("${e.backupAppId}") and cannot be restored here.', + error: true, + ); + } on SecurityKeyVerificationException catch (e) { + _setImportMessage(e.message, error: true); + } on InvalidBackupFileException catch (e) { + _setImportMessage(e.message, error: true); + } on Object catch (e) { + _setImportMessage('Restore failed: $e', error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + // Key prompt. + + // Show a modal form of one or more masked security-key fields. Returns a map + // of field key to entered value, or null if the user cancelled. + + Future?> _promptForKeys({ + required String title, + required String message, + required List<({String key, String label})> fields, + }) { + final formKey = GlobalKey(); + + return showDialog>( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: FormBuilder( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message), + const Gap(16), + for (final field in fields) ...[ + SecretTextField( + fieldKey: field.key, + fieldLabel: field.label, + validateFunc: (value) => + value.isEmpty ? 'Please enter ${field.label}.' : null, + ), + const Gap(8), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.saveAndValidate() ?? false) { + final values = formKey.currentState!.value; + Navigator.of(dialogContext).pop({ + for (final field in fields) + field.key: values[field.key].toString(), + }); + } + }, + child: const Text('Continue'), + ), + ], + ); + }, + ); + } + + // Build. + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.backup_outlined), + SizedBox(width: 12), + Text('Backup'), + ], + ), + content: SizedBox( + width: 460, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Save a complete, encrypted backup of this app\'s data folder, ' + 'or restore one previously created on this or another POD.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const Gap(20), + + // Export. + + _sectionTitle(context, 'Export'), + const Gap(8), + if (_exportMessage != null) ...[ + _MessageBanner( + message: _exportMessage!, + isError: _exportError, + colorScheme: cs, + ), + const Gap(12), + ], + MarkdownTooltip( + message: '**Export Backup**\n\n' + 'Save all files in this app\'s data folder to a single, ' + 'compressed and encrypted backup file on this device.', + child: FilledButton.icon( + icon: const Icon(Icons.download), + label: const Text('Export Backup'), + onPressed: _busy ? null : _handleExport, + ), + ), + const Gap(24), + + // Import. + + _sectionTitle(context, 'Import'), + const Gap(8), + if (_importMessage != null) ...[ + _MessageBanner( + message: _importMessage!, + isError: _importError, + colorScheme: cs, + ), + const Gap(12), + ], + MarkdownTooltip( + message: '**Import Backup**\n\n' + 'Restore a backup created by this application. The backup ' + 'is decrypted with its original security key and ' + 're-encrypted with this POD\'s current security key, ' + 'overwriting the data folder\'s contents.', + child: OutlinedButton.icon( + icon: const Icon(Icons.upload), + label: const Text('Import Backup'), + onPressed: _busy ? null : _handleImport, + ), + ), + + if (_busy) ...[ + const Gap(20), + const Center(child: CircularProgressIndicator()), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } + + Widget _sectionTitle(BuildContext context, String text) => Text( + text, + style: Theme.of(context).textTheme.titleMedium, + ); +} + +// A compact status banner mirroring the TodoPod import screen's message style. + +class _MessageBanner extends StatelessWidget { + const _MessageBanner({ + required this.message, + required this.isError, + required this.colorScheme, + }); + + final String message; + final bool isError; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final background = isError + ? colorScheme.errorContainer + : colorScheme.secondaryContainer; + final foreground = isError + ? colorScheme.onErrorContainer + : colorScheme.onSecondaryContainer; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.check_circle_outline, + size: 20, + color: foreground, + ), + const Gap(8), + Expanded( + child: Text( + message, + style: TextStyle(color: foreground), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/solid_scaffold_appbar_builder.dart b/lib/src/widgets/solid_scaffold_appbar_builder.dart index 1ac6a9c..6edebce 100644 --- a/lib/src/widgets/solid_scaffold_appbar_builder.dart +++ b/lib/src/widgets/solid_scaffold_appbar_builder.dart @@ -39,6 +39,7 @@ import 'package:solidui/src/handlers/solid_auth_handler.dart'; import 'package:solidui/src/services/solid_profile_notifier.dart'; import 'package:solidui/src/utils/snack_bar.dart'; import 'package:solidui/src/widgets/change_password_dialog.dart'; +import 'package:solidui/src/widgets/solid_backup_dialog.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; import 'package:solidui/src/widgets/solid_invite_others_models.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; @@ -275,6 +276,9 @@ class _ProfileMenuChipState extends State<_ProfileMenuChip> { case 'change_password': _handleChangePassword(); break; + case 'backup': + SolidBackupDialog.show(context); + break; case 'logout': if (widget.onLogout != null) { widget.onLogout!(context); @@ -396,6 +400,32 @@ class _ProfileMenuChipState extends State<_ProfileMenuChip> { ); } + // Backup — only meaningful while signed in. Opens a dialog to + // export the app's data folder as a single encrypted, compressed + // file, or to restore such a backup (from this POD or another). + + if (!_statusLoaded || _isLoggedIn) { + items.add(const PopupMenuDivider()); + items.add( + const PopupMenuItem( + value: 'backup', + child: MarkdownTooltip( + message: '**Backup**\n\n' + 'Export all of this app\'s data in your POD to a ' + 'single encrypted, compressed backup file — or ' + 'restore a backup created here or on another POD.', + child: Row( + children: [ + Icon(Icons.backup_outlined, size: 20), + SizedBox(width: 12), + Text('Backup'), + ], + ), + ), + ), + ); + } + // Auth entry — Logout when signed in, Login otherwise. // Both entries respect the scaffold's showLogout / // showLogin flags so applications using a dedicated diff --git a/pubspec.yaml b/pubspec.yaml index a62c6f0..8ad6e1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ environment: dependencies: flutter: sdk: flutter + archive: ^4.0.8 + crypto: ^3.0.7 + encrypter_plus: ^5.1.0 file_picker: ^11.0.2 flutter_form_builder: ^10.3.0+1 flutter_markdown_plus: ^1.0.7 @@ -52,6 +55,8 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter window_manager: ^0.5.1 flutter: diff --git a/test/backup_crypto_test.dart b/test/backup_crypto_test.dart new file mode 100644 index 0000000..eb2ec16 --- /dev/null +++ b/test/backup_crypto_test.dart @@ -0,0 +1,207 @@ +/// Tests for the cryptographic core used by the backup service. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart' show GZipDecoder, GZipEncoder; +import 'package:crypto/crypto.dart' show Hmac, sha256; +import 'package:encrypter_plus/encrypter_plus.dart' + show AES, AESMode, Encrypted, Encrypter, IV, Key; +import 'package:flutter_test/flutter_test.dart'; + +// Run the test file: +// +// ```bash +// cd solidui +// flutter test test/backup_crypto_test.dart +// ``` +// +// Other useful options: +// +// ```bash +// # Show detailed output for each test +// flutter test test/backup_crypto_test.dart --reporter expanded +// +// # Run only tests whose names match a specific keyword +// flutter test test/backup_crypto_test.dart --name "AES" +// +// # Generate a code coverage report (output: coverage/lcov.info) +// flutter test --coverage +// ``` + +// A copy of the service's PBKDF2 so the test can reproduce the exact bytes. + +Uint8List pbkdf2({ + required List password, + required List salt, + required int iterations, + required int keyLength, +}) { + final hmac = Hmac(sha256, password); + const blockSize = 32; + final blockCount = (keyLength + blockSize - 1) ~/ blockSize; + final derived = Uint8List(blockCount * blockSize); + + for (var block = 1; block <= blockCount; block++) { + final blockIndex = Uint8List(4) + ..[0] = (block >> 24) & 0xff + ..[1] = (block >> 16) & 0xff + ..[2] = (block >> 8) & 0xff + ..[3] = block & 0xff; + + var u = hmac.convert([...salt, ...blockIndex]).bytes; + final t = List.from(u); + + for (var i = 1; i < iterations; i++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < blockSize; j++) { + t[j] ^= u[j]; + } + } + + derived.setRange((block - 1) * blockSize, block * blockSize, t); + } + + return Uint8List.sublistView(derived, 0, keyLength); +} + +void main() { + const salt = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + + test('PBKDF2 is deterministic and produces the requested length', () { + final a = pbkdf2( + password: utf8.encode('secret-key'), + salt: salt, + iterations: 1000, + keyLength: 32, + ); + final b = pbkdf2( + password: utf8.encode('secret-key'), + salt: salt, + iterations: 1000, + keyLength: 32, + ); + + expect(a.length, 32); + expect(a, equals(b)); + }); + + test('PBKDF2 with domain separation yields distinct outputs', () { + final aesKey = pbkdf2( + password: [...utf8.encode('aes'), ...utf8.encode('key')], + salt: salt, + iterations: 1000, + keyLength: 32, + ); + final fingerprint = pbkdf2( + password: [...utf8.encode('fingerprint'), ...utf8.encode('key')], + salt: salt, + iterations: 1000, + keyLength: 32, + ); + + expect(aesKey, isNot(equals(fingerprint))); + }); + + test('AES-256-CBC round-trips the payload', () { + final key = Key( + pbkdf2( + password: utf8.encode('the-key'), + salt: salt, + iterations: 1000, + keyLength: 32, + ), + ); + final iv = IV(Uint8List.fromList(List.filled(16, 7))); + final plaintext = jsonEncode({ + 'files': [ + {'path': 'notes/a.ttl', 'content': 'hello world', 'encrypted': true}, + ], + }); + + final cipherText = + Encrypter(AES(key, mode: AESMode.cbc)).encrypt(plaintext, iv: iv).base64; + final recovered = Encrypter(AES(key, mode: AESMode.cbc)) + .decrypt(Encrypted.fromBase64(cipherText), iv: iv); + + expect(recovered, equals(plaintext)); + }); + + test('Wrong key fails to recover the plaintext', () { + final iv = IV(Uint8List.fromList(List.filled(16, 7))); + final right = Key( + pbkdf2( + password: utf8.encode('right'), + salt: salt, + iterations: 1000, + keyLength: 32, + ), + ); + final wrong = Key( + pbkdf2( + password: utf8.encode('wrong'), + salt: salt, + iterations: 1000, + keyLength: 32, + ), + ); + const plaintext = 'sensitive backup payload'; + + final cipherText = + Encrypter(AES(right, mode: AESMode.cbc)).encrypt(plaintext, iv: iv).base64; + + // A wrong key either throws (padding failure) or returns garbage; either + // way it must not reproduce the plaintext. + + String? recovered; + try { + recovered = Encrypter(AES(wrong, mode: AESMode.cbc)) + .decrypt(Encrypted.fromBase64(cipherText), iv: iv); + } on Object { + recovered = null; + } + expect(recovered, isNot(equals(plaintext))); + }); + + test('gzip round-trips the header document', () { + final header = jsonEncode({ + 'magic': 'solidui-backup', + 'formatVersion': 1, + 'payload': base64.encode(List.generate(256, (i) => i % 256)), + }); + + final compressed = const GZipEncoder().encodeBytes(utf8.encode(header)); + final decompressed = + utf8.decode(const GZipDecoder().decodeBytes(compressed)); + + expect(decompressed, equals(header)); + }); +} From e9c85454fc6bff53652a7f63954acedea3a46694 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 15 Jul 2026 22:17:57 +1000 Subject: [PATCH 2/7] Lint --- lib/src/services/solid_backup_service.dart | 7 ++++--- lib/src/widgets/solid_backup_dialog.dart | 10 ++++------ lib/src/widgets/solid_scaffold_appbar_builder.dart | 2 +- test/backup_crypto_test.dart | 10 ++++++---- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/src/services/solid_backup_service.dart b/lib/src/services/solid_backup_service.dart index 6dddd93..2b258c1 100644 --- a/lib/src/services/solid_backup_service.dart +++ b/lib/src/services/solid_backup_service.dart @@ -32,12 +32,12 @@ import 'dart:convert'; import 'dart:math' show Random; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show debugPrint; + import 'package:archive/archive.dart' show GZipEncoder, GZipDecoder; import 'package:crypto/crypto.dart' show Hmac, sha256; import 'package:encrypter_plus/encrypter_plus.dart' show AES, AESMode, Encrypted, Encrypter, IV, Key; -import 'package:flutter/foundation.dart' show debugPrint; - import 'package:solidpod/solidpod.dart' show SecurityKeyVerificationException, @@ -586,7 +586,8 @@ class SolidBackupService { restoredCount++; } on Object catch (e) { skipped[entry.path] = e.toString(); - debugPrint('[SolidBackupService] failed to restore "${entry.path}": $e'); + debugPrint( + '[SolidBackupService] failed to restore "${entry.path}": $e'); } } diff --git a/lib/src/widgets/solid_backup_dialog.dart b/lib/src/widgets/solid_backup_dialog.dart index 33a7e6f..0961cef 100644 --- a/lib/src/widgets/solid_backup_dialog.dart +++ b/lib/src/widgets/solid_backup_dialog.dart @@ -254,9 +254,8 @@ class _SolidBackupDialogState extends State { currentKey: keys['currentKey']!, ); - final skippedNote = result.skipped.isEmpty - ? '' - : ' (${result.skipped.length} skipped)'; + final skippedNote = + result.skipped.isEmpty ? '' : ' (${result.skipped.length} skipped)'; _setImportMessage( 'Restored ${result.restoredCount} ' 'file${result.restoredCount == 1 ? '' : 's'}$skippedNote.', @@ -455,9 +454,8 @@ class _MessageBanner extends StatelessWidget { @override Widget build(BuildContext context) { - final background = isError - ? colorScheme.errorContainer - : colorScheme.secondaryContainer; + final background = + isError ? colorScheme.errorContainer : colorScheme.secondaryContainer; final foreground = isError ? colorScheme.onErrorContainer : colorScheme.onSecondaryContainer; diff --git a/lib/src/widgets/solid_scaffold_appbar_builder.dart b/lib/src/widgets/solid_scaffold_appbar_builder.dart index 6edebce..67b3709 100644 --- a/lib/src/widgets/solid_scaffold_appbar_builder.dart +++ b/lib/src/widgets/solid_scaffold_appbar_builder.dart @@ -39,8 +39,8 @@ import 'package:solidui/src/handlers/solid_auth_handler.dart'; import 'package:solidui/src/services/solid_profile_notifier.dart'; import 'package:solidui/src/utils/snack_bar.dart'; import 'package:solidui/src/widgets/change_password_dialog.dart'; -import 'package:solidui/src/widgets/solid_backup_dialog.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; +import 'package:solidui/src/widgets/solid_backup_dialog.dart'; import 'package:solidui/src/widgets/solid_invite_others_models.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; import 'package:solidui/src/widgets/solid_profile_avatar.dart'; diff --git a/test/backup_crypto_test.dart b/test/backup_crypto_test.dart index eb2ec16..c484f8f 100644 --- a/test/backup_crypto_test.dart +++ b/test/backup_crypto_test.dart @@ -147,8 +147,9 @@ void main() { ], }); - final cipherText = - Encrypter(AES(key, mode: AESMode.cbc)).encrypt(plaintext, iv: iv).base64; + final cipherText = Encrypter(AES(key, mode: AESMode.cbc)) + .encrypt(plaintext, iv: iv) + .base64; final recovered = Encrypter(AES(key, mode: AESMode.cbc)) .decrypt(Encrypted.fromBase64(cipherText), iv: iv); @@ -175,8 +176,9 @@ void main() { ); const plaintext = 'sensitive backup payload'; - final cipherText = - Encrypter(AES(right, mode: AESMode.cbc)).encrypt(plaintext, iv: iv).base64; + final cipherText = Encrypter(AES(right, mode: AESMode.cbc)) + .encrypt(plaintext, iv: iv) + .base64; // A wrong key either throws (padding failure) or returns garbage; either // way it must not reproduce the plaintext. From 92bb5c0015507b55162e320329e77648dca1a3c9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 16 Jul 2026 00:01:07 +1000 Subject: [PATCH 3/7] Adjust the UI layout --- lib/src/widgets/solid_about_button.dart | 1 + lib/src/widgets/solid_backup_dialog.dart | 5 +++-- .../widgets/solid_security_key_view_dialogs.dart | 11 +++++++---- lib/src/widgets/solid_theme.dart | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/src/widgets/solid_about_button.dart b/lib/src/widgets/solid_about_button.dart index 619f5f2..e3b27f7 100644 --- a/lib/src/widgets/solid_about_button.dart +++ b/lib/src/widgets/solid_about_button.dart @@ -44,6 +44,7 @@ import 'package:solidui/src/widgets/solid_feedback_models.dart'; import 'package:solidui/src/widgets/solid_invite_others.dart'; import 'package:solidui/src/widgets/solid_menu_preferences_dialog.dart'; import 'package:solidui/src/widgets/solid_preferences_dialog.dart'; +import 'package:solidui/src/widgets/solid_theme.dart'; /// A button that shows an About dialogue when pressed. diff --git a/lib/src/widgets/solid_backup_dialog.dart b/lib/src/widgets/solid_backup_dialog.dart index 0961cef..28d6d3a 100644 --- a/lib/src/widgets/solid_backup_dialog.dart +++ b/lib/src/widgets/solid_backup_dialog.dart @@ -112,8 +112,9 @@ class _SolidBackupDialogState extends State { final keys = await _promptForKeys( title: 'Export Backup', - message: 'Enter your current security key. The backup will be encrypted ' - 'with this key, and you will need it again when restoring.', + message: 'Please verify your current security key. The backup will be ' + 'encrypted with this key, and you will need it again when ' + 'restoring.', fields: const [ (key: 'securityKey', label: 'Security Key'), ], diff --git a/lib/src/widgets/solid_security_key_view_dialogs.dart b/lib/src/widgets/solid_security_key_view_dialogs.dart index 7306818..d5637b7 100644 --- a/lib/src/widgets/solid_security_key_view_dialogs.dart +++ b/lib/src/widgets/solid_security_key_view_dialogs.dart @@ -35,6 +35,7 @@ import 'package:solidpod/solidpod.dart' import 'package:solidui/src/widgets/solid_security_key_ui_helpers.dart'; import 'package:solidui/src/widgets/solid_security_key_utils.dart'; +import 'package:solidui/src/widgets/solid_theme.dart'; /// Dialogs for viewing security keys. @@ -162,7 +163,7 @@ class SecurityKeyViewDialogs { ), DataCell( ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), + constraints: const BoxConstraints(maxWidth: 220), child: Text( entry.value[1] as String, overflow: TextOverflow.ellipsis, @@ -195,7 +196,8 @@ class SecurityKeyViewDialogs { ), ), content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), + constraints: + const BoxConstraints(maxWidth: SolidTheme.maxDialogWidth), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -213,9 +215,10 @@ class SecurityKeyViewDialogs { ), ), const SizedBox(height: 16), - Center( + SingleChildScrollView( + scrollDirection: Axis.horizontal, child: DataTable( - columnSpacing: 30.0, + columnSpacing: 20.0, columns: [ DataColumn( label: Text( diff --git a/lib/src/widgets/solid_theme.dart b/lib/src/widgets/solid_theme.dart index d7e7e47..87d15bd 100644 --- a/lib/src/widgets/solid_theme.dart +++ b/lib/src/widgets/solid_theme.dart @@ -55,6 +55,14 @@ class SolidTheme { static const Color secondaryTextColor = Colors.black54; + /// Maximum width allowed for any pop up dialog (e.g. [AlertDialog], + /// [Dialog], [AboutDialog]) across SolidUI applications, in logical + /// pixels. Applied via [ThemeData.dialogTheme] so it covers dialogs + /// throughout the app without each call site needing to set it + /// individually. + + static const double maxDialogWidth = 500.0; + /// Creates a light theme with optional customisation. static ThemeData lightTheme({Color? primaryColor, ColorScheme? colorScheme}) { @@ -91,6 +99,9 @@ class SolidTheme { ), ), ), + dialogTheme: const DialogThemeData( + constraints: BoxConstraints(maxWidth: maxDialogWidth), + ), ); } @@ -130,6 +141,9 @@ class SolidTheme { ), ), ), + dialogTheme: const DialogThemeData( + constraints: BoxConstraints(maxWidth: maxDialogWidth), + ), ); } } From 31a89971136ba1c21d498a20729ba793fefee60c Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 16 Jul 2026 00:02:30 +1000 Subject: [PATCH 4/7] Lint --- lib/src/services/solid_backup_service.dart | 3 ++- lib/src/widgets/solid_about_button.dart | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/services/solid_backup_service.dart b/lib/src/services/solid_backup_service.dart index 2b258c1..7afea65 100644 --- a/lib/src/services/solid_backup_service.dart +++ b/lib/src/services/solid_backup_service.dart @@ -587,7 +587,8 @@ class SolidBackupService { } on Object catch (e) { skipped[entry.path] = e.toString(); debugPrint( - '[SolidBackupService] failed to restore "${entry.path}": $e'); + '[SolidBackupService] failed to restore "${entry.path}": $e', + ); } } diff --git a/lib/src/widgets/solid_about_button.dart b/lib/src/widgets/solid_about_button.dart index e3b27f7..619f5f2 100644 --- a/lib/src/widgets/solid_about_button.dart +++ b/lib/src/widgets/solid_about_button.dart @@ -44,7 +44,6 @@ import 'package:solidui/src/widgets/solid_feedback_models.dart'; import 'package:solidui/src/widgets/solid_invite_others.dart'; import 'package:solidui/src/widgets/solid_menu_preferences_dialog.dart'; import 'package:solidui/src/widgets/solid_preferences_dialog.dart'; -import 'package:solidui/src/widgets/solid_theme.dart'; /// A button that shows an About dialogue when pressed. From 77fe109972d5fac4f7ef70e7f6cc364af5768ffc Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 16 Jul 2026 00:04:47 +1000 Subject: [PATCH 5/7] Lint --- .lycheeignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.lycheeignore b/.lycheeignore index f0b4d29..d4f15f7 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -89,6 +89,7 @@ https://host/ https://pods.au/me/app/data https://pub.dev/documentation/solidui/latest/solidui/SolidScaffold-class.html https://anusii.github.io/soliduieg/client-profile.jsonld +scheme://$host:$port # 20260605 gjw Failing solid servers From e8d141d89af8011d4af41babc6f3e71405ea1ee0 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 16 Jul 2026 09:58:13 +1000 Subject: [PATCH 6/7] Lint --- .lycheeignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lycheeignore b/.lycheeignore index d4f15f7..b873698 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -89,7 +89,7 @@ https://host/ https://pods.au/me/app/data https://pub.dev/documentation/solidui/latest/solidui/SolidScaffold-class.html https://anusii.github.io/soliduieg/client-profile.jsonld -scheme://$host:$port +^scheme://\$host:\$port$ # 20260605 gjw Failing solid servers From 8fde1ac66c267065c3b6d3270acfdfab6a5339df Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 16 Jul 2026 10:05:12 +1000 Subject: [PATCH 7/7] Lint --- .lycheeignore | 1 - lib/src/utils/web_id_parser.dart | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.lycheeignore b/.lycheeignore index b873698..f0b4d29 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -89,7 +89,6 @@ https://host/ https://pods.au/me/app/data https://pub.dev/documentation/solidui/latest/solidui/SolidScaffold-class.html https://anusii.github.io/soliduieg/client-profile.jsonld -^scheme://\$host:\$port$ # 20260605 gjw Failing solid servers diff --git a/lib/src/utils/web_id_parser.dart b/lib/src/utils/web_id_parser.dart index f9dec7b..0990470 100644 --- a/lib/src/utils/web_id_parser.dart +++ b/lib/src/utils/web_id_parser.dart @@ -75,7 +75,8 @@ class WebIdParts { String get serverUri { final hasCustomPort = port != 0 && port != 80 && port != 443; - return hasCustomPort ? '$scheme://$host:$port' : '$scheme://$host'; + final schemeUri = 'scheme://$host' ':$port'; + return hasCustomPort ? schemeUri : '$scheme://$host'; } /// Host and username joined with a slash for compact display, for example