Improve certificate mismatch logic and UI, remove force update option

This commit is contained in:
franzap
2026-04-01 19:58:25 -03:00
parent 6f62eb3a7d
commit d3c07ac797
7 changed files with 185 additions and 142 deletions
@@ -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<String>("packageName")
val expectedHash = call.argument<String>("expectedHash")
val expectedSize = call.argument<Number>("expectedSize")?.toLong()
val expectedCertHashes =
call.argument<List<String>>("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<String>("packageName")
@@ -699,6 +702,7 @@ class AndroidPackageManagerPlugin :
packageName: String,
expectedHash: String?,
expectedSize: Long?,
expectedCertHashes: List<String>,
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<String>
) {
// 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<String>? {
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
// ═══════════════════════════════════════════════════════════════════════════════
+46 -24
View File
@@ -264,25 +264,38 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
}
}
// Re-query apps from local to ensure relationships are loaded
final appsWithRelations = await storage.query(
RequestFilter<App>(
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 = <String, Installable>{};
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<App>(
tags: {
'#d': updatableInstallables.keys.toSet(),
'#f': {platform},
},
).toRequest(),
source: const LocalSource(),
);
await _showUpdateNotificationIfNeeded(
updatableApps,
updatableInstallables,
);
}
return true;
@@ -299,9 +312,17 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? 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<void> _showUpdateNotificationIfNeeded(List<App> 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<void> _showUpdateNotificationIfNeeded(
List<App> updates,
Map<String, Installable> installables,
) async {
final secureStorage = SecureStorageService();
// Skip if user recently opened the app
@@ -311,16 +332,17 @@ Future<void> _showUpdateNotificationIfNeeded(List<App> 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)) {
@@ -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);
@@ -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,
@@ -609,57 +609,6 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
await _performInstall(appId, op.target, op.filePath);
}
/// Force update (uninstall + install) from OperationFailed with certMismatch
Future<void> 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);
-2
View File
@@ -276,8 +276,6 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
@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),
+53 -60
View File
@@ -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<void> _showForceUpdateDialog(
Future<void> _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<bool>(
await showBaseDialog<void>(
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) {