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 ff38a98..e52ba7f 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 @@ -12,6 +12,7 @@ import android.os.Looper import android.os.UserManager import android.provider.Settings import android.system.Os +import android.util.Base64 import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -24,9 +25,11 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import java.io.ByteArrayInputStream import java.io.File import java.io.FileInputStream import java.security.MessageDigest +import java.security.cert.CertificateFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -72,6 +75,7 @@ object ErrorCode { const val INSTALL_FAILED = "installFailed" const val CERT_MISMATCH = "certMismatch" const val CERT_METADATA_MISMATCH = "certMetadataMismatch" + const val C1_PROOF_MISMATCH = "c1ProofMismatch" const val PERMISSION_DENIED = "permissionDenied" const val INSUFFICIENT_STORAGE = "insufficientStorage" const val INCOMPATIBLE = "incompatible" @@ -80,6 +84,14 @@ object ErrorCode { const val INSTALL_TIMEOUT = "installTimeout" } +private data class C1Proof( + val pubkey: String, + val certificateHash: String, + val signature: String, + val createdAt: Long, + val expiry: Long +) + /** * AndroidPackageManagerPlugin - Event-driven architecture for clean state management. * @@ -652,12 +664,13 @@ class AndroidPackageManagerPlugin : val expectedSize = call.argument("expectedSize")?.toLong() val expectedCertHashes = call.argument>("expectedCertHashes") ?: emptyList() + val c1Proof = parseC1Proof(call.argument>("c1Proof")) if (filePath == null || packageName == null) { result.error("MISSING_ARGUMENT", "filePath and packageName required", null) return } - installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, result) + installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, c1Proof, result) } "canInstallSilently" -> { val packageName = call.argument("packageName") @@ -698,11 +711,12 @@ class AndroidPackageManagerPlugin : val expectedHash = call.argument("expectedHash") val expectedCertHashes = call.argument>("expectedCertHashes") ?: emptyList() + val c1Proof = parseC1Proof(call.argument>("c1Proof")) if (filePath == null || expectedHash == null) { result.error("MISSING_ARGUMENT", "filePath and expectedHash required", null) return } - verifyApkOnly(filePath, expectedHash, expectedCertHashes, result) + verifyApkOnly(filePath, expectedHash, expectedCertHashes, c1Proof, result) } "installAndAwait" -> { val filePath = call.argument("filePath") @@ -711,6 +725,7 @@ class AndroidPackageManagerPlugin : val expectedSize = call.argument("expectedSize")?.toLong() val expectedCertHashes = call.argument>("expectedCertHashes") ?: emptyList() + val c1Proof = parseC1Proof(call.argument>("c1Proof")) if (filePath == null || packageName == null) { result.error("MISSING_ARGUMENT", "filePath and packageName required", null) @@ -722,6 +737,7 @@ class AndroidPackageManagerPlugin : expectedHash, expectedSize, expectedCertHashes, + c1Proof, result ) } @@ -737,6 +753,7 @@ class AndroidPackageManagerPlugin : filePath: String, expectedHash: String, expectedCertHashes: List, + c1Proof: C1Proof?, result: Result ) { Thread { @@ -784,6 +801,21 @@ class AndroidPackageManagerPlugin : return@Thread } } + if (c1Proof != null) { + val proofError = verifyC1Proof(file.absolutePath, c1Proof) + if (proofError != null) { + mainHandler.post { + result.success( + mapOf( + "valid" to false, + "error" to proofError, + "errorCode" to ErrorCode.C1_PROOF_MISMATCH + ) + ) + } + return@Thread + } + } val digest = MessageDigest.getInstance("SHA-256") FileInputStream(file).use { fis -> val buffer = ByteArray(COPY_BUFFER_SIZE) @@ -809,12 +841,13 @@ class AndroidPackageManagerPlugin : expectedHash: String?, expectedSize: Long?, expectedCertHashes: List, + c1Proof: C1Proof?, result: Result ) { val latch = CountDownLatch(1) awaitLatches[packageName] = latch - installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, object : Result { + installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, c1Proof, object : Result { override fun success(response: Any?) { val responseMap = (response as? Map<*, *>)?.entries?.associate { @@ -871,6 +904,7 @@ class AndroidPackageManagerPlugin : expectedHash: String?, expectedSize: Long?, expectedCertHashes: List, + c1Proof: C1Proof?, result: Result ) { val file = File(filePath) @@ -942,7 +976,7 @@ class AndroidPackageManagerPlugin : // All heavy I/O work runs on background thread to prevent ANRs val t = Thread { - verifyAndInstall(file, packageName, expectedHash, expectedSize, expectedCertHashes) + verifyAndInstall(file, packageName, expectedHash, expectedSize, expectedCertHashes, c1Proof) verificationThreads.remove(packageName) } verificationThreads[packageName] = t @@ -970,7 +1004,8 @@ class AndroidPackageManagerPlugin : packageName: String, expectedHash: String?, expectedSize: Long?, - expectedCertHashes: List + expectedCertHashes: List, + c1Proof: C1Proof? ) { // Step 1: Validate APK format before doing any heavy work if (!isValidApkFormat(apkFile)) { @@ -1040,6 +1075,23 @@ class AndroidPackageManagerPlugin : } } + if (c1Proof != null) { + val proofError = verifyC1Proof(apkFile.absolutePath, c1Proof) + if (proofError != null) { + Log.e(TAG, "C1 proof verification failed for $packageName: $proofError") + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + "Cryptographic identity proof failed", + ErrorCode.C1_PROOF_MISMATCH, + proofError + ) + } + return + } + } + // Step 3: Check if this is an update val isUpdate = try { @@ -1248,6 +1300,103 @@ class AndroidPackageManagerPlugin : .joinToString("") { "%02x".format(it) } } + private fun android.content.pm.Signature.publicKey(): java.security.PublicKey { + val certificate = + CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(toByteArray())) + return certificate.publicKey + } + + private fun parseC1Proof(raw: Map<*, *>?): C1Proof? { + if (raw == null) return null + val pubkey = raw["pubkey"] as? String ?: return null + val certificateHash = raw["certificateHash"] as? String ?: return null + val signature = raw["signature"] as? String ?: return null + val createdAt = (raw["createdAt"] as? Number)?.toLong() ?: return null + val expiry = (raw["expiry"] as? Number)?.toLong() ?: return null + return C1Proof(pubkey, certificateHash, signature, createdAt, expiry) + } + + private fun verifyC1Proof(apkPath: String, proof: C1Proof): String? { + if (proof.expiry <= proof.createdAt) { + return "C1 proof expiry is not after creation time." + } + val now = System.currentTimeMillis() / 1000 + if (now >= proof.expiry) { + return "C1 proof is expired." + } + + val signers = extractApkSigners(apkPath) + ?: return "Could not read APK signing certificate for C1 proof verification." + val matchingSigner = + signers.firstOrNull { it.sha256Hex().equals(proof.certificateHash, ignoreCase = true) } + ?: return "Downloaded APK certificate does not match the C1 proof certificate hash." + + val publicKey = try { + matchingSigner.publicKey() + } catch (e: Exception) { + return "Could not extract public key from APK signing certificate: ${e.message}" + } + + val algorithm = + when (publicKey.algorithm.uppercase()) { + "RSA" -> "SHA256withRSA" + "EC", "ECDSA" -> "SHA256withECDSA" + else -> return "Unsupported APK signing certificate key type: ${publicKey.algorithm}" + } + + val signatureBytes = try { + Base64.decode(proof.signature, Base64.DEFAULT) + } catch (e: Exception) { + return "C1 proof signature is not valid base64." + } + + val message = + "Verifying at ${proof.createdAt} until ${proof.expiry} that I control the following Nostr public key: ${proof.pubkey}" + return try { + val verifier = java.security.Signature.getInstance(algorithm) + verifier.initVerify(publicKey) + verifier.update(message.toByteArray(Charsets.UTF_8)) + if (verifier.verify(signatureBytes)) null else "C1 proof signature does not verify." + } catch (e: Exception) { + "C1 proof verification failed: ${e.message}" + } + } + + private fun extractApkSigners(apkPath: String): Array? { + return try { + val packageInfo = + context.packageManager.getPackageArchiveInfo( + apkPath, + PackageManager.GET_SIGNING_CERTIFICATES + ) + if (packageInfo == null) { + Log.w(TAG, "extractApkSigners: getPackageArchiveInfo returned null for $apkPath") + return null + } + + val signingInfo = packageInfo.signingInfo + if (signingInfo == null) { + Log.w(TAG, "extractApkSigners: signingInfo is null for $apkPath") + return null + } + + val signers = signingInfo.currentSigners() + if (signers.isEmpty()) { + Log.w(TAG, "extractApkSigners: currentSigners() is empty for $apkPath " + + "(hasMultipleSigners=${signingInfo.hasMultipleSigners()}, " + + "apkContentsSigners=${signingInfo.apkContentsSigners?.size ?: "null"}, " + + "historySize=${signingInfo.signingCertificateHistory?.size ?: "null"})") + return null + } + + signers + } catch (e: Exception) { + Log.w(TAG, "Failed to extract APK signers from $apkPath: ${e.message}") + null + } + } + /** * Extracts signing certificate hashes from an APK file. * @@ -1260,37 +1409,7 @@ class AndroidPackageManagerPlugin : * 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 - ) - if (packageInfo == null) { - Log.w(TAG, "extractApkCertHashes: getPackageArchiveInfo returned null for $apkPath") - return null - } - - val signingInfo = packageInfo.signingInfo - if (signingInfo == null) { - Log.w(TAG, "extractApkCertHashes: signingInfo is null for $apkPath") - return null - } - - val signers = signingInfo.currentSigners() - if (signers.isEmpty()) { - Log.w(TAG, "extractApkCertHashes: currentSigners() is empty for $apkPath " + - "(hasMultipleSigners=${signingInfo.hasMultipleSigners()}, " + - "apkContentsSigners=${signingInfo.apkContentsSigners?.size ?: "null"}, " + - "historySize=${signingInfo.signingCertificateHistory?.size ?: "null"})") - return null - } - - signers.map { it.sha256Hex() } - } catch (e: Exception) { - Log.w(TAG, "Failed to extract APK cert hashes from $apkPath: ${e.message}") - null - } + return extractApkSigners(apkPath)?.map { it.sha256Hex() } } // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 87f49b9..cd2bacc 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -145,14 +145,38 @@ class _AppDetailContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final showDebugSections = kDebugMode || isDebugMode(signedInPubkey); + final latestRelease = app.latestRelease.value; + final latestMetadata = app.installable; + final certificateHashes = + latestMetadata?.certificateHashes ?? const {}; + + final identityProofState = certificateHashes.isEmpty + ? null + : ref.watch( + query( + tags: {'#d': certificateHashes}, + limit: 10, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-detail-c1-${app.identifier}', + ), + ); + final identityProof = identityProofState?.models + .where((proof) => proof.isActive) + .sortedBy((proof) => proof.createdAt) + .lastOrNull; + final publisherPubkey = app.isRelaySigned && identityProof != null + ? identityProof.pubkey + : app.pubkey; // Query author profile from social relays final authorState = ref.watch( query( - authors: {app.pubkey}, + authors: {app.pubkey, if (identityProof != null) identityProof.pubkey}, source: const LocalAndRemoteSource( relays: {'social', 'vertex'}, cachedFor: Duration(hours: 2), @@ -160,10 +184,11 @@ class _AppDetailContent extends HookConsumerWidget { subscriptionPrefix: 'app-detail-profile', ), ); - final author = authorState.models.firstOrNull; - - final latestRelease = app.latestRelease.value; - final latestMetadata = app.installable; + final author = authorState.models.firstWhereOrNull( + (profile) => profile.pubkey == publisherPubkey, + ); + final isAuthorLoading = + authorState is StorageLoading && author == null; return Scaffold( body: SafeArea( @@ -190,25 +215,31 @@ class _AppDetailContent extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (app.isRelaySigned && latestMetadata != null) + if (app.isRelaySigned && + identityProof == null && + latestMetadata != null) Padding( padding: const EdgeInsets.all(4), child: DownloadTextContainer( url: latestMetadata.urls.first, size: 14, onTap: app.repository != null - ? () => launchUrl( - Uri.parse(app.repository!)) + ? () => launchUrl(Uri.parse(app.repository!)) : null, ), ) - else if (author != null) + else if (!app.isRelaySigned || identityProof != null) AuthorContainer( profile: author, - beforeText: 'Published by', + pubkey: publisherPubkey, + beforeText: + !app.isRelaySigned || identityProof == null + ? 'Published by' + : 'Claimed by', oneLine: true, size: 14, - app: app, + app: identityProof == null ? app : null, + isLoading: isAuthorLoading, onTap: () { final segments = GoRouterState.of( context, @@ -216,9 +247,7 @@ class _AppDetailContent extends HookConsumerWidget { final first = segments.isNotEmpty ? segments.first : 'search'; - context.push( - '/$first/user/${author.pubkey}', - ); + context.push('/$first/user/$publisherPubkey'); }, ) else @@ -325,9 +354,8 @@ class _AppDetailContent extends HookConsumerWidget { vertical: 8, ), decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.05), + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -341,17 +369,20 @@ class _AppDetailContent extends HookConsumerWidget { Text( latestMetadata.version, style: context.textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), + ?.copyWith( + fontWeight: FontWeight.bold, + ), ), Gap(4), Text( '(${formatDate(latestMetadata.createdAt)})', - style: context.textTheme.bodyMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), - ), + style: context.textTheme.bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), ), ], ), @@ -376,7 +407,13 @@ class _AppDetailContent extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: AppInfoTable(app: app, fileMetadata: latestMetadata), + child: AppInfoTable( + app: app, + fileMetadata: latestMetadata, + identityProof: identityProof, + identityProfile: author, + isIdentityProfileLoading: isAuthorLoading, + ), ), if (latestMetadata != null) @@ -400,14 +437,12 @@ class _AppDetailContent extends HookConsumerWidget { // Floating three-dot menu FloatingOverflowMenu( shareUrl: getAppShareUrl(app), - publisherPubkey: app.pubkey, + publisherPubkey: publisherPubkey, app: app, ), - ], ), ), ); } - } diff --git a/lib/services/background_auto_update_executor.dart b/lib/services/background_auto_update_executor.dart index bd1c913..d3083e1 100644 --- a/lib/services/background_auto_update_executor.dart +++ b/lib/services/background_auto_update_executor.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:models/models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:zapstore/services/c1_proof_verification.dart'; import 'package:zapstore/services/background_native_installer.dart'; import 'package:zapstore/services/background_pending_install_store.dart'; import 'package:zapstore/services/log_service.dart'; @@ -69,6 +70,7 @@ class BackgroundAutoUpdateExecutor { filePath: filePath, expectedHash: target.hash, expectedCertHashes: target.certificateHashes.toList(), + c1Proof: (await c1ProofPayloadForInstallable(target))?.toMap(), ); if (!verified) { await _deleteFile(filePath); @@ -83,6 +85,7 @@ class BackgroundAutoUpdateExecutor { expectedHash: target.hash, expectedSize: target.size ?? 0, expectedCertHashes: target.certificateHashes.toList(), + c1Proof: (await c1ProofPayloadForInstallable(target))?.toMap(), ); if (result.success) { updatedAppIds.add(appId); diff --git a/lib/services/background_native_installer.dart b/lib/services/background_native_installer.dart index b08fbd1..252ba35 100644 --- a/lib/services/background_native_installer.dart +++ b/lib/services/background_native_installer.dart @@ -10,16 +10,16 @@ class BackgroundNativeInstaller { required String filePath, required String expectedHash, required List expectedCertHashes, + Map? c1Proof, }) async { try { - final result = await _channel.invokeMethod>( - 'verifyApk', - { - 'filePath': filePath, - 'expectedHash': expectedHash, - 'expectedCertHashes': expectedCertHashes, - }, - ); + final result = await _channel + .invokeMethod>('verifyApk', { + 'filePath': filePath, + 'expectedHash': expectedHash, + 'expectedCertHashes': expectedCertHashes, + 'c1Proof': c1Proof, + }); final map = Map.from(result ?? {}); return map['valid'] == true; } catch (e, st) { @@ -41,18 +41,18 @@ class BackgroundNativeInstaller { required String expectedHash, required int expectedSize, required List expectedCertHashes, + Map? c1Proof, }) async { try { - final result = await _channel.invokeMethod>( - 'installAndAwait', - { - 'filePath': filePath, - 'packageName': appId, - 'expectedHash': expectedHash, - 'expectedSize': expectedSize, - 'expectedCertHashes': expectedCertHashes, - }, - ); + final result = await _channel + .invokeMethod>('installAndAwait', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'expectedCertHashes': expectedCertHashes, + 'c1Proof': c1Proof, + }); final map = Map.from(result ?? {}); return BackgroundInstallResult( success: map['success'] == true, @@ -79,18 +79,18 @@ class BackgroundNativeInstaller { required String expectedHash, required int expectedSize, required List expectedCertHashes, + Map? c1Proof, }) async { try { - final result = await _channel.invokeMethod>( - 'install', - { - 'filePath': filePath, - 'packageName': appId, - 'expectedHash': expectedHash, - 'expectedSize': expectedSize, - 'expectedCertHashes': expectedCertHashes, - }, - ); + final result = await _channel + .invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'expectedCertHashes': expectedCertHashes, + 'c1Proof': c1Proof, + }); final map = Map.from(result ?? {}); return map['started'] == true || map['alreadyInProgress'] == true; } catch (e, st) { diff --git a/lib/services/c1_proof_verification.dart b/lib/services/c1_proof_verification.dart new file mode 100644 index 0000000..61e8120 --- /dev/null +++ b/lib/services/c1_proof_verification.dart @@ -0,0 +1,66 @@ +import 'package:models/models.dart'; +import 'package:zapstore/utils/extensions.dart'; + +class C1ProofVerificationPayload { + const C1ProofVerificationPayload({ + required this.pubkey, + required this.certificateHash, + required this.signature, + required this.createdAt, + required this.expiry, + }); + + final String pubkey; + final String certificateHash; + final String signature; + final DateTime createdAt; + final DateTime expiry; + + Map toMap() => { + 'pubkey': pubkey, + 'certificateHash': certificateHash, + 'signature': signature, + 'createdAt': createdAt.millisecondsSinceEpoch ~/ 1000, + 'expiry': expiry.millisecondsSinceEpoch ~/ 1000, + }; +} + +Future c1ProofPayloadForInstallable( + Installable target, +) async { + final certificateHashes = target.certificateHashes; + if (certificateHashes.isEmpty || target is! Model) return null; + final model = target as Model; + + final List proofs; + try { + proofs = await model.storage.query( + RequestFilter( + tags: {'#d': certificateHashes}, + limit: 10, + ).toRequest(), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'install-c1-${model.id}', + ); + } catch (_) { + return null; + } + + final activeProofs = + proofs + .where((proof) => proof.isActive && proof.signature != null) + .toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final proof = activeProofs.isEmpty ? null : activeProofs.last; + final expiry = proof?.expiry; + final signature = proof?.signature; + if (proof == null || expiry == null || signature == null) return null; + + return C1ProofVerificationPayload( + pubkey: proof.pubkey, + certificateHash: proof.certificateHash, + signature: signature, + createdAt: proof.createdAt, + expiry: expiry, + ); +} diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index b6dc1a5..8087e86 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:models/models.dart'; +import 'package:zapstore/services/c1_proof_verification.dart'; import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; @@ -51,6 +52,7 @@ class NativeErrorCode { static const installFailed = 'installFailed'; static const certMismatch = 'certMismatch'; static const certMetadataMismatch = 'certMetadataMismatch'; + static const c1ProofMismatch = 'c1ProofMismatch'; static const permissionDenied = 'permissionDenied'; static const insufficientStorage = 'insufficientStorage'; static const incompatible = 'incompatible'; @@ -100,11 +102,7 @@ final class AndroidPackageManager extends PackageManager { _eventSubscription = _eventChannel.receiveBroadcastStream().listen( _handleInstallEvent, onError: (e) { - LogService.I.warn( - 'EventChannel error', - tag: 'package_manager', - err: e, - ); + LogService.I.warn('EventChannel error', tag: 'package_manager', err: e); _attemptEventStreamReconnect(); }, onDone: () { @@ -201,8 +199,7 @@ final class AndroidPackageManager extends PackageManager { if (existingOp is Completed) { return; } - if (existingOp is OperationFailed && - status != InstallStatus.success) { + if (existingOp is OperationFailed && status != InstallStatus.success) { return; } @@ -285,8 +282,9 @@ final class AndroidPackageManager extends PackageManager { // CRITICAL: When transitioning from Installing, preserve the original // startedAt so the Dart watchdog doesn't reset its countdown. if (filePath != null && existingOp is! SystemProcessing) { - final preservedStart = - existingOp is Installing ? existingOp.startedAt : null; + final preservedStart = existingOp is Installing + ? existingOp.startedAt + : null; setOperation( appId, SystemProcessing( @@ -395,7 +393,9 @@ final class AndroidPackageManager extends PackageManager { NativeErrorCode.invalidFile => FailureType.invalidFile, NativeErrorCode.installFailed => FailureType.installFailed, NativeErrorCode.certMismatch => FailureType.certMismatch, - NativeErrorCode.certMetadataMismatch => FailureType.certMetadataMismatch, + NativeErrorCode.certMetadataMismatch => + FailureType.certMetadataMismatch, + NativeErrorCode.c1ProofMismatch => FailureType.c1ProofMismatch, NativeErrorCode.permissionDenied => FailureType.permissionDenied, NativeErrorCode.insufficientStorage => FailureType.insufficientStorage, NativeErrorCode.incompatible => FailureType.incompatible, @@ -447,6 +447,8 @@ final class AndroidPackageManager extends PackageManager { '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.c1ProofMismatch => + 'Cryptographic identity proof failed. This file may not be from the claimed developer.', FailureType.permissionDenied => 'Permission required. Please grant install permission and try again.', FailureType.insufficientStorage => @@ -511,6 +513,7 @@ final class AndroidPackageManager extends PackageManager { // VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events. try { + final c1Proof = await c1ProofPayloadForInstallable(target); final result = await _methodChannel .invokeMethod>('install', { 'filePath': filePath, @@ -518,6 +521,7 @@ final class AndroidPackageManager extends PackageManager { 'expectedHash': expectedHash, 'expectedSize': expectedSize, 'expectedCertHashes': target.certificateHashes.toList(), + 'c1Proof': c1Proof?.toMap(), }) .timeout(const Duration(seconds: 30), onTimeout: () => null); @@ -786,9 +790,7 @@ final class AndroidPackageManager extends PackageManager { // Only use versionCode comparison — version string matching could // give false positives when the same string maps to different builds. final completed = - targetVc != null && - installedVc != null && - installedVc >= targetVc; + targetVc != null && installedVc != null && installedVc >= targetVc; // Only clear if we can establish completion reliably. // Transition to Completed (not clearOperation) so the op stays until diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 3c07a98..696cb09 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -191,8 +191,11 @@ class Completed extends InstallOperation { /// Whether this was an update (app was already installed) or a new install. final bool isUpdate; - Completed({required super.target, DateTime? completedAt, this.isUpdate = false}) - : completedAt = completedAt ?? DateTime.now(); + Completed({ + required super.target, + DateTime? completedAt, + this.isUpdate = false, + }) : completedAt = completedAt ?? DateTime.now(); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -213,7 +216,6 @@ class OperationFailed extends InstallOperation { this.description, this.filePath, }); - } /// Types of failures that can occur during install operations @@ -236,6 +238,9 @@ enum FailureType { /// APK signing certificate does not match what the publisher declared in Nostr metadata certMetadataMismatch, + /// NIP-C1 proof did not verify against the downloaded APK signing certificate + c1ProofMismatch, + /// User doesn't have install permission permissionDenied, diff --git a/lib/widgets/app_info_table.dart b/lib/widgets/app_info_table.dart index 840596f..09e5375 100644 --- a/lib/widgets/app_info_table.dart +++ b/lib/widgets/app_info_table.dart @@ -7,14 +7,25 @@ import 'package:models/models.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/app_detail_widgets.dart'; +import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/download_text_container.dart'; import 'package:zapstore/theme.dart'; class AppInfoTable extends HookConsumerWidget { - const AppInfoTable({super.key, required this.app, this.fileMetadata}); + const AppInfoTable({ + super.key, + required this.app, + this.fileMetadata, + this.identityProof, + this.identityProfile, + this.isIdentityProfileLoading = false, + }); final App app; final Installable? fileMetadata; + final CryptographicIdentityProof? identityProof; + final Profile? identityProfile; + final bool isIdentityProfileLoading; @override Widget build(BuildContext context, WidgetRef ref) { @@ -75,6 +86,32 @@ class AppInfoTable extends HookConsumerWidget { rows.add(_InfoRow(label: 'App ID', value: app.identifier)); + if (identityProof != null) { + final npub = Utils.encodeShareableFromString( + identityProof!.pubkey, + type: 'npub', + ); + rows.add( + _InfoRow( + label: 'Linked identity', + value: npub.abbreviateNpub(), + copyValue: npub, + valueWidget: Flexible( + child: Align( + alignment: Alignment.centerRight, + child: AuthorContainer( + profile: identityProfile, + pubkey: identityProof!.pubkey, + oneLine: true, + size: context.textTheme.bodyMedium?.fontSize, + isLoading: isIdentityProfileLoading, + ), + ), + ), + ), + ); + } + if (!app.isRelaySigned) { // Author npub final npub = Utils.encodeShareableFromString(app.pubkey, type: 'npub'); @@ -91,7 +128,11 @@ class AppInfoTable extends HookConsumerWidget { if (fileMetadata!.hash.isNotEmpty) { final full = fileMetadata!.hash; rows.add( - _InfoRow(label: 'File hash', value: full.abbreviate(), copyValue: full), + _InfoRow( + label: 'File hash', + value: full.abbreviate(), + copyValue: full, + ), ); } diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 7294a08..6afede9 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -659,6 +659,7 @@ class InstallButton extends ConsumerWidget { // Show toast for errors with technical details or specific types if (operation.description != null || operation.type == FailureType.certMismatch || + operation.type == FailureType.c1ProofMismatch || operation.type == FailureType.incompatible) { _showErrorDetails(context, operation); } @@ -692,7 +693,8 @@ class InstallButton extends ConsumerWidget { Installable fileMetadata, ) async { final currentVersion = installedPkg?.version ?? 'Unknown'; - final currentCertHash = installedPkg?.signatureHashes.firstOrNull ?? 'Unknown'; + final currentCertHash = + installedPkg?.signatureHashes.firstOrNull ?? 'Unknown'; final updateCertHash = fileMetadata.certificateHash ?? 'Unknown'; final updateVersion = fileMetadata.version; final author = app.author.value;