Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter context is unused.

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter mediaHash is unused.

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter outFile is unused.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package net.opendasharchive.openarchive.util

import android.content.Context

Check warning

Code scanning / detekt

Detects imports in non default order Warning

Imports must be ordered in lexicographic order without any empty lines in-between with "java", "javax", "kotlin" and aliases in the end
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),

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Argument should be on a separate line (unless all arguments can fit a single line)

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline after "("
Base64.URL_SAFE or Base64.NO_WRAP)

Check warning

Code scanning / detekt

Reports missing newlines (e.g. between parentheses of a multi-line function call Warning

Missing newline before ")"

Check warning

Code scanning / detekt

Reports incorrect argument list wrapping Warning

Missing newline before ")"
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) {

Check warning

Code scanning / detekt

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled. Warning

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
AppLogger.w("[GST] Play Integrity request failed: ${e.message}")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
}
Expand All @@ -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}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,39 @@
}

/**
* 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)

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

val proofJsonFile = File(outDir, "$mediaHash.proof.json")
val proofCsvFile = File(outDir, "$mediaHash.proof.csv")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
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) {
Expand All @@ -103,37 +121,52 @@
}

/**
* 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<File> = 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")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
val proofCsvFile = File(outDir, "$mediaHash.proof.csv")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
val mediaSigFile = File(outDir, "$mediaHash.asc")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
val jsonSigFile = File(outDir, "$mediaHash.proof.json.asc")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
val csvSigFile = File(outDir, "$mediaHash.proof.csv.asc")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
val howToFile = File(outDir, "HowToVerifyProofData.txt")

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

// 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")
}
Expand All @@ -144,15 +177,16 @@
}

// ---------------------------------------------------------------------------
// Proof JSONProofMode v1 field names
// Proof fieldsshared between JSON and CSV builders
// ---------------------------------------------------------------------------

private fun buildProofJson(file: File, hash: String): String {
private fun buildProofFields(

Check warning

Code scanning / detekt

One method should have one responsibility. Long methods tend to handle many things at once. Prefer smaller methods to make them easier to understand. Warning

The function buildProofFields is too long (63). The maximum length is 60.

Check warning

Code scanning / detekt

Prefer splitting up complex methods into smaller, easier to test methods. Warning

The function buildProofFields appears to be too complex based on Cyclomatic Complexity (complexity: 27). Defined complexity threshold for methods is set to '15'
file: File,
hash: String,
metadata: MetadataCollector.CaptureMetadata?
): LinkedHashMap<String, String?> {
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)
Expand All @@ -168,35 +202,62 @@

val fields = linkedMapOf<String, String?>()
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

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["File Created"] = created

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["File Modified"] = created

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Proof Generated"] = now

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Notes"] = "${metadata?.appName ?: "OpenArchive Save"} ${BuildConfig.VERSION_NAME}"

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["App.Name"] = metadata?.appName

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Manufacturer"] = metadata?.deviceMake ?: Build.MANUFACTURER

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Device.Brand"] = metadata?.deviceBrand ?: Build.BRAND

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Device.Model"] = metadata?.deviceModel ?: Build.MODEL

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Hardware"] = "${metadata?.deviceMake ?: Build.MANUFACTURER} ${metadata?.deviceModel ?: Build.MODEL}"

Check warning

Code scanning / detekt

Reports lines with exceeded length Warning

Exceeded max line length (120)

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Locale"] = metadata?.locale ?: Locale.getDefault().country

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Language"] = metadata?.language ?: Locale.getDefault().displayLanguage

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Screen.Size"] = metadata?.screenSizeInches?.let { "%.2f".format(it) }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Network.Type"] = metadata?.networkType

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Network.IPv4"] = metadata?.ipv4

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Network.IPv6"] = metadata?.ipv6

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
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()

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
fields["Location.Longitude"] = metadata.longitude.toString()
metadata.locationAltitude?.let { fields["Location.Altitude"] = it.toString() }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
metadata.locationSpeed?.let { fields["Location.Speed"] = it.toString() }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
metadata.locationBearing?.let { fields["Location.Bearing"] = it.toString() }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
metadata.locationProvider?.let { fields["Location.Provider"] = it }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
metadata.locationAccuracy?.let { fields["Location.Accuracy"] = it.toString() }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
metadata.locationTime?.let { fields["Location.Time"] = it.toString() }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
} else {
try {
val exif = ExifInterface(file.absolutePath)
val latLon = exif.latLong
if (latLon != null) {
fields["Location.Latitude"] = latLon[0].toString()

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
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, String?>): String {
val sb = StringBuilder("{\n")
val entries = fields.entries.filter { it.value != null }.toList()
entries.forEachIndexed { i, (k, v) ->
Expand All @@ -208,6 +269,65 @@
return sb.toString()
}

// ---------------------------------------------------------------------------
// Proof CSV — same fields, header + data row
// ---------------------------------------------------------------------------

private fun buildProofCsv(fields: LinkedHashMap<String, String?>): String {
val present = fields.entries.filter { it.value != null }
val header = present.joinToString(",") { csvEscape(it.key) }
val data = present.joinToString(",") { csvEscape(it.value!!) }

Check warning

Code scanning / detekt

Reports multiple space usages Warning

Unnecessary long whitespace
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -371,7 +491,7 @@
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) }
Expand Down Expand Up @@ -470,4 +590,12 @@
.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
}
}
}
Loading