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.os.UserManager
import android.provider.Settings import android.provider.Settings
import android.system.Os import android.system.Os
import android.util.Base64
import android.util.Log import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner 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
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -72,6 +75,7 @@ object ErrorCode {
const val INSTALL_FAILED = "installFailed" const val INSTALL_FAILED = "installFailed"
const val CERT_MISMATCH = "certMismatch" const val CERT_MISMATCH = "certMismatch"
const val CERT_METADATA_MISMATCH = "certMetadataMismatch" const val CERT_METADATA_MISMATCH = "certMetadataMismatch"
const val C1_PROOF_MISMATCH = "c1ProofMismatch"
const val PERMISSION_DENIED = "permissionDenied" const val PERMISSION_DENIED = "permissionDenied"
const val INSUFFICIENT_STORAGE = "insufficientStorage" const val INSUFFICIENT_STORAGE = "insufficientStorage"
const val INCOMPATIBLE = "incompatible" const val INCOMPATIBLE = "incompatible"
@@ -80,6 +84,14 @@ object ErrorCode {
const val INSTALL_TIMEOUT = "installTimeout" 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. * AndroidPackageManagerPlugin - Event-driven architecture for clean state management.
* *
@@ -652,12 +664,13 @@ class AndroidPackageManagerPlugin :
val expectedSize = call.argument<Number>("expectedSize")?.toLong() val expectedSize = call.argument<Number>("expectedSize")?.toLong()
val expectedCertHashes = val expectedCertHashes =
call.argument<List<String>>("expectedCertHashes") ?: emptyList() call.argument<List<String>>("expectedCertHashes") ?: emptyList()
val c1Proof = parseC1Proof(call.argument<Map<*, *>>("c1Proof"))
if (filePath == null || packageName == null) { if (filePath == null || packageName == null) {
result.error("MISSING_ARGUMENT", "filePath and packageName required", null) result.error("MISSING_ARGUMENT", "filePath and packageName required", null)
return return
} }
installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, result) installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, c1Proof, result)
} }
"canInstallSilently" -> { "canInstallSilently" -> {
val packageName = call.argument<String>("packageName") val packageName = call.argument<String>("packageName")
@@ -698,11 +711,12 @@ class AndroidPackageManagerPlugin :
val expectedHash = call.argument<String>("expectedHash") val expectedHash = call.argument<String>("expectedHash")
val expectedCertHashes = val expectedCertHashes =
call.argument<List<String>>("expectedCertHashes") ?: emptyList() call.argument<List<String>>("expectedCertHashes") ?: emptyList()
val c1Proof = parseC1Proof(call.argument<Map<*, *>>("c1Proof"))
if (filePath == null || expectedHash == null) { if (filePath == null || expectedHash == null) {
result.error("MISSING_ARGUMENT", "filePath and expectedHash required", null) result.error("MISSING_ARGUMENT", "filePath and expectedHash required", null)
return return
} }
verifyApkOnly(filePath, expectedHash, expectedCertHashes, result) verifyApkOnly(filePath, expectedHash, expectedCertHashes, c1Proof, result)
} }
"installAndAwait" -> { "installAndAwait" -> {
val filePath = call.argument<String>("filePath") val filePath = call.argument<String>("filePath")
@@ -711,6 +725,7 @@ class AndroidPackageManagerPlugin :
val expectedSize = call.argument<Number>("expectedSize")?.toLong() val expectedSize = call.argument<Number>("expectedSize")?.toLong()
val expectedCertHashes = val expectedCertHashes =
call.argument<List<String>>("expectedCertHashes") ?: emptyList() call.argument<List<String>>("expectedCertHashes") ?: emptyList()
val c1Proof = parseC1Proof(call.argument<Map<*, *>>("c1Proof"))
if (filePath == null || packageName == null) { if (filePath == null || packageName == null) {
result.error("MISSING_ARGUMENT", "filePath and packageName required", null) result.error("MISSING_ARGUMENT", "filePath and packageName required", null)
@@ -722,6 +737,7 @@ class AndroidPackageManagerPlugin :
expectedHash, expectedHash,
expectedSize, expectedSize,
expectedCertHashes, expectedCertHashes,
c1Proof,
result result
) )
} }
@@ -737,6 +753,7 @@ class AndroidPackageManagerPlugin :
filePath: String, filePath: String,
expectedHash: String, expectedHash: String,
expectedCertHashes: List<String>, expectedCertHashes: List<String>,
c1Proof: C1Proof?,
result: Result result: Result
) { ) {
Thread { Thread {
@@ -784,6 +801,21 @@ class AndroidPackageManagerPlugin :
return@Thread 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") val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { fis -> FileInputStream(file).use { fis ->
val buffer = ByteArray(COPY_BUFFER_SIZE) val buffer = ByteArray(COPY_BUFFER_SIZE)
@@ -809,12 +841,13 @@ class AndroidPackageManagerPlugin :
expectedHash: String?, expectedHash: String?,
expectedSize: Long?, expectedSize: Long?,
expectedCertHashes: List<String>, expectedCertHashes: List<String>,
c1Proof: C1Proof?,
result: Result result: Result
) { ) {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
awaitLatches[packageName] = latch 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?) { override fun success(response: Any?) {
val responseMap = val responseMap =
(response as? Map<*, *>)?.entries?.associate { (response as? Map<*, *>)?.entries?.associate {
@@ -871,6 +904,7 @@ class AndroidPackageManagerPlugin :
expectedHash: String?, expectedHash: String?,
expectedSize: Long?, expectedSize: Long?,
expectedCertHashes: List<String>, expectedCertHashes: List<String>,
c1Proof: C1Proof?,
result: Result result: Result
) { ) {
val file = File(filePath) val file = File(filePath)
@@ -942,7 +976,7 @@ class AndroidPackageManagerPlugin :
// All heavy I/O work runs on background thread to prevent ANRs // All heavy I/O work runs on background thread to prevent ANRs
val t = Thread { val t = Thread {
verifyAndInstall(file, packageName, expectedHash, expectedSize, expectedCertHashes) verifyAndInstall(file, packageName, expectedHash, expectedSize, expectedCertHashes, c1Proof)
verificationThreads.remove(packageName) verificationThreads.remove(packageName)
} }
verificationThreads[packageName] = t verificationThreads[packageName] = t
@@ -970,7 +1004,8 @@ class AndroidPackageManagerPlugin :
packageName: String, packageName: String,
expectedHash: String?, expectedHash: String?,
expectedSize: Long?, expectedSize: Long?,
expectedCertHashes: List<String> expectedCertHashes: List<String>,
c1Proof: C1Proof?
) { ) {
// Step 1: Validate APK format before doing any heavy work // Step 1: Validate APK format before doing any heavy work
if (!isValidApkFormat(apkFile)) { 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 // Step 3: Check if this is an update
val isUpdate = val isUpdate =
try { try {
@@ -1248,6 +1300,103 @@ class AndroidPackageManagerPlugin :
.joinToString("") { "%02x".format(it) } .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. * 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). * Returns null if extraction fails (treat as verification error, not skip).
*/ */
private fun extractApkCertHashes(apkPath: String): List<String>? { private fun extractApkCertHashes(apkPath: String): List<String>? {
return try { return extractApkSigners(apkPath)?.map { it.sha256Hex() }
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
}
} }
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
+64 -29
View File
@@ -145,14 +145,38 @@ class _AppDetailContent extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final showDebugSections = kDebugMode || isDebugMode(signedInPubkey); 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 // Query author profile from social relays
final authorState = ref.watch( final authorState = ref.watch(
query<Profile>( query<Profile>(
authors: {app.pubkey}, authors: {app.pubkey, if (identityProof != null) identityProof.pubkey},
source: const LocalAndRemoteSource( source: const LocalAndRemoteSource(
relays: {'social', 'vertex'}, relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2), cachedFor: Duration(hours: 2),
@@ -160,10 +184,11 @@ class _AppDetailContent extends HookConsumerWidget {
subscriptionPrefix: 'app-detail-profile', subscriptionPrefix: 'app-detail-profile',
), ),
); );
final author = authorState.models.firstOrNull; final author = authorState.models.firstWhereOrNull(
(profile) => profile.pubkey == publisherPubkey,
final latestRelease = app.latestRelease.value; );
final latestMetadata = app.installable; final isAuthorLoading =
authorState is StorageLoading<Profile> && author == null;
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
@@ -190,25 +215,31 @@ class _AppDetailContent extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (app.isRelaySigned && latestMetadata != null) if (app.isRelaySigned &&
identityProof == null &&
latestMetadata != null)
Padding( Padding(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
child: DownloadTextContainer( child: DownloadTextContainer(
url: latestMetadata.urls.first, url: latestMetadata.urls.first,
size: 14, size: 14,
onTap: app.repository != null onTap: app.repository != null
? () => launchUrl( ? () => launchUrl(Uri.parse(app.repository!))
Uri.parse(app.repository!))
: null, : null,
), ),
) )
else if (author != null) else if (!app.isRelaySigned || identityProof != null)
AuthorContainer( AuthorContainer(
profile: author, profile: author,
beforeText: 'Published by', pubkey: publisherPubkey,
beforeText:
!app.isRelaySigned || identityProof == null
? 'Published by'
: 'Claimed by',
oneLine: true, oneLine: true,
size: 14, size: 14,
app: app, app: identityProof == null ? app : null,
isLoading: isAuthorLoading,
onTap: () { onTap: () {
final segments = GoRouterState.of( final segments = GoRouterState.of(
context, context,
@@ -216,9 +247,7 @@ class _AppDetailContent extends HookConsumerWidget {
final first = segments.isNotEmpty final first = segments.isNotEmpty
? segments.first ? segments.first
: 'search'; : 'search';
context.push( context.push('/$first/user/$publisherPubkey');
'/$first/user/${author.pubkey}',
);
}, },
) )
else else
@@ -325,9 +354,8 @@ class _AppDetailContent extends HookConsumerWidget {
vertical: 8, vertical: 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(context).colorScheme.onSurface
context, .withValues(alpha: 0.05),
).colorScheme.onSurface.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -341,17 +369,20 @@ class _AppDetailContent extends HookConsumerWidget {
Text( Text(
latestMetadata.version, latestMetadata.version,
style: context.textTheme.bodyMedium style: context.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(
fontWeight: FontWeight.bold,
),
), ),
Gap(4), Gap(4),
Text( Text(
'(${formatDate(latestMetadata.createdAt)})', '(${formatDate(latestMetadata.createdAt)})',
style: context.textTheme.bodyMedium?.copyWith( style: context.textTheme.bodyMedium
color: Theme.of(context) ?.copyWith(
.colorScheme color: Theme.of(context)
.onSurface .colorScheme
.withValues(alpha: 0.6), .onSurface
), .withValues(alpha: 0.6),
),
), ),
], ],
), ),
@@ -376,7 +407,13 @@ class _AppDetailContent extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), 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) if (latestMetadata != null)
@@ -400,14 +437,12 @@ class _AppDetailContent extends HookConsumerWidget {
// Floating three-dot menu // Floating three-dot menu
FloatingOverflowMenu( FloatingOverflowMenu(
shareUrl: getAppShareUrl(app), shareUrl: getAppShareUrl(app),
publisherPubkey: app.pubkey, publisherPubkey: publisherPubkey,
app: app, app: app,
), ),
], ],
), ),
), ),
); );
} }
} }
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; 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_native_installer.dart';
import 'package:zapstore/services/background_pending_install_store.dart'; import 'package:zapstore/services/background_pending_install_store.dart';
import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/log_service.dart';
@@ -69,6 +70,7 @@ class BackgroundAutoUpdateExecutor {
filePath: filePath, filePath: filePath,
expectedHash: target.hash, expectedHash: target.hash,
expectedCertHashes: target.certificateHashes.toList(), expectedCertHashes: target.certificateHashes.toList(),
c1Proof: (await c1ProofPayloadForInstallable(target))?.toMap(),
); );
if (!verified) { if (!verified) {
await _deleteFile(filePath); await _deleteFile(filePath);
@@ -83,6 +85,7 @@ class BackgroundAutoUpdateExecutor {
expectedHash: target.hash, expectedHash: target.hash,
expectedSize: target.size ?? 0, expectedSize: target.size ?? 0,
expectedCertHashes: target.certificateHashes.toList(), expectedCertHashes: target.certificateHashes.toList(),
c1Proof: (await c1ProofPayloadForInstallable(target))?.toMap(),
); );
if (result.success) { if (result.success) {
updatedAppIds.add(appId); updatedAppIds.add(appId);
+28 -28
View File
@@ -10,16 +10,16 @@ class BackgroundNativeInstaller {
required String filePath, required String filePath,
required String expectedHash, required String expectedHash,
required List<String> expectedCertHashes, required List<String> expectedCertHashes,
Map<String, Object>? c1Proof,
}) async { }) async {
try { try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>( final result = await _channel
'verifyApk', .invokeMethod<Map<Object?, Object?>>('verifyApk', {
{ 'filePath': filePath,
'filePath': filePath, 'expectedHash': expectedHash,
'expectedHash': expectedHash, 'expectedCertHashes': expectedCertHashes,
'expectedCertHashes': expectedCertHashes, 'c1Proof': c1Proof,
}, });
);
final map = Map<String, dynamic>.from(result ?? {}); final map = Map<String, dynamic>.from(result ?? {});
return map['valid'] == true; return map['valid'] == true;
} catch (e, st) { } catch (e, st) {
@@ -41,18 +41,18 @@ class BackgroundNativeInstaller {
required String expectedHash, required String expectedHash,
required int expectedSize, required int expectedSize,
required List<String> expectedCertHashes, required List<String> expectedCertHashes,
Map<String, Object>? c1Proof,
}) async { }) async {
try { try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>( final result = await _channel
'installAndAwait', .invokeMethod<Map<Object?, Object?>>('installAndAwait', {
{ 'filePath': filePath,
'filePath': filePath, 'packageName': appId,
'packageName': appId, 'expectedHash': expectedHash,
'expectedHash': expectedHash, 'expectedSize': expectedSize,
'expectedSize': expectedSize, 'expectedCertHashes': expectedCertHashes,
'expectedCertHashes': expectedCertHashes, 'c1Proof': c1Proof,
}, });
);
final map = Map<String, dynamic>.from(result ?? {}); final map = Map<String, dynamic>.from(result ?? {});
return BackgroundInstallResult( return BackgroundInstallResult(
success: map['success'] == true, success: map['success'] == true,
@@ -79,18 +79,18 @@ class BackgroundNativeInstaller {
required String expectedHash, required String expectedHash,
required int expectedSize, required int expectedSize,
required List<String> expectedCertHashes, required List<String> expectedCertHashes,
Map<String, Object>? c1Proof,
}) async { }) async {
try { try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>( final result = await _channel
'install', .invokeMethod<Map<Object?, Object?>>('install', {
{ 'filePath': filePath,
'filePath': filePath, 'packageName': appId,
'packageName': appId, 'expectedHash': expectedHash,
'expectedHash': expectedHash, 'expectedSize': expectedSize,
'expectedSize': expectedSize, 'expectedCertHashes': expectedCertHashes,
'expectedCertHashes': expectedCertHashes, 'c1Proof': c1Proof,
}, });
);
final map = Map<String, dynamic>.from(result ?? {}); final map = Map<String, dynamic>.from(result ?? {});
return map['started'] == true || map['alreadyInProgress'] == true; return map['started'] == true || map['alreadyInProgress'] == true;
} catch (e, st) { } 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:flutter/services.dart';
import 'package:models/models.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/log_service.dart';
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
@@ -51,6 +52,7 @@ class NativeErrorCode {
static const installFailed = 'installFailed'; static const installFailed = 'installFailed';
static const certMismatch = 'certMismatch'; static const certMismatch = 'certMismatch';
static const certMetadataMismatch = 'certMetadataMismatch'; static const certMetadataMismatch = 'certMetadataMismatch';
static const c1ProofMismatch = 'c1ProofMismatch';
static const permissionDenied = 'permissionDenied'; static const permissionDenied = 'permissionDenied';
static const insufficientStorage = 'insufficientStorage'; static const insufficientStorage = 'insufficientStorage';
static const incompatible = 'incompatible'; static const incompatible = 'incompatible';
@@ -100,11 +102,7 @@ final class AndroidPackageManager extends PackageManager {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen( _eventSubscription = _eventChannel.receiveBroadcastStream().listen(
_handleInstallEvent, _handleInstallEvent,
onError: (e) { onError: (e) {
LogService.I.warn( LogService.I.warn('EventChannel error', tag: 'package_manager', err: e);
'EventChannel error',
tag: 'package_manager',
err: e,
);
_attemptEventStreamReconnect(); _attemptEventStreamReconnect();
}, },
onDone: () { onDone: () {
@@ -201,8 +199,7 @@ final class AndroidPackageManager extends PackageManager {
if (existingOp is Completed) { if (existingOp is Completed) {
return; return;
} }
if (existingOp is OperationFailed && if (existingOp is OperationFailed && status != InstallStatus.success) {
status != InstallStatus.success) {
return; return;
} }
@@ -285,8 +282,9 @@ final class AndroidPackageManager extends PackageManager {
// CRITICAL: When transitioning from Installing, preserve the original // CRITICAL: When transitioning from Installing, preserve the original
// startedAt so the Dart watchdog doesn't reset its countdown. // startedAt so the Dart watchdog doesn't reset its countdown.
if (filePath != null && existingOp is! SystemProcessing) { if (filePath != null && existingOp is! SystemProcessing) {
final preservedStart = final preservedStart = existingOp is Installing
existingOp is Installing ? existingOp.startedAt : null; ? existingOp.startedAt
: null;
setOperation( setOperation(
appId, appId,
SystemProcessing( SystemProcessing(
@@ -395,7 +393,9 @@ final class AndroidPackageManager extends PackageManager {
NativeErrorCode.invalidFile => FailureType.invalidFile, NativeErrorCode.invalidFile => FailureType.invalidFile,
NativeErrorCode.installFailed => FailureType.installFailed, NativeErrorCode.installFailed => FailureType.installFailed,
NativeErrorCode.certMismatch => FailureType.certMismatch, NativeErrorCode.certMismatch => FailureType.certMismatch,
NativeErrorCode.certMetadataMismatch => FailureType.certMetadataMismatch, NativeErrorCode.certMetadataMismatch =>
FailureType.certMetadataMismatch,
NativeErrorCode.c1ProofMismatch => FailureType.c1ProofMismatch,
NativeErrorCode.permissionDenied => FailureType.permissionDenied, NativeErrorCode.permissionDenied => FailureType.permissionDenied,
NativeErrorCode.insufficientStorage => FailureType.insufficientStorage, NativeErrorCode.insufficientStorage => FailureType.insufficientStorage,
NativeErrorCode.incompatible => FailureType.incompatible, NativeErrorCode.incompatible => FailureType.incompatible,
@@ -447,6 +447,8 @@ final class AndroidPackageManager extends PackageManager {
'Update signed by different developer. Uninstall current version to update.', 'Update signed by different developer. Uninstall current version to update.',
FailureType.certMetadataMismatch => FailureType.certMetadataMismatch =>
'APK signing certificate does not match what the publisher declared. This file may have been tampered with.', '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 => FailureType.permissionDenied =>
'Permission required. Please grant install permission and try again.', 'Permission required. Please grant install permission and try again.',
FailureType.insufficientStorage => FailureType.insufficientStorage =>
@@ -511,6 +513,7 @@ final class AndroidPackageManager extends PackageManager {
// VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events. // VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events.
try { try {
final c1Proof = await c1ProofPayloadForInstallable(target);
final result = await _methodChannel final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('install', { .invokeMethod<Map<Object?, Object?>>('install', {
'filePath': filePath, 'filePath': filePath,
@@ -518,6 +521,7 @@ final class AndroidPackageManager extends PackageManager {
'expectedHash': expectedHash, 'expectedHash': expectedHash,
'expectedSize': expectedSize, 'expectedSize': expectedSize,
'expectedCertHashes': target.certificateHashes.toList(), 'expectedCertHashes': target.certificateHashes.toList(),
'c1Proof': c1Proof?.toMap(),
}) })
.timeout(const Duration(seconds: 30), onTimeout: () => null); .timeout(const Duration(seconds: 30), onTimeout: () => null);
@@ -786,9 +790,7 @@ final class AndroidPackageManager extends PackageManager {
// Only use versionCode comparison — version string matching could // Only use versionCode comparison — version string matching could
// give false positives when the same string maps to different builds. // give false positives when the same string maps to different builds.
final completed = final completed =
targetVc != null && targetVc != null && installedVc != null && installedVc >= targetVc;
installedVc != null &&
installedVc >= targetVc;
// Only clear if we can establish completion reliably. // Only clear if we can establish completion reliably.
// Transition to Completed (not clearOperation) so the op stays until // 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. /// Whether this was an update (app was already installed) or a new install.
final bool isUpdate; final bool isUpdate;
Completed({required super.target, DateTime? completedAt, this.isUpdate = false}) Completed({
: completedAt = completedAt ?? DateTime.now(); required super.target,
DateTime? completedAt,
this.isUpdate = false,
}) : completedAt = completedAt ?? DateTime.now();
} }
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@@ -213,7 +216,6 @@ class OperationFailed extends InstallOperation {
this.description, this.description,
this.filePath, this.filePath,
}); });
} }
/// Types of failures that can occur during install operations /// 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 /// APK signing certificate does not match what the publisher declared in Nostr metadata
certMetadataMismatch, certMetadataMismatch,
/// NIP-C1 proof did not verify against the downloaded APK signing certificate
c1ProofMismatch,
/// User doesn't have install permission /// User doesn't have install permission
permissionDenied, permissionDenied,
+43 -2
View File
@@ -7,14 +7,25 @@ import 'package:models/models.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/app_detail_widgets.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/widgets/download_text_container.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
class AppInfoTable extends HookConsumerWidget { 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 App app;
final Installable? fileMetadata; final Installable? fileMetadata;
final CryptographicIdentityProof? identityProof;
final Profile? identityProfile;
final bool isIdentityProfileLoading;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -75,6 +86,32 @@ class AppInfoTable extends HookConsumerWidget {
rows.add(_InfoRow(label: 'App ID', value: app.identifier)); 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) { if (!app.isRelaySigned) {
// Author npub // Author npub
final npub = Utils.encodeShareableFromString(app.pubkey, type: 'npub'); final npub = Utils.encodeShareableFromString(app.pubkey, type: 'npub');
@@ -91,7 +128,11 @@ class AppInfoTable extends HookConsumerWidget {
if (fileMetadata!.hash.isNotEmpty) { if (fileMetadata!.hash.isNotEmpty) {
final full = fileMetadata!.hash; final full = fileMetadata!.hash;
rows.add( 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 // Show toast for errors with technical details or specific types
if (operation.description != null || if (operation.description != null ||
operation.type == FailureType.certMismatch || operation.type == FailureType.certMismatch ||
operation.type == FailureType.c1ProofMismatch ||
operation.type == FailureType.incompatible) { operation.type == FailureType.incompatible) {
_showErrorDetails(context, operation); _showErrorDetails(context, operation);
} }
@@ -692,7 +693,8 @@ class InstallButton extends ConsumerWidget {
Installable fileMetadata, Installable fileMetadata,
) async { ) async {
final currentVersion = installedPkg?.version ?? 'Unknown'; final currentVersion = installedPkg?.version ?? 'Unknown';
final currentCertHash = installedPkg?.signatureHashes.firstOrNull ?? 'Unknown'; final currentCertHash =
installedPkg?.signatureHashes.firstOrNull ?? 'Unknown';
final updateCertHash = fileMetadata.certificateHash ?? 'Unknown'; final updateCertHash = fileMetadata.certificateHash ?? 'Unknown';
final updateVersion = fileMetadata.version; final updateVersion = fileMetadata.version;
final author = app.author.value; final author = app.author.value;