From d3c07ac7970d1eb8efbe6b8601483cac403ad9bd Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Wed, 1 Apr 2026 19:58:25 -0300 Subject: [PATCH] Improve certificate mismatch logic and UI, remove force update option --- .../plugins/AndroidPackageManagerPlugin.kt | 80 ++++++++++++- lib/services/background_update_service.dart | 70 +++++++---- .../android_package_manager.dart | 6 + .../package_manager/install_operation.dart | 5 +- .../package_manager/package_manager.dart | 51 -------- lib/services/updates_service.dart | 2 - lib/widgets/install_button.dart | 113 ++++++++---------- 7 files changed, 185 insertions(+), 142 deletions(-) diff --git a/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt index b3215aa..dadb4b1 100644 --- a/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt @@ -72,6 +72,7 @@ object ErrorCode { const val INVALID_FILE = "invalidFile" const val INSTALL_FAILED = "installFailed" const val CERT_MISMATCH = "certMismatch" + const val CERT_METADATA_MISMATCH = "certMetadataMismatch" const val PERMISSION_DENIED = "permissionDenied" const val INSUFFICIENT_STORAGE = "insufficientStorage" const val INCOMPATIBLE = "incompatible" @@ -645,12 +646,14 @@ class AndroidPackageManagerPlugin : val packageName = call.argument("packageName") val expectedHash = call.argument("expectedHash") val expectedSize = call.argument("expectedSize")?.toLong() + val expectedCertHashes = + call.argument>("expectedCertHashes") ?: emptyList() if (filePath == null || packageName == null) { result.error("MISSING_ARGUMENT", "filePath and packageName required", null) return } - installApk(filePath, packageName, expectedHash, expectedSize, result) + installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, result) } "canInstallSilently" -> { val packageName = call.argument("packageName") @@ -699,6 +702,7 @@ class AndroidPackageManagerPlugin : packageName: String, expectedHash: String?, expectedSize: Long?, + expectedCertHashes: List, result: Result ) { val file = File(filePath) @@ -770,7 +774,7 @@ class AndroidPackageManagerPlugin : // All heavy I/O work runs on background thread to prevent ANRs val t = Thread { - verifyAndInstall(file, packageName, expectedHash, expectedSize) + verifyAndInstall(file, packageName, expectedHash, expectedSize, expectedCertHashes) verificationThreads.remove(packageName) } verificationThreads[packageName] = t @@ -797,7 +801,8 @@ class AndroidPackageManagerPlugin : apkFile: File, packageName: String, expectedHash: String?, - expectedSize: Long? + expectedSize: Long?, + expectedCertHashes: List ) { // Step 1: Validate APK format before doing any heavy work if (!isValidApkFormat(apkFile)) { @@ -832,6 +837,40 @@ class AndroidPackageManagerPlugin : Log.w(TAG, "APK package name '$apkPackageName' differs from event identifier '$packageName'") } + // Step 2b: Verify APK signing certificate against Nostr metadata (apk_certificate_hash) + if (expectedCertHashes.isNotEmpty()) { + val actualCertHashes = extractApkCertHashes(apkFile.absolutePath) + if (actualCertHashes == null) { + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + "Could not extract signing certificate from APK", + ErrorCode.CERT_METADATA_MISMATCH + ) + } + return + } + val matched = actualCertHashes.any { actual -> + expectedCertHashes.any { expected -> actual.equals(expected, ignoreCase = true) } + } + if (!matched) { + val actualHex = actualCertHashes.joinToString(", ") + val expectedHex = expectedCertHashes.joinToString(", ") + Log.w(TAG, "Cert mismatch for $packageName. Expected: $expectedHex Actual: $actualHex") + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + "Certificate mismatch", + ErrorCode.CERT_METADATA_MISMATCH, + "APK signing certificate does not match what the publisher declared.\n\nExpected: $expectedHex\nActual: $actualHex" + ) + } + return + } + } + // Step 3: Check if this is an update val isUpdate = try { @@ -1008,6 +1047,41 @@ class AndroidPackageManagerPlugin : } } + /** + * Extracts signing certificate hashes from an APK file. + * + * Returns SHA-256 hashes of the DER-encoded X.509 certificates (lowercase hex), matching the + * algorithm used by zsp to produce apk_certificate_hash in NIP-82 SoftwareAsset events. + * + * Uses SigningInfo.apkContentsSigners (API 28+) which prefers v3 key-rotation signers over + * v2/v1, consistent with apkverifier.PickBestApkCert used in zsp. + * + * Returns null if extraction fails (treat as verification error, not skip). + */ + private fun extractApkCertHashes(apkPath: String): List? { + return try { + val packageInfo = + context.packageManager.getPackageArchiveInfo( + apkPath, + PackageManager.GET_SIGNING_CERTIFICATES + ) + ?: return null + + val signers = packageInfo.signingInfo?.apkContentsSigners ?: return null + if (signers.isEmpty()) return null + + val digest = MessageDigest.getInstance("SHA-256") + signers.map { sig -> + digest.reset() + digest.update(sig.toByteArray()) + digest.digest().joinToString("") { "%02x".format(it) } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to extract APK cert hashes from $apkPath: ${e.message}") + null + } + } + // ═══════════════════════════════════════════════════════════════════════════════ // SESSION MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index f12e302..da118bb 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -264,25 +264,38 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { } } - // Re-query apps from local to ensure relationships are loaded - final appsWithRelations = await storage.query( - RequestFilter( - tags: { - '#d': installedIds, - '#f': {platform}, - }, - ).toRequest(), - source: const LocalSource(), - ); - - // Collect apps with updates - final updatableApps = appsWithRelations - .where((app) => app.hasUpdate) - .toList(); + // Determine which apps have updates directly from the installables we + // already fetched — avoids a relationship-loading re-query that would + // always produce null installables (no `and:` in background context). + final updatableInstallables = {}; + for (final asset in assets) { + if (packageManager.hasUpdate(asset.appIdentifier, asset)) { + updatableInstallables[asset.appIdentifier] = asset; + } + } + for (final meta in metadatas) { + if (!updatableInstallables.containsKey(meta.appIdentifier) && + packageManager.hasUpdate(meta.appIdentifier, meta)) { + updatableInstallables[meta.appIdentifier] = meta; + } + } // Show notification if updates found (throttled to once per 72h) - if (updatableApps.isNotEmpty) { - await _showUpdateNotificationIfNeeded(updatableApps); + if (updatableInstallables.isNotEmpty) { + // Fetch App objects from local DB for display names only + final updatableApps = await storage.query( + RequestFilter( + tags: { + '#d': updatableInstallables.keys.toSet(), + '#f': {platform}, + }, + ).toRequest(), + source: const LocalSource(), + ); + await _showUpdateNotificationIfNeeded( + updatableApps, + updatableInstallables, + ); } return true; @@ -299,9 +312,17 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { /// Show a local notification for available updates. /// Only notifies if: /// 1. User hasn't opened app in 24+ hours -/// 2. There are updates with release.createdAt > seenUntil AND > lastOpened +/// 2. There are updates with installable.createdAt > seenUntil AND > lastOpened /// (new since both last notification AND last time user saw the app) -Future _showUpdateNotificationIfNeeded(List updates) async { +/// +/// [installables] maps app identifier → the installable (SoftwareAsset or +/// FileMetadata) that represents the available update. Its `createdAt` is used +/// for freshness checks instead of `app.latestRelease.value` which is not +/// populated in the background isolate context. +Future _showUpdateNotificationIfNeeded( + List updates, + Map installables, +) async { final secureStorage = SecureStorageService(); // Skip if user recently opened the app @@ -311,16 +332,17 @@ Future _showUpdateNotificationIfNeeded(List updates) async { return; } - // Get the "seen until" timestamp - updates with release.createdAt > this are new + // Get the "seen until" timestamp - updates with createdAt > this are new final seenUntil = await secureStorage.getSeenUntil(); // Filter to only updates that are genuinely new: - // - release.createdAt > seenUntil (not already notified via background) - // - release.createdAt > lastOpened (not already seen when user opened app) + // - installable.createdAt > seenUntil (not already notified via background) + // - installable.createdAt > lastOpened (not already seen when user opened app) // This prevents nagging about updates user saw in the app but chose to ignore final newUpdates = updates.where((app) { - final releaseTime = app.latestRelease.value?.event.createdAt; - if (releaseTime == null) return false; + final installable = installables[app.identifier]; + if (installable == null) return false; + final releaseTime = installable.createdAt; // Must be newer than last notification (if any) if (seenUntil != null && !releaseTime.isAfter(seenUntil)) { diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index cf31410..0d8cc85 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; /// Install status values from native side. /// @@ -49,6 +50,7 @@ class NativeErrorCode { static const invalidFile = 'invalidFile'; static const installFailed = 'installFailed'; static const certMismatch = 'certMismatch'; + static const certMetadataMismatch = 'certMetadataMismatch'; static const permissionDenied = 'permissionDenied'; static const insufficientStorage = 'insufficientStorage'; static const incompatible = 'incompatible'; @@ -392,6 +394,7 @@ final class AndroidPackageManager extends PackageManager { NativeErrorCode.invalidFile => FailureType.invalidFile, NativeErrorCode.installFailed => FailureType.installFailed, NativeErrorCode.certMismatch => FailureType.certMismatch, + NativeErrorCode.certMetadataMismatch => FailureType.certMetadataMismatch, NativeErrorCode.permissionDenied => FailureType.permissionDenied, NativeErrorCode.insufficientStorage => FailureType.insufficientStorage, NativeErrorCode.incompatible => FailureType.incompatible, @@ -441,6 +444,8 @@ final class AndroidPackageManager extends PackageManager { 'Invalid app file. The download may be corrupt.', FailureType.certMismatch => 'Update signed by different developer. Uninstall current version to update.', + FailureType.certMetadataMismatch => + 'APK signing certificate does not match what the publisher declared. This file may have been tampered with.', FailureType.permissionDenied => 'Permission required. Please grant install permission and try again.', FailureType.insufficientStorage => @@ -512,6 +517,7 @@ final class AndroidPackageManager extends PackageManager { 'packageName': appId, 'expectedHash': expectedHash, 'expectedSize': expectedSize, + 'expectedCertHashes': target.certificateHashes.toList(), }) .timeout(const Duration(seconds: 30), onTimeout: () => null); diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index a42b580..494a48d 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -214,8 +214,6 @@ class OperationFailed extends InstallOperation { this.filePath, }); - /// Whether this requires force update (uninstall + install) - bool get needsForceUpdate => type == FailureType.certMismatch; } /// Types of failures that can occur during install operations @@ -235,6 +233,9 @@ enum FailureType { /// Certificate/signature mismatch - needs force update (uninstall + install) certMismatch, + /// APK signing certificate does not match what the publisher declared in Nostr metadata + certMetadataMismatch, + /// User doesn't have install permission permissionDenied, diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 012e517..98e3b4a 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -609,57 +609,6 @@ abstract class PackageManager extends StateNotifier { await _performInstall(appId, op.target, op.filePath); } - /// Force update (uninstall + install) from OperationFailed with certMismatch - Future forceUpdate(String appId) async { - final op = getOperation(appId); - if (op is! OperationFailed || !op.needsForceUpdate) return; - - final filePath = op.filePath; - if (filePath == null || !await File(filePath).exists()) { - setOperation( - appId, - OperationFailed( - target: op.target, - type: FailureType.downloadFailed, - message: 'Downloaded file not found. Please download again.', - ), - ); - return; - } - - setOperation(appId, Uninstalling(target: op.target, filePath: filePath)); - - try { - await uninstall(appId); - await _performInstall(appId, op.target, filePath); - } catch (e) { - final errorMessage = e.toString(); - if (!errorMessage.contains('cancelled')) { - setOperation( - appId, - OperationFailed( - target: op.target, - type: FailureType.installFailed, - message: 'Update failed.', - description: errorMessage, - filePath: filePath, - ), - ); - } else { - setOperation( - appId, - OperationFailed( - target: op.target, - type: FailureType.certMismatch, - message: - 'Update signed by different developer. Uninstall current version to update.', - filePath: filePath, - ), - ); - } - } - } - /// Dismiss error and clean up void dismissError(String appId) { final op = getOperation(appId); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index b77465f..3b5daec 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -276,8 +276,6 @@ class CategorizedUpdatesNotifier extends Notifier { @override CategorizedUpdates build() { - ref.onDispose(() => _lastCategorization = null); - // Keep poller alive; only rebuild when completion status changes final pollerHasCompleted = ref.watch( updatePollerProvider.select((s) => s.lastCheckTime != null), diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 02d60f6..eb74c42 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -13,11 +13,7 @@ import 'package:zapstore/widgets/install_alert_dialog.dart'; import 'package:zapstore/theme.dart'; class InstallButton extends ConsumerWidget { - const InstallButton({ - super.key, - required this.app, - this.compact = false, - }); + const InstallButton({super.key, required this.app, this.compact = false}); final App app; final bool compact; @@ -57,6 +53,7 @@ class InstallButton extends ConsumerWidget { hasUpdate: hasUpdate, hasDowngrade: hasDowngrade, fileMetadata: fileMetadata, + installedPkg: installedPkg, fontSize: fontSize, ); @@ -100,13 +97,15 @@ class InstallButton extends ConsumerWidget { required bool hasUpdate, required bool hasDowngrade, required Installable? fileMetadata, + required PackageInfo? installedPkg, required double fontSize, }) { // Completed is a terminal "result" state and may linger for batch progress UX. // If the app is no longer installed (e.g. user uninstalled right after install), // ignore a stale Completed op so we fall back to the normal "Install" UI. - final effectiveOperation = - (!isInstalled && operation is Completed) ? null : operation; + final effectiveOperation = (!isInstalled && operation is Completed) + ? null + : operation; // Handle operation states first if (effectiveOperation != null) { @@ -205,14 +204,12 @@ class InstallButton extends ConsumerWidget { showSpinner: true, ), - OperationFailed(:final type, :final needsForceUpdate) => - _buildErrorButton( - context, - ref, - type: type, - needsForceUpdate: needsForceUpdate, - fontSize: fontSize, - ), + OperationFailed(:final type) => _buildErrorButton( + context, + ref, + type: type, + fontSize: fontSize, + ), Completed() => _buildAsyncButton( context, @@ -240,11 +237,33 @@ class InstallButton extends ConsumerWidget { if (hasUpdate) { if (fileMetadata == null) { return _buildSimpleButton( - context, 'Update', null, + context, + 'Update', + null, fontSize: fontSize, isDisabled: true, ); } + final installedHash = installedPkg?.signatureHash ?? ''; + final targetHashes = fileMetadata.certificateHashes; + final isCertMismatch = + installedHash.isNotEmpty && + targetHashes.isNotEmpty && + !targetHashes.contains(installedHash); + if (isCertMismatch) { + return _buildSimpleButton( + context, + 'Certificate mismatch', + () => _showCertMismatchDialog( + ref, + context, + installedPkg, + fileMetadata, + ), + fontSize: fontSize, + isError: true, + ); + } return _buildAsyncButton( context, ref, @@ -268,7 +287,9 @@ class InstallButton extends ConsumerWidget { // Not installed if (fileMetadata == null) { return _buildSimpleButton( - context, 'Install', null, + context, + 'Install', + null, fontSize: fontSize, isDisabled: true, ); @@ -476,19 +497,8 @@ class InstallButton extends ConsumerWidget { BuildContext context, WidgetRef ref, { required FailureType type, - required bool needsForceUpdate, required double fontSize, }) { - if (needsForceUpdate) { - return _buildSimpleButton( - context, - 'Force update', - () => _showForceUpdateDialog(ref, context), - fontSize: fontSize, - isError: true, - ); - } - return _buildSimpleButton( context, 'Error (tap for details)', @@ -675,21 +685,19 @@ class InstallButton extends ConsumerWidget { ); } - Future _showForceUpdateDialog( + Future _showCertMismatchDialog( WidgetRef ref, BuildContext context, + PackageInfo? installedPkg, + Installable fileMetadata, ) async { - final installedPkg = ref.read(installedPackageProvider(app.identifier)); - final operation = ref.read(installOperationProvider(app.identifier)); - if (operation is! OperationFailed) return; - - final updateVersion = operation.target.version; final currentVersion = installedPkg?.version ?? 'Unknown'; final currentCertHash = installedPkg?.signatureHash ?? 'Unknown'; - final updateCertHash = operation.target.apkSignatureHash ?? 'Unknown'; + final updateCertHash = fileMetadata.certificateHash ?? 'Unknown'; + final updateVersion = fileMetadata.version; final author = app.author.value; - final shouldProceed = await showBaseDialog( + await showBaseDialog( context: context, dialog: Builder( builder: (dialogContext) => BaseDialog( @@ -697,7 +705,7 @@ class InstallButton extends ConsumerWidget { Icons.security, color: Theme.of(dialogContext).colorScheme.error, ), - title: const BaseDialogTitle('Certificate Mismatch'), + title: const BaseDialogTitle('Certificate mismatch'), content: BaseDialogContent( children: [ if (author != null) @@ -731,46 +739,31 @@ class InstallButton extends ConsumerWidget { const SizedBox(height: 12), const Text( 'Android security prevents updating apps signed by different certificates. ' - 'Contact the publisher for details.', - style: TextStyle(fontSize: 14), - ), - const SizedBox(height: 8), - const Text( - 'To proceed anyway, uninstall the current version and install the new one. ' - 'ALL APP DATA WILL BE LOST.', + 'Contact the publisher for details. You can uninstall and install again.', style: TextStyle(fontSize: 14), ), ], ), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), ), FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), + onPressed: () { + Navigator.of(dialogContext).pop(); + _uninstallApp(ref, context); + }, style: FilledButton.styleFrom( backgroundColor: Theme.of(dialogContext).colorScheme.error, foregroundColor: Colors.white, ), - child: const Text('Uninstall & Install'), + child: const Text('Uninstall'), ), ], ), ), ); - - if (shouldProceed == true && context.mounted) { - try { - final pm = ref.read(packageManagerProvider.notifier); - await pm.forceUpdate(app.identifier); - } catch (e) { - final errorMessage = e.toString(); - if (context.mounted && !errorMessage.contains('cancelled')) { - context.showError('Update failed', technicalDetails: errorMessage); - } - } - } } String _abbr(String v) {