#!/usr/bin/env python3 """TUVAMBES GeoCam - reference verifier. Checks photos (.jpg) and videos (.mp4) signed by TUVAMBES GeoCam independently of the app, step by step as published at https://tuvambes.in/geocam/verification and gives the same result the app gives: Verified, Verified - Mark-up / Measurements, Modified or Unverified. It also checks a capture's custody log and an evidence package exported from the app. Everything runs on this computer. Nothing is uploaded, unless you add --google-revocation, which downloads Google's public attestation status list. Needs Python 3.9 or later and the 'cryptography' package: pip install cryptography Usage: python geocam_verify.py PHOTO_OR_VIDEO [PHOTO_OR_VIDEO ...] python geocam_verify.py --package GeoCam_evidence_YYYYMMDD_HHMMSS.zip python geocam_verify.py --custody custody_log.json add --json for machine-readable output Exit code: 0 = every file Verified, 1 = a file is Modified, 2 = a file is Unverified (or a check failed), 3 = usage error. (c) 2026 TUVAMBES Technologies, a division of TUVAMBES Enterprises. Provided as is, without warranty. You may use, copy and run this script to check files, and adapt it to build your own verifier. """ import argparse import base64 import hashlib import json import math import os import sys import tempfile import zipfile from datetime import datetime, timedelta, timezone try: from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa except ImportError: # pragma: no cover sys.stderr.write( "This verifier needs the 'cryptography' package: pip install cryptography\n" ) sys.exit(3) VERIFIER_VERSION = "1.0" # The official app. APP_PACKAGE = "com.tuvambes.geocam" # SHA-256 of the official app's signing certificate (lowercase hex), as # published on the verification page. Empty = not enforced; the package name # and the hardware-backed key are still checked. OFFICIAL_APP_CERT_SHA256 = [] # Withdrawn signing keys (first 16 hex characters of the SHA-256 of the # public key) and certificate serial numbers (lowercase hex) - the app's # signed trust list. REVOKED_KEY_IDS = [] REVOKED_CERT_SERIALS = [] # The manifest version this verifier checks (every release build writes it). MANIFEST_VERSION = 2 JPEG_ID = b"TUVAMBES-AUTH\x00" # APP11 segment identifier MP4_UUID = b"TUVAMBESAUTH0001" # 'uuid' box user type KEY_ATTESTATION_OID = x509.ObjectIdentifier("1.3.6.1.4.1.11129.2.1.17") CLOCK_TOLERANCE_SECONDS = 60 GOOGLE_STATUS_URL = "https://android.googleapis.com/attestation/status" # Google's hardware attestation roots (public certificates published at # developer.android.com/privacy-and-security/security-key-attestation). TRUST_ROOTS_PEM = [ # Google Hardware Attestation Root (RSA-4096), SPKI SHA-256 # feb2ea7551ee316ed4bb443c8293b884dbfdea40b603ee3e4f4a897e4580fbae """-----BEGIN CERTIFICATE----- MIIFHDCCAwSgAwIBAgIJAPHBcqaZ6vUdMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMzIwMTgwNzQ4WhcNNDIwMzE1MTgw NzQ4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7 tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQB8cMqTllHc8U+qCrOlg3H7 174lmaCsbo/bJ0C17JEgMLb4kvrqsXZs01U3mB/qABg/1t5Pd5AORHARs1hhqGIC W/nKMav574f9rZN4PC2ZlufGXb7sIdJpGiO9ctRhiLuYuly10JccUZGEHpHSYM2G tkgYbZba6lsCPYAAP83cyDV+1aOkTf1RCp/lM0PKvmxYN10RYsK631jrleGdcdkx oSK//mSQbgcWnmAEZrzHoF1/0gso1HZgIn0YLzVhLSA/iXCX4QT2h3J5z3znluKG 1nv8NQdxei2DIIhASWfu804CA96cQKTTlaae2fweqXjdN1/v2nqOhngNyz1361mF mr4XmaKH/ItTwOe72NI9ZcwS1lVaCvsIkTDCEXdm9rCNPAY10iTunIHFXRh+7KPz lHGewCq/8TOohBRn0/NNfh7uRslOSZ/xKbN9tMBtw37Z8d2vvnXq/YWdsm1+JLVw n6yYD/yacNJBlwpddla8eaVMjsF6nBnIgQOf9zKSe06nSTqvgwUHosgOECZJZ1Eu zbH4yswbt02tKtKEFhx+v+OTge/06V+jGsqTWLsfrOCNLuA8H++z+pUENmpqnnHo vaI47gC+TNpkgYGkkBT6B/m/U01BuOBBTzhIlMEZq9qkDWuM2cA5kW5V3FJUcfHn w1IdYIg2Wxg7yHcQZemFQg== -----END CERTIFICATE-----""", # Google Key Attestation CA1 (ECDSA P-384), SPKI SHA-256 # 3ee44512a1af2beb39c889490c60ea3f82e43f5d5a5532f5ab9419f676cd07ec """-----BEGIN CERTIFICATE----- MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4 WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5 Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY uR2zh/80lQyu9vAFCj6E4AXc+osmRg== -----END CERTIFICATE-----""", ] BOOT_STATES = ["verified", "selfSigned", "unverified", "failed"] HEADLINES = { "verified": "Verified", "modified": "Modified", "unverified": "Unverified", } EXPLANATIONS = { "unchanged": "This file is exactly as TUVAMBES GeoCam saved it. It was signed " "by the official app on a genuine phone and has not changed since. The " "GeoStamp was added by GeoCam at capture.", "contentChanged": "This file was changed after GeoCam signed it. The picture " "or video no longer matches its signature, so it is not the original capture.", "recordAltered": "The capture record in this file (time, place or other " "details) no longer matches its signature: it was changed after signing.", "locationChanged": "The location written in this file's details was changed " "after capture. The picture is unchanged; only the signed location can be " "relied on.", "noSignature": "This file has no GeoCam signature, so its origin cannot be " "confirmed. Apps that re-save or compress files remove it. This alone does " "not mean the file was edited.", "notOfficial": "This file is signed, but not by the official TUVAMBES GeoCam " "app on a genuine phone, so its origin cannot be confirmed.", "keyWithdrawn": "TUVAMBES has withdrawn the key that signed this file. Its " "signature can no longer be relied on.", "certificateRevoked": "Google has revoked a security certificate of the phone " "that signed this file (for example because the phone's keys leaked). Its " "signature can no longer be relied on.", "deviceSecurityOff": "The phone that signed this file reported its security " "as off (bootloader unlocked or unverified software). Its signatures cannot " "be relied on.", "damaged": "The file or its signature is damaged, so it cannot be checked.", "unsupported": "Only JPEG photos and MP4 videos can be checked.", "newerVersion": "This file was signed by a newer version of GeoCam; a newer " "version of this verifier is needed to check it.", "earlierFormat": "This file was signed in an earlier format used only by " "pre-release builds of GeoCam. Check it in the app.", } # --------------------------------------------------------------------- DER def der_read(b, pos): """One DER TLV at pos: (class, tag number, content, offset after it).""" first = b[pos] tag = first & 0x1F p = pos + 1 if tag == 0x1F: tag = 0 guard = 0 while True: x = b[p] p += 1 tag = (tag << 7) | (x & 0x7F) guard += 1 if (x & 0x80) == 0 or guard > 4: break length = b[p] p += 1 if length >= 0x80: n = length & 0x7F if n == 0 or n > 4: raise ValueError("unsupported DER length") length = int.from_bytes(b[p : p + n], "big") p += n if p + length > len(b): raise ValueError("DER overrun") return first >> 6, tag, b[p : p + length], p + length def der_children(content): out = [] pos = 0 while pos < len(content): t = der_read(content, pos) out.append(t) pos = t[3] return out def parse_attestation(cert): """The Android key-attestation fields of a leaf certificate, or None.""" try: ext = cert.extensions.get_extension_for_oid(KEY_ATTESTATION_OID) except x509.ExtensionNotFound: return None try: kd = der_children(der_read(ext.value.value, 0)[2]) level = kd[1][2] out = { "securityLevel": level[0] if level else 0, "packages": [], "appCertificateSha256": [], "verifiedBootState": None, "bootloaderLocked": None, "osVersion": None, "osPatchLevel": None, "vendorPatchLevel": None, "bootPatchLevel": None, } # softwareEnforced, then hardwareEnforced (hardware values win). for idx in (6, 7): if idx >= len(kd): continue for cls, tag, content, _ in der_children(kd[idx][2]): if cls != 2: continue inner = der_read(content, 0)[2] if tag == 709: # attestationApplicationId if not out["packages"]: app = der_children(der_read(inner, 0)[2]) for info in der_children(app[0][2]): name = der_children(info[2])[0][2] out["packages"].append(name.decode("ascii")) if len(app) > 1: for d in der_children(app[1][2]): out["appCertificateSha256"].append(d[2].hex()) elif tag == 704: # rootOfTrust rot = der_children(inner) if len(rot) >= 3: locked = rot[1][2] out["bootloaderLocked"] = len(locked) > 0 and locked[0] != 0 state = rot[2][2][0] if rot[2][2] else 3 out["verifiedBootState"] = ( BOOT_STATES[state] if state < 4 else "failed" ) elif tag == 705: out["osVersion"] = int.from_bytes(inner, "big") elif tag == 706: out["osPatchLevel"] = int.from_bytes(inner, "big") elif tag == 718: out["vendorPatchLevel"] = int.from_bytes(inner, "big") elif tag == 719: out["bootPatchLevel"] = int.from_bytes(inner, "big") return out except Exception: return None # ------------------------------------------------------------------- media class Media: """A photo or video and its GeoCam signature block, if any. The signed media is every byte of the file except the signature block. """ def __init__(self, path): self.path = path self.size = os.path.getsize(path) lower = path.lower() if lower.endswith((".jpg", ".jpeg")): self.kind = "jpeg" elif lower.endswith((".mp4", ".mov", ".m4v")): self.kind = "mp4" else: self.kind = None self.manifest = None self.block = None if self.kind == "jpeg": self._find_jpeg() elif self.kind == "mp4": self._find_mp4() def _find_jpeg(self): with open(self.path, "rb") as f: data = f.read() pos = 2 # after SOI while pos + 4 <= len(data) and data[pos] == 0xFF: marker = data[pos + 1] if marker in (0xD8, 0xD9, 0x01) or 0xD0 <= marker <= 0xD7: pos += 2 continue seg_end = pos + 2 + ((data[pos + 2] << 8) | data[pos + 3]) if seg_end > len(data): break if marker == 0xEB and data[pos + 4 : pos + 4 + len(JPEG_ID)] == JPEG_ID: self.manifest = data[pos + 4 + len(JPEG_ID) : seg_end] self.block = (pos, seg_end) return if marker == 0xDA: break # start of scan pos = seg_end def _find_mp4(self): with open(self.path, "rb") as f: pos = 0 while pos + 8 <= self.size: f.seek(pos) hdr = f.read(8) if len(hdr) < 8: break size = int.from_bytes(hdr[0:4], "big") header = 8 if size == 1: size = int.from_bytes(f.read(8), "big") header = 16 elif size == 0: size = self.size - pos if size < header or pos + size > self.size: break if hdr[4:8] == b"uuid": f.seek(pos + header) if f.read(16) == MP4_UUID: start = pos + header + 16 f.seek(start) self.manifest = f.read(pos + size - start) self.block = (pos, pos + size) return pos += size def pieces(self): """The file ranges that make up the signed media.""" if self.block is None: return [(0, self.size)] return [(0, self.block[0]), (self.block[1], self.size)] def media_length(self): return sum(b - a for a, b in self.pieces()) def read(self, start, end): """Bytes [start, end) of the signed media.""" out = bytearray() offset = 0 with open(self.path, "rb") as f: for a, b in self.pieces(): lo = max(start, offset) hi = min(end, offset + (b - a)) if lo < hi: f.seek(a + lo - offset) out += f.read(hi - lo) offset += b - a return bytes(out) def digest(self, zeroed): """SHA-256 of the signed media with the ranges in `zeroed` read as 0.""" h = hashlib.sha256() offset = 0 with open(self.path, "rb") as f: for a, b in self.pieces(): f.seek(a) remaining = b - a while remaining > 0: chunk = f.read(min(1 << 20, remaining)) if not chunk: raise IOError("the file ended early") buf = bytearray(chunk) for zs, ze in zeroed: lo = max(zs, offset) hi = min(ze, offset + len(buf)) if lo < hi: buf[lo - offset : hi - offset] = bytes(hi - lo) h.update(buf) offset += len(buf) remaining -= len(buf) return h.hexdigest() def file_sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() # ------------------------------------------------------------------ crypto def verify_es256(spki_der, signature_der, message): """ECDSA P-256 / SHA-256 (DER signature) over message.""" try: key = serialization.load_der_public_key(spki_der) if not isinstance(key, ec.EllipticCurvePublicKey): return False if key.curve.name != "secp256r1": return False key.verify(signature_der, message, ec.ECDSA(hashes.SHA256())) return True except Exception: return False def same_key(a, b): try: return a.public_numbers() == b.public_numbers() except Exception: return False def signed_by(cert, issuer_key): """Whether issuer_key made cert's signature (ECDSA or RSA, SHA-256/384).""" try: oid = cert.signature_algorithm_oid.dotted_string if oid == "1.2.840.10045.4.3.2": alg = hashes.SHA256() elif oid == "1.2.840.10045.4.3.3": alg = hashes.SHA384() elif oid == "1.2.840.113549.1.1.11": alg = hashes.SHA256() elif oid == "1.2.840.113549.1.1.12": alg = hashes.SHA384() else: return False if oid.startswith("1.2.840.10045"): if not isinstance(issuer_key, ec.EllipticCurvePublicKey): return False issuer_key.verify(cert.signature, cert.tbs_certificate_bytes, ec.ECDSA(alg)) else: if not isinstance(issuer_key, rsa.RSAPublicKey): return False issuer_key.verify( cert.signature, cert.tbs_certificate_bytes, padding.PKCS1v15(), alg ) return True except Exception: return False def _not_before(cert): t = getattr(cert, "not_valid_before_utc", None) return t if t is not None else cert.not_valid_before.replace(tzinfo=timezone.utc) def _not_after(cert): t = getattr(cert, "not_valid_after_utc", None) return t if t is not None else cert.not_valid_after.replace(tzinfo=timezone.utc) def spki_sha256(key): return hashlib.sha256( key.public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, ) ).hexdigest() def check_chain(chain_der, signer_spki, captured_at): """Validates the attestation chain (leaf first) the way the app does.""" out = { "trusted": False, "rootedToGoogle": False, "leafKeyIsSigner": False, "datesValid": False, "appBound": False, "leafSerial": None, "attestation": None, "rootSpkiSha256": None, } try: certs = [x509.load_der_x509_certificate(d) for d in chain_der] leaf = certs[0] out["leafSerial"] = format(leaf.serial_number, "x") att = parse_attestation(leaf) out["attestation"] = att out["leafKeyIsSigner"] = ( leaf.public_key().public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, ) == signer_spki ) out["appBound"] = ( att is not None and att["securityLevel"] >= 1 and APP_PACKAGE in att["packages"] and ( not OFFICIAL_APP_CERT_SHA256 or any(d in OFFICIAL_APP_CERT_SHA256 for d in att["appCertificateSha256"]) ) ) # Only the capture key's own certificate (the leaf) must have been # valid when the picture was taken: Google's roots are trusted by their # key, and phones still send the 2016 copy of the root (expired 24 May # 2026) and factory intermediates reaching their end dates. Withdrawn # certificates are caught by the revocation list. (Same as the app.) out["datesValid"] = captured_at is None or all( _not_before(c) <= captured_at <= _not_after(c) for c in certs[:1] ) links_ok = all( signed_by(certs[i], certs[i + 1].public_key()) for i in range(len(certs) - 1) ) roots = [x509.load_pem_x509_certificate(p.encode()) for p in TRUST_ROOTS_PEM] top = certs[-1] rooted = False for root in roots: if same_key(top.public_key(), root.public_key()) or signed_by( top, root.public_key() ): rooted = True out["rootSpkiSha256"] = spki_sha256(root.public_key()) break out["rootedToGoogle"] = links_ok and rooted out["trusted"] = ( out["rootedToGoogle"] and out["leafKeyIsSigner"] and out["datesValid"] and out["appBound"] ) except Exception: out["trusted"] = False return out _GOOGLE_ENTRIES = None def google_entries(): """Google's attestation status list (lowercase hex serial -> entry), downloaded once per run.""" global _GOOGLE_ENTRIES if _GOOGLE_ENTRIES is None: import urllib.request with urllib.request.urlopen(GOOGLE_STATUS_URL, timeout=30) as r: raw = json.load(r).get("entries", {}) _GOOGLE_ENTRIES = { (str(k).lower().lstrip("0") or "0"): (v if isinstance(v, dict) else {}) for k, v in raw.items() } return _GOOGLE_ENTRIES def google_revocations(chain_der): """Certificates in the chain that Google's attestation status list revokes or suspends.""" entries = google_entries() hits = [] for d in chain_der: serial = format(x509.load_der_x509_certificate(d).serial_number, "x") if serial in entries: hits.append({"serial": serial, **entries[serial]}) return hits # ------------------------------------------------------------------ verify def parse_time(s): if not isinstance(s, str) or not s: return None try: t = datetime.fromisoformat(s[:-1] + "+00:00" if s.endswith("Z") else s) return t if t.tzinfo else t.replace(tzinfo=timezone.utc) except ValueError: return None def round_half_away(x): return int(math.copysign(math.floor(abs(x) + 0.5), x)) def clock_offset_seconds(a): device = parse_time(a.get("capturedAtUtc")) reference = parse_time(a.get("gnssTimeUtc")) or parse_time(a.get("networkTimeUtc")) if device is None or reference is None: return None diff_us = (device - reference) // timedelta(microseconds=1) ms = abs(diff_us) // 1000 * (1 if diff_us >= 0 else -1) return round_half_away(ms / 1000) def edits_of(transform): parts = set((transform or "").split("+")) return [e for e in ("markup", "measure") if e in parts] def capture_mode_of(transform): parts = set((transform or "").split("+")) for mode in ("camera-original", "dual-lens", "orientation-pair"): if mode in parts: return mode return "single" def verify_file(path, google=False): """Checks one file; returns the result as a dict.""" media = Media(path) result = { "file": os.path.basename(path), "sizeBytes": media.size, "sha256": file_sha256(path), } o = { "unsupported": False, "newer": False, "earlier": False, "manifestPresent": False, "malformed": False, "trusted": False, "attestationPresent": False, "keyRevoked": False, "googleRevoked": False, "deviceSecurityOff": False, "signatureValid": False, "contentMatch": False, "locationHidden": False, "locationChanged": False, } assertion = {} signed = {} chain = None if media.kind is None: o["unsupported"] = True elif media.manifest is not None: o["manifestPresent"] = True try: manifest = json.loads(media.manifest.decode("utf-8")) if not isinstance(manifest, dict): raise ValueError("not an object") version = manifest.get("schemaVersion") version = version if isinstance(version, int) else 1 if version > MANIFEST_VERSION: o["newer"] = True elif version < MANIFEST_VERSION: o["earlier"] = True else: payload_bytes = base64.b64decode(manifest["signedPayloadB64"]) payload = json.loads(payload_bytes.decode("utf-8")) assertion = payload["assertion"] media_hash = payload["mediaHashHex"] key_b64 = payload["publicKeyB64"] raw_ranges = payload["zeroedRanges"] if ( not isinstance(payload.get("schemaVersion"), int) or not isinstance(assertion, dict) or not isinstance(media_hash, str) or not media_hash or not isinstance(key_b64, str) or not isinstance(raw_ranges, list) ): raise ValueError("bad payload") ranges = [] for r in raw_ranges: s, e, h = r.get("start"), r.get("end"), r.get("sha256") if ( type(s) is not int or type(e) is not int or not isinstance(h, str) or s < 0 or e <= s or (ranges and s < ranges[-1][1]) ): raise ValueError("bad range") ranges.append((s, e, h)) spki = base64.b64decode(key_b64) signature = manifest.get("signatureB64") o["signatureValid"] = verify_es256( spki, base64.b64decode(signature) if isinstance(signature, str) else b"", payload_bytes, ) # The media: everything but the block, location ranges zeroed. states = [] if not ranges or ranges[-1][1] <= media.media_length(): if media.digest([(s, e) for s, e, _ in ranges]) == media_hash: o["contentMatch"] = True for s, e, h in ranges: now = media.read(s, e) if hashlib.sha256(now).hexdigest() == h: states.append("unchanged") elif not any(now): states.append("hidden") else: states.append("changed") o["locationHidden"] = ( o["contentMatch"] and "hidden" in states and "changed" not in states ) o["locationChanged"] = o["contentMatch"] and "changed" in states signed = { "manifestVersion": version, "signatureAlgorithm": payload.get("signAlg", "ES256"), "signedMediaSha256": media_hash, "locationRanges": states, "signingKeyId": hashlib.sha256(spki).hexdigest()[:16], } raw_chain = manifest.get("certChainB64") chain_b64 = [ c for c in (raw_chain if isinstance(raw_chain, list) else []) if isinstance(c, str) ] o["attestationPresent"] = bool(chain_b64) if chain_b64: try: chain = [base64.b64decode(c) for c in chain_b64] except Exception: chain = None if chain: signed["certificateChainSha256"] = [ hashlib.sha256(d).hexdigest() for d in chain ] c = check_chain(chain, spki, parse_time(assertion.get("capturedAtUtc"))) o["trusted"] = c["trusted"] att = c["attestation"] signed["chain"] = { k: c[k] for k in ( "rootedToGoogle", "leafKeyIsSigner", "datesValid", "appBound", "leafSerial", "rootSpkiSha256", ) } signed["attestation"] = att o["keyRevoked"] = ( c["leafSerial"] in REVOKED_CERT_SERIALS or signed["signingKeyId"] in REVOKED_KEY_IDS ) if att is not None: o["deviceSecurityOff"] = ( att["verifiedBootState"] is not None and att["verifiedBootState"] != "verified" ) or att["bootloaderLocked"] is False if google: try: signed["googleRevocations"] = google_revocations(chain) o["googleRevoked"] = bool(signed["googleRevocations"]) except Exception as error: # network or format problem signed["googleRevocationsError"] = str(error) except Exception: o["malformed"] = True # The decision - the same order as the app. if o["unsupported"]: status, reason, code = "unverified", "unsupported", "AUTH-009" elif o["newer"]: status, reason, code = "unverified", "newerVersion", "AUTH-015" elif not o["manifestPresent"]: status, reason, code = "unverified", "noSignature", "AUTH-001" elif o["malformed"]: status, reason, code = "unverified", "damaged", "AUTH-010" elif o["earlier"]: status, reason, code = "unverified", "earlierFormat", "AUTH-002" elif not o["trusted"]: status, reason = "unverified", "notOfficial" code = "AUTH-006" if o["attestationPresent"] else "AUTH-014" elif o["keyRevoked"]: status, reason, code = "unverified", "keyWithdrawn", "AUTH-007" elif o["googleRevoked"]: status, reason, code = "unverified", "certificateRevoked", "AUTH-007" elif o["deviceSecurityOff"]: status, reason, code = "unverified", "deviceSecurityOff", "AUTH-018" elif not o["signatureValid"]: status, reason, code = "modified", "recordAltered", "AUTH-003" elif not o["contentMatch"]: status, reason, code = "modified", "contentChanged", "AUTH-004" elif o["locationChanged"]: status, reason, code = "modified", "locationChanged", "AUTH-016" else: edits = edits_of(assertion.get("transform")) status = "verifiedWithEdits" if edits else "verified" reason = "locationHidden" if o["locationHidden"] else "unchanged" code = None edits = edits_of(assertion.get("transform")) result.update( { "status": status, "headline": headline(status, edits), "reason": reason, "explanation": explanation(status, reason, edits), "code": code, } ) verified = status in ("verified", "verifiedWithEdits") if verified: result["notes"] = notes(assertion, reason) if assertion: result["signedRecord"] = assertion if signed: result["signature"] = signed if verified and google and "googleRevocationsError" in signed: result["notes"].append( "Google's list of revoked phone certificates could not be checked." ) return result def headline(status, edits): if status == "verifiedWithEdits": if len(edits) == 2: return "Verified - Mark-up & measurements" return "Verified - Mark-up" if "markup" in edits else "Verified - Measurements" return HEADLINES[status] def explanation(status, reason, edits): if status == "verifiedWithEdits": markup, measured = "markup" in edits, "measure" in edits if markup and measured: what = "measurements and mark-up (drawings, shapes or text) were added in GeoCam" elif markup: what = "mark-up (drawings, shapes or text) was added in GeoCam" else: what = "measurements were added in GeoCam" return ( "This file is exactly as GeoCam saved it. Before saving, %s. These " "additions are not part of the scene%s. Everything else is as captured." % (what, ", and the measurements are estimates" if measured else "") ) if reason == "locationHidden": return EXPLANATIONS["unchanged"] return EXPLANATIONS[reason] def notes(a, reason): out = [] if a.get("mockLocationDetected"): out.append( "WARNING: a mock-location (fake GPS) app was active at capture; the " "location cannot be relied on." ) offset = clock_offset_seconds(a) if offset is not None and abs(offset) > CLOCK_TOLERANCE_SECONDS: out.append( "The phone's clock was %d seconds off independent time at capture; " "rely on the satellite / network time." % abs(offset) ) elif a.get("timeSource") == "gnss": out.append("Capture time confirmed by satellite (GNSS) time.") elif a.get("timeSource") == "network": out.append("Capture time confirmed by network time.") else: out.append("Capture time from the phone's clock only.") if reason == "locationHidden": out.append( "The phone hid the location in this copy's file details (Android does " "this when another app opens or shares a file). The picture is " "unchanged; the signed location is in the record." ) mode = capture_mode_of(a.get("transform")) if mode == "dual-lens": out.append("Dual camera: front and back cameras combined by GeoCam.") elif mode == "orientation-pair": out.append("Portrait + landscape: this copy is cropped from the same shot.") elif mode == "camera-original": out.append("The camera's own photo, before the GeoStamp, kept with the capture.") if a.get("parentProvenanceId"): out.append( "An edited copy: its unedited original was kept and signed too " "(provenance ID %s)." % a["parentProvenanceId"] ) return out # ----------------------------------------------------------------- custody def canonical(obj): """Canonical JSON: keys sorted, no spaces, UTF-8.""" return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( "utf-8" ) def check_custody(doc): """Checks a custody log: hashes, links, order and signatures.""" record_id = doc.get("recordId") key_b64 = doc.get("publicKeyB64") key = base64.b64decode(key_b64) if key_b64 else None entries = doc.get("entries") or [] intact = True signed_ok = 0 prev = "" rows = [] for i, e in enumerate(entries): body = canonical( { "recordId": record_id, "seq": e.get("seq"), "type": e.get("type"), "atUtc": e.get("atUtc"), "fileSha256": e.get("fileSha256"), "fileSize": e.get("fileSize"), "fileName": e.get("fileName"), "detail": e.get("detail"), "prevHashHex": e.get("prevHashHex"), } ) ok = ( e.get("seq") == i and e.get("prevHashHex") == prev and hashlib.sha256(body).hexdigest() == e.get("hashHex") ) intact = intact and ok sig_ok = None if key and e.get("signatureB64"): sig_ok = verify_es256(key, base64.b64decode(e["signatureB64"]), body) signed_ok += 1 if sig_ok else 0 rows.append( { "seq": e.get("seq"), "atUtc": e.get("atUtc"), "type": e.get("type"), "fileName": e.get("fileName"), "fileSha256": e.get("fileSha256"), "linkOk": ok, "signatureOk": sig_ok, } ) prev = e.get("hashHex") return { "recordId": record_id, "entries": len(entries), "chainIntact": intact, "signaturesVerified": signed_ok, "rows": rows, } # ----------------------------------------------------------------- package def check_package(path, google=False): """Checks an evidence package (a .zip from the app, or its unzipped folder).""" if os.path.isfile(path) and path.lower().endswith(".zip"): with tempfile.TemporaryDirectory() as tmp: with zipfile.ZipFile(path) as z: for name in z.namelist(): # The app's packages are flat; refuse anything else. if "/" in name or "\\" in name or name in ("", ".", ".."): raise ValueError("unexpected entry in the package: %r" % name) with z.open(name) as src, open(os.path.join(tmp, name), "wb") as dst: for chunk in iter(lambda: src.read(1 << 20), b""): dst.write(chunk) return check_package(tmp, google) out = {"sums": [], "files": [], "custody": None} sums_path = os.path.join(path, "SHA256SUMS.txt") if os.path.exists(sums_path): with open(sums_path, encoding="utf-8") as f: for line in f: line = line.rstrip("\r\n") if not line: continue digest, sep, name = line.partition(" ") if not sep or "/" in name or "\\" in name: out["sums"].append({"file": line, "match": False}) continue target = os.path.join(path, name) actual = file_sha256(target) if os.path.isfile(target) else None out["sums"].append({"file": name, "match": actual == digest}) for name in sorted(os.listdir(path)): if name.lower().endswith((".jpg", ".jpeg", ".mp4", ".mov", ".m4v")): out["files"].append(verify_file(os.path.join(path, name), google)) custody_path = os.path.join(path, "custody_log.json") if os.path.exists(custody_path): with open(custody_path, encoding="utf-8") as f: out["custody"] = check_custody(json.load(f)) return out # --------------------------------------------------------------------- CLI def print_file(r): print("=" * 72) print("File: %s (%d bytes)" % (r["file"], r["sizeBytes"])) print("SHA-256: %s" % r["sha256"]) print("Result: %s%s" % (r["headline"], " [%s]" % r["code"] if r.get("code") else "")) print(" %s" % r["explanation"]) for n in r.get("notes", []): print(" - %s" % n) a = r.get("signedRecord") or {} s = r.get("signature") or {} if a and r["status"] in ("verified", "verifiedWithEdits"): print("Signed record:") for key, label in ( ("capturedAtUtc", "Capture time (UTC)"), ("tzOffsetMinutes", "Time zone offset (min)"), ("timeSource", "Time confirmed by"), ("gnssTimeUtc", "Satellite time"), ("networkTimeUtc", "Network time"), ("latitude", "Latitude"), ("longitude", "Longitude"), ("gpsAccuracy", "Accuracy (m)"), ("altitude", "Altitude (m)"), ("satellitesUsed", "Satellites used"), ("mockLocationDetected", "Mock location flagged"), ("deviceMake", "Phone maker"), ("deviceModel", "Phone model"), ("osRelease", "Operating system"), ("appVersion", "App version"), ("transform", "Signed transform list"), ("provenanceId", "Provenance ID"), ("parentProvenanceId", "Original's provenance ID"), ): if a.get(key) is not None: print(" %-26s %s" % (label, a[key])) if s: print("Signature:") print(" %-26s %s" % ("Signed media hash", s.get("signedMediaSha256"))) if s.get("locationRanges"): print(" %-26s %s" % ("Location ranges", ", ".join(s["locationRanges"]))) chain = s.get("chain") or {} for key in ("rootedToGoogle", "leafKeyIsSigner", "datesValid", "appBound"): if key in chain: print(" %-26s %s" % (key, chain[key])) att = s.get("attestation") or {} for key in ("securityLevel", "verifiedBootState", "bootloaderLocked", "osPatchLevel"): if att.get(key) is not None: print(" %-26s %s" % (key, att[key])) if att.get("packages"): print(" %-26s %s" % ("App bound to the key", ", ".join(att["packages"]))) for d in att.get("appCertificateSha256", []): print(" %-26s %s" % ("App certificate SHA-256", d)) def main(argv=None): p = argparse.ArgumentParser( description="Check TUVAMBES GeoCam photos and videos independently of the app." ) p.add_argument("files", nargs="*", help="photos (.jpg) or videos (.mp4) to check") p.add_argument("--package", help="an evidence package (.zip) or its unzipped folder") p.add_argument("--custody", help="a custody_log.json to check") p.add_argument("--json", action="store_true", help="machine-readable output") p.add_argument( "--google-revocation", action="store_true", help="also download Google's attestation status list; a revoked certificate " "in the signer's chain makes the file Unverified (as on the website)", ) args = p.parse_args(argv) if not args.files and not args.package and not args.custody: p.print_help() return 3 results = [verify_file(f, args.google_revocation) for f in args.files] package = check_package(args.package, args.google_revocation) if args.package else None custody = None if args.custody: with open(args.custody, encoding="utf-8") as f: custody = check_custody(json.load(f)) if package: results += package["files"] custody = custody or package["custody"] if args.json: print( json.dumps( { "verifier": "geocam_verify.py %s" % VERIFIER_VERSION, "checkedAtUtc": datetime.now(timezone.utc).isoformat(), "files": results, "package": package and package["sums"], "custody": custody, }, indent=2, ensure_ascii=False, ) ) else: print("TUVAMBES GeoCam reference verifier %s" % VERIFIER_VERSION) if package: bad = [s["file"] for s in package["sums"] if not s["match"]] print("=" * 72) print( "Package: %d files listed in SHA256SUMS.txt - %s" % (len(package["sums"]), "all match" if not bad else "MISMATCH: " + ", ".join(bad)) ) for r in results: print_file(r) if custody: print("=" * 72) print( "Custody log: %d entries, hash chain %s, signatures verified %d of %d" % ( custody["entries"], "intact" if custody["chainIntact"] else "BROKEN", custody["signaturesVerified"], custody["entries"], ) ) for row in custody["rows"]: print( " #%s %s %-16s %s" % (row["seq"], row["atUtc"], row["type"], row["fileName"] or "") ) print("=" * 72) print( "A result shows whether a file matches the signature GeoCam made when it " "saved it, and what the signed record states - not that the scene is true." ) statuses = [r["status"] for r in results] failed = (package and not all(s["match"] for s in package["sums"])) or ( custody and not custody["chainIntact"] ) if "modified" in statuses: return 1 if "unverified" in statuses or failed: return 2 return 0 if __name__ == "__main__": sys.exit(main())