mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Verify C1 proof before install
This commit is contained in:
+155
-36
@@ -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() }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user