From 4d7cc80971d80e60595f9b3e4a1c049ab4ac6638 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 1 Jun 2026 07:21:12 +0530 Subject: [PATCH 01/15] Replace custom Rust FFI implementation with official c2pa-android library and embedded manifests. Specific changes: - Integrated `com.github.contentauth:c2pa-android` library to handle Content Authenticity (C2PA). - Removed `rust-c2pa-ffi` directory and associated JNI wrappers, Cargo configurations, and custom Gradle build tasks. - Implemented `ProofmodeC2paManager` to manage embedded manifest signing using software-backed EC keys and self-signed X.509 certificate chains via BouncyCastle. - Updated `CameraViewModel` to embed C2PA manifests directly into media files (JPEG/MP4) immediately after capture, replacing the previous sidecar approach. - Removed "sidecar" manifest logic (separate `.c2pa.json` files) across the application, including `C2paHelper`, `WebDavConduit`, and `FileCleanupHelper`. - Removed `C2paKeyStore` and associated security migration logic in favor of the new certificate management in `ProofmodeC2paManager`. - Added BouncyCastle dependencies (`bcprov-jdk18on`, `bcpkix-jdk18on`) to support cryptographic operations for manifest signing. - Simplified `SaveApp` and `HomeActivity` initialization logic to use the new manager. --- app/build.gradle.kts | 249 +- .../opendasharchive/openarchive/SaveApp.kt | 13 +- .../openarchive/core/di/CoreModule.kt | 3 - .../core/repositories/FileCleanupHelper.kt | 9 - .../openarchive/core/security/C2paKeyStore.kt | 63 - .../openarchive/features/main/HomeActivity.kt | 3 - .../openarchive/features/media/MediaPicker.kt | 14 - .../features/media/camera/CameraViewModel.kt | 92 +- .../openarchive/services/Conduit.kt | 33 - .../services/webdav/data/WebDavConduit.kt | 11 - .../openarchive/util/C2paFfi.kt | 145 - .../openarchive/util/C2paHelper.kt | 178 - .../openarchive/util/ProofmodeC2paManager.kt | 382 +++ rust-c2pa-ffi/.cargo/config.toml.template | 67 - rust-c2pa-ffi/Cargo.lock | 3018 ----------------- rust-c2pa-ffi/Cargo.toml | 36 - rust-c2pa-ffi/build-android.sh | 219 -- rust-c2pa-ffi/src/lib.rs | 185 - settings.gradle.kts | 1 + 19 files changed, 397 insertions(+), 4324 deletions(-) delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/C2paFfi.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/C2paHelper.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt delete mode 100644 rust-c2pa-ffi/.cargo/config.toml.template delete mode 100644 rust-c2pa-ffi/Cargo.lock delete mode 100644 rust-c2pa-ffi/Cargo.toml delete mode 100755 rust-c2pa-ffi/build-android.sh delete mode 100644 rust-c2pa-ffi/src/lib.rs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a19c17d..34b8932b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,16 +315,10 @@ dependencies { implementation(libs.tor.android) implementation(libs.jtorctl) - // C2PA - Content Authenticity - // TODO: Add actual C2PA library once available - // simple-c2pa (org.witness:simple-c2pa:0.0.13) is not available in Maven - // Options: - // 1. Use c2pa-android (https://github.com/contentauth/c2pa-android) - // 2. Use c2pa-rs directly via JNI - // 3. Build simple-c2pa from source - // For now, using stub implementation in C2paHelper - // implementation(libs.simple.c2pa) - // implementation(libs.jna) + // C2PA - Content Authenticity (contentauth/c2pa-android) + implementation("com.github.contentauth:c2pa-android:0.0.9") + implementation("org.bouncycastle:bcprov-jdk18on:1.81") + implementation("org.bouncycastle:bcpkix-jdk18on:1.81") // Utilities implementation(libs.timber) @@ -368,241 +362,6 @@ detekt { ignoreFailures = true } -// ============================================================ -// C2PA Rust FFI — Auto-build for FOSS variants -// ============================================================ -// -// Task graph (runs only for FOSS builds): -// mergeXxxJniLibFolders -// └── buildC2paRustLibs [incremental: skipped if .so files are up-to-date] -// └── generateCargoConfig [skipped if .cargo/config.toml already exists] -// └── installRustTargets [idempotent rustup target add] -// └── restoreC2paSource [skipped if Cargo.toml exists] -// -// To force a full rebuild: ./gradlew buildC2paRustLibs --rerun-tasks -// To regenerate NDK config: delete rust-c2pa-ffi/.cargo/config.toml and rebuild - -val preferredNdkVersion = "27.1.12297006" -val androidApiLevel = "30" -val rustC2paDir = rootProject.file("rust-c2pa-ffi") -val jniLibsDir = project.file("src/main/jniLibs") - -// Rust target triple → Android ABI directory name -val rustToAbi = linkedMapOf( - "aarch64-linux-android" to "arm64-v8a", - "armv7-linux-androideabi" to "armeabi-v7a", - "i686-linux-android" to "x86", - "x86_64-linux-android" to "x86_64" -) - -fun findNdk(): File { - // 1. Explicit NDK env var (highest priority) - listOfNotNull( - System.getenv("ANDROID_NDK_HOME"), - System.getenv("ANDROID_NDK_ROOT"), - ).map(::File).firstOrNull { it.isDirectory }?.let { return it } - - // 2. Find SDK, then look for ndk/ subdirectory - val sdk = listOfNotNull( - System.getenv("ANDROID_HOME"), - System.getenv("ANDROID_SDK_ROOT"), - "${System.getProperty("user.home")}/Library/Android/sdk", // macOS default - "${System.getProperty("user.home")}/Android/Sdk", // Linux/Windows default - ).map(::File).firstOrNull { it.isDirectory } - ?: throw GradleException( - "Android SDK not found.\n" + - "Set ANDROID_HOME to your SDK root directory and try again." - ) - - val ndkParent = File(sdk, "ndk") - if (!ndkParent.isDirectory) throw GradleException( - "Android NDK not found at $ndkParent.\n" + - "Install it via Android Studio → SDK Manager → SDK Tools → NDK (Side by side)." - ) - - // Prefer the pinned version; otherwise use the latest installed - File(ndkParent, preferredNdkVersion).takeIf { it.isDirectory }?.let { return it } - return ndkParent.listFiles() - ?.filter { it.isDirectory } - ?.maxByOrNull { it.name } - ?: throw GradleException("No NDK versions found in $ndkParent") -} - -fun ndkBinDir(ndk: File): File { - val prebuilt = File(ndk, "toolchains/llvm/prebuilt") - return prebuilt.listFiles() - ?.firstOrNull { it.isDirectory } - ?.let { File(it, "bin") } - ?: throw GradleException("NDK prebuilt toolchain not found under $prebuilt") -} - -/** - * Resolves a bare executable name (e.g. "cargo", "rustup") to its absolute path. - * Gradle's daemon process does not inherit the user's shell PATH, so tools installed - * in ~/.cargo/bin are not visible to a plain ProcessBuilder call. Searching common - * locations here bypasses that limitation without requiring a shell wrapper. - */ -fun resolveExe(name: String): String { - if (name.contains("/")) return name // already a path - val searchDirs = listOf( - "${System.getProperty("user.home")}/.cargo/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - ) + (System.getenv("PATH") ?: "").split(File.pathSeparatorChar) - return searchDirs - .map { File(it, name) } - .firstOrNull { it.canExecute() } - ?.absolutePath - ?: name -} - -/** Runs a command via ProcessBuilder, streams output to stdout/stderr, throws on non-zero exit. */ -fun runCommand(vararg cmd: String, workDir: File = rootProject.projectDir, env: Map = emptyMap()) { - val resolved = listOf(resolveExe(cmd[0])) + cmd.drop(1) - val pb = ProcessBuilder(resolved).directory(workDir).inheritIO() - if (env.isNotEmpty()) pb.environment().putAll(env) - val result = pb.start().waitFor() - check(result == 0) { "Command failed (exit $result): ${cmd.joinToString(" ")}" } -} - -// Maps each Rust triple to its (clang binary prefix, CC env-var key) -val targetDetails = linkedMapOf( - "aarch64-linux-android" to Pair("aarch64-linux-android${androidApiLevel}", "aarch64_linux_android"), - "armv7-linux-androideabi" to Pair("armv7a-linux-androideabi${androidApiLevel}", "armv7_linux_androideabi"), - "i686-linux-android" to Pair("i686-linux-android${androidApiLevel}", "i686_linux_android"), - "x86_64-linux-android" to Pair("x86_64-linux-android${androidApiLevel}", "x86_64_linux_android"), -) - -// ─── Task 1: Restore source from git if the directory was deleted ──────────── -val restoreC2paSource by tasks.registering { - group = "c2pa" - description = "Restores rust-c2pa-ffi/ from git HEAD if the directory is missing" - onlyIf { !File(rustC2paDir, "Cargo.toml").exists() } - doLast { - logger.lifecycle("rust-c2pa-ffi/ missing — restoring from git HEAD...") - runCommand("git", "checkout", "HEAD", "--", "rust-c2pa-ffi") - logger.lifecycle("✓ rust-c2pa-ffi/ restored from git") - } -} - -// ─── Task 2: Validate Rust toolchain; auto-install Android targets ─────────── -val installRustTargets by tasks.registering { - group = "c2pa" - description = "Verifies Cargo is installed and ensures all Android Rust targets are added" - dependsOn(restoreC2paSource) - doLast { - val cargoOk = ProcessBuilder(resolveExe("cargo"), "--version") - .redirectErrorStream(true) - .start() - .waitFor() == 0 - if (!cargoOk) throw GradleException( - "Cargo not found. Install Rust from https://rustup.rs/\n" + - "Then run: rustup target add ${rustToAbi.keys.joinToString(" ")}" - ) - // rustup target add is idempotent — safe to call on every build - runCommand(*( listOf("rustup", "target", "add") + rustToAbi.keys ).toTypedArray()) - logger.lifecycle("✓ Rust Android targets verified") - } -} - -// ─── Task 3: Generate .cargo/config.toml using the installed NDK ───────────── -// Runs on every build but only writes to disk when content changes, so -// buildC2paRustLibs (which uses the file as input) stays UP-TO-DATE when NDK is unchanged. -val generateCargoConfig by tasks.registering { - group = "c2pa" - description = "Generates rust-c2pa-ffi/.cargo/config.toml with NDK linker paths" - dependsOn(installRustTargets) - doLast { - val ndk = findNdk() - val bin = ndkBinDir(ndk) - - // NOTE: CC_*/AR_* env vars must live in the top-level [env] section. - // [target.X.env] is NOT a valid Cargo config key and is silently ignored, - // which causes cc-rs (used by the ring crate) to fail with "tool not found". - val newContent = buildString { - appendLine("# Auto-generated by Gradle — do not edit manually.") - appendLine("# Regenerate: run ./gradlew generateCargoConfig --rerun-tasks") - appendLine("# NDK: ${ndk.absolutePath}") - appendLine() - appendLine("[env]") - appendLine("ANDROID_NDK_HOME = \"${ndk.absolutePath}\"") - // CC/CXX/AR vars for cc-rs and other build scripts (one entry per ABI, all in [env]) - for ((_, pair) in targetDetails) { - val (clangPrefix, envKey) = pair - appendLine("CC_$envKey = \"$bin/${clangPrefix}-clang\"") - appendLine("CXX_$envKey = \"$bin/${clangPrefix}-clang++\"") - appendLine("AR_$envKey = \"$bin/llvm-ar\"") - } - appendLine() - for ((triple, pair) in targetDetails) { - val (clangPrefix, _) = pair - appendLine("[target.$triple]") - appendLine("linker = \"$bin/${clangPrefix}-clang\"") - appendLine("ar = \"$bin/llvm-ar\"") - appendLine("rustflags = [\"-C\", \"link-arg=-Wl,-z,max-page-size=16384\"]") - appendLine() - } - appendLine("[build]") - appendLine("target-dir = \"target\"") - } - - val configFile = File(rustC2paDir, ".cargo/config.toml") - File(rustC2paDir, ".cargo").mkdirs() - if (!configFile.exists() || configFile.readText() != newContent) { - configFile.writeText(newContent) - logger.lifecycle("✓ .cargo/config.toml written (NDK: ${ndk.absolutePath})") - } else { - logger.lifecycle("✓ .cargo/config.toml unchanged") - } - } -} - -// ─── Task 4: Compile the Rust library for all Android ABIs ─────────────────── -val buildC2paRustLibs by tasks.registering { - group = "c2pa" - description = "Compiles libc2pa_ffi.so for all Android ABIs (incremental)" - dependsOn(generateCargoConfig) - - // Gradle skips this task automatically when inputs are unchanged and outputs exist - inputs.dir(File(rustC2paDir, "src")) - inputs.files( - File(rustC2paDir, "Cargo.toml"), - File(rustC2paDir, "Cargo.lock"), - File(rustC2paDir, ".cargo/config.toml"), - ) - outputs.files(rustToAbi.values.map { abi -> jniLibsDir.resolve("$abi/libc2pa_ffi.so") }) - - doLast { - val bin = ndkBinDir(findNdk()) - rustToAbi.forEach { (rustTarget, abi) -> - logger.lifecycle(" Building $abi ($rustTarget)...") - val (clangPrefix, envKey) = targetDetails[rustTarget]!! - // Pass CC/AR explicitly — belt-and-suspenders alongside .cargo/config.toml [env] - val env = mapOf( - "CC_$envKey" to "$bin/${clangPrefix}-clang", - "CXX_$envKey" to "$bin/${clangPrefix}-clang++", - "AR_$envKey" to "$bin/llvm-ar", - ) - runCommand("cargo", "build", "--target", rustTarget, "--release", workDir = rustC2paDir, env = env) - val soFile = rustC2paDir.resolve("target/$rustTarget/release/libc2pa_ffi.so") - check(soFile.exists()) { - "cargo build succeeded but .so not found at: ${soFile.absolutePath}" - } - jniLibsDir.resolve(abi).mkdirs() - soFile.copyTo(jniLibsDir.resolve("$abi/libc2pa_ffi.so"), overwrite = true) - logger.lifecycle(" ✓ $abi/libc2pa_ffi.so (${soFile.length() / 1024} KB)") - } - logger.lifecycle("C2PA Rust FFI build complete.") - } -} - -// ─── Wire buildC2paRustLibs into ALL variant builds (GMS + FOSS) ───────────── -afterEvaluate { - tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") } - .configureEach { dependsOn(buildC2paRustLibs) } -} - // Conditionally apply Google Services plugins only for GMS builds if (gradle.startParameter.taskRequests.toString().contains("Gms", ignoreCase = true)) { apply(plugin = "com.google.gms.google-services") diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index aecb6e95..c6249759 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -31,7 +31,6 @@ import net.opendasharchive.openarchive.services.tor.TorServiceManager import kotlinx.coroutines.launch import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import net.opendasharchive.openarchive.analytics.api.session.SessionTracker -import net.opendasharchive.openarchive.core.security.C2paKeyStore import net.opendasharchive.openarchive.analytics.di.analyticsModule import net.opendasharchive.openarchive.db.AppDatabase import net.opendasharchive.openarchive.db.MigrationWorker @@ -43,7 +42,7 @@ import net.opendasharchive.openarchive.core.di.passcodeModule import net.opendasharchive.openarchive.features.settings.passcode.PasscodeGate import net.opendasharchive.openarchive.core.di.retrofitModule import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.util.C2paHelper +import net.opendasharchive.openarchive.util.ProofmodeC2paManager import net.opendasharchive.openarchive.core.repositories.MediaRepository import net.opendasharchive.openarchive.util.CleanInsightsManager import net.opendasharchive.openarchive.util.Prefs @@ -102,8 +101,8 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv Prefs.load(this) - // Initialize C2PA Helper - C2paHelper.init(this) + // Initialize ProofmodeC2paManager (generates self-signed key on first run) + ProofmodeC2paManager.init(this) // --- 2-launch synchronous migration strategy --- // L1: If Sugar DB exists and Room migration hasn't run yet, open Room directly @@ -185,12 +184,6 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv applyTheme() - // Migrate C2PA keys from plaintext SharedPreferences to SecureStorage (one-time) - val c2paKeyStore: C2paKeyStore by inject() - c2paKeyStore.migrateFromPrefsIfNeeded( - androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) - ) - // Schedule periodic cache cleanup (runs every 7 days when battery is not low) WorkManager.getInstance(this).enqueueUniquePeriodicWork( CacheCleanupWorker.WORK_NAME, diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt index ff2edaeb..63b3541b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/CoreModule.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers import android.content.SharedPreferences import androidx.preference.PreferenceManager import net.opendasharchive.openarchive.core.config.AppConfig -import net.opendasharchive.openarchive.core.security.C2paKeyStore import net.opendasharchive.openarchive.core.security.SecurityManager import net.opendasharchive.openarchive.features.core.dialog.DefaultResourceProvider import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager @@ -68,8 +67,6 @@ val coreModule = module { // Centrally managed security flags single { SecurityManager(get(named("default_prefs"))) } - // Secure storage for C2PA signing keys (Android Keystore backed) - single { C2paKeyStore(androidApplication()) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt index 833d61ae..878cee41 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/repositories/FileCleanupHelper.kt @@ -4,7 +4,6 @@ import android.content.Context import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.db.sugar.Media -import net.opendasharchive.openarchive.util.C2paHelper import java.io.File /** @@ -17,7 +16,6 @@ class FileCleanupHelper(private val context: Context) { */ fun deleteUploadedMediaFiles(evidence: Evidence) { deleteInternalMediaFile(evidence) - deleteC2paSidecar(evidence.mediaHashString) } /** @@ -42,7 +40,6 @@ class FileCleanupHelper(private val context: Context) { } } - deleteC2paSidecar(media.mediaHashString) } private fun deleteInternalMediaFile(evidence: Evidence) { @@ -58,12 +55,6 @@ class FileCleanupHelper(private val context: Context) { } } - private fun deleteC2paSidecar(mediaHashString: String) { - if (mediaHashString.isNotEmpty()) { - C2paHelper.removeC2paFiles(context, mediaHashString) - } - } - /** * Checks if a file resides within the application's internal files directory. * We only want to delete files we imported to our internal storage. diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt b/app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt deleted file mode 100644 index c802b762..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/security/C2paKeyStore.kt +++ /dev/null @@ -1,63 +0,0 @@ -package net.opendasharchive.openarchive.core.security - -import android.content.Context -import android.content.SharedPreferences -import android.util.Base64 -import net.opendasharchive.openarchive.core.security.SecureStorage - -/** - * Secure storage for C2PA signing keys using Android Keystore (AES-GCM). - * Replaces plaintext storage in SharedPreferences. - */ -class C2paKeyStore(context: Context) { - - private val secureStorage = SecureStorage(context, alias = "C2paKeyStore") - - companion object { - private const val KEY_SIGNING_KEY = "c2pa_signing_key" - private const val KEY_PASSPHRASE = "c2pa_passphrase" - - // Legacy SharedPreferences keys used before this class was introduced - private const val LEGACY_KEY_SIGNING_KEY = "c2pa_signing_key" - private const val LEGACY_KEY_PASSPHRASE = "c2pa_encrypted_passphrase" - } - - fun getSigningKey(): String? = secureStorage.getString(KEY_SIGNING_KEY) - - fun putSigningKey(value: String?) = secureStorage.putString(KEY_SIGNING_KEY, value) - - fun getPassphrase(): ByteArray? = secureStorage.getString(KEY_PASSPHRASE) - ?.let { Base64.decode(it, Base64.DEFAULT) } - - fun putPassphrase(value: ByteArray?) { - val encoded = value?.let { Base64.encodeToString(it, Base64.DEFAULT) } - secureStorage.putString(KEY_PASSPHRASE, encoded) - } - - fun clear() { - secureStorage.remove(KEY_SIGNING_KEY) - secureStorage.remove(KEY_PASSPHRASE) - } - - /** - * One-time migration: moves C2PA keys from plaintext SharedPreferences to SecureStorage, - * then removes them from SharedPreferences. - */ - fun migrateFromPrefsIfNeeded(prefs: SharedPreferences) { - val legacyKey = prefs.getString(LEGACY_KEY_SIGNING_KEY, null) - if (legacyKey != null && secureStorage.getString(KEY_SIGNING_KEY) == null) { - secureStorage.putString(KEY_SIGNING_KEY, legacyKey) - } - if (legacyKey != null) { - prefs.edit().remove(LEGACY_KEY_SIGNING_KEY).apply() - } - - val legacyPassphrase = prefs.getString(LEGACY_KEY_PASSPHRASE, null) - if (legacyPassphrase != null && secureStorage.getString(KEY_PASSPHRASE) == null) { - secureStorage.putString(KEY_PASSPHRASE, legacyPassphrase) - } - if (legacyPassphrase != null) { - prefs.edit().remove(LEGACY_KEY_PASSPHRASE).apply() - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt index a78a0b70..87c32a9e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/HomeActivity.kt @@ -28,7 +28,6 @@ import org.koin.androidx.scope.activityRetainedScope import net.opendasharchive.openarchive.features.main.ui.SharedImportState import net.opendasharchive.openarchive.upload.UploadGate import net.opendasharchive.openarchive.upload.UploadJobScheduler -import net.opendasharchive.openarchive.util.C2paHelper class HomeActivity : BaseComposeActivity(), AndroidScopeComponent { @@ -98,8 +97,6 @@ class HomeActivity : BaseComposeActivity(), AndroidScopeComponent { override fun onStart() { super.onStart() - C2paHelper.init(this) - // On every foreground return: if already unlocked, re-schedule any queued uploads. // This recovers from stalled JobService runs (e.g. OS killed the job mid-upload). if (!passcodeGate.locked.value) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt index 5e1b971c..6d7732f2 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/MediaPicker.kt @@ -7,7 +7,6 @@ import net.opendasharchive.openarchive.core.domain.Evidence import net.opendasharchive.openarchive.core.domain.EvidenceStatus import net.opendasharchive.openarchive.core.domain.VaultType import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.util.C2paHelper import net.opendasharchive.openarchive.util.DateUtils import net.opendasharchive.openarchive.util.MediaThumbnailGenerator import net.opendasharchive.openarchive.util.Utility @@ -128,19 +127,6 @@ object MediaPicker { "" } - if (logC2pa) { - AppLogger.d("[C2PA_DEBUG] MediaPicker hash of copied file: $mediaHashString (file size=${file?.length()})") - val expectedManifest = C2paHelper.getC2paFile(context, mediaHashString) - AppLogger.d("[C2PA_DEBUG] Expected C2PA manifest path: ${expectedManifest.absolutePath}, exists=${expectedManifest.exists()}") - if (!expectedManifest.exists()) { - AppLogger.w("[C2PA_DEBUG] *** C2PA MANIFEST MISSING for hash $mediaHashString — upload will skip C2PA ***") - // List all existing manifests for comparison - val c2paDir = expectedManifest.parentFile - val existing = c2paDir?.listFiles()?.map { it.name } ?: emptyList() - AppLogger.d("[C2PA_DEBUG] Existing manifests in dir: $existing") - } - } - val thumbnail = try { file?.let { MediaThumbnailGenerator.generateThumbnailBytes(it, mimeType) } } catch (e: Exception) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt index f31bb97c..279475bd 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt @@ -21,11 +21,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.util.C2paHelper import net.opendasharchive.openarchive.util.MetadataCollector +import net.opendasharchive.openarchive.util.ProofmodeC2paManager import net.opendasharchive.openarchive.util.Utility import java.io.File -import java.security.MessageDigest class CameraViewModel : ViewModel() { @@ -251,19 +250,9 @@ class CameraViewModel : ViewModel() { val metadata = MetadataCollector.collectMetadata(context) MetadataCollector.writeExifMetadata(file, metadata) AppLogger.d("[C2PA_DEBUG] EXIF written, file size after EXIF: ${file.length()}") - val hash = sha256(file) - AppLogger.d("[C2PA_DEBUG] SHA-256 of original capture file: $hash") - if (hash.isNotEmpty()) { - val manifest = C2paHelper.generateManifest( - context = context, - mediaFile = file, - mediaHash = hash, - metadata = buildProofMetadata(context, file, hash, metadata) - ) - AppLogger.d("[C2PA_DEBUG] Manifest generated: ${manifest?.absolutePath}, exists=${manifest?.exists()}") - } else { - AppLogger.w("[C2PA_DEBUG] Empty hash — manifest NOT generated for ${file.name}") - } + ProofmodeC2paManager.embedProof(file, "image/jpeg", metadata, context) + ?.let { AppLogger.d("[C2PA] Proof embedded: ${it.name}") } + ?: AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") } catch (e: Exception) { AppLogger.e("Provenance write failed for ${file.name}", e) } @@ -273,81 +262,14 @@ class CameraViewModel : ViewModel() { try { AppLogger.d("[C2PA_DEBUG] writeProvenanceForVideo: file=${file.absolutePath} exists=${file.exists()} size=${file.length()}") val metadata = MetadataCollector.collectMetadata(context) - val hash = sha256(file) - AppLogger.d("[C2PA_DEBUG] SHA-256 of original capture file (video): $hash") - if (hash.isNotEmpty()) { - val manifest = C2paHelper.generateManifest( - context = context, - mediaFile = file, - mediaHash = hash, - metadata = buildProofMetadata(context, file, hash, metadata) - ) - AppLogger.d("[C2PA_DEBUG] Manifest generated: ${manifest?.absolutePath}, exists=${manifest?.exists()}") - } else { - AppLogger.w("[C2PA_DEBUG] Empty hash — manifest NOT generated for ${file.name}") - } + ProofmodeC2paManager.embedProof(file, "video/mp4", metadata, context) + ?.let { AppLogger.d("[C2PA] Proof embedded: ${it.name}") } + ?: AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") } catch (e: Exception) { AppLogger.e("Provenance write failed for ${file.name}", e) } } - private fun sha256(file: File): String = try { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { stream -> - val buffer = ByteArray(8192) - var bytesRead: Int - while (stream.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - digest.digest().joinToString("") { "%02x".format(it) } - } catch (e: Exception) { - AppLogger.e("SHA-256 hash failed for ${file.name}", e) - "" - } - - private fun buildProofMetadata( - context: Context, - file: File, - hash: String, - metadata: MetadataCollector.CaptureMetadata - ): Map { - val isoFmt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).also { - it.timeZone = java.util.TimeZone.getTimeZone("UTC") - } - val mimeType = android.webkit.MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(file.extension.lowercase()) - ?: "application/octet-stream" - return buildMap { - put("title", file.name) - put("mimeType", mimeType) - put("File Hash SHA256", hash) - put("File Path", file.absolutePath) - put("File Created", isoFmt.format(java.util.Date(metadata.captureTime))) - put("File Modified", isoFmt.format(java.util.Date(file.lastModified()))) - put("Proof Generated", isoFmt.format(java.util.Date(metadata.captureTime))) - put("Notes", "${metadata.appName} ${metadata.appVersion}") - put("Manufacturer", metadata.deviceMake) - put("Hardware", "${metadata.deviceMake} ${metadata.deviceModel}") - put("Locale", metadata.locale) - put("Language", metadata.language) - metadata.screenSizeInches?.let { put("ScreenSize", it.toString()) } - metadata.latitude?.let { put("Location.Latitude", it.toString()) } - metadata.longitude?.let { put("Location.Longitude", it.toString()) } - metadata.locationAltitude?.let { put("Location.Altitude", it.toString()) } - metadata.locationAccuracy?.let { put("Location.Accuracy", it.toString()) } - metadata.locationBearing?.let { put("Location.Bearing", it.toString()) } - metadata.locationSpeed?.let { put("Location.Speed", it.toString()) } - metadata.locationTime?.let { put("Location.Time", it.toString()) } - metadata.locationProvider?.let { put("Location.Provider", it) } - metadata.networkType?.let { put("NetworkType", it) } - metadata.networkType?.let { put("DataType", it) } - metadata.ipv4?.let { put("IPv4", it) } - metadata.ipv6?.let { put("IPv6", it) } - metadata.cellInfo?.let { put("CellInfo", it) } - } - } - fun stopVideoRecording() { if (_state.value.isRecording) { currentRecording?.stop() diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index 8bc47c84..395b0328 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -30,7 +30,6 @@ import net.opendasharchive.openarchive.services.internetarchive.data.IaConduit import net.opendasharchive.openarchive.services.webdav.data.WebDavConduit import net.opendasharchive.openarchive.upload.BroadcastManager import net.opendasharchive.openarchive.upload.UploadEventBus -import net.opendasharchive.openarchive.util.C2paHelper import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.toJavaDate import kotlinx.serialization.encodeToString @@ -124,38 +123,6 @@ abstract class Conduit( scope.cancel() } - /** - * Get C2PA manifest file for this media - * Returns the sidecar .c2pa.json file if C2PA is enabled and manifest exists - */ - fun getC2paManifest(): File? { - if (!Prefs.useC2pa) { - AppLogger.d("[C2PA] Disabled, skipping manifest retrieval") - return null - } - - try { - AppLogger.d("[C2PA_DEBUG] getC2paManifest: evidenceHash=${mEvidence.mediaHashString} evidenceFile=${mEvidence.originalFilePath}") - val manifestFile = C2paHelper.getC2paFile(mContext, mEvidence.mediaHashString) - AppLogger.d("[C2PA_DEBUG] Looking for manifest at: ${manifestFile.absolutePath}") - - if (manifestFile.exists()) { - AppLogger.d("[C2PA] Manifest found: ${manifestFile.absolutePath}") - return manifestFile - } else { - AppLogger.w("[C2PA] Manifest not found for ${mEvidence.mediaHashString}") - // List existing manifests to help diagnose hash mismatch - val c2paDir = manifestFile.parentFile - val existing = c2paDir?.listFiles()?.map { it.name } ?: emptyList() - AppLogger.d("[C2PA_DEBUG] Existing manifests: $existing") - return null - } - } catch (exception: Exception) { - AppLogger.e("[C2PA] Error retrieving manifest", exception) - return null - } - } - /** * result is a site specific unique id that we can use to fetch the data, * build an embed tag, etc. for some sites this might be a URL diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt index bc149c72..7e71ca68 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt @@ -260,17 +260,6 @@ class WebDavConduit(evidence: Evidence, context: Context) : Conduit(evidence, co .build() ) - val c2paManifest = getC2paManifest() - if (c2paManifest != null) { - if (mCancelled) throw Exception("Cancelled") - AppLogger.d("Uploading C2PA manifest: ${c2paManifest.name}") - execute( - Request.Builder() - .url(construct(base, path, c2paManifest.name)) - .put(c2paManifest.readBytes().toRequestBody("application/json".toMediaTypeOrNull())) - .build() - ) - } } // --- WebDAV HTTP helpers --- diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/C2paFfi.kt b/app/src/main/java/net/opendasharchive/openarchive/util/C2paFfi.kt deleted file mode 100644 index ea448623..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/util/C2paFfi.kt +++ /dev/null @@ -1,145 +0,0 @@ -package net.opendasharchive.openarchive.util - -import net.opendasharchive.openarchive.core.logger.AppLogger - -/** - * JNI wrapper for C2PA Rust FFI library - * - * This class provides a Kotlin interface to the native Rust implementation of C2PA. - * The Rust library is compiled from source for FOSS builds, ensuring F-Droid compliance. - */ -object C2paFfi { - - private var initialized = false - private var libraryLoaded = false - - init { - try { - // Load the native library compiled from Rust - System.loadLibrary("c2pa_ffi") - libraryLoaded = true - AppLogger.d("C2PA FFI native library loaded successfully") - } catch (e: UnsatisfiedLinkError) { - AppLogger.e("Failed to load C2PA FFI native library", e) - AppLogger.e("Make sure Rust is installed and the library is compiled") - libraryLoaded = false - } - } - - /** - * Initialize the C2PA FFI library - * Must be called before using any other functions - * - * @return true if initialization succeeded, false otherwise - */ - fun initialize(): Boolean { - if (!libraryLoaded) { - AppLogger.w("Cannot initialize C2PA FFI - native library not loaded") - return false - } - - if (initialized) { - AppLogger.d("C2PA FFI already initialized") - return true - } - - return try { - val result = nativeInit() - initialized = result - if (result) { - AppLogger.i("C2PA FFI initialized successfully") - } else { - AppLogger.w("C2PA FFI initialization returned false") - } - result - } catch (e: Exception) { - AppLogger.e("Failed to initialize C2PA FFI", e) - false - } - } - - /** - * Generate a C2PA manifest for a media file - * - * @param filePath Absolute path to the media file - * @param metadataJson JSON string containing metadata (title, description, author, etc.) - * @return JSON string containing the C2PA manifest, or null if generation failed - */ - fun generateManifest(filePath: String, metadataJson: String): String? { - if (!initialized) { - AppLogger.w("C2PA FFI not initialized, attempting to initialize now") - if (!initialize()) { - AppLogger.e("Cannot generate manifest - FFI initialization failed") - return null - } - } - - return try { - val result = nativeGenerateManifest(filePath, metadataJson) - if (result != null) { - AppLogger.d("C2PA manifest generated for: $filePath") - } else { - AppLogger.w("C2PA manifest generation returned null for: $filePath") - } - result - } catch (e: Exception) { - AppLogger.e("Failed to generate C2PA manifest", e) - null - } - } - - /** - * Verify a C2PA manifest - * - * @param manifestJson JSON string containing the C2PA manifest - * @return true if the manifest is valid, false otherwise - */ - fun verifyManifest(manifestJson: String): Boolean { - if (!initialized) { - AppLogger.w("C2PA FFI not initialized, attempting to initialize now") - if (!initialize()) { - AppLogger.e("Cannot verify manifest - FFI initialization failed") - return false - } - } - - return try { - val result = nativeVerifyManifest(manifestJson) - AppLogger.d("C2PA manifest verification result: $result") - result - } catch (e: Exception) { - AppLogger.e("Failed to verify C2PA manifest", e) - false - } - } - - /** - * Check if the native library is loaded and ready to use - * - * @return true if library is loaded, false otherwise - */ - fun isAvailable(): Boolean { - return libraryLoaded && initialized - } - - // Native method declarations - // These are implemented in rust-c2pa-ffi/src/lib.rs - - /** - * Initialize the C2PA FFI library (native implementation) - */ - private external fun nativeInit(): Boolean - - /** - * Generate C2PA manifest (native implementation) - */ - private external fun nativeGenerateManifest( - filePath: String, - metadataJson: String - ): String? - - /** - * Verify C2PA manifest (native implementation) - */ - private external fun nativeVerifyManifest(manifestJson: String): Boolean -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/C2paHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/util/C2paHelper.kt deleted file mode 100644 index d80b7185..00000000 --- a/app/src/main/java/net/opendasharchive/openarchive/util/C2paHelper.kt +++ /dev/null @@ -1,178 +0,0 @@ -package net.opendasharchive.openarchive.util - -import android.content.Context -import net.opendasharchive.openarchive.BuildConfig -import net.opendasharchive.openarchive.core.logger.AppLogger -import java.io.File - -/** - * C2PA Helper for generating cryptographic content provenance manifests - * - * This implementation uses a custom sidecar approach: - * - Generates C2PA manifests - * - Extracts manifest JSON before embedding - * - Saves as separate .c2pa.json files - * - Original files remain COMPLETELY UNTOUCHED (MD5 preserved) - */ -object C2paHelper { - - private var initialized = false - - /** - * Initialize C2PA library - */ - fun init(context: Context) { - if (initialized) return - - try { - // Try to initialize FFI (FOSS builds with Rust compiled from source) - if (C2paFfi.initialize()) { - initialized = true - AppLogger.i("C2PA Helper initialized with FFI (Rust native)") - } else { - // Fall back to stub for GMS builds or if FFI unavailable - initialized = true - AppLogger.d("C2PA Helper initialized (stub implementation - FFI unavailable)") - } - } catch (e: Exception) { - AppLogger.e("Failed to initialize C2PA", e) - // Still mark as initialized to allow stub functionality - initialized = true - } - } - - /** - * Generate C2PA manifest for a media file - * Saves as SIDECAR .c2pa.json file (does NOT modify original) - * - * @param context Application context - * @param mediaFile The original media file - * @param mediaHash MD5 hash string for the media - * @param metadata Additional metadata to include in manifest - * @return The sidecar manifest file, or null if disabled/failed - */ - fun generateManifest( - context: Context, - mediaFile: File, - mediaHash: String, - metadata: Map = emptyMap() - ): File? { - AppLogger.d("[C2PA] generateManifest called - mediaFile: ${mediaFile.absolutePath}, mediaHash: $mediaHash") - - if (!Prefs.useC2pa) { - AppLogger.w("[C2PA] Disabled in preferences") - return null - } - - if (!mediaFile.exists()) { - AppLogger.e("[C2PA] Media file does not exist: ${mediaFile.absolutePath}") - return null - } - - AppLogger.d("[C2PA] Media file exists, size: ${mediaFile.length()} bytes") - - try { - // Try to use FFI implementation (FOSS builds) - val manifestJson = if (C2paFfi.isAvailable()) { - AppLogger.d("[C2PA] Using FFI to generate manifest") - val metadataJson = metadata.toJsonString() - AppLogger.d("[C2PA] Calling FFI with metadata: $metadataJson") - val result = C2paFfi.generateManifest(mediaFile.absolutePath, metadataJson) - if (result == null) { - AppLogger.w("[C2PA] FFI returned null, falling back to stub") - buildStubManifestJson(mediaFile, metadata) - } else { - AppLogger.d("[C2PA] FFI returned manifest, length: ${result.length}") - result - } - } else { - AppLogger.d("[C2PA] FFI unavailable, using stub implementation") - buildStubManifestJson(mediaFile, metadata) - } - - // Save as sidecar file - val sidecarFile = getC2paFile(context, mediaHash) - AppLogger.d("[C2PA] Sidecar file path: ${sidecarFile.absolutePath}") - - val parentCreated = sidecarFile.parentFile?.mkdirs() ?: false - AppLogger.d("[C2PA] Parent directory created/exists: $parentCreated, path: ${sidecarFile.parentFile?.absolutePath}") - - sidecarFile.writeText(manifestJson) - AppLogger.i("[C2PA] Manifest saved successfully: ${sidecarFile.absolutePath}, size: ${sidecarFile.length()} bytes") - - return sidecarFile - - } catch (e: Exception) { - AppLogger.e("[C2PA] Failed to generate manifest", e) - return null - } - } - - /** - * Temporary stub implementation - builds a placeholder manifest JSON - * This will be replaced with actual C2PA library calls - */ - private fun buildStubManifestJson( - mediaFile: File, - metadata: Map - ): String { - // TODO: Replace with actual C2PA manifest generation using simple-c2pa - return """ - { - "claim_generator": "OpenArchive Save/${BuildConfig.VERSION_NAME}", - "assertions": [ - { - "label": "stds.schema-org.CreativeWork", - "data": ${metadata.toJsonString()} - } - ], - "signature": { - "note": "Placeholder - will be replaced with actual C2PA signature" - } - } - """.trimIndent() - } - - /** - * Convert metadata map to JSON string - */ - private fun Map.toJsonString(): String { - // Properly escape JSON values - val jsonEntries = this.entries.joinToString(",\n ") { (key, value) -> - val escapedValue = value - .replace("\\", "\\\\") // Escape backslashes - .replace("\"", "\\\"") // Escape quotes - .replace("\n", "\\n") // Escape newlines - .replace("\r", "\\r") // Escape carriage returns - .replace("\t", "\\t") // Escape tabs - "\"$key\": \"$escapedValue\"" - } - return "{\n $jsonEntries\n}" - } - - /** - * Get the sidecar C2PA manifest file for a given media hash - * - * @param context Application context - * @param mediaHash MD5 hash string for the media - * @return File object pointing to where the .c2pa.json sidecar should be stored - */ - fun getC2paFile(context: Context, mediaHash: String): File { - val c2paDir = File(context.filesDir, "c2pa_manifests") - return File(c2paDir, "$mediaHash.c2pa.json") - } - - /** - * Remove C2PA manifest files for a given media hash - * - * @param context Application context - * @param mediaHash MD5 hash string for the media - */ - fun removeC2paFiles(context: Context, mediaHash: String) { - val c2paFile = getC2paFile(context, mediaHash) - if (c2paFile.exists()) { - c2paFile.delete() - AppLogger.d("Deleted C2PA manifest: ${c2paFile.absolutePath}") - } - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt new file mode 100644 index 00000000..d55fad18 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt @@ -0,0 +1,382 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import net.opendasharchive.openarchive.BuildConfig +import net.opendasharchive.openarchive.core.logger.AppLogger +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier +import org.bouncycastle.asn1.x509.BasicConstraints +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.ExtendedKeyUsage +import org.bouncycastle.asn1.x509.KeyPurposeId +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.asn1.x509.SubjectKeyIdentifier +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.crypto.digests.SHA1Digest +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.jce.spec.ECNamedCurveGenParameterSpec +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemWriter +import org.contentauth.c2pa.Builder +import org.contentauth.c2pa.ByteArrayStream +import org.contentauth.c2pa.C2PA +import org.contentauth.c2pa.DataStream +import org.contentauth.c2pa.Signer +import org.contentauth.c2pa.SigningAlgorithm +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.StringWriter +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Security +import java.security.Signature +import java.security.cert.X509Certificate +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +object ProofmodeC2paManager { + + private const val KEY_FILE = "c2pa_private.key" + private const val CERT_CHAIN_FILE = "c2pa_cert_chain.pem" + private const val CA_CERT_FILE = "c2pa_ca_cert.pem" + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + fun init(context: Context) { + try { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.insertProviderAt(BouncyCastleProvider(), 1) + ensureCertsExist(context) + AppLogger.i("[C2PA] ProofmodeC2paManager initialized") + } catch (e: Exception) { + AppLogger.e("[C2PA] Initialization failed", e) + } + } + + suspend fun embedProof( + file: File, + mimeType: String, + metadata: MetadataCollector.CaptureMetadata, + context: Context, + ): File? { + if (!Prefs.useC2pa) return null + if (!file.exists()) { + AppLogger.e("[C2PA] File does not exist: ${file.absolutePath}") + return null + } + + return try { + val privateKeyPem = loadFile(context, KEY_FILE) ?: run { + ensureCertsExist(context) + loadFile(context, KEY_FILE) ?: return null + } + val certChainPem = loadFile(context, CERT_CHAIN_FILE) ?: run { + ensureCertsExist(context) + loadFile(context, CERT_CHAIN_FILE) ?: return null + } + val caCertPem = loadFile(context, CA_CERT_FILE) ?: return null + + // Register our root CA as a trust anchor so c2pa-rs accepts our self-signed chain + val trustSettings = buildTrustSettings(caCertPem) + try { + C2PA.loadSettings(trustSettings, "json") + } catch (e: Exception) { + AppLogger.w("[C2PA] loadSettings warning: ${e.message}") + } + + val manifestJson = buildManifestJson(metadata) + + val signedBytes = Builder.fromJson(manifestJson).use { builder -> + Signer.fromKeys(certChainPem, privateKeyPem, SigningAlgorithm.ES256).use { signer -> + DataStream(file.readBytes()).use { source -> + ByteArrayStream().use { dest -> + builder.sign(mimeType, source, dest, signer) + dest.getData() + } + } + } + } + + val tmp = File(file.parent, "${file.nameWithoutExtension}_c2pa_tmp.${file.extension}") + tmp.writeBytes(signedBytes) + if (!tmp.renameTo(file)) { + file.writeBytes(signedBytes) + tmp.delete() + } + + AppLogger.i("[C2PA] Manifest embedded: ${file.name} (${signedBytes.size / 1024} KB)") + file + } catch (e: Exception) { + AppLogger.e("[C2PA] Failed to embed proof in ${file.name}", e) + null + } + } + + // --------------------------------------------------------------------------- + // Key + cert chain generation (mini CA → leaf, all in software via BouncyCastle) + // --------------------------------------------------------------------------- + + private fun ensureCertsExist(context: Context) { + val keyFile = File(context.filesDir, KEY_FILE) + val chainFile = File(context.filesDir, CERT_CHAIN_FILE) + val caFile = File(context.filesDir, CA_CERT_FILE) + if (keyFile.exists() && chainFile.exists() && caFile.exists()) return + + AppLogger.i("[C2PA] Generating EC key pair and self-signed cert chain") + + val kpg = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) + kpg.initialize(ECNamedCurveGenParameterSpec("secp256r1")) + val keyPair = kpg.generateKeyPair() + + val caCert = buildCaCert(keyPair.private, keyPair.public) + val leafCert = buildLeafCert(keyPair.private, keyPair.public, caCert) + + // Chain = leaf first, then CA + val chainPem = toPem("CERTIFICATE", leafCert.encoded) + toPem("CERTIFICATE", caCert.encoded) + val caCertPem = toPem("CERTIFICATE", caCert.encoded) + val privateKeyPem = toPem("PRIVATE KEY", keyPair.private.encoded) + + keyFile.writeText(privateKeyPem) + chainFile.writeText(chainPem) + caFile.writeText(caCertPem) + + AppLogger.i("[C2PA] Key + cert chain written to internal storage") + } + + /** Self-signed root CA cert (CA:TRUE) — used only as trust anchor */ + private fun buildCaCert(privateKey: PrivateKey, publicKey: PublicKey): X509Certificate { + val subject = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") + val serial = BigInteger.valueOf(1) + val notBefore = Date(System.currentTimeMillis() - 86_400_000L) + val notAfter = Date(System.currentTimeMillis() + 20L * 365 * 24 * 60 * 60 * 1000) + + val builder = JcaX509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, publicKey) + + val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) + val ski = computeSki(spki) + builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) + builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) + builder.addExtension( + Extension.keyUsage, true, + KeyUsage(KeyUsage.keyCertSign or KeyUsage.cRLSign or KeyUsage.digitalSignature) + ) + + val signer = BcContentSigner(privateKey, "SHA256withECDSA") + val holder = builder.build(signer) + return JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(holder) + } + + /** Leaf signing cert (CA:FALSE) with extensions matching c2pa-rs requirements */ + private fun buildLeafCert(privateKey: PrivateKey, publicKey: PublicKey, caCert: X509Certificate): X509Certificate { + val subject = X500Name("CN=OpenArchive Save, O=OpenArchive, C=US") + val issuer = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") + val serial = BigInteger.valueOf(2) + val notBefore = Date(System.currentTimeMillis() - 86_400_000L) + val notAfter = Date(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000) + + val builder = JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, publicKey) + + val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) + val ski = computeSki(spki) + val caSki = computeSki(SubjectPublicKeyInfo.getInstance(caCert.publicKey.encoded)) + + builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) + builder.addExtension(Extension.authorityKeyIdentifier, false, AuthorityKeyIdentifier(caSki)) + builder.addExtension(Extension.basicConstraints, true, BasicConstraints(false)) + builder.addExtension( + Extension.keyUsage, true, + KeyUsage(KeyUsage.digitalSignature or KeyUsage.nonRepudiation) + ) + builder.addExtension( + Extension.extendedKeyUsage, true, + ExtendedKeyUsage(KeyPurposeId.id_kp_emailProtection) + ) + + val signer = BcContentSigner(privateKey, "SHA256withECDSA") + val holder = builder.build(signer) + return JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(holder) + } + + /** Compute SHA-1 of SubjectPublicKeyInfo bit string (standard SKI method) */ + private fun computeSki(spki: SubjectPublicKeyInfo): ByteArray { + val keyBytes = spki.publicKeyData.bytes + val digest = SHA1Digest() + val result = ByteArray(digest.digestSize) + digest.update(keyBytes, 0, keyBytes.size) + digest.doFinal(result, 0) + return result + } + + private fun toPem(type: String, encoded: ByteArray): String { + val sw = StringWriter() + PemWriter(sw).use { it.writeObject(PemObject(type, encoded)) } + return sw.toString() + } + + private fun loadFile(context: Context, name: String): String? { + val f = File(context.filesDir, name) + return if (f.exists()) f.readText() else null + } + + // --------------------------------------------------------------------------- + // BouncyCastle ContentSigner backed by software private key + // --------------------------------------------------------------------------- + + private class BcContentSigner( + private val privateKey: PrivateKey, + private val algorithm: String, + ) : ContentSigner { + + private val sig = Signature.getInstance(algorithm, BouncyCastleProvider.PROVIDER_NAME) + .also { it.initSign(privateKey) } + private val buf = ByteArrayOutputStream() + + override fun getAlgorithmIdentifier(): AlgorithmIdentifier = + AlgorithmIdentifier(ASN1ObjectIdentifier("1.2.840.10045.4.3.2")) + + override fun getOutputStream(): java.io.OutputStream = buf + + override fun getSignature(): ByteArray { + sig.update(buf.toByteArray()) + return sig.sign() + } + } + + // --------------------------------------------------------------------------- + // Trust settings JSON for c2pa-rs + // --------------------------------------------------------------------------- + + private fun buildTrustSettings(caCertPem: String): String { + val escaped = caCertPem + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + return """{"trust":{"trust_anchors":"$escaped"}}""" + } + + // --------------------------------------------------------------------------- + // Manifest JSON + // --------------------------------------------------------------------------- + + private fun buildManifestJson(m: MetadataCollector.CaptureMetadata): String { + val captureIso = isoFormat.format(Date(m.captureTime)) + val claimGenerator = "OpenArchive Save/${BuildConfig.VERSION_NAME} (build ${BuildConfig.VERSION_CODE})" + + val assertions = buildList { + add(actionsAssertion()) + add(creativeWorkAssertion(m, captureIso)) + add(deviceAssertion(m)) + if (m.latitude != null && m.longitude != null) add(locationAssertion(m)) + if (m.networkType != null || m.ipv4 != null || m.ipv6 != null || m.cellInfo != null) add(networkAssertion(m)) + }.joinToString(",\n") + + return """ + { + "claim_generator": ${claimGenerator.toJsonString()}, + "assertions": [ + $assertions + ] + } + """.trimIndent() + } + + private fun actionsAssertion(): String = """ + { + "label": "c2pa.actions", + "data": { + "actions": [{ + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" + }] + } + } + """.trimIndent() + + private fun creativeWorkAssertion(m: MetadataCollector.CaptureMetadata, captureIso: String): String { + val locationBlock = if (m.latitude != null && m.longitude != null) { + buildString { + append(""","locationCreated":{"@type":"Place","geo":{"@type":"GeoCoordinates","latitude":${m.latitude},"longitude":${m.longitude}""") + if (m.locationAltitude != null) append(""","elevation":${m.locationAltitude}""") + append("}}") + } + } else "" + + return """ + { + "label": "stds.schema-org.CreativeWork", + "data": { + "@context": "https://schema.org", + "@type": "CreativeWork", + "author": [{ + "@type": "SoftwareApplication", + "name": "OpenArchive Save", + "version": ${BuildConfig.VERSION_NAME.toJsonString()}, + "buildNumber": "${BuildConfig.VERSION_CODE}" + }], + "dateCreated": "$captureIso" + $locationBlock + } + } + """.trimIndent() + } + + private fun deviceAssertion(m: MetadataCollector.CaptureMetadata): String = """ + { + "label": "org.openarchive.save.device", + "data": { + "make": ${m.deviceMake.toJsonString()}, + "model": ${m.deviceModel.toJsonString()}, + "brand": ${m.deviceBrand.toJsonString()}, + "hardware": ${m.hardware.toJsonString()}, + "locale": ${m.locale.toJsonString()}, + "language": ${m.language.toJsonString()} + ${if (m.screenSizeInches != null) ""","screenSizeInches": ${m.screenSizeInches}""" else ""} + } + } + """.trimIndent() + + private fun locationAssertion(m: MetadataCollector.CaptureMetadata): String { + val fields = buildString { + append(""""latitude": ${m.latitude}""") + append(""","longitude": ${m.longitude}""") + if (m.locationAltitude != null) append(""","altitude": ${m.locationAltitude}""") + if (m.locationAccuracy != null) append(""","accuracy": ${m.locationAccuracy}""") + if (m.locationBearing != null) append(""","bearing": ${m.locationBearing}""") + if (m.locationSpeed != null) append(""","speed": ${m.locationSpeed}""") + if (m.locationProvider != null) append(""","provider": ${m.locationProvider.toJsonString()}""") + if (m.locationTime != null) append(""","timestamp": ${m.locationTime}""") + } + return """{"label":"org.openarchive.save.location","data":{$fields}}""" + } + + private fun networkAssertion(m: MetadataCollector.CaptureMetadata): String { + val fields = buildString { + if (m.networkType != null) append(""""networkType": ${m.networkType.toJsonString()}""") + if (m.ipv4 != null) append(""","ipv4": ${m.ipv4.toJsonString()}""") + if (m.ipv6 != null) append(""","ipv6": ${m.ipv6.toJsonString()}""") + if (m.cellInfo != null) append(""","cellInfo": ${m.cellInfo}""") + } + return """{"label":"org.openarchive.save.network","data":{$fields}}""" + } + + private fun String.toJsonString(): String { + val escaped = replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + return "\"$escaped\"" + } +} diff --git a/rust-c2pa-ffi/.cargo/config.toml.template b/rust-c2pa-ffi/.cargo/config.toml.template deleted file mode 100644 index 1851c59f..00000000 --- a/rust-c2pa-ffi/.cargo/config.toml.template +++ /dev/null @@ -1,67 +0,0 @@ -# Cargo configuration for Android builds -# This file configures the linkers and compilers for each Android architecture -# -# SETUP INSTRUCTIONS: -# 1. Copy this file to config.toml: cp config.toml.template config.toml -# 2. Replace {NDK_HOME} with your actual NDK path, for example: -# - macOS: /Users/USERNAME/Library/Android/sdk/ndk/27.1.12297006 -# - Linux: /home/USERNAME/Android/Sdk/ndk/27.1.12297006 -# - Windows: C:/Users/USERNAME/AppData/Local/Android/Sdk/ndk/27.1.12297006 -# 3. Replace {PLATFORM} with your host platform: -# - macOS: darwin-x86_64 -# - Linux: linux-x86_64 -# - Windows: windows-x86_64 - -[env] -# Set NDK path for easy reference -ANDROID_NDK_HOME = "{NDK_HOME}" - -[target.aarch64-linux-android] -linker = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/aarch64-linux-android30-clang" -ar = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" -# Android 15+ 16 KB page size compatibility -rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] - -[target.aarch64-linux-android.env] -CC_aarch64_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/aarch64-linux-android30-clang" -CXX_aarch64_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/aarch64-linux-android30-clang++" -AR_aarch64_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" - -[target.armv7-linux-androideabi] -linker = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/armv7a-linux-androideabi30-clang" -ar = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" -# Android 15+ 16 KB page size compatibility -rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] - -[target.armv7-linux-androideabi.env] -CC_armv7_linux_androideabi = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/armv7a-linux-androideabi30-clang" -CXX_armv7_linux_androideabi = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/armv7a-linux-androideabi30-clang++" -AR_armv7_linux_androideabi = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" - -[target.i686-linux-android] -linker = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/i686-linux-android30-clang" -ar = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" -# Android 15+ 16 KB page size compatibility -rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] - -[target.i686-linux-android.env] -CC_i686_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/i686-linux-android30-clang" -CXX_i686_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/i686-linux-android30-clang++" -AR_i686_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" - -[target.x86_64-linux-android] -linker = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/x86_64-linux-android30-clang" -ar = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" -# Android 15+ 16 KB page size compatibility -rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] - -[target.x86_64-linux-android.env] -CC_x86_64_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/x86_64-linux-android30-clang" -CXX_x86_64_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/x86_64-linux-android30-clang++" -AR_x86_64_linux_android = "{NDK_HOME}/toolchains/llvm/prebuilt/{PLATFORM}/bin/llvm-ar" - -# Build settings -[build] -target-dir = "target" - -# Profile settings inherited from Cargo.toml diff --git a/rust-c2pa-ffi/Cargo.lock b/rust-c2pa-ffi/Cargo.lock deleted file mode 100644 index 54309822..00000000 --- a/rust-c2pa-ffi/Cargo.lock +++ /dev/null @@ -1,3018 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_log-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" - -[[package]] -name = "android_logger" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" -dependencies = [ - "android_log-sys", - "env_filter", - "log", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "asn1-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" -dependencies = [ - "asn1-rs-derive 0.4.0", - "asn1-rs-impl 0.1.0", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror", -] - -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive 0.5.1", - "asn1-rs-impl 0.2.0", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "synstructure 0.12.6", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure 0.13.2", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "async-generic" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf3728566eefa873833159754f5732fb0951d3649e6e5b891cc70d56dd41673" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "atree" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132573478eb9ff973c6f75d0ed425ac12da77d266506483345f46743ecc83a98" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" - -[[package]] -name = "bcder" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7c42c9913f68cf9390a225e81ad56a5c515347287eb98baa710090ca1de86d" -dependencies = [ - "bytes", - "smallvec", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "bitvec-nom2" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988fcc40055ceaa85edc55875a08f8abd29018582647fd82ad6128dba14a5f0" -dependencies = [ - "bitvec", - "nom", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteordered" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbf2cd9424f5ff404aba1959c835cbc448ee8b689b870a9981c76c0fd46280e6" -dependencies = [ - "byteorder", -] - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "c2pa" -version = "0.36.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211fe13328036af8ec57e039379e38ec01a9c6fb275b8752d46d22e6992530e8" -dependencies = [ - "asn1-rs 0.5.2", - "async-generic", - "async-recursion", - "async-trait", - "atree", - "base64 0.22.1", - "bcder", - "byteorder", - "byteordered", - "bytes", - "chrono", - "ciborium", - "config", - "console_log", - "conv", - "coset", - "ed25519-dalek", - "extfmt", - "fast-xml", - "getrandom 0.2.16", - "hex", - "id3", - "img-parts", - "instant", - "jfifdump", - "js-sys", - "lazy_static", - "log", - "memchr", - "mp4", - "pem 3.0.6", - "png_pong", - "rand", - "rand_chacha", - "rand_core 0.9.3", - "range-set", - "rasn", - "rasn-ocsp", - "rasn-pkix", - "riff", - "rsa", - "serde", - "serde-transcode", - "serde-wasm-bindgen", - "serde_bytes", - "serde_cbor", - "serde_derive", - "serde_json", - "serde_with", - "sha1", - "sha2", - "spki", - "tempfile", - "thiserror", - "treeline", - "ureq", - "url", - "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "x509-certificate", - "x509-parser", - "zip", -] - -[[package]] -name = "c2pa-ffi" -version = "0.1.0" -dependencies = [ - "android_logger", - "anyhow", - "c2pa", - "jni", - "log", - "serde", - "serde_json", - "sha2", -] - -[[package]] -name = "cc" -version = "1.2.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half 2.7.1", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "config" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" -dependencies = [ - "json5", - "nom", - "pathdiff", - "ron", - "rust-ini", - "serde", - "serde_json", - "toml", -] - -[[package]] -name = "console_log" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" -dependencies = [ - "log", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.16", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "const_panic" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" -dependencies = [ - "typewit", -] - -[[package]] -name = "conv" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ff10625fd0ac447827aa30ea8b861fead473bb60aeb73af6c1c58caf0d1299" -dependencies = [ - "custom_derive", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "coset" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8cc80f631f8307b887faca24dcc3abc427cd0367f6eb6188f6e8f5b7ad8fb" -dependencies = [ - "ciborium", - "ciborium-io", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "custom_derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8ae57c4978a2acd8b869ce6b9ca1dfe817bff704c220209fdef2c0b75a01b9" - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs 0.6.2", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - -[[package]] -name = "doc-comment" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "extfmt" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a48fe53466ab1f4ea6303bf9d7a0ca8060778590f09fd6c3304cc817aeb9935d" - -[[package]] -name = "fast-xml" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f7ffc2f9e1373cd82b4d4ce2f84f8162edd48e3932abeb19c06170b66dbbd6c" -dependencies = [ - "memchr", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" - -[[package]] -name = "flate2" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "half" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id3" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f4e785f2c700217ee82a1c727c720449421742abd5fcb2f1df04e1244760e9" -dependencies = [ - "bitflags", - "byteorder", - "flate2", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "img-parts" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b4e24cfdc6f897b582508e3c382eaf5378076898f80500a80d10d761ae85e90" -dependencies = [ - "bytes", - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jfifdump" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "326647c83ea8fb213e58a272f11d4569611277d8730f9905a59fba9d30b12c05" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - -[[package]] -name = "konst" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4381b9b00c55f251f2ebe9473aef7c117e96828def1a7cb3bd3f0f903c6894e9" -dependencies = [ - "const_panic", - "konst_kernel", - "typewit", -] - -[[package]] -name = "konst_kernel" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4b1eb7788f3824c629b1116a7a9060d6e898c358ebff59070093d51103dcc3c" -dependencies = [ - "typewit", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.8", -] - -[[package]] -name = "libc" -version = "0.2.179" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" - -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mp4" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" -dependencies = [ - "byteorder", - "bytes", - "num-rational", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", - "serde", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs 0.6.2", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - -[[package]] -name = "parsenic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c695d2b8bcf1dd62a5173a9c43e6ebbe9261701c002f9b462fc9690c144bb61" -dependencies = [ - "traitful", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pem" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b13fe415cdf3c8e44518e18a7c95a13431d9bdf6d15367d82b23c377fdd441a" -dependencies = [ - "base64 0.21.7", - "serde", -] - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "pest_meta" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" -dependencies = [ - "pest", - "sha2", -] - -[[package]] -name = "pix" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6574ffbea793ab0c9a9b09ae99831f1513fdd7732b12aca4c7182c46dc3bc9f" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "png_pong" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a767b8b17e388cb7f1e74cf3c2ebce14e6b7ae123fa27150889a353fd9eb981f" -dependencies = [ - "miniz_oxide", - "parsenic", - "pix", - "simd-adler32", - "traitful", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" - -[[package]] -name = "range-set" -version = "0.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714bc4849c399f77ab82177e9b4f012e02f3a7d6191023e016334edde5b21050" -dependencies = [ - "num-traits", - "smallvec", -] - -[[package]] -name = "rasn" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81a9162f91f371403ed7469be915bc0d39b84246ac2783822c4197fcaf3dbfe2" -dependencies = [ - "arrayvec", - "bitvec", - "bitvec-nom2", - "bytes", - "chrono", - "either", - "hashbrown 0.14.5", - "konst", - "nom", - "num-bigint", - "num-integer", - "num-traits", - "once_cell", - "rasn-derive", - "serde_json", - "snafu", -] - -[[package]] -name = "rasn-derive" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd02be4b376758d6a12b2751c5e86f0befc8e47bd6847b1fc126dcb79cb005e" -dependencies = [ - "either", - "itertools", - "proc-macro2", - "quote", - "rayon", - "syn 1.0.109", - "uuid", -] - -[[package]] -name = "rasn-ocsp" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ecc5a6f83a09d3c8f2bd8c200a28986db30b1ad0a33bb828442d2efee5871f1" -dependencies = [ - "rasn", - "rasn-pkix", -] - -[[package]] -name = "rasn-pkix" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3da2b04cf81b8135d5b362cf29143018eb708039670c2680092f1f1b79d501f" -dependencies = [ - "rasn", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "riff" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c601484456988d75017d86700d3743b949c21cdc7399f940c75e34680d185c5" - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "ron" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" -dependencies = [ - "base64 0.21.7", - "bitflags", - "serde", - "serde_derive", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "sha2", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rust-ini" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "log", - "once_cell", - "ring 0.17.14", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "ring 0.17.14", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-transcode" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590c0e25c2a5bb6e85bf5c1bce768ceb86b316e7a01bdf07d2cb4ec2271990e2" -dependencies = [ - "serde", -] - -[[package]] -name = "serde-wasm-bindgen" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_cbor" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" -dependencies = [ - "half 1.8.3", - "serde", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "indexmap 2.13.0", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_with" -version = "3.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.0", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "snafu" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" -dependencies = [ - "doc-comment", - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-xid", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "traitful" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d856e22ead1fb79b9fc3cec63300086f680924f2f7b0e2701f6835a28b9c4425" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "treeline" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "typewit" -version = "1.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" -dependencies = [ - "typewit_proc_macros", -] - -[[package]] -name = "typewit_proc_macros" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.114", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.5", -] - -[[package]] -name = "webpki-roots" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x509-certificate" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5d27c90840e84503cf44364de338794d5d5680bdd1da6272d13f80b0769ee0" -dependencies = [ - "bcder", - "bytes", - "chrono", - "der", - "hex", - "pem 2.0.1", - "ring 0.16.20", - "signature", - "spki", - "thiserror", -] - -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs 0.6.2", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure 0.13.2", -] - -[[package]] -name = "zerocopy" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "synstructure 0.13.2", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "byteorder", - "crc32fast", - "crossbeam-utils", -] - -[[package]] -name = "zmij" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/rust-c2pa-ffi/Cargo.toml b/rust-c2pa-ffi/Cargo.toml deleted file mode 100644 index 9e68d2e9..00000000 --- a/rust-c2pa-ffi/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "c2pa-ffi" -version = "0.1.0" -edition = "2021" -rust-version = "1.88.0" - -[lib] -name = "c2pa_ffi" -crate-type = ["cdylib"] - -[dependencies] -# C2PA library for content authenticity -c2pa = "0.36" - -# JNI bindings for Android -jni = "0.21" - -# JSON serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling -anyhow = "1.0" - -# Logging -log = "0.4" -android_logger = "0.14" - -# Hashing -sha2 = "0.10" - -[profile.release] -opt-level = "z" # Optimize for size -lto = true # Enable Link Time Optimization -codegen-units = 1 # Reduce parallel codegen units for better optimization -strip = "debuginfo" # Strip debug info only, keep exported JNI symbols diff --git a/rust-c2pa-ffi/build-android.sh b/rust-c2pa-ffi/build-android.sh deleted file mode 100755 index 52fa6fbb..00000000 --- a/rust-c2pa-ffi/build-android.sh +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env bash -# build-android.sh — Standalone build script for C2PA Rust FFI (all Android ABIs) -# -# Usage: -# ./build-android.sh # build all ABIs -# ./build-android.sh arm64-v8a # build a single ABI -# -# Prerequisites: -# - Rust + Cargo (https://rustup.rs/) -# - Android NDK (via Android Studio → SDK Manager → SDK Tools → NDK) -# - Android Rust targets (auto-installed by this script) -# -# The Gradle task `buildC2paRustLibs` calls this logic automatically for FOSS builds. -# Use this script for manual builds or CI environments without Gradle. - -set -euo pipefail - -BOLD='\033[1m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' - -info() { echo -e "${GREEN}✓ $*${NC}"; } -warn() { echo -e "${YELLOW}⚠ $*${NC}"; } -error() { echo -e "${RED}✗ $*${NC}" >&2; exit 1; } -step() { echo -e "${BOLD}▸ $*${NC}"; } - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs" -ANDROID_API_LEVEL="30" -PREFERRED_NDK_VERSION="27.1.12297006" - -# ─── Find Android NDK ──────────────────────────────────────────────────────── -find_ndk() { - # 1. Explicit NDK env vars - for var in ANDROID_NDK_HOME ANDROID_NDK_ROOT; do - if [[ -n "${!var:-}" && -d "${!var}" ]]; then - echo "${!var}"; return - fi - done - - # 2. Derive from SDK root - local sdk="" - for candidate in \ - "${ANDROID_HOME:-}" \ - "${ANDROID_SDK_ROOT:-}" \ - "$HOME/Library/Android/sdk" \ - "$HOME/Android/Sdk"; do - if [[ -n "$candidate" && -d "$candidate" ]]; then - sdk="$candidate"; break - fi - done - - [[ -z "$sdk" ]] && error \ - "Android SDK not found.\nSet ANDROID_HOME to your SDK directory." - - local ndk_parent="$sdk/ndk" - [[ -d "$ndk_parent" ]] || error \ - "NDK not found at $ndk_parent.\nInstall it via Android Studio → SDK Manager → SDK Tools → NDK (Side by side)." - - # Prefer the pinned version; otherwise use the latest - if [[ -d "$ndk_parent/$PREFERRED_NDK_VERSION" ]]; then - echo "$ndk_parent/$PREFERRED_NDK_VERSION"; return - fi - - local latest - latest=$(ls -1 "$ndk_parent" | sort -V | tail -1) - [[ -n "$latest" ]] || error "No NDK versions found in $ndk_parent" - warn "NDK $PREFERRED_NDK_VERSION not found — using $latest" - echo "$ndk_parent/$latest" -} - -# ─── Detect host OS/arch for toolchain prebuilt dir ───────────────────────── -detect_host() { - local os arch - case "$(uname -s)" in - Darwin) os="darwin" ;; - Linux) os="linux" ;; - *) error "Unsupported host OS: $(uname -s)" ;; - esac - case "$(uname -m)" in - x86_64) arch="x86_64" ;; - arm64|aarch64) arch="x86_64" ;; # NDK ships x86_64 host tools even on Apple Silicon - *) error "Unsupported host arch: $(uname -m)" ;; - esac - echo "${os}-${arch}" -} - -# ─── Prerequisite checks ───────────────────────────────────────────────────── -step "Checking prerequisites" - -command -v cargo >/dev/null 2>&1 || error \ - "Cargo not found. Install Rust from https://rustup.rs/\nThen run: source \$HOME/.cargo/env" - -command -v rustup >/dev/null 2>&1 || error \ - "rustup not found. Install Rust from https://rustup.rs/" - -info "Rust $(rustc --version)" - -# ─── Determine NDK and toolchain paths ─────────────────────────────────────── -step "Locating Android NDK" -NDK="$(find_ndk)" -HOST="$(detect_host)" -BIN="$NDK/toolchains/llvm/prebuilt/$HOST/bin" - -[[ -d "$BIN" ]] || error "NDK toolchain bin not found at $BIN" -info "NDK: $NDK" - -# ─── Install Android Rust targets (idempotent) ─────────────────────────────── -step "Ensuring Android Rust targets are installed" -RUST_TARGETS=( - "aarch64-linux-android" - "armv7-linux-androideabi" - "i686-linux-android" - "x86_64-linux-android" -) -rustup target add "${RUST_TARGETS[@]}" -info "Rust targets verified" - -# ─── Generate .cargo/config.toml ───────────────────────────────────────────── -step "Generating .cargo/config.toml" -mkdir -p "$SCRIPT_DIR/.cargo" -cat > "$SCRIPT_DIR/.cargo/config.toml" < jboolean { - // Initialize Android logger - android_logger::init_once( - android_logger::Config::default() - .with_max_level(LevelFilter::Debug) - .with_tag("C2PA-FFI"), - ); - - log::info!("C2PA FFI initialized"); - 1 // true -} - -/// C2PA Manifest structure for JSON serialization -#[derive(Serialize, Deserialize, Debug)] -struct C2paManifest { - claim_generator: String, - assertions: Vec, - signature: SignatureInfo, -} - -#[derive(Serialize, Deserialize, Debug)] -struct Assertion { - label: String, - data: serde_json::Value, -} - -#[derive(Serialize, Deserialize, Debug)] -struct SignatureInfo { - algorithm: String, - value: String, -} - -/// Generate a C2PA manifest for a media file -/// -/// # Arguments -/// * `file_path` - Path to the media file -/// * `metadata_json` - JSON string containing metadata (title, description, etc.) -/// -/// # Returns -/// JSON string containing the C2PA manifest, or null on error -#[no_mangle] -pub extern "C" fn Java_net_opendasharchive_openarchive_util_C2paFfi_nativeGenerateManifest( - mut env: JNIEnv, - _class: JClass, - file_path: JString, - metadata_json: JString, -) -> jstring { - // Convert JString to Rust String - let file_path_str: String = match env.get_string(&file_path) { - Ok(s) => s.into(), - Err(e) => { - log::error!("Failed to get file path string: {:?}", e); - return JObject::null().into_raw(); - } - }; - - let metadata_str: String = match env.get_string(&metadata_json) { - Ok(s) => s.into(), - Err(e) => { - log::error!("Failed to get metadata JSON string: {:?}", e); - return JObject::null().into_raw(); - } - }; - - log::info!("Generating C2PA manifest for: {}", file_path_str); - - // Parse metadata JSON - let metadata: serde_json::Value = match serde_json::from_str(&metadata_str) { - Ok(m) => m, - Err(e) => { - log::error!("Failed to parse metadata JSON: {:?}", e); - return JObject::null().into_raw(); - } - }; - - // TODO: Implement actual C2PA manifest generation using c2pa crate - // For now, create a stub manifest structure - let manifest = create_stub_manifest(&file_path_str, metadata); - - // Serialize manifest to JSON - let manifest_json = match serde_json::to_string_pretty(&manifest) { - Ok(json) => json, - Err(e) => { - log::error!("Failed to serialize manifest: {:?}", e); - return JObject::null().into_raw(); - } - }; - - // Convert Rust String to JString - match env.new_string(manifest_json) { - Ok(s) => s.into_raw(), - Err(e) => { - log::error!("Failed to create JString: {:?}", e); - JObject::null().into_raw() - } - } -} - -/// Create a stub C2PA manifest -/// TODO: Replace with actual c2pa library implementation -fn create_stub_manifest(file_path: &str, metadata: serde_json::Value) -> C2paManifest { - // Calculate file hash for signature - let file_hash = calculate_file_hash(file_path).unwrap_or_else(|_| "unknown".to_string()); - - C2paManifest { - claim_generator: "OpenArchive Save/Rust FFI".to_string(), - assertions: vec![ - Assertion { - label: "stds.schema-org.CreativeWork".to_string(), - data: metadata, - }, - Assertion { - label: "c2pa.hash.data".to_string(), - data: serde_json::json!({ - "alg": "sha256", - "hash": file_hash, - "name": "jumbf manifest" - }), - }, - ], - signature: SignatureInfo { - algorithm: "es256".to_string(), - value: format!("stub_signature_{}", &file_hash[..16]), - }, - } -} - -/// Calculate SHA-256 hash of a file -fn calculate_file_hash(file_path: &str) -> Result { - use sha2::{Digest, Sha256}; - - let contents = fs::read(file_path)?; - let hash = Sha256::digest(&contents); - Ok(format!("{:x}", hash)) -} - -/// Verify a C2PA manifest -/// -/// # Arguments -/// * `manifest_json` - JSON string containing the C2PA manifest -/// -/// # Returns -/// true if manifest is valid, false otherwise -#[no_mangle] -pub extern "C" fn Java_net_opendasharchive_openarchive_util_C2paFfi_nativeVerifyManifest( - mut env: JNIEnv, - _class: JClass, - manifest_json: JString, -) -> jboolean { - let manifest_str: String = match env.get_string(&manifest_json) { - Ok(s) => s.into(), - Err(e) => { - log::error!("Failed to get manifest JSON string: {:?}", e); - return 0; // false - } - }; - - // Parse manifest JSON - match serde_json::from_str::(&manifest_str) { - Ok(_manifest) => { - log::info!("Manifest verification: valid structure"); - // TODO: Implement actual signature verification using c2pa crate - 1 // true - } - Err(e) => { - log::error!("Manifest verification failed: {:?}", e); - 0 // false - } - } -} - -// Add sha2 dependency for hashing -// This will be added to Cargo.toml diff --git a/settings.gradle.kts b/settings.gradle.kts index 894d5ba4..107491f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -85,6 +85,7 @@ dependencyResolutionManagement { content { includeModule("com.github.esafirm", "android-image-picker") includeModule("com.github.abdularis", "circularimageview") + includeModule("com.github.contentauth", "c2pa-android") } } } From d5786636fad0c67124fe765f8280596fdd0b2903 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 1 Jun 2026 08:22:21 +0530 Subject: [PATCH 02/15] Implement ProofMode companion file generation and WebDAV upload. Specific changes: **ProofMode Integration** - Added `ProofCompanionGenerator` to create verification metadata, including `.proof.json`, PGP signatures (`.asc`), and OpenTimestamps (`.ots`) files. - Implemented PGP key pair generation and storage using Bouncy Castle, supporting detached signatures for both media and metadata. - Added logic to populate proof JSON with ProofMode v1 fields, including hardware details and EXIF-derived location data. - Integrated OpenTimestamps (OTS) to submit media hashes to a remote calendar and store the resulting timestamp proof. **Upload & Infrastructure** - Updated `WebDavConduit` to automatically detect and upload companion files during the media upload process. - Added `getProofFiles()` to the base `Conduit` class to facilitate metadata generation at upload time. - Added `org.bouncycastle:bcpg-jdk18on` dependency for OpenPGP support. - Initialized `ProofCompanionGenerator` in the application class to ensure PGP keys are ready for use. --- app/build.gradle.kts | 1 + .../opendasharchive/openarchive/SaveApp.kt | 4 + .../openarchive/services/Conduit.kt | 8 + .../services/webdav/data/WebDavConduit.kt | 11 + .../util/ProofCompanionGenerator.kt | 291 ++++++++++++++++++ settings.gradle.kts | 1 + 6 files changed, 316 insertions(+) create mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 34b8932b..25b35e71 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -319,6 +319,7 @@ dependencies { implementation("com.github.contentauth:c2pa-android:0.0.9") implementation("org.bouncycastle:bcprov-jdk18on:1.81") implementation("org.bouncycastle:bcpkix-jdk18on:1.81") + implementation("org.bouncycastle:bcpg-jdk18on:1.81") // Utilities implementation(libs.timber) diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index c6249759..d60471be 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -42,6 +42,7 @@ import net.opendasharchive.openarchive.core.di.passcodeModule import net.opendasharchive.openarchive.features.settings.passcode.PasscodeGate import net.opendasharchive.openarchive.core.di.retrofitModule import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.ProofCompanionGenerator import net.opendasharchive.openarchive.util.ProofmodeC2paManager import net.opendasharchive.openarchive.core.repositories.MediaRepository import net.opendasharchive.openarchive.util.CleanInsightsManager @@ -104,6 +105,9 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv // Initialize ProofmodeC2paManager (generates self-signed key on first run) ProofmodeC2paManager.init(this) + // Initialize PGP key for proof companion files + ProofCompanionGenerator.init(this) + // --- 2-launch synchronous migration strategy --- // L1: If Sugar DB exists and Room migration hasn't run yet, open Room directly // (before Koin), run migration synchronously, then mark done. diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index 395b0328..58691b19 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -30,6 +30,7 @@ import net.opendasharchive.openarchive.services.internetarchive.data.IaConduit import net.opendasharchive.openarchive.services.webdav.data.WebDavConduit import net.opendasharchive.openarchive.upload.BroadcastManager import net.opendasharchive.openarchive.upload.UploadEventBus +import net.opendasharchive.openarchive.util.ProofCompanionGenerator import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.toJavaDate import kotlinx.serialization.encodeToString @@ -123,6 +124,13 @@ abstract class Conduit( scope.cancel() } + /** + * Generate ProofMode companion files (.proof.json, .asc, .ots) for the evidence file. + * Returns the generated files ready for upload. Call at upload time. + */ + fun getProofFiles(): List = + ProofCompanionGenerator.generateCompanionFiles(mContext, mEvidence.file, mEvidence.mediaHashString) + /** * result is a site specific unique id that we can use to fetch the data, * build an embed tag, etc. for some sites this might be a URL diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt index 7e71ca68..8ad14475 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt @@ -260,6 +260,17 @@ class WebDavConduit(evidence: Evidence, context: Context) : Conduit(evidence, co .build() ) + // Upload ProofMode companion files: .proof.json, .asc (PGP sigs), .ots (OpenTimestamps), pubkey.asc + for (proofFile in getProofFiles()) { + if (mCancelled) throw Exception("Cancelled") + AppLogger.d("[ProofMode] Uploading companion file: ${proofFile.name}") + execute( + Request.Builder() + .url(construct(base, path, proofFile.name)) + .put(proofFile.readBytes().toRequestBody("application/octet-stream".toMediaTypeOrNull())) + .build() + ) + } } // --- WebDAV HTTP helpers --- diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt new file mode 100644 index 00000000..2810bcbe --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt @@ -0,0 +1,291 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import android.os.Build +import androidx.exifinterface.media.ExifInterface +import net.opendasharchive.openarchive.BuildConfig +import net.opendasharchive.openarchive.core.logger.AppLogger +import org.bouncycastle.bcpg.ArmoredOutputStream +import org.bouncycastle.bcpg.BCPGOutputStream +import org.bouncycastle.bcpg.HashAlgorithmTags +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.openpgp.PGPEncryptedData +import org.bouncycastle.openpgp.PGPKeyRingGenerator +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPSecretKeyRing +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.PGPSignature +import org.bouncycastle.openpgp.PGPSignatureGenerator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.Security +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +object ProofCompanionGenerator { + + private const val SECRET_KEY_FILE = "proof_pgp_secret.bpg" + private const val PUBLIC_KEY_FILE = "pubkey.asc" + private const val PGP_IDENTITY = "OpenArchive Save " + private const val OTS_CALENDAR = "https://a.pool.opentimestamps.org/timestamp" + private const val OTS_TIMEOUT_MS = 15_000 + + private val PASSPHRASE = charArrayOf() + + private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + fun init(context: Context) { + try { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.insertProviderAt(BouncyCastleProvider(), 1) + ensureKeyExists(context) + AppLogger.i("[Proof] ProofCompanionGenerator initialized") + } catch (e: Exception) { + AppLogger.e("[Proof] Init failed", e) + } + } + + /** + * Generate ProofMode-compatible companion files: + * {hash}.proof.json, {hash}.asc, {hash}.proof.json.asc, pubkey.asc, {hash}.ots + * Returns all files ready for upload. + */ + fun generateCompanionFiles(context: Context, evidenceFile: File, mediaHash: String): List { + if (!Prefs.useC2pa) return emptyList() + return try { + val outDir = File(context.filesDir, "proof_companions/$mediaHash").also { it.mkdirs() } + + // 1. Build proof JSON with ProofMode field names + val proofJson = buildProofJson(evidenceFile, mediaHash) + val proofJsonFile = File(outDir, "$mediaHash.proof.json") + proofJsonFile.writeText(proofJson) + + // 2. PGP sign the media file + val mediaSigFile = File(outDir, "$mediaHash.asc") + pgpSign(context, evidenceFile.readBytes(), mediaSigFile) + + // 3. PGP sign the proof JSON + val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc") + pgpSign(context, proofJson.toByteArray(Charsets.UTF_8), jsonSigFile) + + // 4. Public key (verifier needs this to check PGP sigs) + val pubKeyFile = File(context.filesDir, PUBLIC_KEY_FILE) + + // 5. OpenTimestamps — submit hash to OTS calendar, save .ots + val otsFile = File(outDir, "$mediaHash.ots") + submitOts(mediaHash, otsFile) + + buildList { + add(proofJsonFile) + add(mediaSigFile) + add(jsonSigFile) + if (pubKeyFile.exists()) add(pubKeyFile) + if (otsFile.exists()) add(otsFile) + }.also { + AppLogger.i("[Proof] Generated ${it.size} companion files for $mediaHash") + } + } catch (e: Exception) { + AppLogger.e("[Proof] Failed for $mediaHash", e) + emptyList() + } + } + + // --------------------------------------------------------------------------- + // Proof JSON — ProofMode v1 field names + // --------------------------------------------------------------------------- + + private fun buildProofJson(file: File, hash: String): String { + val now = isoFmt.format(Date()) + val created = isoFmt.format(Date(file.lastModified())) + + val fields = linkedMapOf() + fields["File Hash SHA256"] = hash + fields["File Path"] = file.absolutePath + fields["File Created"] = created + fields["File Modified"] = created + fields["Proof Generated"] = now + fields["Notes"] = "OpenArchive Save ${BuildConfig.VERSION_NAME}" + fields["Manufacturer"] = Build.MANUFACTURER + fields["Hardware"] = "${Build.MANUFACTURER} ${Build.MODEL}" + fields["Locale"] = Locale.getDefault().country + fields["Language"] = Locale.getDefault().displayLanguage + + // Read GPS + device info from EXIF written at capture time + try { + val exif = ExifInterface(file.absolutePath) + val latLon = exif.latLong + if (latLon != null) { + fields["Location.Latitude"] = latLon[0].toString() + fields["Location.Longitude"] = latLon[1].toString() + } + exif.getAttribute(ExifInterface.TAG_GPS_ALTITUDE) + ?.let { fields["Location.Altitude"] = it } + exif.getAttribute(ExifInterface.TAG_GPS_SPEED) + ?.let { fields["Location.Speed"] = it } + exif.getAttribute(ExifInterface.TAG_GPS_TRACK) + ?.let { fields["Location.Bearing"] = it } + exif.getAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD) + ?.removePrefix("charset=Ascii ")?.lowercase() + ?.let { fields["Location.Provider"] = it } + } catch (_: Exception) {} + + val sb = StringBuilder("{\n") + val entries = fields.entries.filter { it.value != null }.toList() + entries.forEachIndexed { i, (k, v) -> + sb.append(" ").append(jsonString(k)).append(": ").append(jsonString(v!!)) + if (i < entries.size - 1) sb.append(",") + sb.append("\n") + } + sb.append("}") + return sb.toString() + } + + // --------------------------------------------------------------------------- + // PGP key generation and detached signing + // --------------------------------------------------------------------------- + + private fun ensureKeyExists(context: Context) { + val secretFile = File(context.filesDir, SECRET_KEY_FILE) + val publicFile = File(context.filesDir, PUBLIC_KEY_FILE) + if (secretFile.exists() && publicFile.exists()) return + + AppLogger.i("[Proof] Generating PGP key pair") + + val kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleProvider.PROVIDER_NAME) + kpg.initialize(2048, SecureRandom()) + val keyPair = kpg.generateKeyPair() + + val digestCalc = JcaPGPDigestCalculatorProviderBuilder() + .setProvider(BouncyCastleProvider.PROVIDER_NAME).build() + .get(HashAlgorithmTags.SHA1) + + val pgpKeyPair = JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, keyPair, Date()) + + val secretKeyEncryptor = JcePBESecretKeyEncryptorBuilder( + PGPEncryptedData.AES_256, digestCalc + ).setProvider(BouncyCastleProvider.PROVIDER_NAME).build(PASSPHRASE) + + val signerBuilder = JcaPGPContentSignerBuilder( + pgpKeyPair.publicKey.algorithm, HashAlgorithmTags.SHA256 + ).setProvider(BouncyCastleProvider.PROVIDER_NAME) + + val keyRingGen = PGPKeyRingGenerator( + PGPSignature.POSITIVE_CERTIFICATION, + pgpKeyPair, + PGP_IDENTITY, + digestCalc, + null, + null, + signerBuilder, + secretKeyEncryptor + ) + + // Write secret key ring + secretFile.outputStream().use { keyRingGen.generateSecretKeyRing().encode(it) } + + // Write public key in ASCII armor + publicFile.outputStream().use { out -> + ArmoredOutputStream(out).use { armoredOut -> + keyRingGen.generatePublicKeyRing().encode(armoredOut) + } + } + AppLogger.i("[Proof] PGP key pair written") + } + + private fun pgpSign(context: Context, data: ByteArray, outFile: File) { + val secretFile = File(context.filesDir, SECRET_KEY_FILE) + if (!secretFile.exists()) { + ensureKeyExists(context) + if (!secretFile.exists()) return + } + + val secretKeyRing = PGPSecretKeyRing( + PGPUtil.getDecoderStream(secretFile.inputStream()), + JcaKeyFingerprintCalculator() + ) + + val secretKey = secretKeyRing.secretKey + val privateKey = secretKey.extractPrivateKey( + JcePBESecretKeyDecryptorBuilder() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(PASSPHRASE) + ) + + val sigGen = PGPSignatureGenerator( + JcaPGPContentSignerBuilder(secretKey.publicKey.algorithm, HashAlgorithmTags.SHA256) + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + ) + sigGen.init(PGPSignature.BINARY_DOCUMENT, privateKey) + sigGen.update(data) + + ByteArrayOutputStream().use { baos -> + ArmoredOutputStream(baos).use { armoredOut -> + BCPGOutputStream(armoredOut).use { bcpgOut -> + sigGen.generate().encode(bcpgOut) + } + } + outFile.writeBytes(baos.toByteArray()) + } + } + + // --------------------------------------------------------------------------- + // OpenTimestamps — submit SHA256 hash to OTS calendar + // --------------------------------------------------------------------------- + + private fun submitOts(hexHash: String, outFile: File) { + try { + val hashBytes = hexToBytes(hexHash) + val url = URL(OTS_CALENDAR) + val conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = OTS_TIMEOUT_MS + readTimeout = OTS_TIMEOUT_MS + setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + setRequestProperty("Accept", "application/octet-stream") + } + conn.outputStream.use { it.write(hashBytes) } + if (conn.responseCode == 200) { + val bytes = conn.inputStream.use { it.readBytes() } + outFile.writeBytes(bytes) + AppLogger.i("[OTS] Timestamp received (${bytes.size} bytes)") + } else { + AppLogger.w("[OTS] Calendar returned HTTP ${conn.responseCode}") + } + conn.disconnect() + } catch (e: Exception) { + AppLogger.w("[OTS] Submission failed: ${e.message}") + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private fun hexToBytes(hex: String): ByteArray { + val len = hex.length + return ByteArray(len / 2) { i -> + ((hex[i * 2].digitToInt(16) shl 4) + hex[i * 2 + 1].digitToInt(16)).toByte() + } + } + + private fun jsonString(s: String): String { + val escaped = s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + return "\"$escaped\"" + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 107491f7..32ca80bc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -88,6 +88,7 @@ dependencyResolutionManagement { includeModule("com.github.contentauth", "c2pa-android") } } + } } rootProject.name = "save-android-old" From 88b1d2a44d8010048bae2dbd2e6665b98b08df4f Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 1 Jun 2026 15:20:39 +0530 Subject: [PATCH 03/15] Bump version to 4.0.6 (30042). --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 25b35e71..520fa85e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,8 +67,8 @@ android { applicationId = "net.opendasharchive.openarchive" minSdk = 29 targetSdk = 36 - versionCode = 30041 - versionName = "4.0.5" + versionCode = 30042 + versionName = "4.0.6" multiDexEnabled = true vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From d2c47f719785ac0a74bb67b4970647bf4588ea9f Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 1 Jun 2026 22:40:59 +0530 Subject: [PATCH 04/15] Refactor C2PA and Proofmode security, implement capture-time signing, and improve metadata reliability. Specific changes: - **C2PA Security & Performance**: - Implemented encryption-at-rest for C2PA private keys using AES-256-GCM with wrap keys stored in Android Keystore. - Optimized signing workflow to use `FileStream`, preventing large files from being loaded entirely into memory. - Added a post-sign verification step to ensure C2PA manifests are correctly embedded before modifying original files. - Updated certificate profiles to use `id-kp-documentSigning` (RFC 9336) and improved serial number generation. - **Proof Generation & PGP**: - Decoupled proof generation into local capture-time signing (PGP/JSON) and upload-time network tasks (OTS). - Migrated PGP keys from RSA to Ed25519 and implemented streaming PGP signing. - Added Android Keystore-backed encryption for the PGP secret key ring. - Implemented a persistent retry queue (`pending_ots.txt`) for OpenTimestamps submissions to handle network failures. - **Metadata & EXIF**: - Enhanced EXIF metadata with UTC offset tags (`TAG_OFFSET_TIME`) for forensic timestamp accuracy. - Improved `CellInfo` collection by filtering out `Int.MAX_VALUE` sentinel values. - Standardized capture time extraction using `ExifInterface` for consistency between proof JSON and C2PA manifests. - **Storage & Cleanup**: - Updated `WebDavConduit` to recursively delete local proof companion files after successful uploads. - Modified `Conduit.getProofFiles` to be a suspend function to support asynchronous OTS preparation. --- .../features/media/camera/CameraViewModel.kt | 43 ++- .../openarchive/services/Conduit.kt | 9 +- .../services/webdav/data/WebDavConduit.kt | 4 + .../openarchive/util/MetadataCollector.kt | 24 +- .../util/ProofCompanionGenerator.kt | 268 +++++++++++--- .../openarchive/util/ProofmodeC2paManager.kt | 330 ++++++++++++------ 6 files changed, 502 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt index 279475bd..f9265f42 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt @@ -22,9 +22,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.util.MetadataCollector +import net.opendasharchive.openarchive.util.ProofCompanionGenerator import net.opendasharchive.openarchive.util.ProofmodeC2paManager import net.opendasharchive.openarchive.util.Utility import java.io.File +import java.security.MessageDigest class CameraViewModel : ViewModel() { @@ -250,9 +252,17 @@ class CameraViewModel : ViewModel() { val metadata = MetadataCollector.collectMetadata(context) MetadataCollector.writeExifMetadata(file, metadata) AppLogger.d("[C2PA_DEBUG] EXIF written, file size after EXIF: ${file.length()}") - ProofmodeC2paManager.embedProof(file, "image/jpeg", metadata, context) - ?.let { AppLogger.d("[C2PA] Proof embedded: ${it.name}") } - ?: AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") + val mimeType = android.webkit.MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(file.extension.lowercase()) + ?: "image/jpeg" + val signedFile = ProofmodeC2paManager.embedProof(file, mimeType, metadata, context) + if (signedFile != null) { + AppLogger.d("[C2PA] Proof embedded: ${signedFile.name}") + val hash = computeFileHash(signedFile) + if (hash.isNotEmpty()) ProofCompanionGenerator.generateLocalProof(context, signedFile, hash) + } else { + AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") + } } catch (e: Exception) { AppLogger.e("Provenance write failed for ${file.name}", e) } @@ -262,14 +272,35 @@ class CameraViewModel : ViewModel() { try { AppLogger.d("[C2PA_DEBUG] writeProvenanceForVideo: file=${file.absolutePath} exists=${file.exists()} size=${file.length()}") val metadata = MetadataCollector.collectMetadata(context) - ProofmodeC2paManager.embedProof(file, "video/mp4", metadata, context) - ?.let { AppLogger.d("[C2PA] Proof embedded: ${it.name}") } - ?: AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") + val mimeType = android.webkit.MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(file.extension.lowercase()) + ?: "video/mp4" + val signedFile = ProofmodeC2paManager.embedProof(file, mimeType, metadata, context) + if (signedFile != null) { + AppLogger.d("[C2PA] Proof embedded: ${signedFile.name}") + val hash = computeFileHash(signedFile) + if (hash.isNotEmpty()) ProofCompanionGenerator.generateLocalProof(context, signedFile, hash) + } else { + AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") + } } catch (e: Exception) { AppLogger.e("Provenance write failed for ${file.name}", e) } } + private fun computeFileHash(file: File): String = try { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { stream -> + val buf = ByteArray(8192) + var n: Int + while (stream.read(buf).also { n = it } != -1) digest.update(buf, 0, n) + } + digest.digest().joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + AppLogger.e("[C2PA] Hash computation failed for ${file.name}", e) + "" + } + fun stopVideoRecording() { if (_state.value.isRecording) { currentRecording?.stop() diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index 58691b19..86a28874 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -125,11 +125,12 @@ abstract class Conduit( } /** - * Generate ProofMode companion files (.proof.json, .asc, .ots) for the evidence file. - * Returns the generated files ready for upload. Call at upload time. + * Returns proof companion files for upload. + * PGP signatures and proof.json were generated at capture time by CameraViewModel. + * This call only submits OTS (network) and returns the pre-generated file list. */ - fun getProofFiles(): List = - ProofCompanionGenerator.generateCompanionFiles(mContext, mEvidence.file, mEvidence.mediaHashString) + suspend fun getProofFiles(): List = + ProofCompanionGenerator.prepareForUpload(mContext, mEvidence.file, mEvidence.mediaHashString) /** * result is a site specific unique id that we can use to fetch the data, diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt index 8ad14475..433e2fb5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/data/WebDavConduit.kt @@ -271,6 +271,10 @@ class WebDavConduit(evidence: Evidence, context: Context) : Conduit(evidence, co .build() ) } + + // Delete companion files from local storage after successful upload + java.io.File(mContext.filesDir, "proof_companions/${mEvidence.mediaHashString}") + .deleteRecursively() } // --- WebDAV HTTP helpers --- diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/MetadataCollector.kt b/app/src/main/java/net/opendasharchive/openarchive/util/MetadataCollector.kt index 3a3800c1..affb5109 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/MetadataCollector.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/MetadataCollector.kt @@ -217,9 +217,24 @@ object MetadataCollector { "${metadata.appName} ${metadata.appVersion}" ) - // --- Capture datetime --- + // --- Capture datetime (local time, per EXIF spec) --- val dtFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) - exif.setAttribute(ExifInterface.TAG_DATETIME, dtFormat.format(Date(metadata.captureTime))) + val captureDate = Date(metadata.captureTime) + val localDateStr = dtFormat.format(captureDate) + exif.setAttribute(ExifInterface.TAG_DATETIME, localDateStr) + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, localDateStr) + exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, localDateStr) + + // UTC offset (EXIF 2.31) — makes timestamps timezone-unambiguous for forensic tools + val offsetMs = java.util.TimeZone.getDefault().getOffset(metadata.captureTime) + val offsetSign = if (offsetMs >= 0) "+" else "-" + val absOffset = kotlin.math.abs(offsetMs) + val offsetHours = absOffset / 3_600_000 + val offsetMins = (absOffset % 3_600_000) / 60_000 + val offsetStr = "%s%02d:%02d".format(offsetSign, offsetHours, offsetMins) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME, offsetStr) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, offsetStr) + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_DIGITIZED, offsetStr) exif.saveAttributes() AppLogger.d("[Metadata] EXIF written to ${file.name}") @@ -369,7 +384,10 @@ object MetadataCollector { if (entries.isEmpty()) return null entries.joinToString(",", "[", "]") { entry -> - entry.entries.joinToString(",", "{", "}") { (k, v) -> "\"$k\":$v" } + // Android returns Int.MAX_VALUE as sentinel for unavailable cell fields — exclude them + entry.entries + .filter { (_, v) -> v !is Int || (v != Int.MAX_VALUE && v != Int.MIN_VALUE) } + .joinToString(",", "{", "}") { (k, v) -> "\"$k\":$v" } } } catch (e: Exception) { AppLogger.w("[Metadata] CellInfo unavailable: ${e.message}") diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt index 2810bcbe..b1f3d464 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt @@ -2,6 +2,8 @@ package net.opendasharchive.openarchive.util import android.content.Context import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import androidx.exifinterface.media.ExifInterface import net.opendasharchive.openarchive.BuildConfig import net.opendasharchive.openarchive.core.logger.AppLogger @@ -27,23 +29,38 @@ import java.io.File import java.net.HttpURLConnection import java.net.URL import java.security.KeyPairGenerator +import java.security.KeyStore import java.security.SecureRandom import java.security.Security import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.util.TimeZone +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec object ProofCompanionGenerator { private const val SECRET_KEY_FILE = "proof_pgp_secret.bpg" private const val PUBLIC_KEY_FILE = "pubkey.asc" + private const val PENDING_OTS_FILE = "pending_ots.txt" private const val PGP_IDENTITY = "OpenArchive Save " private const val OTS_CALENDAR = "https://a.pool.opentimestamps.org/timestamp" private const val OTS_TIMEOUT_MS = 15_000 + // PGP BouncyCastle API requires a passphrase for its internal AES layer. + // The secret key ring file itself is additionally encrypted at rest via a + // Keystore-backed AES-256-GCM key (see writeEncryptedKey / readEncryptedKey). private val PASSPHRASE = charArrayOf() + private const val KEYSTORE_ALIAS = "openarchive_pgp_wrap_key" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" + private const val GCM_TAG_LEN = 128 + private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } @@ -60,35 +77,55 @@ object ProofCompanionGenerator { } /** - * Generate ProofMode-compatible companion files: - * {hash}.proof.json, {hash}.asc, {hash}.proof.json.asc, pubkey.asc, {hash}.ots - * Returns all files ready for upload. + * Called at capture time (no network). Generates proof.json and PGP signatures + * immediately after C2PA embedding while the file bytes are known-final. + * This ensures PGP signatures are provably of the same bytes the SHA-256 hash covers. */ - fun generateCompanionFiles(context: Context, evidenceFile: File, mediaHash: String): List { - if (!Prefs.useC2pa) return emptyList() - return try { - val outDir = File(context.filesDir, "proof_companions/$mediaHash").also { it.mkdirs() } + suspend fun generateLocalProof(context: Context, evidenceFile: File, mediaHash: String) { + if (!Prefs.useC2pa) return + withContext(Dispatchers.IO) { + try { + val outDir = File(context.filesDir, "proof_companions/$mediaHash").also { it.mkdirs() } - // 1. Build proof JSON with ProofMode field names - val proofJson = buildProofJson(evidenceFile, mediaHash) - val proofJsonFile = File(outDir, "$mediaHash.proof.json") - proofJsonFile.writeText(proofJson) + val proofJson = buildProofJson(evidenceFile, mediaHash) + File(outDir, "$mediaHash.proof.json").writeText(proofJson) - // 2. PGP sign the media file - val mediaSigFile = File(outDir, "$mediaHash.asc") - pgpSign(context, evidenceFile.readBytes(), mediaSigFile) + pgpSignStream(context, evidenceFile, File(outDir, "$mediaHash.asc")) + pgpSign(context, proofJson.toByteArray(Charsets.UTF_8), File(outDir, "$mediaHash.proof.json.asc")) - // 3. PGP sign the proof JSON - val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc") - pgpSign(context, proofJson.toByteArray(Charsets.UTF_8), jsonSigFile) + AppLogger.i("[Proof] Local proof generated at capture time for $mediaHash") + } catch (e: Exception) { + AppLogger.e("[Proof] Local proof generation failed for $mediaHash", e) + } + } + } - // 4. Public key (verifier needs this to check PGP sigs) - val pubKeyFile = File(context.filesDir, PUBLIC_KEY_FILE) + /** + * Called at upload time. Submits OTS (network), falls back to generating proof files + * if generateLocalProof was not called at capture, then returns all files for upload. + */ + suspend fun prepareForUpload(context: Context, evidenceFile: File, mediaHash: String): List = withContext(Dispatchers.IO) { + if (!Prefs.useC2pa) return@withContext emptyList() + try { + val outDir = File(context.filesDir, "proof_companions/$mediaHash").also { it.mkdirs() } + val proofJsonFile = File(outDir, "$mediaHash.proof.json") + val mediaSigFile = File(outDir, "$mediaHash.asc") + val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc") + + // Defensive fallback — should not occur for camera captures but handles edge cases + if (!proofJsonFile.exists() || !mediaSigFile.exists() || !jsonSigFile.exists()) { + AppLogger.w("[Proof] Local proof missing for $mediaHash — generating at upload time") + val proofJson = buildProofJson(evidenceFile, mediaHash) + proofJsonFile.writeText(proofJson) + pgpSignStream(context, evidenceFile, mediaSigFile) + pgpSign(context, proofJson.toByteArray(Charsets.UTF_8), jsonSigFile) + } - // 5. OpenTimestamps — submit hash to OTS calendar, save .ots + // OTS is network-only — submitted at upload time with retry queue val otsFile = File(outDir, "$mediaHash.ots") - submitOts(mediaHash, otsFile) + submitOts(context, mediaHash, otsFile) + val pubKeyFile = File(context.filesDir, PUBLIC_KEY_FILE) buildList { add(proofJsonFile) add(mediaSigFile) @@ -96,10 +133,10 @@ object ProofCompanionGenerator { if (pubKeyFile.exists()) add(pubKeyFile) if (otsFile.exists()) add(otsFile) }.also { - AppLogger.i("[Proof] Generated ${it.size} companion files for $mediaHash") + AppLogger.i("[Proof] ${it.size} companion files ready for upload: $mediaHash") } } catch (e: Exception) { - AppLogger.e("[Proof] Failed for $mediaHash", e) + AppLogger.e("[Proof] Upload prep failed for $mediaHash", e) emptyList() } } @@ -110,7 +147,20 @@ object ProofCompanionGenerator { private fun buildProofJson(file: File, hash: String): String { val now = isoFmt.format(Date()) - val created = isoFmt.format(Date(file.lastModified())) + + // Read capture time from EXIF TAG_DATETIME — written at capture time by MetadataCollector, + // guaranteed consistent with the C2PA manifest timestamp and the EXIF in the signed file. + val exifDtFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val created = try { + ExifInterface(file.absolutePath) + .getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + ?.let { raw -> exifDtFormat.parse(raw)?.let { isoFmt.format(it) } } + ?: isoFmt.format(Date(file.lastModified())) + } catch (_: Exception) { + isoFmt.format(Date(file.lastModified())) + } val fields = linkedMapOf() fields["File Hash SHA256"] = hash @@ -163,17 +213,17 @@ object ProofCompanionGenerator { val publicFile = File(context.filesDir, PUBLIC_KEY_FILE) if (secretFile.exists() && publicFile.exists()) return - AppLogger.i("[Proof] Generating PGP key pair") + AppLogger.i("[Proof] Generating Ed25519 PGP key pair") - val kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleProvider.PROVIDER_NAME) - kpg.initialize(2048, SecureRandom()) + val kpg = KeyPairGenerator.getInstance("Ed25519", BouncyCastleProvider.PROVIDER_NAME) + kpg.initialize(256, SecureRandom()) val keyPair = kpg.generateKeyPair() val digestCalc = JcaPGPDigestCalculatorProviderBuilder() .setProvider(BouncyCastleProvider.PROVIDER_NAME).build() .get(HashAlgorithmTags.SHA1) - val pgpKeyPair = JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, keyPair, Date()) + val pgpKeyPair = JcaPGPKeyPair(PGPPublicKey.EDDSA, keyPair, Date()) val secretKeyEncryptor = JcePBESecretKeyEncryptorBuilder( PGPEncryptedData.AES_256, digestCalc @@ -194,8 +244,10 @@ object ProofCompanionGenerator { secretKeyEncryptor ) - // Write secret key ring - secretFile.outputStream().use { keyRingGen.generateSecretKeyRing().encode(it) } + // Write secret key ring — encrypted with Keystore-backed AES-256-GCM + val secretBytes = ByteArrayOutputStream() + .also { keyRingGen.generateSecretKeyRing().encode(it) }.toByteArray() + writeEncryptedKey(secretFile, secretBytes) // Write public key in ASCII armor publicFile.outputStream().use { out -> @@ -207,31 +259,55 @@ object ProofCompanionGenerator { } private fun pgpSign(context: Context, data: ByteArray, outFile: File) { + val sigGen = initPgpSigGen(context) ?: return + sigGen.update(data) + writePgpSig(sigGen, outFile) + } + + private fun pgpSignStream(context: Context, sourceFile: File, outFile: File) { + val sigGen = initPgpSigGen(context) ?: return + sourceFile.inputStream().use { stream -> + val buf = ByteArray(8192) + var n: Int + while (stream.read(buf).also { n = it } != -1) sigGen.update(buf, 0, n) + } + writePgpSig(sigGen, outFile) + } + + private fun initPgpSigGen(context: Context): PGPSignatureGenerator? { val secretFile = File(context.filesDir, SECRET_KEY_FILE) if (!secretFile.exists()) { ensureKeyExists(context) - if (!secretFile.exists()) return + if (!secretFile.exists()) return null + } + val secretBytes = readEncryptedKey(secretFile) ?: run { + // Keystore wrap key lost — wipe and regenerate both PGP key files + AppLogger.w("[Proof] Wrap key lost — wiping stale PGP key files and regenerating") + File(context.filesDir, SECRET_KEY_FILE).delete() + File(context.filesDir, PUBLIC_KEY_FILE).delete() + ensureKeyExists(context) + readEncryptedKey(File(context.filesDir, SECRET_KEY_FILE)) ?: run { + AppLogger.e("[Proof] Failed to decrypt PGP secret key after regeneration") + return null + } } - val secretKeyRing = PGPSecretKeyRing( - PGPUtil.getDecoderStream(secretFile.inputStream()), + PGPUtil.getDecoderStream(secretBytes.inputStream()), JcaKeyFingerprintCalculator() ) - val secretKey = secretKeyRing.secretKey val privateKey = secretKey.extractPrivateKey( JcePBESecretKeyDecryptorBuilder() .setProvider(BouncyCastleProvider.PROVIDER_NAME) .build(PASSPHRASE) ) - - val sigGen = PGPSignatureGenerator( + return PGPSignatureGenerator( JcaPGPContentSignerBuilder(secretKey.publicKey.algorithm, HashAlgorithmTags.SHA256) .setProvider(BouncyCastleProvider.PROVIDER_NAME) - ) - sigGen.init(PGPSignature.BINARY_DOCUMENT, privateKey) - sigGen.update(data) + ).also { it.init(PGPSignature.BINARY_DOCUMENT, privateKey) } + } + private fun writePgpSig(sigGen: PGPSignatureGenerator, outFile: File) { ByteArrayOutputStream().use { baos -> ArmoredOutputStream(baos).use { armoredOut -> BCPGOutputStream(armoredOut).use { bcpgOut -> @@ -243,14 +319,44 @@ object ProofCompanionGenerator { } // --------------------------------------------------------------------------- - // OpenTimestamps — submit SHA256 hash to OTS calendar + // OpenTimestamps — submit SHA256 hash to OTS calendar with retry queue // --------------------------------------------------------------------------- - private fun submitOts(hexHash: String, outFile: File) { - try { + private fun submitOts(context: Context, hexHash: String, outFile: File) { + // Drain any previously failed hashes before submitting the new one + drainPendingOts(context) + + if (!postOts(hexHash, outFile)) { + // Network unavailable — queue for next upload + enqueuePendingOts(context, hexHash) + AppLogger.w("[OTS] Queued $hexHash for retry") + } + } + + private fun drainPendingOts(context: Context) { + val queue = File(context.filesDir, PENDING_OTS_FILE) + if (!queue.exists()) return + val remaining = mutableListOf() + queue.readLines().filter { it.isNotBlank() }.forEach { hash -> + val companionDir = File(context.filesDir, "proof_companions/$hash") + // Companion dir deleted after upload — nothing left to anchor, drop from queue + if (!companionDir.exists()) return@forEach + val otsFile = File(companionDir, "$hash.ots") + if (otsFile.exists()) return@forEach // already succeeded + if (!postOts(hash, otsFile)) remaining.add(hash) + } + if (remaining.isEmpty()) queue.delete() else queue.writeText(remaining.joinToString("\n")) + } + + private fun enqueuePendingOts(context: Context, hexHash: String) { + val queue = File(context.filesDir, PENDING_OTS_FILE) + queue.appendText("$hexHash\n") + } + + private fun postOts(hexHash: String, outFile: File): Boolean { + return try { val hashBytes = hexToBytes(hexHash) - val url = URL(OTS_CALENDAR) - val conn = (url.openConnection() as HttpURLConnection).apply { + val conn = (URL(OTS_CALENDAR).openConnection() as HttpURLConnection).apply { requestMethod = "POST" doOutput = true connectTimeout = OTS_TIMEOUT_MS @@ -259,19 +365,85 @@ object ProofCompanionGenerator { setRequestProperty("Accept", "application/octet-stream") } conn.outputStream.use { it.write(hashBytes) } - if (conn.responseCode == 200) { - val bytes = conn.inputStream.use { it.readBytes() } - outFile.writeBytes(bytes) - AppLogger.i("[OTS] Timestamp received (${bytes.size} bytes)") + val success = conn.responseCode == 200 + if (success) { + outFile.parentFile?.mkdirs() + outFile.writeBytes(conn.inputStream.use { it.readBytes() }) + AppLogger.i("[OTS] Timestamp received for $hexHash") } else { AppLogger.w("[OTS] Calendar returned HTTP ${conn.responseCode}") } conn.disconnect() + success } catch (e: Exception) { AppLogger.w("[OTS] Submission failed: ${e.message}") + false + } + } + + // --------------------------------------------------------------------------- + // AES-256-GCM key wrapping for PGP secret key ring + // --------------------------------------------------------------------------- + + private fun ensureWrapKey() { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + if (ks.containsAlias(KEYSTORE_ALIAS)) return + val spec = KeyGenParameterSpec.Builder( + KEYSTORE_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + .also { it.init(spec) } + .generateKey() + } + + private fun writeEncryptedKey(file: File, plaintext: ByteArray) { + ensureWrapKey() + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + val key = ks.getKey(KEYSTORE_ALIAS, null) as javax.crypto.SecretKey + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext) + file.outputStream().use { out -> + out.write(iv.size.to4Bytes()) + out.write(iv) + out.write(ciphertext) } } + private fun readEncryptedKey(file: File): ByteArray? { + return try { + ensureWrapKey() + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + val key = ks.getKey(KEYSTORE_ALIAS, null) as javax.crypto.SecretKey + val bytes = file.readBytes() + val ivLen = bytes.sliceArray(0..3).from4Bytes() + val iv = bytes.sliceArray(4 until 4 + ivLen) + val ciphertext = bytes.sliceArray(4 + ivLen until bytes.size) + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LEN, iv)) + cipher.doFinal(ciphertext) + } catch (e: Exception) { + AppLogger.e("[Proof] Failed to decrypt PGP key file", e) + null + } + } + + private fun Int.to4Bytes(): ByteArray = byteArrayOf( + (this shr 24).toByte(), (this shr 16).toByte(), (this shr 8).toByte(), this.toByte() + ) + + private fun ByteArray.from4Bytes(): Int = + ((this[0].toInt() and 0xFF) shl 24) or + ((this[1].toInt() and 0xFF) shl 16) or + ((this[2].toInt() and 0xFF) shl 8) or + (this[3].toInt() and 0xFF) + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt index d55fad18..b240eeed 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt @@ -1,6 +1,8 @@ package net.opendasharchive.openarchive.util import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import net.opendasharchive.openarchive.BuildConfig import net.opendasharchive.openarchive.core.logger.AppLogger import org.bouncycastle.asn1.ASN1ObjectIdentifier @@ -23,9 +25,9 @@ import org.bouncycastle.operator.ContentSigner import org.bouncycastle.util.io.pem.PemObject import org.bouncycastle.util.io.pem.PemWriter import org.contentauth.c2pa.Builder -import org.contentauth.c2pa.ByteArrayStream import org.contentauth.c2pa.C2PA -import org.contentauth.c2pa.DataStream +import org.contentauth.c2pa.FileStream +import org.contentauth.c2pa.Reader import org.contentauth.c2pa.Signer import org.contentauth.c2pa.SigningAlgorithm import java.io.ByteArrayOutputStream @@ -33,21 +35,39 @@ import java.io.File import java.io.StringWriter import java.math.BigInteger import java.security.KeyPairGenerator +import java.security.KeyStore import java.security.PrivateKey import java.security.PublicKey +import java.security.SecureRandom import java.security.Security import java.security.Signature import java.security.cert.X509Certificate +import java.security.spec.ECGenParameterSpec import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec object ProofmodeC2paManager { - private const val KEY_FILE = "c2pa_private.key" + // EC P-256 signing key pair generated by BouncyCastle (software). + // Private key is encrypted at rest with AES-256-GCM; the AES wrap key lives + // in Android Keystore (TEE-backed, never exported to disk). + // Signer.fromKeys is used because KeyStoreSigner.createSigner is broken in + // c2pa-android 0.0.9 — consistently throws "COSE Signer does not contain + // signing certificate" regardless of cert format. + private const val KEY_FILE = "c2pa_private.key" private const val CERT_CHAIN_FILE = "c2pa_cert_chain.pem" - private const val CA_CERT_FILE = "c2pa_ca_cert.pem" + private const val CA_CERT_FILE = "c2pa_ca_cert.pem" + private const val WRAP_KEY_ALIAS = "openarchive_c2pa_wrap_key" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" + private const val GCM_TAG_LEN = 128 + + @Volatile private var trustSettingsLoaded = false private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") @@ -57,6 +77,7 @@ object ProofmodeC2paManager { try { Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) Security.insertProviderAt(BouncyCastleProvider(), 1) + trustSettingsLoaded = false ensureCertsExist(context) AppLogger.i("[C2PA] ProofmodeC2paManager initialized") } catch (e: Exception) { @@ -77,45 +98,66 @@ object ProofmodeC2paManager { } return try { - val privateKeyPem = loadFile(context, KEY_FILE) ?: run { + val privateKeyPem = loadKeyPem(context) ?: run { + ensureCertsExist(context) + loadKeyPem(context) ?: return null + } + val certChainPem = loadPlain(context, CERT_CHAIN_FILE) ?: run { ensureCertsExist(context) - loadFile(context, KEY_FILE) ?: return null + loadPlain(context, CERT_CHAIN_FILE) ?: return null } - val certChainPem = loadFile(context, CERT_CHAIN_FILE) ?: run { + val caCertPem = loadPlain(context, CA_CERT_FILE) ?: run { ensureCertsExist(context) - loadFile(context, CERT_CHAIN_FILE) ?: return null + loadPlain(context, CA_CERT_FILE) ?: return null } - val caCertPem = loadFile(context, CA_CERT_FILE) ?: return null - // Register our root CA as a trust anchor so c2pa-rs accepts our self-signed chain - val trustSettings = buildTrustSettings(caCertPem) - try { - C2PA.loadSettings(trustSettings, "json") - } catch (e: Exception) { - AppLogger.w("[C2PA] loadSettings warning: ${e.message}") + if (!trustSettingsLoaded) { + try { + C2PA.loadSettings(buildTrustSettings(caCertPem), "json") + trustSettingsLoaded = true + } catch (e: Exception) { + AppLogger.w("[C2PA] loadSettings warning: ${e.message}") + } } val manifestJson = buildManifestJson(metadata) - val signedBytes = Builder.fromJson(manifestJson).use { builder -> + val tmp = File(file.parent, "${file.nameWithoutExtension}_c2pa_tmp.${file.extension}") + tmp.delete() + + // Signer.fromKeys — proven working in c2pa-android 0.0.9. + // FileStream source + dest: full file never loaded into RAM. + Builder.fromJson(manifestJson).use { builder -> Signer.fromKeys(certChainPem, privateKeyPem, SigningAlgorithm.ES256).use { signer -> - DataStream(file.readBytes()).use { source -> - ByteArrayStream().use { dest -> + FileStream(file, FileStream.Mode.READ, false).use { source -> + FileStream(tmp, FileStream.Mode.READ_WRITE, true).use { dest -> builder.sign(mimeType, source, dest, signer) - dest.getData() } } } } - val tmp = File(file.parent, "${file.nameWithoutExtension}_c2pa_tmp.${file.extension}") - tmp.writeBytes(signedBytes) - if (!tmp.renameTo(file)) { - file.writeBytes(signedBytes) + val verified = try { + FileStream(tmp, FileStream.Mode.READ, false).use { s -> + Reader.fromStream(mimeType, s).use { it.isEmbedded() } + } + } catch (e: Exception) { + AppLogger.e("[C2PA] Post-sign verification threw: ${e.message}") tmp.delete() + false + } + if (!verified) { + AppLogger.e("[C2PA] Post-sign verification failed — original preserved: ${file.name}") + tmp.delete() + return null } - AppLogger.i("[C2PA] Manifest embedded: ${file.name} (${signedBytes.size / 1024} KB)") + if (!tmp.renameTo(file)) { + file.delete() + tmp.renameTo(file) + } + + AppLogger.i("[C2PA] Manifest embedded + verified: ${file.name} (${file.length() / 1024} KB)") file } catch (e: Exception) { AppLogger.e("[C2PA] Failed to embed proof in ${file.name}", e) @@ -124,95 +166,173 @@ object ProofmodeC2paManager { } // --------------------------------------------------------------------------- - // Key + cert chain generation (mini CA → leaf, all in software via BouncyCastle) + // Key + cert generation — software EC key, Keystore-wrapped at rest // --------------------------------------------------------------------------- private fun ensureCertsExist(context: Context) { - val keyFile = File(context.filesDir, KEY_FILE) + val keyFile = File(context.filesDir, KEY_FILE) val chainFile = File(context.filesDir, CERT_CHAIN_FILE) - val caFile = File(context.filesDir, CA_CERT_FILE) - if (keyFile.exists() && chainFile.exists() && caFile.exists()) return + val caFile = File(context.filesDir, CA_CERT_FILE) + if (keyFile.exists() && chainFile.exists() && caFile.exists()) { + // Verify encrypted key is still readable (detects Keystore wrap-key loss) + if (loadKeyPem(context) != null) return + AppLogger.w("[C2PA] Wrap key lost — wiping cert files and regenerating") + keyFile.delete(); chainFile.delete(); caFile.delete() + } - AppLogger.i("[C2PA] Generating EC key pair and self-signed cert chain") + AppLogger.i("[C2PA] Generating EC P-256 key pair + cert chain") val kpg = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) kpg.initialize(ECNamedCurveGenParameterSpec("secp256r1")) val keyPair = kpg.generateKeyPair() - val caCert = buildCaCert(keyPair.private, keyPair.public) + val caCert = buildCaCert(keyPair.private, keyPair.public) val leafCert = buildLeafCert(keyPair.private, keyPair.public, caCert) - // Chain = leaf first, then CA - val chainPem = toPem("CERTIFICATE", leafCert.encoded) + toPem("CERTIFICATE", caCert.encoded) - val caCertPem = toPem("CERTIFICATE", caCert.encoded) + val chainPem = toPem("CERTIFICATE", leafCert.encoded) + toPem("CERTIFICATE", caCert.encoded) + val caCertPem = toPem("CERTIFICATE", caCert.encoded) val privateKeyPem = toPem("PRIVATE KEY", keyPair.private.encoded) - keyFile.writeText(privateKeyPem) - chainFile.writeText(chainPem) - caFile.writeText(caCertPem) + // Atomic write via tmp → rename + val tmpKey = File(context.filesDir, "$KEY_FILE.tmp") + val tmpChain = File(context.filesDir, "$CERT_CHAIN_FILE.tmp") + val tmpCa = File(context.filesDir, "$CA_CERT_FILE.tmp") + try { + writeEncrypted(tmpKey, privateKeyPem.toByteArray(Charsets.UTF_8)) + tmpChain.writeText(chainPem) + tmpCa.writeText(caCertPem) + tmpKey.renameTo(keyFile) + tmpChain.renameTo(chainFile) + tmpCa.renameTo(caFile) + } catch (e: Exception) { + tmpKey.delete(); tmpChain.delete(); tmpCa.delete() + throw e + } + + trustSettingsLoaded = false + AppLogger.i("[C2PA] Key + cert chain written") + } - AppLogger.i("[C2PA] Key + cert chain written to internal storage") + private fun loadKeyPem(context: Context): String? { + val f = File(context.filesDir, KEY_FILE) + if (!f.exists()) return null + return readEncrypted(f)?.toString(Charsets.UTF_8) } - /** Self-signed root CA cert (CA:TRUE) — used only as trust anchor */ - private fun buildCaCert(privateKey: PrivateKey, publicKey: PublicKey): X509Certificate { - val subject = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") - val serial = BigInteger.valueOf(1) - val notBefore = Date(System.currentTimeMillis() - 86_400_000L) - val notAfter = Date(System.currentTimeMillis() + 20L * 365 * 24 * 60 * 60 * 1000) + private fun loadPlain(context: Context, name: String): String? { + val f = File(context.filesDir, name) + return if (f.exists()) f.readText() else null + } - val builder = JcaX509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, publicKey) + // --------------------------------------------------------------------------- + // AES-256-GCM wrap for private key file + // --------------------------------------------------------------------------- - val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) - val ski = computeSki(spki) - builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) - builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) - builder.addExtension( - Extension.keyUsage, true, - KeyUsage(KeyUsage.keyCertSign or KeyUsage.cRLSign or KeyUsage.digitalSignature) + private fun ensureWrapKey() { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + if (ks.containsAlias(WRAP_KEY_ALIAS)) return + val spec = KeyGenParameterSpec.Builder( + WRAP_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + .also { it.init(spec) } + .generateKey() + } - val signer = BcContentSigner(privateKey, "SHA256withECDSA") - val holder = builder.build(signer) - return JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(holder) + private fun writeEncrypted(file: File, plaintext: ByteArray) { + ensureWrapKey() + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + val key = ks.getKey(WRAP_KEY_ALIAS, null) as javax.crypto.SecretKey + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext) + file.outputStream().use { out -> + out.write(iv.size.to4Bytes()) + out.write(iv) + out.write(ciphertext) + } } - /** Leaf signing cert (CA:FALSE) with extensions matching c2pa-rs requirements */ - private fun buildLeafCert(privateKey: PrivateKey, publicKey: PublicKey, caCert: X509Certificate): X509Certificate { - val subject = X500Name("CN=OpenArchive Save, O=OpenArchive, C=US") - val issuer = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") - val serial = BigInteger.valueOf(2) - val notBefore = Date(System.currentTimeMillis() - 86_400_000L) - val notAfter = Date(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000) + private fun readEncrypted(file: File): ByteArray? = try { + ensureWrapKey() + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + val key = ks.getKey(WRAP_KEY_ALIAS, null) as javax.crypto.SecretKey + val bytes = file.readBytes() + val ivLen = bytes.sliceArray(0..3).from4Bytes() + val iv = bytes.sliceArray(4 until 4 + ivLen) + val ciphertext = bytes.sliceArray(4 + ivLen until bytes.size) + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LEN, iv)) + cipher.doFinal(ciphertext) + } catch (e: Exception) { + AppLogger.e("[C2PA] Failed to decrypt key file", e) + null + } + + private fun Int.to4Bytes(): ByteArray = byteArrayOf( + (this shr 24).toByte(), (this shr 16).toByte(), (this shr 8).toByte(), this.toByte() + ) + + private fun ByteArray.from4Bytes(): Int = + ((this[0].toInt() and 0xFF) shl 24) or + ((this[1].toInt() and 0xFF) shl 16) or + ((this[2].toInt() and 0xFF) shl 8) or + (this[3].toInt() and 0xFF) - val builder = JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, publicKey) + // --------------------------------------------------------------------------- + // Certificate building — BouncyCastle with software key (works correctly) + // --------------------------------------------------------------------------- + private fun buildCaCert(privateKey: PrivateKey, publicKey: PublicKey): X509Certificate { + val subject = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") + val serial = BigInteger(64, SecureRandom()) + val notBefore = Date(System.currentTimeMillis() - 86_400_000L) + val notAfter = Date(System.currentTimeMillis() + 20L * 365 * 24 * 60 * 60 * 1000) + val builder = JcaX509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, publicKey) val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) - val ski = computeSki(spki) - val caSki = computeSki(SubjectPublicKeyInfo.getInstance(caCert.publicKey.encoded)) + val ski = computeSki(spki) + builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) + builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) + builder.addExtension(Extension.keyUsage, true, + KeyUsage(KeyUsage.keyCertSign or KeyUsage.cRLSign or KeyUsage.digitalSignature)) + return JcaX509CertificateConverter() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(BcContentSigner(privateKey, "SHA256withECDSA"))) + } + private fun buildLeafCert(privateKey: PrivateKey, publicKey: PublicKey, caCert: X509Certificate): X509Certificate { + val subject = X500Name("CN=OpenArchive Save, O=OpenArchive, C=US") + val issuer = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") + val serial = BigInteger(64, SecureRandom()) + val notBefore = Date(System.currentTimeMillis() - 86_400_000L) + val notAfter = Date(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000) + val builder = JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, publicKey) + val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) + val ski = computeSki(spki) + val caSki = computeSki(SubjectPublicKeyInfo.getInstance(caCert.publicKey.encoded)) builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) builder.addExtension(Extension.authorityKeyIdentifier, false, AuthorityKeyIdentifier(caSki)) builder.addExtension(Extension.basicConstraints, true, BasicConstraints(false)) - builder.addExtension( - Extension.keyUsage, true, - KeyUsage(KeyUsage.digitalSignature or KeyUsage.nonRepudiation) - ) - builder.addExtension( - Extension.extendedKeyUsage, true, - ExtendedKeyUsage(KeyPurposeId.id_kp_emailProtection) - ) - - val signer = BcContentSigner(privateKey, "SHA256withECDSA") - val holder = builder.build(signer) - return JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(holder) + builder.addExtension(Extension.keyUsage, true, + KeyUsage(KeyUsage.digitalSignature or KeyUsage.nonRepudiation)) + // id-kp-documentSigning (RFC 9336) — correct EKU for media signing certs + builder.addExtension(Extension.extendedKeyUsage, true, + ExtendedKeyUsage(KeyPurposeId.getInstance(ASN1ObjectIdentifier("1.3.6.1.5.5.7.3.36")))) + return JcaX509CertificateConverter() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(BcContentSigner(privateKey, "SHA256withECDSA"))) } - /** Compute SHA-1 of SubjectPublicKeyInfo bit string (standard SKI method) */ private fun computeSki(spki: SubjectPublicKeyInfo): ByteArray { val keyBytes = spki.publicKeyData.bytes - val digest = SHA1Digest() - val result = ByteArray(digest.digestSize) + val digest = SHA1Digest() + val result = ByteArray(digest.digestSize) digest.update(keyBytes, 0, keyBytes.size) digest.doFinal(result, 0) return result @@ -224,29 +344,16 @@ object ProofmodeC2paManager { return sw.toString() } - private fun loadFile(context: Context, name: String): String? { - val f = File(context.filesDir, name) - return if (f.exists()) f.readText() else null - } - - // --------------------------------------------------------------------------- - // BouncyCastle ContentSigner backed by software private key - // --------------------------------------------------------------------------- - private class BcContentSigner( private val privateKey: PrivateKey, private val algorithm: String, ) : ContentSigner { - private val sig = Signature.getInstance(algorithm, BouncyCastleProvider.PROVIDER_NAME) .also { it.initSign(privateKey) } private val buf = ByteArrayOutputStream() - override fun getAlgorithmIdentifier(): AlgorithmIdentifier = AlgorithmIdentifier(ASN1ObjectIdentifier("1.2.840.10045.4.3.2")) - override fun getOutputStream(): java.io.OutputStream = buf - override fun getSignature(): ByteArray { sig.update(buf.toByteArray()) return sig.sign() @@ -254,7 +361,7 @@ object ProofmodeC2paManager { } // --------------------------------------------------------------------------- - // Trust settings JSON for c2pa-rs + // Trust settings + manifest JSON // --------------------------------------------------------------------------- private fun buildTrustSettings(caCertPem: String): String { @@ -266,14 +373,9 @@ object ProofmodeC2paManager { return """{"trust":{"trust_anchors":"$escaped"}}""" } - // --------------------------------------------------------------------------- - // Manifest JSON - // --------------------------------------------------------------------------- - private fun buildManifestJson(m: MetadataCollector.CaptureMetadata): String { - val captureIso = isoFormat.format(Date(m.captureTime)) + val captureIso = isoFormat.format(Date(m.captureTime)) val claimGenerator = "OpenArchive Save/${BuildConfig.VERSION_NAME} (build ${BuildConfig.VERSION_CODE})" - val assertions = buildList { add(actionsAssertion()) add(creativeWorkAssertion(m, captureIso)) @@ -281,7 +383,6 @@ object ProofmodeC2paManager { if (m.latitude != null && m.longitude != null) add(locationAssertion(m)) if (m.networkType != null || m.ipv4 != null || m.ipv6 != null || m.cellInfo != null) add(networkAssertion(m)) }.joinToString(",\n") - return """ { "claim_generator": ${claimGenerator.toJsonString()}, @@ -292,7 +393,7 @@ object ProofmodeC2paManager { """.trimIndent() } - private fun actionsAssertion(): String = """ + private fun actionsAssertion() = """ { "label": "c2pa.actions", "data": { @@ -312,7 +413,6 @@ object ProofmodeC2paManager { append("}}") } } else "" - return """ { "label": "stds.schema-org.CreativeWork", @@ -332,7 +432,7 @@ object ProofmodeC2paManager { """.trimIndent() } - private fun deviceAssertion(m: MetadataCollector.CaptureMetadata): String = """ + private fun deviceAssertion(m: MetadataCollector.CaptureMetadata) = """ { "label": "org.openarchive.save.device", "data": { @@ -351,23 +451,23 @@ object ProofmodeC2paManager { val fields = buildString { append(""""latitude": ${m.latitude}""") append(""","longitude": ${m.longitude}""") - if (m.locationAltitude != null) append(""","altitude": ${m.locationAltitude}""") - if (m.locationAccuracy != null) append(""","accuracy": ${m.locationAccuracy}""") - if (m.locationBearing != null) append(""","bearing": ${m.locationBearing}""") - if (m.locationSpeed != null) append(""","speed": ${m.locationSpeed}""") - if (m.locationProvider != null) append(""","provider": ${m.locationProvider.toJsonString()}""") - if (m.locationTime != null) append(""","timestamp": ${m.locationTime}""") + if (m.locationAltitude != null) append(""","altitude": ${m.locationAltitude}""") + if (m.locationAccuracy != null) append(""","accuracy": ${m.locationAccuracy}""") + if (m.locationBearing != null) append(""","bearing": ${m.locationBearing}""") + if (m.locationSpeed != null) append(""","speed": ${m.locationSpeed}""") + if (m.locationProvider != null) append(""","provider": ${m.locationProvider.toJsonString()}""") + if (m.locationTime != null) append(""","timestamp": ${isoFormat.format(Date(m.locationTime)).toJsonString()}""") } return """{"label":"org.openarchive.save.location","data":{$fields}}""" } private fun networkAssertion(m: MetadataCollector.CaptureMetadata): String { - val fields = buildString { - if (m.networkType != null) append(""""networkType": ${m.networkType.toJsonString()}""") - if (m.ipv4 != null) append(""","ipv4": ${m.ipv4.toJsonString()}""") - if (m.ipv6 != null) append(""","ipv6": ${m.ipv6.toJsonString()}""") - if (m.cellInfo != null) append(""","cellInfo": ${m.cellInfo}""") - } + val fields = buildList { + if (m.networkType != null) add(""""networkType": ${m.networkType.toJsonString()}""") + if (m.ipv4 != null) add(""""ipv4": ${m.ipv4.toJsonString()}""") + if (m.ipv6 != null) add(""""ipv6": ${m.ipv6.toJsonString()}""") + if (m.cellInfo != null) add(""""cellInfo": ${m.cellInfo}""") + }.joinToString(",") return """{"label":"org.openarchive.save.network","data":{$fields}}""" } From d6505ed5f98d58e83cc9571f1f7e458c8006d194 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 1 Jun 2026 23:05:42 +0530 Subject: [PATCH 05/15] Implement TEE-backed hardware signing for C2PA manifests. Specific changes: - Transitioned from software-only signing to a two-key design to ensure media content is signed within the Android Keystore TEE. - Implemented `SIGNING_KEY_ALIAS` for a persistent EC P-256 key in secure hardware, ensuring private key material never leaves the TEE. - Retained a software-based CA key (encrypted at rest via AES-256-GCM) used solely to sign leaf certificates that carry the hardware public key. - Updated `embedProof` to use `Signer.withCallback`, bypassing issues with `KeyStoreSigner` by performing manual signing via `Signature.getInstance("SHA256withECDSA")`. - Added `generateKeystoreSigningKey` and `keystoreSign` to manage the hardware key lifecycle and signing operations. - Refactored C2PA assertion JSON generation (Actions, CreativeWork, Location, Network) for better performance and reduced string allocations. - Enhanced error handling and recovery for Keystore wrap-key loss scenarios. --- .../openarchive/util/ProofmodeC2paManager.kt | 328 ++++++++---------- 1 file changed, 152 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt index b240eeed..a8414d28 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt @@ -53,19 +53,26 @@ import javax.crypto.spec.GCMParameterSpec object ProofmodeC2paManager { - // EC P-256 signing key pair generated by BouncyCastle (software). - // Private key is encrypted at rest with AES-256-GCM; the AES wrap key lives - // in Android Keystore (TEE-backed, never exported to disk). - // Signer.fromKeys is used because KeyStoreSigner.createSigner is broken in - // c2pa-android 0.0.9 — consistently throws "COSE Signer does not contain - // signing certificate" regardless of cert format. - private const val KEY_FILE = "c2pa_private.key" - private const val CERT_CHAIN_FILE = "c2pa_cert_chain.pem" - private const val CA_CERT_FILE = "c2pa_ca_cert.pem" - private const val WRAP_KEY_ALIAS = "openarchive_c2pa_wrap_key" + // Two-key design for full hardware signing: + // + // SIGNING_KEY_ALIAS — EC P-256 in Android Keystore TEE. This key signs media content. + // Key bytes never leave secure hardware. Signing is done via Signer.withCallback + // (called directly — KeyStoreSigner.createSigner is broken in c2pa-android 0.0.9). + // + // Software CA key — BouncyCastle EC P-256, encrypted at rest with Keystore-backed + // AES-256-GCM. Only used to sign the leaf certificate (not media content). + // Cert building with a Keystore key via BouncyCastle fails (BouncyCastle can't + // access TEE key material), so we use a software key solely for cert signing. + // The leaf cert carries the Keystore key's PUBLIC key, so manifest signatures + // are verified against the TEE key. + private const val SIGNING_KEY_ALIAS = "openarchive_c2pa_signing_key" + private const val CA_KEY_FILE = "c2pa_ca_private.key" // encrypted, software CA + private const val CERT_CHAIN_FILE = "c2pa_cert_chain.pem" // public, on disk + private const val CA_CERT_FILE = "c2pa_ca_cert.pem" // public, on disk + private const val WRAP_KEY_ALIAS = "openarchive_c2pa_wrap_key" private const val KEYSTORE_PROVIDER = "AndroidKeyStore" private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" - private const val GCM_TAG_LEN = 128 + private const val GCM_TAG_LEN = 128 @Volatile private var trustSettingsLoaded = false @@ -98,10 +105,6 @@ object ProofmodeC2paManager { } return try { - val privateKeyPem = loadKeyPem(context) ?: run { - ensureCertsExist(context) - loadKeyPem(context) ?: return null - } val certChainPem = loadPlain(context, CERT_CHAIN_FILE) ?: run { ensureCertsExist(context) loadPlain(context, CERT_CHAIN_FILE) ?: return null @@ -121,14 +124,15 @@ object ProofmodeC2paManager { } val manifestJson = buildManifestJson(metadata) - val tmp = File(file.parent, "${file.nameWithoutExtension}_c2pa_tmp.${file.extension}") tmp.delete() - // Signer.fromKeys — proven working in c2pa-android 0.0.9. - // FileStream source + dest: full file never loaded into RAM. + // Signer.withCallback called directly — bypasses the broken KeyStoreSigner.createSigner. + // The signing callback uses the Keystore TEE key: key never enters app memory. Builder.fromJson(manifestJson).use { builder -> - Signer.fromKeys(certChainPem, privateKeyPem, SigningAlgorithm.ES256).use { signer -> + Signer.withCallback(SigningAlgorithm.ES256, certChainPem, null) { data -> + keystoreSign(data) + }.use { signer -> FileStream(file, FileStream.Mode.READ, false).use { source -> FileStream(tmp, FileStream.Mode.READ_WRITE, true).use { dest -> builder.sign(mimeType, source, dest, signer) @@ -152,12 +156,9 @@ object ProofmodeC2paManager { return null } - if (!tmp.renameTo(file)) { - file.delete() - tmp.renameTo(file) - } + if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) } - AppLogger.i("[C2PA] Manifest embedded + verified: ${file.name} (${file.length() / 1024} KB)") + AppLogger.i("[C2PA] Manifest embedded + verified (TEE key): ${file.name} (${file.length() / 1024} KB)") file } catch (e: Exception) { AppLogger.e("[C2PA] Failed to embed proof in ${file.name}", e) @@ -166,66 +167,92 @@ object ProofmodeC2paManager { } // --------------------------------------------------------------------------- - // Key + cert generation — software EC key, Keystore-wrapped at rest + // Key and cert management // --------------------------------------------------------------------------- private fun ensureCertsExist(context: Context) { - val keyFile = File(context.filesDir, KEY_FILE) - val chainFile = File(context.filesDir, CERT_CHAIN_FILE) - val caFile = File(context.filesDir, CA_CERT_FILE) - if (keyFile.exists() && chainFile.exists() && caFile.exists()) { - // Verify encrypted key is still readable (detects Keystore wrap-key loss) - if (loadKeyPem(context) != null) return - AppLogger.w("[C2PA] Wrap key lost — wiping cert files and regenerating") - keyFile.delete(); chainFile.delete(); caFile.delete() + val caKeyFile = File(context.filesDir, CA_KEY_FILE) + val chainFile = File(context.filesDir, CERT_CHAIN_FILE) + val caFile = File(context.filesDir, CA_CERT_FILE) + val signingKeyExists = keystoreKeyExists() + + if (signingKeyExists && caKeyFile.exists() && chainFile.exists() && caFile.exists()) { + if (readEncrypted(caKeyFile) != null) return + AppLogger.w("[C2PA] Wrap key lost — wiping files and regenerating") } + // Wipe any partial state + listOf(caKeyFile, chainFile, caFile).forEach { it.delete() } + if (!signingKeyExists) generateKeystoreSigningKey() - AppLogger.i("[C2PA] Generating EC P-256 key pair + cert chain") + AppLogger.i("[C2PA] Generating software CA key + cert chain (Keystore pubkey in leaf)") - val kpg = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) - kpg.initialize(ECNamedCurveGenParameterSpec("secp256r1")) - val keyPair = kpg.generateKeyPair() + // Software CA key — only used to sign certificates, not content + val caKpg = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) + caKpg.initialize(ECNamedCurveGenParameterSpec("secp256r1")) + val caKeyPair = caKpg.generateKeyPair() - val caCert = buildCaCert(keyPair.private, keyPair.public) - val leafCert = buildLeafCert(keyPair.private, keyPair.public, caCert) + // Leaf cert carries the TEE key's PUBLIC KEY — manifest signatures verify against it + val keystorePublicKey = getKeystorePublicKey() + ?: throw IllegalStateException("Cannot read Keystore public key") + + val caCert = buildCaCert(caKeyPair.private, caKeyPair.public) + val leafCert = buildLeafCert(caKeyPair.private, keystorePublicKey, caCert) val chainPem = toPem("CERTIFICATE", leafCert.encoded) + toPem("CERTIFICATE", caCert.encoded) val caCertPem = toPem("CERTIFICATE", caCert.encoded) - val privateKeyPem = toPem("PRIVATE KEY", keyPair.private.encoded) + val caPrivKeyPem = toPem("PRIVATE KEY", caKeyPair.private.encoded) - // Atomic write via tmp → rename - val tmpKey = File(context.filesDir, "$KEY_FILE.tmp") + val tmpCaKey = File(context.filesDir, "$CA_KEY_FILE.tmp") val tmpChain = File(context.filesDir, "$CERT_CHAIN_FILE.tmp") val tmpCa = File(context.filesDir, "$CA_CERT_FILE.tmp") try { - writeEncrypted(tmpKey, privateKeyPem.toByteArray(Charsets.UTF_8)) + writeEncrypted(tmpCaKey, caPrivKeyPem.toByteArray(Charsets.UTF_8)) tmpChain.writeText(chainPem) tmpCa.writeText(caCertPem) - tmpKey.renameTo(keyFile) + tmpCaKey.renameTo(caKeyFile) tmpChain.renameTo(chainFile) tmpCa.renameTo(caFile) } catch (e: Exception) { - tmpKey.delete(); tmpChain.delete(); tmpCa.delete() - throw e + tmpCaKey.delete(); tmpChain.delete(); tmpCa.delete(); throw e } trustSettingsLoaded = false - AppLogger.i("[C2PA] Key + cert chain written") + AppLogger.i("[C2PA] Keystore TEE signing key + cert chain ready") } - private fun loadKeyPem(context: Context): String? { - val f = File(context.filesDir, KEY_FILE) - if (!f.exists()) return null - return readEncrypted(f)?.toString(Charsets.UTF_8) + private fun generateKeystoreSigningKey() { + val spec = KeyGenParameterSpec.Builder(SIGNING_KEY_ALIAS, KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) + .build() + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER) + .also { it.initialize(spec) } + .generateKeyPair() + AppLogger.i("[C2PA] Keystore EC signing key generated") } - private fun loadPlain(context: Context, name: String): String? { - val f = File(context.filesDir, name) - return if (f.exists()) f.readText() else null + private fun keystoreKeyExists(): Boolean = runCatching { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + ks.containsAlias(SIGNING_KEY_ALIAS) + }.getOrDefault(false) + + private fun getKeystorePublicKey(): PublicKey? = runCatching { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + ks.getCertificate(SIGNING_KEY_ALIAS)?.publicKey + }.getOrNull() + + /** Signs data inside the TEE — Keystore private key never leaves secure hardware. */ + private fun keystoreSign(data: ByteArray): ByteArray { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + val privateKey = ks.getKey(SIGNING_KEY_ALIAS, null) as PrivateKey + return Signature.getInstance("SHA256withECDSA").apply { + initSign(privateKey) + update(data) + }.sign() } // --------------------------------------------------------------------------- - // AES-256-GCM wrap for private key file + // AES-256-GCM wrap for software CA private key file // --------------------------------------------------------------------------- private fun ensureWrapKey() { @@ -250,92 +277,88 @@ object ProofmodeC2paManager { val key = ks.getKey(WRAP_KEY_ALIAS, null) as javax.crypto.SecretKey val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - val ciphertext = cipher.doFinal(plaintext) - file.outputStream().use { out -> - out.write(iv.size.to4Bytes()) - out.write(iv) - out.write(ciphertext) - } + val iv = cipher.iv; val ct = cipher.doFinal(plaintext) + file.outputStream().use { it.write(iv.size.to4Bytes()); it.write(iv); it.write(ct) } } - private fun readEncrypted(file: File): ByteArray? = try { + private fun readEncrypted(file: File): ByteArray? = runCatching { ensureWrapKey() val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } val key = ks.getKey(WRAP_KEY_ALIAS, null) as javax.crypto.SecretKey - val bytes = file.readBytes() - val ivLen = bytes.sliceArray(0..3).from4Bytes() - val iv = bytes.sliceArray(4 until 4 + ivLen) - val ciphertext = bytes.sliceArray(4 + ivLen until bytes.size) - val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LEN, iv)) - cipher.doFinal(ciphertext) - } catch (e: Exception) { - AppLogger.e("[C2PA] Failed to decrypt key file", e) - null + val b = file.readBytes() + val ivLen = b.sliceArray(0..3).from4Bytes() + val iv = b.sliceArray(4 until 4 + ivLen) + val ct = b.sliceArray(4 + ivLen until b.size) + Cipher.getInstance(AES_GCM_TRANSFORM) + .also { it.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LEN, iv)) } + .doFinal(ct) + }.getOrElse { AppLogger.e("[C2PA] Failed to decrypt CA key file", it); null } + + private fun loadPlain(context: Context, name: String): String? { + val f = File(context.filesDir, name) + return if (f.exists()) f.readText() else null } private fun Int.to4Bytes(): ByteArray = byteArrayOf( (this shr 24).toByte(), (this shr 16).toByte(), (this shr 8).toByte(), this.toByte() ) - private fun ByteArray.from4Bytes(): Int = - ((this[0].toInt() and 0xFF) shl 24) or - ((this[1].toInt() and 0xFF) shl 16) or - ((this[2].toInt() and 0xFF) shl 8) or - (this[3].toInt() and 0xFF) + ((this[0].toInt() and 0xFF) shl 24) or ((this[1].toInt() and 0xFF) shl 16) or + ((this[2].toInt() and 0xFF) shl 8) or (this[3].toInt() and 0xFF) // --------------------------------------------------------------------------- - // Certificate building — BouncyCastle with software key (works correctly) + // Certificate building — software CA key signs certs (works; no Keystore key used here) // --------------------------------------------------------------------------- - private fun buildCaCert(privateKey: PrivateKey, publicKey: PublicKey): X509Certificate { - val subject = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") - val serial = BigInteger(64, SecureRandom()) + private fun buildCaCert(caPrivateKey: PrivateKey, caPublicKey: PublicKey): X509Certificate { + val subject = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") + val serial = BigInteger(64, SecureRandom()) val notBefore = Date(System.currentTimeMillis() - 86_400_000L) val notAfter = Date(System.currentTimeMillis() + 20L * 365 * 24 * 60 * 60 * 1000) - val builder = JcaX509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, publicKey) - val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) - val ski = computeSki(spki) + val builder = JcaX509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, caPublicKey) + val ski = computeSki(SubjectPublicKeyInfo.getInstance(caPublicKey.encoded)) builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) builder.addExtension(Extension.keyUsage, true, KeyUsage(KeyUsage.keyCertSign or KeyUsage.cRLSign or KeyUsage.digitalSignature)) - return JcaX509CertificateConverter() - .setProvider(BouncyCastleProvider.PROVIDER_NAME) - .getCertificate(builder.build(BcContentSigner(privateKey, "SHA256withECDSA"))) + return JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(BcContentSigner(caPrivateKey, "SHA256withECDSA"))) } - private fun buildLeafCert(privateKey: PrivateKey, publicKey: PublicKey, caCert: X509Certificate): X509Certificate { + /** + * Leaf cert whose SubjectPublicKeyInfo carries [subjectPublicKey] (the Keystore TEE key). + * Signed by [caPrivateKey] (software CA key — BouncyCastle handles this correctly). + * Manifest signatures are made with the Keystore key and verified against this cert. + */ + private fun buildLeafCert( + caPrivateKey: PrivateKey, + subjectPublicKey: PublicKey, + caCert: X509Certificate, + ): X509Certificate { val subject = X500Name("CN=OpenArchive Save, O=OpenArchive, C=US") val issuer = X500Name("CN=OpenArchive Save Root CA, O=OpenArchive, C=US") val serial = BigInteger(64, SecureRandom()) val notBefore = Date(System.currentTimeMillis() - 86_400_000L) val notAfter = Date(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000) - val builder = JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, publicKey) - val spki = SubjectPublicKeyInfo.getInstance(publicKey.encoded) - val ski = computeSki(spki) + val builder = JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, subjectPublicKey) + val ski = computeSki(SubjectPublicKeyInfo.getInstance(subjectPublicKey.encoded)) val caSki = computeSki(SubjectPublicKeyInfo.getInstance(caCert.publicKey.encoded)) - builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) + builder.addExtension(Extension.subjectKeyIdentifier, false, SubjectKeyIdentifier(ski)) builder.addExtension(Extension.authorityKeyIdentifier, false, AuthorityKeyIdentifier(caSki)) builder.addExtension(Extension.basicConstraints, true, BasicConstraints(false)) builder.addExtension(Extension.keyUsage, true, KeyUsage(KeyUsage.digitalSignature or KeyUsage.nonRepudiation)) - // id-kp-documentSigning (RFC 9336) — correct EKU for media signing certs + // id-kp-documentSigning (RFC 9336) builder.addExtension(Extension.extendedKeyUsage, true, ExtendedKeyUsage(KeyPurposeId.getInstance(ASN1ObjectIdentifier("1.3.6.1.5.5.7.3.36")))) - return JcaX509CertificateConverter() - .setProvider(BouncyCastleProvider.PROVIDER_NAME) - .getCertificate(builder.build(BcContentSigner(privateKey, "SHA256withECDSA"))) + return JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(builder.build(BcContentSigner(caPrivateKey, "SHA256withECDSA"))) } private fun computeSki(spki: SubjectPublicKeyInfo): ByteArray { - val keyBytes = spki.publicKeyData.bytes - val digest = SHA1Digest() - val result = ByteArray(digest.digestSize) - digest.update(keyBytes, 0, keyBytes.size) - digest.doFinal(result, 0) - return result + val d = SHA1Digest(); val r = ByteArray(d.digestSize) + val b = spki.publicKeyData.bytes + d.update(b, 0, b.size); d.doFinal(r, 0); return r } private fun toPem(type: String, encoded: ByteArray): String { @@ -351,27 +374,18 @@ object ProofmodeC2paManager { private val sig = Signature.getInstance(algorithm, BouncyCastleProvider.PROVIDER_NAME) .also { it.initSign(privateKey) } private val buf = ByteArrayOutputStream() - override fun getAlgorithmIdentifier(): AlgorithmIdentifier = + override fun getAlgorithmIdentifier() = AlgorithmIdentifier(ASN1ObjectIdentifier("1.2.840.10045.4.3.2")) override fun getOutputStream(): java.io.OutputStream = buf - override fun getSignature(): ByteArray { - sig.update(buf.toByteArray()) - return sig.sign() - } + override fun getSignature(): ByteArray { sig.update(buf.toByteArray()); return sig.sign() } } // --------------------------------------------------------------------------- // Trust settings + manifest JSON // --------------------------------------------------------------------------- - private fun buildTrustSettings(caCertPem: String): String { - val escaped = caCertPem - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - return """{"trust":{"trust_anchors":"$escaped"}}""" - } + private fun buildTrustSettings(caCertPem: String) = + """{"trust":{"trust_anchors":"${caCertPem.replace("\\","\\\\").replace("\"","\\\"").replace("\n","\\n").replace("\r","\\r")}"}}""" private fun buildManifestJson(m: MetadataCollector.CaptureMetadata): String { val captureIso = isoFormat.format(Date(m.captureTime)) @@ -394,89 +408,51 @@ object ProofmodeC2paManager { } private fun actionsAssertion() = """ - { - "label": "c2pa.actions", - "data": { - "actions": [{ - "action": "c2pa.created", - "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" - }] - } - } + {"label":"c2pa.actions","data":{"actions":[{"action":"c2pa.created","digitalSourceType":"http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"}]}} """.trimIndent() private fun creativeWorkAssertion(m: MetadataCollector.CaptureMetadata, captureIso: String): String { - val locationBlock = if (m.latitude != null && m.longitude != null) { - buildString { - append(""","locationCreated":{"@type":"Place","geo":{"@type":"GeoCoordinates","latitude":${m.latitude},"longitude":${m.longitude}""") - if (m.locationAltitude != null) append(""","elevation":${m.locationAltitude}""") - append("}}") - } + val locBlock = if (m.latitude != null && m.longitude != null) buildString { + append(""","locationCreated":{"@type":"Place","geo":{"@type":"GeoCoordinates","latitude":${m.latitude},"longitude":${m.longitude}""") + if (m.locationAltitude != null) append(""","elevation":${m.locationAltitude}""") + append("}}") } else "" return """ - { - "label": "stds.schema-org.CreativeWork", - "data": { - "@context": "https://schema.org", - "@type": "CreativeWork", - "author": [{ - "@type": "SoftwareApplication", - "name": "OpenArchive Save", - "version": ${BuildConfig.VERSION_NAME.toJsonString()}, - "buildNumber": "${BuildConfig.VERSION_CODE}" - }], - "dateCreated": "$captureIso" - $locationBlock - } - } + {"label":"stds.schema-org.CreativeWork","data":{"@context":"https://schema.org","@type":"CreativeWork","author":[{"@type":"SoftwareApplication","name":"OpenArchive Save","version":${BuildConfig.VERSION_NAME.toJsonString()},"buildNumber":"${BuildConfig.VERSION_CODE}"}],"dateCreated":"$captureIso"$locBlock}} """.trimIndent() } private fun deviceAssertion(m: MetadataCollector.CaptureMetadata) = """ - { - "label": "org.openarchive.save.device", - "data": { - "make": ${m.deviceMake.toJsonString()}, - "model": ${m.deviceModel.toJsonString()}, - "brand": ${m.deviceBrand.toJsonString()}, - "hardware": ${m.hardware.toJsonString()}, - "locale": ${m.locale.toJsonString()}, - "language": ${m.language.toJsonString()} - ${if (m.screenSizeInches != null) ""","screenSizeInches": ${m.screenSizeInches}""" else ""} - } - } + {"label":"org.openarchive.save.device","data":{"make":${m.deviceMake.toJsonString()},"model":${m.deviceModel.toJsonString()},"brand":${m.deviceBrand.toJsonString()},"hardware":${m.hardware.toJsonString()},"locale":${m.locale.toJsonString()},"language":${m.language.toJsonString()}${if (m.screenSizeInches != null) ""","screenSizeInches":${m.screenSizeInches}""" else ""}}} """.trimIndent() private fun locationAssertion(m: MetadataCollector.CaptureMetadata): String { val fields = buildString { - append(""""latitude": ${m.latitude}""") - append(""","longitude": ${m.longitude}""") - if (m.locationAltitude != null) append(""","altitude": ${m.locationAltitude}""") - if (m.locationAccuracy != null) append(""","accuracy": ${m.locationAccuracy}""") - if (m.locationBearing != null) append(""","bearing": ${m.locationBearing}""") - if (m.locationSpeed != null) append(""","speed": ${m.locationSpeed}""") - if (m.locationProvider != null) append(""","provider": ${m.locationProvider.toJsonString()}""") - if (m.locationTime != null) append(""","timestamp": ${isoFormat.format(Date(m.locationTime)).toJsonString()}""") + append(""""latitude":${m.latitude}""") + append(""","longitude":${m.longitude}""") + if (m.locationAltitude != null) append(""","altitude":${m.locationAltitude}""") + if (m.locationAccuracy != null) append(""","accuracy":${m.locationAccuracy}""") + if (m.locationBearing != null) append(""","bearing":${m.locationBearing}""") + if (m.locationSpeed != null) append(""","speed":${m.locationSpeed}""") + if (m.locationProvider != null) append(""","provider":${m.locationProvider.toJsonString()}""") + if (m.locationTime != null) append(""","timestamp":${isoFormat.format(Date(m.locationTime)).toJsonString()}""") } return """{"label":"org.openarchive.save.location","data":{$fields}}""" } private fun networkAssertion(m: MetadataCollector.CaptureMetadata): String { val fields = buildList { - if (m.networkType != null) add(""""networkType": ${m.networkType.toJsonString()}""") - if (m.ipv4 != null) add(""""ipv4": ${m.ipv4.toJsonString()}""") - if (m.ipv6 != null) add(""""ipv6": ${m.ipv6.toJsonString()}""") - if (m.cellInfo != null) add(""""cellInfo": ${m.cellInfo}""") + if (m.networkType != null) add(""""networkType":${m.networkType.toJsonString()}""") + if (m.ipv4 != null) add(""""ipv4":${m.ipv4.toJsonString()}""") + if (m.ipv6 != null) add(""""ipv6":${m.ipv6.toJsonString()}""") + if (m.cellInfo != null) add(""""cellInfo":${m.cellInfo}""") }.joinToString(",") return """{"label":"org.openarchive.save.network","data":{$fields}}""" } private fun String.toJsonString(): String { - val escaped = replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - return "\"$escaped\"" + val e = replace("\\","\\\\").replace("\"","\\\"") + .replace("\n","\\n").replace("\r","\\r").replace("\t","\\t") + return "\"$e\"" } } From cc1627baad06abb75a054677b79eea6800e24532 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 1 Jun 2026 23:34:39 +0530 Subject: [PATCH 06/15] Improve C2PA certificate validation and OpenTimestamps queue management. Specific changes: Proofmode/C2PA: - Implement `certMatchesKeystoreKey` to verify that on-disk certificates match the current Android Keystore signing key, preventing the use of stale or orphaned cert files. - Update `ensureC2paState` to trigger a full state regeneration if a certificate-key mismatch is detected. - Explicitly use the "AndroidKeyStoreBCWorkaround" provider for ECDSA signatures to ensure consistent dispatch to hardware-backed Keystore across different OEM builds. OpenTimestamps: - Introduce a cap of 500 entries (`OTS_QUEUE_MAX`) for the pending OTS hash queue. - Implement an eviction policy in `enqueuePendingOts` to drop the oldest entries when the limit is reached, ensuring bounded storage usage. --- .../util/ProofCompanionGenerator.kt | 9 +++++- .../openarchive/util/ProofmodeC2paManager.kt | 32 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt index b1f3d464..28522fac 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt @@ -47,6 +47,7 @@ object ProofCompanionGenerator { private const val SECRET_KEY_FILE = "proof_pgp_secret.bpg" private const val PUBLIC_KEY_FILE = "pubkey.asc" private const val PENDING_OTS_FILE = "pending_ots.txt" + private const val OTS_QUEUE_MAX = 500 private const val PGP_IDENTITY = "OpenArchive Save " private const val OTS_CALENDAR = "https://a.pool.opentimestamps.org/timestamp" private const val OTS_TIMEOUT_MS = 15_000 @@ -350,7 +351,13 @@ object ProofCompanionGenerator { private fun enqueuePendingOts(context: Context, hexHash: String) { val queue = File(context.filesDir, PENDING_OTS_FILE) - queue.appendText("$hexHash\n") + val existing = if (queue.exists()) queue.readLines().filter { it.isNotBlank() } else emptyList() + if (existing.size >= OTS_QUEUE_MAX) { + // Drop oldest entries to stay within cap — keeps storage bounded + queue.writeText((existing.drop(existing.size - OTS_QUEUE_MAX + 1) + hexHash).joinToString("\n") + "\n") + } else { + queue.appendText("$hexHash\n") + } } private fun postOts(hexHash: String, outFile: File): Boolean { diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt index a8414d28..e58255fc 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt @@ -177,8 +177,8 @@ object ProofmodeC2paManager { val signingKeyExists = keystoreKeyExists() if (signingKeyExists && caKeyFile.exists() && chainFile.exists() && caFile.exists()) { - if (readEncrypted(caKeyFile) != null) return - AppLogger.w("[C2PA] Wrap key lost — wiping files and regenerating") + if (readEncrypted(caKeyFile) != null && certMatchesKeystoreKey(chainFile)) return + AppLogger.w("[C2PA] Wrap key lost or cert/key mismatch — wiping files and regenerating") } // Wipe any partial state listOf(caKeyFile, chainFile, caFile).forEach { it.delete() } @@ -241,11 +241,37 @@ object ProofmodeC2paManager { ks.getCertificate(SIGNING_KEY_ALIAS)?.publicKey }.getOrNull() + /** + * Parses the first cert in the on-disk chain file and checks its public key + * matches the current Keystore signing key. Guards against stale cert files + * left over from a previous key generation (e.g. after app update or key loss). + */ + private fun certMatchesKeystoreKey(chainFile: File): Boolean = runCatching { + val keystorePubKey = getKeystorePublicKey() ?: return false + val pem = chainFile.readText() + val certBytes = java.util.Base64.getDecoder().decode( + pem.lines() + .filter { !it.startsWith("-----") && it.isNotBlank() } + .joinToString("") + .takeWhile { it != '-' } // stop at second cert boundary + .let { + // Extract only the first cert's base64 block + pem.substringAfter("-----BEGIN CERTIFICATE-----\n") + .substringBefore("\n-----END CERTIFICATE-----") + .replace("\n", "") + } + ) + val leafCert = java.security.cert.CertificateFactory.getInstance("X.509") + .generateCertificate(certBytes.inputStream()) as java.security.cert.X509Certificate + leafCert.publicKey.encoded.contentEquals(keystorePubKey.encoded) + }.getOrElse { AppLogger.w("[C2PA] Cert/key match check failed: ${it.message}"); false } + /** Signs data inside the TEE — Keystore private key never leaves secure hardware. */ private fun keystoreSign(data: ByteArray): ByteArray { val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } val privateKey = ks.getKey(SIGNING_KEY_ALIAS, null) as PrivateKey - return Signature.getInstance("SHA256withECDSA").apply { + // Explicit provider ensures dispatch to Android Keystore on all OEM builds + return Signature.getInstance("SHA256withECDSA", "AndroidKeyStoreBCWorkaround").apply { initSign(privateKey) update(data) }.sign() From e985c10e8058ca57b9c63cccfb1ade2993c9fc61 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Tue, 2 Jun 2026 00:01:52 +0530 Subject: [PATCH 07/15] Improve C2PA signing reliability and modernize date handling. Specific changes: - Replaced `SimpleDateFormat` with thread-safe `java.time.DateTimeFormatter` and `Instant` across `ProofmodeC2paManager` and `ProofCompanionGenerator` to ensure safety during concurrent coroutine execution. - Implemented `ensureUsableSigningKey` in `ProofmodeC2paManager` to perform a test signature, allowing the system to detect and regenerate stale or unusable Keystore keys following app upgrades. - Enhanced `ProofmodeC2paManager.embedProof` to ensure partial temporary files are deleted if an exception occurs during the signing process. - Refactored PEM certificate parsing in `certMatchesKeystoreKey` for better readability and reliability. - Updated `enqueuePendingOts` in `ProofCompanionGenerator` with thread synchronization and duplicate entry prevention. - Refined the OTS queue management to consistently bound storage by dropping oldest entries when reaching the maximum limit. - Updated EXIF metadata parsing to use `java.time` APIs for consistency with the rest of the signing pipeline. --- .../util/ProofCompanionGenerator.kt | 37 +++++---- .../openarchive/util/ProofmodeC2paManager.kt | 81 ++++++++++++------- 2 files changed, 71 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt index 28522fac..2fca03ec 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt @@ -32,12 +32,13 @@ import java.security.KeyPairGenerator import java.security.KeyStore import java.security.SecureRandom import java.security.Security -import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.TimeZone import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.spec.GCMParameterSpec @@ -62,9 +63,9 @@ object ProofCompanionGenerator { private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" private const val GCM_TAG_LEN = 128 - private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } + // DateTimeFormatter is immutable + thread-safe — safe for concurrent capture coroutines + private val isoFmt: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC) fun init(context: Context) { try { @@ -147,20 +148,22 @@ object ProofCompanionGenerator { // --------------------------------------------------------------------------- private fun buildProofJson(file: File, hash: String): String { - val now = isoFmt.format(Date()) + val now = isoFmt.format(Instant.now()) // Read capture time from EXIF TAG_DATETIME — written at capture time by MetadataCollector, // guaranteed consistent with the C2PA manifest timestamp and the EXIF in the signed file. - val exifDtFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } + // EXIF datetime is in UTC (MetadataCollector writes it that way). + val exifDtFormat = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneOffset.UTC) val created = try { ExifInterface(file.absolutePath) .getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) - ?.let { raw -> exifDtFormat.parse(raw)?.let { isoFmt.format(it) } } - ?: isoFmt.format(Date(file.lastModified())) + ?.let { raw -> + val ldt = java.time.LocalDateTime.parse(raw, exifDtFormat) + isoFmt.format(ldt.toInstant(ZoneOffset.UTC)) + } + ?: isoFmt.format(Instant.ofEpochMilli(file.lastModified())) } catch (_: Exception) { - isoFmt.format(Date(file.lastModified())) + isoFmt.format(Instant.ofEpochMilli(file.lastModified())) } val fields = linkedMapOf() @@ -349,15 +352,15 @@ object ProofCompanionGenerator { if (remaining.isEmpty()) queue.delete() else queue.writeText(remaining.joinToString("\n")) } + @Synchronized private fun enqueuePendingOts(context: Context, hexHash: String) { val queue = File(context.filesDir, PENDING_OTS_FILE) val existing = if (queue.exists()) queue.readLines().filter { it.isNotBlank() } else emptyList() - if (existing.size >= OTS_QUEUE_MAX) { - // Drop oldest entries to stay within cap — keeps storage bounded - queue.writeText((existing.drop(existing.size - OTS_QUEUE_MAX + 1) + hexHash).joinToString("\n") + "\n") - } else { - queue.appendText("$hexHash\n") + if (hexHash in existing) return // already queued — no duplicate entries + val updated = (existing + hexHash).let { + if (it.size > OTS_QUEUE_MAX) it.drop(it.size - OTS_QUEUE_MAX) else it // bound size, drop oldest } + queue.writeText(updated.joinToString("\n") + "\n") } private fun postOts(hexHash: String, outFile: File): Boolean { diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt index e58255fc..7b351289 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofmodeC2paManager.kt @@ -43,10 +43,10 @@ import java.security.Security import java.security.Signature import java.security.cert.X509Certificate import java.security.spec.ECGenParameterSpec -import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.Date -import java.util.Locale -import java.util.TimeZone import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.spec.GCMParameterSpec @@ -76,9 +76,9 @@ object ProofmodeC2paManager { @Volatile private var trustSettingsLoaded = false - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } + // DateTimeFormatter is thread-safe (unlike SimpleDateFormat) — safe across concurrent coroutines + private val isoFormat: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC) fun init(context: Context) { try { @@ -104,6 +104,7 @@ object ProofmodeC2paManager { return null } + val tmp = File(file.parent, "${file.nameWithoutExtension}_c2pa_tmp.${file.extension}") return try { val certChainPem = loadPlain(context, CERT_CHAIN_FILE) ?: run { ensureCertsExist(context) @@ -124,7 +125,6 @@ object ProofmodeC2paManager { } val manifestJson = buildManifestJson(metadata) - val tmp = File(file.parent, "${file.nameWithoutExtension}_c2pa_tmp.${file.extension}") tmp.delete() // Signer.withCallback called directly — bypasses the broken KeyStoreSigner.createSigner. @@ -162,6 +162,7 @@ object ProofmodeC2paManager { file } catch (e: Exception) { AppLogger.e("[C2PA] Failed to embed proof in ${file.name}", e) + tmp.delete() // never leave a partial temp file behind null } } @@ -170,19 +171,22 @@ object ProofmodeC2paManager { // Key and cert management // --------------------------------------------------------------------------- + @Synchronized private fun ensureCertsExist(context: Context) { val caKeyFile = File(context.filesDir, CA_KEY_FILE) val chainFile = File(context.filesDir, CERT_CHAIN_FILE) val caFile = File(context.filesDir, CA_CERT_FILE) - val signingKeyExists = keystoreKeyExists() + // Validate (and repair) the Keystore signing key before anything else. + // A stale key from a prior app version may be unusable for ES256 signing — + // detect by test-sign and regenerate, so an in-place upgrade self-heals. + val signingKeyUsable = ensureUsableSigningKey() - if (signingKeyExists && caKeyFile.exists() && chainFile.exists() && caFile.exists()) { + if (signingKeyUsable && caKeyFile.exists() && chainFile.exists() && caFile.exists()) { if (readEncrypted(caKeyFile) != null && certMatchesKeystoreKey(chainFile)) return AppLogger.w("[C2PA] Wrap key lost or cert/key mismatch — wiping files and regenerating") } // Wipe any partial state listOf(caKeyFile, chainFile, caFile).forEach { it.delete() } - if (!signingKeyExists) generateKeystoreSigningKey() AppLogger.i("[C2PA] Generating software CA key + cert chain (Keystore pubkey in leaf)") @@ -220,6 +224,33 @@ object ProofmodeC2paManager { AppLogger.i("[C2PA] Keystore TEE signing key + cert chain ready") } + /** + * Guarantees a usable ES256 signing key exists in the Keystore under SIGNING_KEY_ALIAS. + * If the existing key is missing or cannot sign (stale key from a prior app version, + * wrong algorithm/purpose, or hardware-invalidated), it is deleted and regenerated. + * Returns true if the key was already present and usable (no regeneration needed). + */ + private fun ensureUsableSigningKey(): Boolean { + if (keystoreSignWorks()) return true + AppLogger.w("[C2PA] Signing key missing or unusable — regenerating") + runCatching { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + if (ks.containsAlias(SIGNING_KEY_ALIAS)) ks.deleteEntry(SIGNING_KEY_ALIAS) + } + generateKeystoreSigningKey() + if (!keystoreSignWorks()) { + throw IllegalStateException("Regenerated Keystore signing key still unusable") + } + return false + } + + /** Test-signs a tiny buffer with the Keystore key to confirm it is present and usable. */ + private fun keystoreSignWorks(): Boolean = runCatching { + val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } + if (!ks.containsAlias(SIGNING_KEY_ALIAS)) return false + keystoreSign(byteArrayOf(0x01)).isNotEmpty() + }.getOrDefault(false) + private fun generateKeystoreSigningKey() { val spec = KeyGenParameterSpec.Builder(SIGNING_KEY_ALIAS, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) @@ -231,11 +262,6 @@ object ProofmodeC2paManager { AppLogger.i("[C2PA] Keystore EC signing key generated") } - private fun keystoreKeyExists(): Boolean = runCatching { - val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } - ks.containsAlias(SIGNING_KEY_ALIAS) - }.getOrDefault(false) - private fun getKeystorePublicKey(): PublicKey? = runCatching { val ks = KeyStore.getInstance(KEYSTORE_PROVIDER).also { it.load(null) } ks.getCertificate(SIGNING_KEY_ALIAS)?.publicKey @@ -248,19 +274,14 @@ object ProofmodeC2paManager { */ private fun certMatchesKeystoreKey(chainFile: File): Boolean = runCatching { val keystorePubKey = getKeystorePublicKey() ?: return false - val pem = chainFile.readText() - val certBytes = java.util.Base64.getDecoder().decode( - pem.lines() - .filter { !it.startsWith("-----") && it.isNotBlank() } - .joinToString("") - .takeWhile { it != '-' } // stop at second cert boundary - .let { - // Extract only the first cert's base64 block - pem.substringAfter("-----BEGIN CERTIFICATE-----\n") - .substringBefore("\n-----END CERTIFICATE-----") - .replace("\n", "") - } - ) + // Parse the first (leaf) cert from the chain PEM + val leafBase64 = chainFile.readText() + .substringAfter("-----BEGIN CERTIFICATE-----") + .substringBefore("-----END CERTIFICATE-----") + .replace("\n", "") + .replace("\r", "") + .trim() + val certBytes = java.util.Base64.getDecoder().decode(leafBase64) val leafCert = java.security.cert.CertificateFactory.getInstance("X.509") .generateCertificate(certBytes.inputStream()) as java.security.cert.X509Certificate leafCert.publicKey.encoded.contentEquals(keystorePubKey.encoded) @@ -414,7 +435,7 @@ object ProofmodeC2paManager { """{"trust":{"trust_anchors":"${caCertPem.replace("\\","\\\\").replace("\"","\\\"").replace("\n","\\n").replace("\r","\\r")}"}}""" private fun buildManifestJson(m: MetadataCollector.CaptureMetadata): String { - val captureIso = isoFormat.format(Date(m.captureTime)) + val captureIso = isoFormat.format(Instant.ofEpochMilli(m.captureTime)) val claimGenerator = "OpenArchive Save/${BuildConfig.VERSION_NAME} (build ${BuildConfig.VERSION_CODE})" val assertions = buildList { add(actionsAssertion()) @@ -461,7 +482,7 @@ object ProofmodeC2paManager { if (m.locationBearing != null) append(""","bearing":${m.locationBearing}""") if (m.locationSpeed != null) append(""","speed":${m.locationSpeed}""") if (m.locationProvider != null) append(""","provider":${m.locationProvider.toJsonString()}""") - if (m.locationTime != null) append(""","timestamp":${isoFormat.format(Date(m.locationTime)).toJsonString()}""") + if (m.locationTime != null) append(""","timestamp":${isoFormat.format(Instant.ofEpochMilli(m.locationTime)).toJsonString()}""") } return """{"label":"org.openarchive.save.location","data":{$fields}}""" } From c390e878945d58b7d81146c72d4d6eb0ab8d4e08 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 8 Jun 2026 09:36:05 +0530 Subject: [PATCH 08/15] Simplify evidence sorting in `EvidenceDao`. - Refactored `observeBySubmission` and `observeByArchive` to use a simple `id ASC` sort order, removing complex status-based prioritization logic. - Updated `getQueueWithErrorsLast` to sort by `id ASC` instead of `id DESC` for more predictable queue processing. --- .../openarchive/db/EvidenceDao.kt | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt index b02dd26d..be5cc320 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/EvidenceDao.kt @@ -7,36 +7,16 @@ import net.opendasharchive.openarchive.core.domain.EvidenceStatus @Dao interface EvidenceDao { @Query(""" - SELECT * FROM evidence - WHERE submissionId = :submissionId - ORDER BY - CASE - WHEN status IN (2, 4) THEN 0 -- Active (Queued, Uploading) - WHEN status = 9 THEN 1 -- Error - WHEN status IN (0, 1) THEN 2 -- Local/New - WHEN status = 5 THEN 3 -- Uploaded - ELSE 4 - END, - CASE WHEN status = 5 THEN uploadedAt ELSE 0 END DESC, - priority DESC, - id DESC + SELECT * FROM evidence + WHERE submissionId = :submissionId + ORDER BY id ASC """) fun observeBySubmission(submissionId: Long): Flow> @Query(""" - SELECT * FROM evidence - WHERE archiveId = :archiveId - ORDER BY - CASE - WHEN status IN (2, 4) THEN 0 -- Active - WHEN status = 9 THEN 1 -- Error - WHEN status IN (0, 1) THEN 2 -- Local/New - WHEN status = 5 THEN 3 -- Uploaded - ELSE 4 - END, - CASE WHEN status = 5 THEN uploadedAt ELSE 0 END DESC, - priority DESC, - id DESC + SELECT * FROM evidence + WHERE archiveId = :archiveId + ORDER BY id ASC """) fun observeByArchive(archiveId: Long): Flow> @@ -53,7 +33,7 @@ interface EvidenceDao { ORDER BY CASE WHEN status = 9 THEN 1 ELSE 0 END, priority DESC, - id DESC + id ASC """) suspend fun getQueueWithErrorsLast(now: Long): List From 289745bcb893612ae89adeb6d62032b30a926eeb Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 8 Jun 2026 09:59:43 +0530 Subject: [PATCH 09/15] Rebrand C2PA content credentials to Proofmode across the application. Specific changes: - Updated `SettingsScreen` and `C2paScreen` to use new Proofmode-specific string resources for titles, summaries, and descriptions. - Updated introductory text and links in `strings.xml` to refer to Proofmode and point to `proofmode.org` instead of `c2pa.org`. - Added new string resources for Proofmode branding while maintaining compatibility for localization. - Updated the "Learn More" URL for content authenticity to point to the Proofmode website. --- .../openarchive/features/settings/C2paScreen.kt | 8 ++++---- .../openarchive/features/settings/SettingsScreen.kt | 4 ++-- app/src/main/res/values/strings.xml | 12 +++++++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt index cac53810..86c9392c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/C2paScreen.kt @@ -62,7 +62,7 @@ fun C2paScreen( DefaultScaffold( topAppBar = { ComposeAppBar( - title = stringResource(R.string.c2pa_content_authenticity), + title = stringResource(R.string.proofmode), onNavigateBack = { onNavigateBack() } @@ -141,11 +141,11 @@ fun C2paScreenContent() { ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.prefs_use_c2pa_title), + text = stringResource(R.string.prefs_use_proofmode_title), style = MaterialTheme.typography.bodyLarge ) Text( - text = stringResource(R.string.prefs_use_c2pa_summary), + text = stringResource(R.string.prefs_use_proofmode_summary), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -195,7 +195,7 @@ fun C2paScreenContent() { item { Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { HtmlText( - textRes = R.string.prefs_use_c2pa_description, + textRes = R.string.prefs_use_proofmode_description, linkRes = R.string.c2pa_learn_more_url, fontSize = 11.sp, linkColor = MaterialTheme.colorScheme.tertiary diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt index a2337746..e1c0170b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsScreen.kt @@ -175,8 +175,8 @@ fun SettingsScreen( summaryMediaServers = stringResource(R.string.pref_summary_media_servers), titleMediaFolders = stringResource(R.string.pref_title_media_folders), summaryMediaFolders = stringResource(R.string.pref_summary_media_folders), - titleC2pa = stringResource(R.string.c2pa_content_authenticity), - summaryC2pa = stringResource(R.string.prefs_use_c2pa_summary), + titleC2pa = stringResource(R.string.proofmode), + summaryC2pa = stringResource(R.string.prefs_use_proofmode_summary), titleTor = stringResource(R.string.prefs_use_tor_title), titleDarkMode = stringResource(R.string.pref_title_dark_mode), titleLanguage = stringResource(R.string.pref_title_language), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a25d78c2..6e738749 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -240,8 +240,8 @@
Choose Creative Commons Licensing.]]>
https://creativecommons.org - C2PA content credentials.]]> - https://c2pa.org + Proofmode content credentials.]]> + https://proofmode.org Save always uploads over TLS (Transport Layer Security) to protect your media in transit.
To further enhance security, enable Tor to prevent interception of your media from your phone to the server.]]>
https://www.torproject.org @@ -543,7 +543,13 @@ Enable C2PA Generate cryptographic content credentials Learn more here.]]> - https://c2pa.org/ + https://proofmode.org/ + + + Proofmode + Enable Proofmode + Generate cryptographic content credentials + Learn more here.]]>
From ad8ec8b7dab32735e6a01eb2e70d196434aecca7 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 8 Jun 2026 10:02:51 +0530 Subject: [PATCH 10/15] Bump version to 4.0.5 (30043). --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 520fa85e..85c2ce1e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,8 +67,8 @@ android { applicationId = "net.opendasharchive.openarchive" minSdk = 29 targetSdk = 36 - versionCode = 30042 - versionName = "4.0.6" + versionCode = 30043 + versionName = "4.0.5" multiDexEnabled = true vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 19b9ba874f35d0f166bc56f343fc4c2e85abce46 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 8 Jun 2026 10:35:02 +0530 Subject: [PATCH 11/15] Restrict NDK ABI filters to arm64-v8a - Drop support for `armeabi-v7a` as all supported devices for `minSdk 29` (Android 10) use 64-bit hardware. - Move `ndk` configuration block to `defaultConfig` to ensure consistent ABI filtering across all build variants. --- app/build.gradle.kts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85c2ce1e..d700368e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,10 @@ android { versionName = "4.0.5" multiDexEnabled = true vectorDrawables.useSupportLibrary = true + // minSdk=29 (Android 10) requires 64-bit hardware — all supported devices are arm64 + ndk { + abiFilters += listOf("arm64-v8a") + } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" val localProps = loadLocalProperties() resValue("string", "mixpanel_key", localProps.getProperty("MIXPANELKEY") ?: "") @@ -119,10 +123,6 @@ android { val localProps = loadLocalProperties() val acraEmail = localProps.getProperty("ACRA_EMAIL") ?: System.getenv("ACRA_EMAIL") ?: "" buildConfigField("String", "ACRA_EMAIL", "\"$acraEmail\"") - // No real devices use x86/x86_64 — emulators can use armeabi-v7a via translation - ndk { - abiFilters += listOf("arm64-v8a", "armeabi-v7a") - } } // Environment dimension From f3f495feaacc5c8a3c0fe794e8c56cc007a9b3e0 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Mon, 8 Jun 2026 10:55:04 +0530 Subject: [PATCH 12/15] Update `getProofFiles` in `Conduit` to handle missing proof directories. - Add a check for the existence of the `proof_companions` directory before attempting to prepare files for upload. - Return an empty list if the directory is missing to support gallery imports, which do not generate proof companion files. --- .../opendasharchive/openarchive/services/Conduit.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index 86a28874..6957219e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -125,12 +125,14 @@ abstract class Conduit( } /** - * Returns proof companion files for upload. - * PGP signatures and proof.json were generated at capture time by CameraViewModel. - * This call only submits OTS (network) and returns the pre-generated file list. + * Returns proof companion files for upload — camera captures only. + * Gallery imports never create the proof_companions dir, so they return empty here. */ - suspend fun getProofFiles(): List = - ProofCompanionGenerator.prepareForUpload(mContext, mEvidence.file, mEvidence.mediaHashString) + suspend fun getProofFiles(): List { + val proofDir = java.io.File(mContext.filesDir, "proof_companions/${mEvidence.mediaHashString}") + if (!proofDir.exists()) return emptyList() + return ProofCompanionGenerator.prepareForUpload(mContext, mEvidence.file, mEvidence.mediaHashString) + } /** * result is a site specific unique id that we can use to fetch the data, From bbd2a4aeddc052c78e19733f125f11ae9164855a Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Tue, 23 Jun 2026 16:11:29 +0530 Subject: [PATCH 13/15] [WIP] Implement Play Integrity attestation and expand proof companion file generation. Specific changes: **Proof Generation & Metadata:** - Expanded `ProofCompanionGenerator` to generate `.csv` proof files and corresponding PGP signatures (`.csv.asc`) alongside existing JSON proofs. - Added a dynamically generated `HowToVerifyProofData.txt` to the proof bundle with instructions for manual GPG, OpenTimestamps, and Play Integrity verification. - Refactored metadata extraction into `buildProofFields` to include richer device and network information (e.g., Brand, Model, Network Type, IPv4/IPv6, CellInfo) directly from `CaptureMetadata`. - Updated `CameraViewModel` to pass live capture metadata to the proof generator, ensuring sensor and device fields are preserved without relying solely on EXIF. **Google Play Integrity (GST):** - Introduced `SafetyNetHelper` with flavor-specific implementations: - **GMS:** Requests a Play Integrity token (GST) using a nonce derived from the media hash and saves it to a `.gst` file. - **FOSS:** Provides a no-op stub for environments without Google Mobile Services. - Added `com.google.android.play:integrity` dependency to the GMS build configuration. **Networking & Utilities:** - Updated OpenTimestamps (OTS) submission to use `application/octet-stream` for raw hash uploads. - Implemented CSV escaping logic to handle special characters and line breaks in metadata fields. - Included the new `.csv`, `.gst`, and instruction files in the list of companions prepared for upload. --- app/build.gradle.kts | 1 + .../openarchive/util/SafetyNetHelper.kt | 9 + .../openarchive/util/SafetyNetHelper.kt | 30 +++ .../features/media/camera/CameraViewModel.kt | 4 +- .../util/ProofCompanionGenerator.kt | 224 ++++++++++++++---- 5 files changed, 218 insertions(+), 50 deletions(-) create mode 100644 app/src/foss/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt create mode 100644 app/src/gms/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 520fa85e..b345e54c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -310,6 +310,7 @@ dependencies { "gmsImplementation"(libs.google.play.review.ktx) "gmsImplementation"(libs.google.play.app.update.ktx) "gmsImplementation"("com.google.android.gms:play-services-location:21.1.0") + "gmsImplementation"("com.google.android.play:integrity:1.4.0") // Tor implementation(libs.tor.android) diff --git a/app/src/foss/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt b/app/src/foss/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt new file mode 100644 index 00000000..d5906e57 --- /dev/null +++ b/app/src/foss/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt @@ -0,0 +1,9 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import java.io.File + +object SafetyNetHelper { + // Play Integrity API requires GMS — not available on FOSS builds. + fun requestGst(context: Context, mediaHash: String, outFile: File) = Unit +} diff --git a/app/src/gms/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt b/app/src/gms/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt new file mode 100644 index 00000000..de793431 --- /dev/null +++ b/app/src/gms/java/net/opendasharchive/openarchive/util/SafetyNetHelper.kt @@ -0,0 +1,30 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import android.util.Base64 +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.google.android.gms.tasks.Tasks +import net.opendasharchive.openarchive.core.logger.AppLogger +import java.io.File + +object SafetyNetHelper { + /** + * Requests a Play Integrity token and writes it as a JWT string to [outFile]. + * No-op on network or API failure — outFile simply won't exist, and the caller + * skips it in the upload list. + */ + fun requestGst(context: Context, mediaHash: String, outFile: File) { + try { + val nonce = Base64.encodeToString(mediaHash.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP) + val manager = IntegrityManagerFactory.create(context.applicationContext) + val request = IntegrityTokenRequest.builder().setNonce(nonce).build() + val response = Tasks.await(manager.requestIntegrityToken(request)) + outFile.writeText(response.token()) + AppLogger.i("[GST] Play Integrity token written for $mediaHash") + } catch (e: Exception) { + AppLogger.w("[GST] Play Integrity request failed: ${e.message}") + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt index f9265f42..e60dab37 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/media/camera/CameraViewModel.kt @@ -259,7 +259,7 @@ class CameraViewModel : ViewModel() { if (signedFile != null) { AppLogger.d("[C2PA] Proof embedded: ${signedFile.name}") val hash = computeFileHash(signedFile) - if (hash.isNotEmpty()) ProofCompanionGenerator.generateLocalProof(context, signedFile, hash) + if (hash.isNotEmpty()) ProofCompanionGenerator.generateLocalProof(context, signedFile, hash, metadata) } else { AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") } @@ -279,7 +279,7 @@ class CameraViewModel : ViewModel() { if (signedFile != null) { AppLogger.d("[C2PA] Proof embedded: ${signedFile.name}") val hash = computeFileHash(signedFile) - if (hash.isNotEmpty()) ProofCompanionGenerator.generateLocalProof(context, signedFile, hash) + if (hash.isNotEmpty()) ProofCompanionGenerator.generateLocalProof(context, signedFile, hash, metadata) } else { AppLogger.w("[C2PA] Proof embedding skipped/failed for ${file.name}") } diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt index 2fca03ec..b7fbf0ae 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt @@ -79,21 +79,39 @@ object ProofCompanionGenerator { } /** - * Called at capture time (no network). Generates proof.json and PGP signatures - * immediately after C2PA embedding while the file bytes are known-final. - * This ensures PGP signatures are provably of the same bytes the SHA-256 hash covers. + * Called at capture time (no network). Generates proof.json, proof.csv, PGP signatures, + * and HowToVerifyProofData.txt immediately after C2PA embedding while the file bytes are + * known-final. Passing [metadata] ensures all sensor/device fields make it into the proof + * without round-tripping through EXIF. */ - suspend fun generateLocalProof(context: Context, evidenceFile: File, mediaHash: String) { + suspend fun generateLocalProof( + context: Context, + evidenceFile: File, + mediaHash: String, + metadata: MetadataCollector.CaptureMetadata? = null + ) { if (!Prefs.useC2pa) return withContext(Dispatchers.IO) { try { val outDir = File(context.filesDir, "proof_companions/$mediaHash").also { it.mkdirs() } - val proofJson = buildProofJson(evidenceFile, mediaHash) - File(outDir, "$mediaHash.proof.json").writeText(proofJson) + val fields = buildProofFields(evidenceFile, mediaHash, metadata) + + val proofJson = buildProofJson(fields) + val proofCsv = buildProofCsv(fields) + + val proofJsonFile = File(outDir, "$mediaHash.proof.json") + val proofCsvFile = File(outDir, "$mediaHash.proof.csv") + proofJsonFile.writeText(proofJson) + proofCsvFile.writeText(proofCsv) pgpSignStream(context, evidenceFile, File(outDir, "$mediaHash.asc")) pgpSign(context, proofJson.toByteArray(Charsets.UTF_8), File(outDir, "$mediaHash.proof.json.asc")) + pgpSign(context, proofCsv.toByteArray(Charsets.UTF_8), File(outDir, "$mediaHash.proof.csv.asc")) + + File(outDir, "HowToVerifyProofData.txt").writeText( + buildHowToVerify(evidenceFile.name, mediaHash) + ) AppLogger.i("[Proof] Local proof generated at capture time for $mediaHash") } catch (e: Exception) { @@ -103,37 +121,52 @@ object ProofCompanionGenerator { } /** - * Called at upload time. Submits OTS (network), falls back to generating proof files - * if generateLocalProof was not called at capture, then returns all files for upload. + * Called at upload time. Submits OTS (network), requests device attestation (.gst), + * falls back to generating proof files if generateLocalProof was not called at capture, + * then returns all files for upload. */ suspend fun prepareForUpload(context: Context, evidenceFile: File, mediaHash: String): List = withContext(Dispatchers.IO) { if (!Prefs.useC2pa) return@withContext emptyList() try { val outDir = File(context.filesDir, "proof_companions/$mediaHash").also { it.mkdirs() } - val proofJsonFile = File(outDir, "$mediaHash.proof.json") - val mediaSigFile = File(outDir, "$mediaHash.asc") - val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc") + val proofJsonFile = File(outDir, "$mediaHash.proof.json") + val proofCsvFile = File(outDir, "$mediaHash.proof.csv") + val mediaSigFile = File(outDir, "$mediaHash.asc") + val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc") + val csvSigFile = File(outDir, "$mediaHash.proof.csv.asc") + val howToFile = File(outDir, "HowToVerifyProofData.txt") // Defensive fallback — should not occur for camera captures but handles edge cases if (!proofJsonFile.exists() || !mediaSigFile.exists() || !jsonSigFile.exists()) { AppLogger.w("[Proof] Local proof missing for $mediaHash — generating at upload time") - val proofJson = buildProofJson(evidenceFile, mediaHash) - proofJsonFile.writeText(proofJson) + val fields = buildProofFields(evidenceFile, mediaHash, null) + proofJsonFile.writeText(buildProofJson(fields)) + proofCsvFile.writeText(buildProofCsv(fields)) pgpSignStream(context, evidenceFile, mediaSigFile) - pgpSign(context, proofJson.toByteArray(Charsets.UTF_8), jsonSigFile) + pgpSign(context, proofJsonFile.readBytes(), jsonSigFile) + pgpSign(context, proofCsvFile.readBytes(), csvSigFile) + howToFile.writeText(buildHowToVerify(evidenceFile.name, mediaHash)) } // OTS is network-only — submitted at upload time with retry queue val otsFile = File(outDir, "$mediaHash.ots") submitOts(context, mediaHash, otsFile) + // Device attestation — GMS builds only; FOSS stub returns null + val gstFile = File(outDir, "$mediaHash.gst") + SafetyNetHelper.requestGst(context, mediaHash, gstFile) + val pubKeyFile = File(context.filesDir, PUBLIC_KEY_FILE) buildList { add(proofJsonFile) + add(proofCsvFile) add(mediaSigFile) add(jsonSigFile) + if (csvSigFile.exists()) add(csvSigFile) if (pubKeyFile.exists()) add(pubKeyFile) if (otsFile.exists()) add(otsFile) + if (gstFile.exists()) add(gstFile) + if (howToFile.exists()) add(howToFile) }.also { AppLogger.i("[Proof] ${it.size} companion files ready for upload: $mediaHash") } @@ -144,15 +177,16 @@ object ProofCompanionGenerator { } // --------------------------------------------------------------------------- - // Proof JSON — ProofMode v1 field names + // Proof fields — shared between JSON and CSV builders // --------------------------------------------------------------------------- - private fun buildProofJson(file: File, hash: String): String { + private fun buildProofFields( + file: File, + hash: String, + metadata: MetadataCollector.CaptureMetadata? + ): LinkedHashMap { val now = isoFmt.format(Instant.now()) - // Read capture time from EXIF TAG_DATETIME — written at capture time by MetadataCollector, - // guaranteed consistent with the C2PA manifest timestamp and the EXIF in the signed file. - // EXIF datetime is in UTC (MetadataCollector writes it that way). val exifDtFormat = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneOffset.UTC) val created = try { ExifInterface(file.absolutePath) @@ -168,35 +202,62 @@ object ProofCompanionGenerator { val fields = linkedMapOf() fields["File Hash SHA256"] = hash - fields["File Path"] = file.absolutePath - fields["File Created"] = created - fields["File Modified"] = created - fields["Proof Generated"] = now - fields["Notes"] = "OpenArchive Save ${BuildConfig.VERSION_NAME}" - fields["Manufacturer"] = Build.MANUFACTURER - fields["Hardware"] = "${Build.MANUFACTURER} ${Build.MODEL}" - fields["Locale"] = Locale.getDefault().country - fields["Language"] = Locale.getDefault().displayLanguage - - // Read GPS + device info from EXIF written at capture time - try { - val exif = ExifInterface(file.absolutePath) - val latLon = exif.latLong - if (latLon != null) { - fields["Location.Latitude"] = latLon[0].toString() - fields["Location.Longitude"] = latLon[1].toString() - } - exif.getAttribute(ExifInterface.TAG_GPS_ALTITUDE) - ?.let { fields["Location.Altitude"] = it } - exif.getAttribute(ExifInterface.TAG_GPS_SPEED) - ?.let { fields["Location.Speed"] = it } - exif.getAttribute(ExifInterface.TAG_GPS_TRACK) - ?.let { fields["Location.Bearing"] = it } - exif.getAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD) - ?.removePrefix("charset=Ascii ")?.lowercase() - ?.let { fields["Location.Provider"] = it } - } catch (_: Exception) {} + fields["File Path"] = file.absolutePath + fields["File Created"] = created + fields["File Modified"] = created + fields["Proof Generated"] = now + fields["Notes"] = "${metadata?.appName ?: "OpenArchive Save"} ${BuildConfig.VERSION_NAME}" + fields["App.Name"] = metadata?.appName + fields["Manufacturer"] = metadata?.deviceMake ?: Build.MANUFACTURER + fields["Device.Brand"] = metadata?.deviceBrand ?: Build.BRAND + fields["Device.Model"] = metadata?.deviceModel ?: Build.MODEL + fields["Hardware"] = "${metadata?.deviceMake ?: Build.MANUFACTURER} ${metadata?.deviceModel ?: Build.MODEL}" + fields["Locale"] = metadata?.locale ?: Locale.getDefault().country + fields["Language"] = metadata?.language ?: Locale.getDefault().displayLanguage + fields["Screen.Size"] = metadata?.screenSizeInches?.let { "%.2f".format(it) } + fields["Network.Type"] = metadata?.networkType + fields["Network.IPv4"] = metadata?.ipv4 + fields["Network.IPv6"] = metadata?.ipv6 + fields["Network.CellInfo"] = metadata?.cellInfo + + // GPS — prefer live metadata, fall back to EXIF + if (metadata?.latitude != null && metadata.longitude != null) { + fields["Location.Latitude"] = metadata.latitude.toString() + fields["Location.Longitude"] = metadata.longitude.toString() + metadata.locationAltitude?.let { fields["Location.Altitude"] = it.toString() } + metadata.locationSpeed?.let { fields["Location.Speed"] = it.toString() } + metadata.locationBearing?.let { fields["Location.Bearing"] = it.toString() } + metadata.locationProvider?.let { fields["Location.Provider"] = it } + metadata.locationAccuracy?.let { fields["Location.Accuracy"] = it.toString() } + metadata.locationTime?.let { fields["Location.Time"] = it.toString() } + } else { + try { + val exif = ExifInterface(file.absolutePath) + val latLon = exif.latLong + if (latLon != null) { + fields["Location.Latitude"] = latLon[0].toString() + fields["Location.Longitude"] = latLon[1].toString() + } + exif.getAttribute(ExifInterface.TAG_GPS_ALTITUDE) + ?.let { fields["Location.Altitude"] = it } + exif.getAttribute(ExifInterface.TAG_GPS_SPEED) + ?.let { fields["Location.Speed"] = it } + exif.getAttribute(ExifInterface.TAG_GPS_TRACK) + ?.let { fields["Location.Bearing"] = it } + exif.getAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD) + ?.removePrefix("charset=Ascii ")?.lowercase() + ?.let { fields["Location.Provider"] = it } + } catch (_: Exception) {} + } + return fields + } + + // --------------------------------------------------------------------------- + // Proof JSON — ProofMode v1 field names + // --------------------------------------------------------------------------- + + private fun buildProofJson(fields: LinkedHashMap): String { val sb = StringBuilder("{\n") val entries = fields.entries.filter { it.value != null }.toList() entries.forEachIndexed { i, (k, v) -> @@ -208,6 +269,65 @@ object ProofCompanionGenerator { return sb.toString() } + // --------------------------------------------------------------------------- + // Proof CSV — same fields, header + data row + // --------------------------------------------------------------------------- + + private fun buildProofCsv(fields: LinkedHashMap): String { + val present = fields.entries.filter { it.value != null } + val header = present.joinToString(",") { csvEscape(it.key) } + val data = present.joinToString(",") { csvEscape(it.value!!) } + return "$header\n$data\n" + } + + // --------------------------------------------------------------------------- + // HowToVerifyProofData.txt — dynamically generated with real hash/filename + // --------------------------------------------------------------------------- + + private fun buildHowToVerify(mediaFileName: String, hash: String): String = """ +Brief information on how to verify the media file, proof and signatures contained in a ProofMode bundle. +Please visit https://proofmode.org or email support@guardianproject.info for more information. + +1) Import public key shared from ProofMode: + +gpg --import pubkey.asc + +gpg: key xxx: public key "proof@openarchive.app" imported +gpg: Total number processed: 1 +gpg: imported: 1 + +2) Check the hash of the media file against the hash in the proof metadata: + +sha256sum $mediaFileName + +$hash $mediaFileName + +3) Verify signature of the media file: + +gpg --dearmor pubkey.asc +gpg --no-default-keyring --keyring ./pubkey.asc.gpg --homedir ./ --verify $hash.asc $mediaFileName + +gpg: Good signature from "proof@openarchive.app" [unknown] + +4) Verify signature of the ProofMode CSV data: + +gpg --verify $hash.proof.csv.asc $hash.proof.csv + +gpg: Good signature from "proof@openarchive.app" [unknown] + +5) Verify signature of the ProofMode JSON data: + +gpg --verify $hash.proof.json.asc $hash.proof.json + +gpg: Good signature from "proof@openarchive.app" [unknown] + +6) If a .ots file is present, visit https://opentimestamps.org/ and upload $hash.ots to verify + the Bitcoin blockchain notarisation. (It can take several hours for the timestamp to confirm.) + +7) If a .gst file is present, that is a JWT from the Google Play Integrity API attesting device + integrity at capture time. Decode the JWT value at https://jwt.io/ to inspect the claims. +""".trimStart() + // --------------------------------------------------------------------------- // PGP key generation and detached signing // --------------------------------------------------------------------------- @@ -371,7 +491,7 @@ object ProofCompanionGenerator { doOutput = true connectTimeout = OTS_TIMEOUT_MS readTimeout = OTS_TIMEOUT_MS - setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + setRequestProperty("Content-Type", "application/octet-stream") setRequestProperty("Accept", "application/octet-stream") } conn.outputStream.use { it.write(hashBytes) } @@ -470,4 +590,12 @@ object ProofCompanionGenerator { .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") return "\"$escaped\"" } + + private fun csvEscape(s: String): String { + return if (s.contains(',') || s.contains('"') || s.contains('\n')) { + "\"${s.replace("\"", "\"\"")}\"" + } else { + s + } + } } From d783fbc157cc9ef378366ed24b3051d1ee06eea6 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Sun, 12 Jul 2026 16:21:27 +0530 Subject: [PATCH 14/15] Added OTS digest generation --- .../util/ProofCompanionGenerator.kt | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt index b7fbf0ae..a7ee5b97 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/ProofCompanionGenerator.kt @@ -50,9 +50,20 @@ object ProofCompanionGenerator { private const val PENDING_OTS_FILE = "pending_ots.txt" private const val OTS_QUEUE_MAX = 500 private const val PGP_IDENTITY = "OpenArchive Save " - private const val OTS_CALENDAR = "https://a.pool.opentimestamps.org/timestamp" + private const val OTS_CALENDAR = "https://a.pool.opentimestamps.org/digest" private const val OTS_TIMEOUT_MS = 15_000 + // OpenTimestamps detached-file format: fixed 31-byte magic + 1-byte major version. + // Every valid .ots file (and every OTS verifier, incl. opentimestamps.org) requires this + // preamble before the hash-op tag + digest + serialized timestamp. + private val OTS_HEADER_MAGIC = byteArrayOf( + 0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, + 0x00, 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x00, + 0xbf.toByte(), 0x89.toByte(), 0xe2.toByte(), 0xe8.toByte(), 0x84.toByte(), 0xe8.toByte(), 0x92.toByte(), 0x94.toByte() + ) + private const val OTS_MAJOR_VERSION: Byte = 0x01 + private const val OTS_OP_SHA256: Byte = 0x08 + // PGP BouncyCastle API requires a passphrase for its internal AES layer. // The secret key ring file itself is additionally encrypted at rest via a // Keystore-backed AES-256-GCM key (see writeEncryptedKey / readEncryptedKey). @@ -79,10 +90,12 @@ object ProofCompanionGenerator { } /** - * Called at capture time (no network). Generates proof.json, proof.csv, PGP signatures, - * and HowToVerifyProofData.txt immediately after C2PA embedding while the file bytes are + * Called at capture time. Generates proof.json, proof.csv, PGP signatures, and + * HowToVerifyProofData.txt immediately after C2PA embedding while the file bytes are * known-final. Passing [metadata] ensures all sensor/device fields make it into the proof - * without round-tripping through EXIF. + * without round-tripping through EXIF. Also submits the OTS timestamp and requests device + * attestation (.gst) right away — all proof material is complete at capture time; nothing + * is generated later at upload time. */ suspend fun generateLocalProof( context: Context, @@ -113,6 +126,11 @@ object ProofCompanionGenerator { buildHowToVerify(evidenceFile.name, mediaHash) ) + // OTS calendar submission and device attestation — done now so every proof + // artifact exists immediately after capture, not deferred to upload time. + submitOts(context, mediaHash, File(outDir, "$mediaHash.ots")) + SafetyNetHelper.requestGst(context, mediaHash, File(outDir, "$mediaHash.gst")) + AppLogger.i("[Proof] Local proof generated at capture time for $mediaHash") } catch (e: Exception) { AppLogger.e("[Proof] Local proof generation failed for $mediaHash", e) @@ -121,9 +139,10 @@ object ProofCompanionGenerator { } /** - * Called at upload time. Submits OTS (network), requests device attestation (.gst), - * falls back to generating proof files if generateLocalProof was not called at capture, - * then returns all files for upload. + * Called at upload time. All proof material — including OTS and .gst — is generated at + * capture time by [generateLocalProof]; this only assembles the file list, with a + * defensive fallback that regenerates anything missing (should not occur for camera + * captures, but covers edge cases like a process death between capture and upload). */ suspend fun prepareForUpload(context: Context, evidenceFile: File, mediaHash: String): List = withContext(Dispatchers.IO) { if (!Prefs.useC2pa) return@withContext emptyList() @@ -135,8 +154,11 @@ object ProofCompanionGenerator { val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc") val csvSigFile = File(outDir, "$mediaHash.proof.csv.asc") val howToFile = File(outDir, "HowToVerifyProofData.txt") + val otsFile = File(outDir, "$mediaHash.ots") + val gstFile = File(outDir, "$mediaHash.gst") // Defensive fallback — should not occur for camera captures but handles edge cases + // (e.g. process death between capture and upload) if (!proofJsonFile.exists() || !mediaSigFile.exists() || !jsonSigFile.exists()) { AppLogger.w("[Proof] Local proof missing for $mediaHash — generating at upload time") val fields = buildProofFields(evidenceFile, mediaHash, null) @@ -147,14 +169,13 @@ object ProofCompanionGenerator { pgpSign(context, proofCsvFile.readBytes(), csvSigFile) howToFile.writeText(buildHowToVerify(evidenceFile.name, mediaHash)) } - - // OTS is network-only — submitted at upload time with retry queue - val otsFile = File(outDir, "$mediaHash.ots") - submitOts(context, mediaHash, otsFile) - - // Device attestation — GMS builds only; FOSS stub returns null - val gstFile = File(outDir, "$mediaHash.gst") - SafetyNetHelper.requestGst(context, mediaHash, gstFile) + if (!otsFile.exists()) { + AppLogger.w("[Proof] OTS missing for $mediaHash — submitting at upload time") + submitOts(context, mediaHash, otsFile) + } + if (!gstFile.exists()) { + SafetyNetHelper.requestGst(context, mediaHash, gstFile) + } val pubKeyFile = File(context.filesDir, PUBLIC_KEY_FILE) buildList { @@ -497,8 +518,18 @@ gpg: Good signature from "proof@openarchive.app" [unknown] conn.outputStream.use { it.write(hashBytes) } val success = conn.responseCode == 200 if (success) { + val calendarResponse = conn.inputStream.use { it.readBytes() } outFile.parentFile?.mkdirs() - outFile.writeBytes(conn.inputStream.use { it.readBytes() }) + // Assemble a spec-compliant detached .ots: magic header + version + hash-op tag + // + digest, followed by the calendar's serialized (pending) timestamp. Writing + // the raw calendar response alone (previous behavior) produced a file no OTS + // verifier could read. + outFile.outputStream().use { out -> + out.write(OTS_HEADER_MAGIC) + out.write(byteArrayOf(OTS_MAJOR_VERSION, OTS_OP_SHA256)) + out.write(hashBytes) + out.write(calendarResponse) + } AppLogger.i("[OTS] Timestamp received for $hexHash") } else { AppLogger.w("[OTS] Calendar returned HTTP ${conn.responseCode}") From e668c0f60c3ff928c9a9fd0d6ed3c6623432c220 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Sun, 12 Jul 2026 20:41:33 +0530 Subject: [PATCH 15/15] Update `IaConduit` to treat `meta.json` upload failure as a fatal error. - Modified metadata upload logic to throw an exception if all retries fail, instead of logging a non-fatal error. - Ensures that failure to upload metadata triggers a job retry, where `isAlreadyUploaded()` will skip existing content and re-attempt only the metadata upload. --- .../services/internetarchive/data/IaConduit.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt index 361b58a9..b0f13a72 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/internetarchive/data/IaConduit.kt @@ -133,11 +133,14 @@ class IaConduit(evidence: Evidence, context: Context) : Conduit(evidence, contex } } - // Upload metadata after content succeeds — non-fatal if it fails. - // IA item already exists with content; missing meta.json just means IA uses defaults. + // Upload metadata after content succeeds. Content + metadata are treated as one + // unit: a meta.json failure fails the whole job (jobFailed below via the outer + // catch) so the queue re-attempts. On retry, isAlreadyUploaded() (above) skips + // the already-present content and only meta.json is re-sent. var metadataUploaded = false var metaAttempt = 0 val maxMetaRetries = 3 + var lastMetaError: Throwable? = null while (!metadataUploaded && metaAttempt < maxMetaRetries) { if (metaAttempt > 0) delay(2_000L * metaAttempt) metaAttempt++ @@ -145,11 +148,12 @@ class IaConduit(evidence: Evidence, context: Context) : Conduit(evidence, contex client.uploadMetaData(metaJson, fileName, auth) metadataUploaded = true } catch (e: Throwable) { + lastMetaError = e AppLogger.w("meta.json attempt $metaAttempt/$maxMetaRetries failed for $fileName: ${e.message}") } } if (!metadataUploaded) { - AppLogger.e("meta.json failed all retries for $fileName — item uploaded without custom metadata") + throw lastMetaError ?: IOException("meta.json upload failed for $fileName") } jobSucceeded()