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.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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user