Handle multiple signature hashes and check on last

This commit is contained in:
franzap
2026-04-09 21:23:17 -03:00
parent 70c11d8d43
commit 1f87d6c571
7 changed files with 64 additions and 42 deletions
@@ -1023,15 +1023,39 @@ class AndroidPackageManagerPlugin :
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// SIGNING CERTIFICATE HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Returns the current signing certificates from a [android.content.pm.SigningInfo].
*
* For multiple v1 signers: returns [apkContentsSigners].
* For single signer (v2/v3, with or without key rotation): returns the
* newest certificate from [signingCertificateHistory] (last element),
* which matches what [apkContentsSigners] returns for APK files.
*/
private fun android.content.pm.SigningInfo.currentSigners(): Array<android.content.pm.Signature> {
return if (hasMultipleSigners()) {
apkContentsSigners ?: emptyArray()
} else {
val history = signingCertificateHistory
if (history.isNullOrEmpty()) emptyArray() else arrayOf(history.last())
}
}
private fun android.content.pm.Signature.sha256Hex(): String {
return MessageDigest.getInstance("SHA-256")
.digest(toByteArray())
.joinToString("") { "%02x".format(it) }
}
/**
* 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>? {
@@ -1043,15 +1067,10 @@ class AndroidPackageManagerPlugin :
)
?: return null
val signers = packageInfo.signingInfo?.apkContentsSigners ?: return null
if (signers.isEmpty()) return null
val signers = packageInfo.signingInfo?.currentSigners()
if (signers.isNullOrEmpty()) return null
val digest = MessageDigest.getInstance("SHA-256")
signers.map { sig ->
digest.reset()
digest.update(sig.toByteArray())
digest.digest().joinToString("") { "%02x".format(it) }
}
signers.map { it.sha256Hex() }
} catch (e: Exception) {
Log.w(TAG, "Failed to extract APK cert hashes from $apkPath: ${e.message}")
null
@@ -1412,13 +1431,13 @@ class AndroidPackageManagerPlugin :
"bundleId" to pkg.packageName,
"versionName" to pkg.versionName,
"versionCode" to versionCode,
"signatureHash" to getSignatureHash(pkg.packageName),
"signatureHashes" to getSignatureHashes(pkg.packageName),
"canInstallSilently" to canInstallSilently(pkg.packageName)
)
}
}
private fun getSignatureHash(packageName: String): String {
private fun getSignatureHashes(packageName: String): List<String> {
return try {
val pm = context.packageManager
val pkgInfo =
@@ -1434,20 +1453,16 @@ class AndroidPackageManagerPlugin :
pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
}
val signatures =
val signers =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pkgInfo.signingInfo?.apkContentsSigners ?: emptyArray()
pkgInfo.signingInfo?.currentSigners() ?: emptyArray()
} else {
@Suppress("DEPRECATION") pkgInfo.signatures ?: emptyArray()
}
if (signatures.isEmpty()) return ""
MessageDigest.getInstance("SHA-256").digest(signatures[0].toByteArray()).joinToString(
""
) { "%02x".format(it) }
signers.map { it.sha256Hex() }
} catch (_: Exception) {
""
emptyList()
}
}
}
@@ -453,8 +453,7 @@ final class AndroidPackageManager extends PackageManager {
name: existingPkg?.name,
version: target.version,
versionCode: target.versionCode,
// Keep existing signature hash if available, will be updated by sync
signatureHash: existingPkg?.signatureHash ?? '',
signatureHashes: existingPkg?.signatureHashes ?? const [],
installTime: DateTime.now(),
canInstallSilently: existingPkg?.canInstallSilently ?? false,
);
@@ -709,7 +708,10 @@ final class AndroidPackageManager extends PackageManager {
final name = app['name'] as String?;
final version = app['versionName'] as String? ?? '0.0.0';
final versionCode = app['versionCode'] as int?;
final signatureHash = app['signatureHash'] as String? ?? '';
final rawHashes = app['signatureHashes'];
final signatureHashes = rawHashes is List
? rawHashes.cast<String>().toList()
: <String>[];
final canInstallSilently = app['canInstallSilently'] as bool? ?? false;
if (canInstallSilently) anyCanInstallSilently = true;
@@ -720,7 +722,7 @@ final class AndroidPackageManager extends PackageManager {
name: name,
version: version,
versionCode: versionCode,
signatureHash: signatureHash,
signatureHashes: signatureHashes,
installTime: null,
canInstallSilently: canInstallSilently,
);
@@ -11,21 +11,21 @@ final class DummyPackageManager extends PackageManager {
name: 'Test App',
version: '1.0.0',
versionCode: 1,
signatureHash: 'dummy_signature_1',
signatureHashes: ['dummy_signature_1'],
),
'dev.zapstore.app': const PackageInfo(
appId: 'dev.zapstore.app',
name: 'Zapstore',
version: '1.0.0',
versionCode: 1,
signatureHash: 'dummy_signature_2',
signatureHashes: ['dummy_signature_2'],
),
'com.dummy.browser': const PackageInfo(
appId: 'com.dummy.browser',
name: 'Dummy Browser',
version: '2.1.0',
versionCode: 210,
signatureHash: 'dummy_signature_3',
signatureHashes: ['dummy_signature_3'],
),
},
operations: const {},
@@ -57,7 +57,7 @@ final class DummyPackageManager extends PackageManager {
appId: appId,
version: target.version,
versionCode: target.versionCode,
signatureHash: 'mock_signature',
signatureHashes: const ['mock_signature'],
installTime: DateTime.now(),
);
state = state.copyWith(installed: newInstalled);
@@ -25,7 +25,7 @@ class InstalledPackagesSnapshot {
'name': p.name,
'version': p.version,
'versionCode': p.versionCode,
'signatureHash': p.signatureHash,
'signatureHashes': p.signatureHashes,
'canInstallSilently': p.canInstallSilently,
},
)
@@ -69,12 +69,16 @@ class InstalledPackagesSnapshot {
final map = Map<String, dynamic>.from(item);
final appId = map['appId'] as String?;
if (appId == null || appId.isEmpty) continue;
final rawHashes = map['signatureHashes'];
final signatureHashes = rawHashes is List
? rawHashes.cast<String>().toList()
: <String>[];
result[appId] = PackageInfo(
appId: appId,
name: map['name'] as String?,
version: (map['version'] as String?) ?? '0.0.0',
versionCode: map['versionCode'] as int?,
signatureHash: (map['signatureHash'] as String?) ?? '',
signatureHashes: signatureHashes,
installTime: null,
canInstallSilently: (map['canInstallSilently'] as bool?) ?? false,
);
@@ -22,7 +22,7 @@ class PackageInfo extends Equatable {
required this.appId,
required this.version,
required this.versionCode,
required this.signatureHash,
this.signatureHashes = const [],
this.name,
this.installTime,
this.canInstallSilently = false,
@@ -32,7 +32,7 @@ class PackageInfo extends Equatable {
final String? name;
final String version;
final int? versionCode;
final String signatureHash;
final List<String> signatureHashes;
final DateTime? installTime;
final bool canInstallSilently;
@@ -42,7 +42,7 @@ class PackageInfo extends Equatable {
name,
version,
versionCode,
signatureHash,
signatureHashes,
installTime,
canInstallSilently,
];
+5 -4
View File
@@ -30,12 +30,13 @@ class UpdateAllRow extends ConsumerWidget {
if (target == null) return false;
// Skip apps with cert mismatch — they will fail during
// install and waste bandwidth.
final installedHash =
pmState.installed[app.identifier]?.signatureHash ?? '';
final installedHashes =
pmState.installed[app.identifier]?.signatureHashes ??
const [];
final targetHashes = target.certificateHashes;
if (installedHash.isNotEmpty &&
if (installedHashes.isNotEmpty &&
targetHashes.isNotEmpty &&
!targetHashes.contains(installedHash)) {
!installedHashes.any(targetHashes.contains)) {
return false;
}
return true;
+4 -4
View File
@@ -244,12 +244,12 @@ class InstallButton extends ConsumerWidget {
isDisabled: true,
);
}
final installedHash = installedPkg?.signatureHash ?? '';
final installedHashes = installedPkg?.signatureHashes ?? const [];
final targetHashes = fileMetadata.certificateHashes;
final isCertMismatch =
installedHash.isNotEmpty &&
installedHashes.isNotEmpty &&
targetHashes.isNotEmpty &&
!targetHashes.contains(installedHash);
!installedHashes.any(targetHashes.contains);
if (isCertMismatch) {
return _buildSimpleButton(
context,
@@ -692,7 +692,7 @@ class InstallButton extends ConsumerWidget {
Installable fileMetadata,
) async {
final currentVersion = installedPkg?.version ?? 'Unknown';
final currentCertHash = installedPkg?.signatureHash ?? 'Unknown';
final currentCertHash = installedPkg?.signatureHashes.firstOrNull ?? 'Unknown';
final updateCertHash = fileMetadata.certificateHash ?? 'Unknown';
final updateVersion = fileMetadata.version;
final author = app.author.value;