Verify C1 proof before install

This commit is contained in:
franzap
2026-06-23 18:09:22 -03:00
parent 6e5da2a979
commit 4aa45996db
9 changed files with 385 additions and 112 deletions
@@ -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<Number>("expectedSize")?.toLong()
val expectedCertHashes =
call.argument<List<String>>("expectedCertHashes") ?: emptyList()
val c1Proof = parseC1Proof(call.argument<Map<*, *>>("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<String>("packageName")
@@ -698,11 +711,12 @@ class AndroidPackageManagerPlugin :
val expectedHash = call.argument<String>("expectedHash")
val expectedCertHashes =
call.argument<List<String>>("expectedCertHashes") ?: emptyList()
val c1Proof = parseC1Proof(call.argument<Map<*, *>>("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<String>("filePath")
@@ -711,6 +725,7 @@ class AndroidPackageManagerPlugin :
val expectedSize = call.argument<Number>("expectedSize")?.toLong()
val expectedCertHashes =
call.argument<List<String>>("expectedCertHashes") ?: emptyList()
val c1Proof = parseC1Proof(call.argument<Map<*, *>>("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<String>,
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<String>,
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<String>,
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<String>
expectedCertHashes: List<String>,
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<android.content.pm.Signature>? {
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<String>? {
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() }
}
// ═══════════════════════════════════════════════════════════════════════════════
+64 -29
View File
@@ -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 <String>{};
final identityProofState = certificateHashes.isEmpty
? null
: ref.watch(
query<CryptographicIdentityProof>(
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<Profile>(
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<Profile> && 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,
),
],
),
),
);
}
}
@@ -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);
+28 -28
View File
@@ -10,16 +10,16 @@ class BackgroundNativeInstaller {
required String filePath,
required String expectedHash,
required List<String> expectedCertHashes,
Map<String, Object>? c1Proof,
}) async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'verifyApk',
{
'filePath': filePath,
'expectedHash': expectedHash,
'expectedCertHashes': expectedCertHashes,
},
);
final result = await _channel
.invokeMethod<Map<Object?, Object?>>('verifyApk', {
'filePath': filePath,
'expectedHash': expectedHash,
'expectedCertHashes': expectedCertHashes,
'c1Proof': c1Proof,
});
final map = Map<String, dynamic>.from(result ?? {});
return map['valid'] == true;
} catch (e, st) {
@@ -41,18 +41,18 @@ class BackgroundNativeInstaller {
required String expectedHash,
required int expectedSize,
required List<String> expectedCertHashes,
Map<String, Object>? c1Proof,
}) async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'installAndAwait',
{
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'expectedCertHashes': expectedCertHashes,
},
);
final result = await _channel
.invokeMethod<Map<Object?, Object?>>('installAndAwait', {
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'expectedCertHashes': expectedCertHashes,
'c1Proof': c1Proof,
});
final map = Map<String, dynamic>.from(result ?? {});
return BackgroundInstallResult(
success: map['success'] == true,
@@ -79,18 +79,18 @@ class BackgroundNativeInstaller {
required String expectedHash,
required int expectedSize,
required List<String> expectedCertHashes,
Map<String, Object>? c1Proof,
}) async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'install',
{
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'expectedCertHashes': expectedCertHashes,
},
);
final result = await _channel
.invokeMethod<Map<Object?, Object?>>('install', {
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'expectedCertHashes': expectedCertHashes,
'c1Proof': c1Proof,
});
final map = Map<String, dynamic>.from(result ?? {});
return map['started'] == true || map['alreadyInProgress'] == true;
} catch (e, st) {
+66
View File
@@ -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<String, Object> toMap() => {
'pubkey': pubkey,
'certificateHash': certificateHash,
'signature': signature,
'createdAt': createdAt.millisecondsSinceEpoch ~/ 1000,
'expiry': expiry.millisecondsSinceEpoch ~/ 1000,
};
}
Future<C1ProofVerificationPayload?> c1ProofPayloadForInstallable(
Installable target,
) async {
final certificateHashes = target.certificateHashes;
if (certificateHashes.isEmpty || target is! Model<dynamic>) return null;
final model = target as Model<dynamic>;
final List<CryptographicIdentityProof> proofs;
try {
proofs = await model.storage.query(
RequestFilter<CryptographicIdentityProof>(
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,
);
}
@@ -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<Map<Object?, Object?>>('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
@@ -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,
+43 -2
View File
@@ -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,
),
);
}
+3 -1
View File
@@ -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;