DEV Community

Building Tamper-Proof Media Apps: C2PA vs CPP Deep Dive for Developers

TL;DR

If you're building apps that need to prove "this photo/video was actually captured" (not AI-generated), you have two main protocol options:

Feature C2PA CPP
Deletion detection ✅ XOR hash sum
Independent timestamp Optional Required (RFC 3161)
Privacy-first Limited By design
Ecosystem maturity Mature (Adobe, Sony, etc.) Emerging
Best for Media distribution Forensic evidence

This article goes deep into both protocols with real code examples. Let's build something.


Why Content Provenance Matters Now

Last month, a colleague showed me an AI-generated image that "won" a photography competition. The judges couldn't tell. Neither could I.

We're entering an era where:

  • AI can generate photorealistic images in seconds
  • Deepfakes can put words in anyone's mouth
  • Trust in digital media is collapsing

The tech industry's response? Provenance protocols—cryptographic systems that prove where content came from.

But here's the thing: not all provenance is created equal. The dominant standard (C2PA) was designed for media workflows. When you need forensic-grade proof—legal evidence, regulatory compliance, insurance claims—its limitations become apparent.

That's where CPP (Content Provenance Protocol) comes in.


What We're Building

Throughout this article, I'll show code from a real implementation: VeriCapture, an iOS app implementing CPP. Think of it as "a camera that cannot lie."

By the end, you'll understand:

  1. How C2PA and CPP work under the hood
  2. When to use each protocol
  3. How to implement key cryptographic patterns
  4. Why "completeness" is the killer feature C2PA lacks

Let's start with the fundamentals.


C2PA: The Industry Standard

Architecture Overview

C2PA (Coalition for Content Provenance and Authenticity) embeds cryptographic manifests directly into media files using JUMBF containers:

┌─────────────────────────────────────┐
│           JPEG/PNG File             │
├─────────────────────────────────────┤
│  Image Data                         │
├─────────────────────────────────────┤
│  C2PA JUMBF Container               │
│  ├── Claim                          │
│  │   ├── Assertions (actions, etc.) │
│  │   └── Hard Binding (content hash)│
│  ├── Claim Signature (COSE)         │
│  └── Certificate Chain (X.509)      │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Supported Algorithms

# C2PA Signature Algorithms
SUPPORTED_ALGORITHMS = {
    "ES256": "ECDSA with P-256 curve",      # Primary
    "ES384": "ECDSA with P-384 curve",
    "ES512": "ECDSA with P-521 curve", 
    "Ed25519": "Edwards-curve DSA",
    "PS256": "RSASSA-PSS with SHA-256",
}

# Hash Algorithms
HASH_ALGORITHMS = ["SHA-256", "SHA-384", "SHA-512"]
Enter fullscreen mode Exit fullscreen mode

Creating a C2PA Manifest (Python)

Using the official c2pa-python library:

from c2pa import Builder, SigningAlg

def create_c2pa_manifest(image_path: str, private_key: bytes, cert_chain: bytes):
    """Create a C2PA manifest for an image."""

    builder = Builder({
        "claim_generator": "MyApp/1.0",
        "assertions": [
            {
                "label": "c2pa.actions",
                "data": {
                    "actions": [
                        {
                            "action": "c2pa.created",
                            "when": "2026-01-24T10:30:00Z",
                            "softwareAgent": "MyApp/1.0"
                        }
                    ]
                }
            }
        ]
    })

    # Sign and embed manifest
    builder.sign(
        image_path,
        output_path="signed_image.jpg",
        signing_alg=SigningAlg.ES256,
        private_key=private_key,
        cert_chain=cert_chain
    )
Enter fullscreen mode Exit fullscreen mode

C2PA's Limitations (Why CPP Exists)

I ran into these issues building a legal evidence app:

1. Self-Attestation Problem

C2PA: Creator signs → "Trust me" → NO INDEPENDENT CHECK
Enter fullscreen mode Exit fullscreen mode

You're trusting the signer. Period. There's no mandatory third-party verification.

2. No Deletion Detection
User takes 100 photos. Deletes 20 unfavorable ones. Submits 80.

Can you prove photos are missing? No.

Each C2PA manifest is independent. There's no mechanism to verify collection completeness.

3. Metadata Stripping

# What happens when you upload to social media:
original_file_size = 2_500_000  # 2.5MB with C2PA manifest
twitter_processed_size = 800_000  # Manifest: GONE

# C2PA estimates 95%+ of images lose manifests on social platforms
Enter fullscreen mode Exit fullscreen mode

4. Trust List Gatekeeping

Your certificate → Intermediate CA → Root CA → C2PA Trust List
                                                    ↑
                                          Controlled by consortium
Enter fullscreen mode Exit fullscreen mode

These aren't bugs—they're design tradeoffs for C2PA's target use case (media distribution). But for forensic evidence? We need more.


CPP: Forensic-Grade Provenance

Design Philosophy

CPP operates on three principles:

1. "Verify, Don't Trust"      → External timestamp required
2. "Absence is Evidence"      → Deletion detection built-in  
3. "Provenance ≠ Truth"       → We prove capture, not content
Enter fullscreen mode Exit fullscreen mode

Three-Layer Architecture

┌────────────────────────────────────────────────────────────┐
│ Layer 3: External Verifiability (RFC 3161 TSA)            │
│   → Independent third-party timestamp                      │
├────────────────────────────────────────────────────────────┤
│ Layer 2: Collection Integrity (Merkle + Completeness)     │
│   → Deletion detection via XOR hash sum                    │
├────────────────────────────────────────────────────────────┤
│ Layer 1: Event Integrity (SHA-256 + ECDSA)                │
│   → Individual event tamper-evidence                       │
└────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Each layer provides progressively stronger guarantees. Let's implement each one.


Layer 1: Event Integrity (Code)

Every capture in CPP generates a CPPEvent—a cryptographically signed record.

Data Model (Swift)

struct CPPEvent: Codable, Sendable {
    // Identification
    let eventId: String         // UUIDv7 (time-sortable)
    let chainId: String         // Device chain identifier
    let prevHash: String        // Links to previous event
    let timestamp: String       // ISO 8601
    let eventType: String       // "CPP_CAPTURE"

    // Algorithms
    let hashAlgo: String        // "SHA256"
    let signAlgo: String        // "ES256"

    // Content
    let asset: Asset            // Media reference
    let captureContext: CaptureContext
    let sensorData: SensorData?
    let humanAttestation: HumanAttestation?

    // Cryptographic Binding
    var eventHash: String       // SHA-256 of canonical JSON
    var signature: String       // "es256:<base64>"
}

struct Asset: Codable, Sendable {
    let assetId: String         // UUIDv7
    let assetType: AssetType    // IMAGE or VIDEO
    let assetHash: String       // "sha256:<hex>" of file content
    let assetName: String       // Filename
    let assetSize: Int          // Bytes
    let mimeType: String        // "image/jpeg"
}
Enter fullscreen mode Exit fullscreen mode

Why UUIDv7?

/// UUIDv7: Time-sortable unique identifiers
/// Format: tttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx
///         ^^^^^^^^ ^^^^
///         timestamp (48 bits)
struct UUIDv7 {
    static func generate() -> String {
        let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
        var uuid = [UInt8](repeating: 0, count: 16)

        // Embed timestamp in first 48 bits
        uuid[0] = UInt8((timestamp >> 40) & 0xFF)
        uuid[1] = UInt8((timestamp >> 32) & 0xFF)
        uuid[2] = UInt8((timestamp >> 24) & 0xFF)
        uuid[3] = UInt8((timestamp >> 16) & 0xFF)
        uuid[4] = UInt8((timestamp >> 8) & 0xFF)
        uuid[5] = UInt8(timestamp & 0xFF)

        // Version 7
        uuid[6] = (uuid[6] & 0x0F) | 0x70

        // Variant bits
        uuid[8] = (uuid[8] & 0x3F) | 0x80

        // Fill remaining with random
        for i in [7, 9, 10, 11, 12, 13, 14, 15] {
            uuid[i] = UInt8.random(in: 0...255)
        }

        return formatUUID(uuid)
    }
}
Enter fullscreen mode Exit fullscreen mode

UUIDv7 gives us chronological sorting for free—critical for chain ordering.

Computing Event Hash

The hash covers the canonical JSON representation:

func computeEventHash(_ event: CPPEvent) throws -> String {
    // 1. Create event WITHOUT hash/signature (they can't hash themselves)
    var hashableEvent = event
    hashableEvent.eventHash = ""
    hashableEvent.signature = ""

    // 2. Encode to canonical JSON (RFC 8785: sorted keys, no whitespace)
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.sortedKeys]  // Critical for reproducibility
    let jsonData = try encoder.encode(hashableEvent)

    // 3. Compute SHA-256
    let hash = SHA256.hash(data: jsonData)

    // 4. Format with prefix
    return "sha256:\(Data(hash).hexString)"
}
Enter fullscreen mode Exit fullscreen mode

Signing with Secure Enclave (iOS)

This is where it gets interesting. We use Apple's Secure Enclave—a hardware security module where the private key never leaves:

class CryptoService {
    private let keyTag = "org.veritaschain.vericapture.signing.key"
    private var privateKey: SecKey?

    func initializeKey() throws {
        // Check if key exists
        if let existingKey = try? loadExistingKey() {
            self.privateKey = existingKey
            return
        }

        // Generate new key IN SECURE ENCLAVE
        var error: Unmanaged<CFError>?

        guard let access = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            [.privateKeyUsage],  // Key can only sign, never export
            &error
        ) else {
            throw CryptoError.accessControlFailed
        }

        let attributes: [String: Any] = [
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecAttrKeySizeInBits as String: 256,
            kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,  // THE MAGIC
            kSecPrivateKeyAttrs as String: [
                kSecAttrIsPermanent as String: true,
                kSecAttrApplicationTag as String: keyTag.data(using: .utf8)!,
                kSecAttrAccessControl as String: access
            ]
        ]

        guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
            throw CryptoError.keyGenerationFailed(error?.takeRetainedValue())
        }

        self.privateKey = key
    }

    func sign(data: Data) throws -> Data {
        var error: Unmanaged<CFError>?

        // Data is pre-hashed, use digest variant
        guard let signature = SecKeyCreateSignature(
            privateKey!,
            .ecdsaSignatureDigestX962SHA256,
            data as CFData,
            &error
        ) as Data? else {
            throw CryptoError.signingFailed(error?.takeRetainedValue())
        }

        return signature
    }

    func signEventHash(_ eventHash: String) throws -> String {
        let hashHex = eventHash.replacingOccurrences(of: "sha256:", with: "")
        let hashData = Data(hexString: hashHex)!
        let signature = try sign(data: hashData)
        return "es256:\(signature.base64EncodedString())"
    }
}
Enter fullscreen mode Exit fullscreen mode

Key properties:

  • Key Type: ECDSA P-256 (secp256r1)
  • Token ID: kSecAttrTokenIDSecureEnclave (hardware isolation)
  • Access: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
  • Export: Only public key can be exported; private key is hardware-bound

Hash Chain Linking

Each event links to its predecessor:

func createCaptureEvent(image: UIImage, previousHash: String?) throws -> CPPEvent {
    let imageData = image.jpegData(compressionQuality: 0.9)!
    let assetHash = "sha256:\(Data(SHA256.hash(data: imageData)).hexString)"

    let event = CPPEvent(
        eventId: UUIDv7.generate(),
        chainId: getDeviceChainId(),
        prevHash: previousHash ?? "GENESIS",  // First event in chain
        timestamp: ISO8601DateFormatter().string(from: Date()),
        eventType: "CPP_CAPTURE",
        hashAlgo: "SHA256",
        signAlgo: "ES256",
        asset: Asset(
            assetId: UUIDv7.generate(),
            assetType: .image,
            assetHash: assetHash,
            assetName: "IMG_\(Date().timeIntervalSince1970).jpg",
            assetSize: imageData.count,
            mimeType: "image/jpeg"
        ),
        // ... other fields
        eventHash: "",
        signature: ""
    )

    // Compute hash and sign
    let eventHash = try computeEventHash(event)
    var signedEvent = event
    signedEvent.eventHash = eventHash
    signedEvent.signature = try cryptoService.signEventHash(eventHash)

    return signedEvent
}
Enter fullscreen mode Exit fullscreen mode

Chain integrity:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  Event N-1  │     │   Event N   │     │  Event N+1  │
├─────────────┤     ├─────────────┤     ├─────────────┤
│ EventHash ──┼────>│ PrevHash    │     │ PrevHash    │
│             │     │ EventHash ──┼────>│             │
│ Signature   │     │ Signature   │     │ Signature   │
└─────────────┘     └─────────────┘     └─────────────┘
Enter fullscreen mode Exit fullscreen mode

Any modification breaks the chain. But can we detect deletion?


Layer 2: The Completeness Invariant (CPP's Killer Feature)

This is what separates CPP from C2PA. Let me explain with a scenario:

The Problem

You're an insurance adjuster. A claimant submits 50 photos of property damage. Each photo has a valid cryptographic signature.

Question: Were there originally more photos that the claimant deleted?

With C2PA: You cannot know.

With CPP: You can prove it mathematically.

How It Works

At session start, CPP commits to a summary of all expected events:

# Completeness Invariant Structure
completeness_invariant = {
    "expected_count": n,
    "hash_sum": H(E)  H(E)  ...  H(Eₙ),  # XOR of all hashes
    "first_timestamp": T,
    "last_timestamp": Tₙ
}
Enter fullscreen mode Exit fullscreen mode

The XOR hash sum is the key insight:

  • XOR is order-independent: A ⊕ B = B ⊕ A
  • XOR is self-inverse: A ⊕ A = 0
  • Missing any element changes the result

Implementation (Swift)

struct CompletenessInvariant: Codable {
    let expectedCount: Int
    let hashSum: String        // XOR of all event hashes
    let firstTimestamp: String
    let lastTimestamp: String
}

class CompletenessService {
    private var runningHashSum: Data = Data(repeating: 0, count: 32)
    private var eventCount: Int = 0
    private var firstTimestamp: String?
    private var lastTimestamp: String?

    func addEvent(_ event: CPPEvent) {
        // Extract hash bytes
        let hashHex = event.eventHash.replacingOccurrences(of: "sha256:", with: "")
        let hashData = Data(hexString: hashHex)!

        // XOR into running sum
        runningHashSum = xor(runningHashSum, hashData)

        eventCount += 1
        if firstTimestamp == nil {
            firstTimestamp = event.timestamp
        }
        lastTimestamp = event.timestamp
    }

    func seal() -> CompletenessInvariant {
        return CompletenessInvariant(
            expectedCount: eventCount,
            hashSum: "sha256:\(runningHashSum.hexString)",
            firstTimestamp: firstTimestamp!,
            lastTimestamp: lastTimestamp!
        )
    }

    private func xor(_ a: Data, _ b: Data) -> Data {
        var result = Data(count: a.count)
        for i in 0..<a.count {
            result[i] = a[i] ^ b[i]
        }
        return result
    }
}
Enter fullscreen mode Exit fullscreen mode

Verification

func verifyCompleteness(events: [CPPEvent], seal: CompletenessInvariant) -> Bool {
    // Check count
    if events.count != seal.expectedCount {
        print("❌ Count mismatch: got \(events.count), expected \(seal.expectedCount)")
        return false
    }

    // Recompute XOR sum
    var computedSum = Data(repeating: 0, count: 32)
    for event in events {
        let hashHex = event.eventHash.replacingOccurrences(of: "sha256:", with: "")
        let hashData = Data(hexString: hashHex)!
        computedSum = xor(computedSum, hashData)
    }

    // Compare
    let expectedSum = Data(hexString: seal.hashSum.replacingOccurrences(of: "sha256:", with: ""))!
    if computedSum != expectedSum {
        print("❌ Hash sum mismatch - events were deleted or modified")
        return false
    }

    print("✅ Completeness verified: \(events.count) events, no deletions detected")
    return true
}
Enter fullscreen mode Exit fullscreen mode

Attack Detection

Attack Detection Mechanism
Delete event Hash sum mismatch
Add fake event Count + hash mismatch
Reorder events Chain hash mismatch (Layer 1)
Modify event Individual signature invalid (Layer 1)

This is mathematically provable deletion detection. C2PA simply doesn't have this.


Layer 3: External Timestamp Anchoring

Self-signed timestamps can be faked. "Trust me, I took this photo yesterday" isn't evidence.

CPP requires RFC 3161 TSA (Time-Stamp Authority) for Silver conformance and above.

The Flow

┌─────────────────────────────────────────────────────────────────┐
│                    TSA BATCH ANCHORING FLOW                     │
└─────────────────────────────────────────────────────────────────┘

        Timer Fires (every 30 minutes)
                │
                ▼
      ┌──────────────────┐
      │ Collect Pending  │
      │ Event Hashes     │
      └────────┬─────────┘
               │
               ▼
      ┌──────────────────────────────────────────┐
      │ Build Merkle Tree                        │
      │                                          │
      │         [MerkleRoot]                     │
      │            /    \                        │
      │        [H01]    [H23]                    │
      │        /   \    /   \                    │
      │      [E0] [E1] [E2] [E3]                 │
      └────────────────┬─────────────────────────┘
                       │
                       ▼
      ┌──────────────────────────────────────────┐
      │ HTTP POST to TSA Server                  │
      │                                          │
      │ POST https://rfc3161.ai.moda            │
      │ Content-Type: application/timestamp-query│
      │ Body: [ASN.1 DER Request]               │
      └────────────────┬─────────────────────────┘
                       │
                       ▼
      ┌──────────────────────────────────────────┐
      │ Receive RFC 3161 Token                   │
      │ (Signed by TSA's certificate)            │
      └──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Merkle Tree Implementation

class MerkleTree {
    private var leaves: [Data] = []

    func addLeaf(_ hash: String) {
        let hashData = Data(hexString: hash.replacingOccurrences(of: "sha256:", with: ""))!
        leaves.append(hashData)
    }

    func computeRoot() -> String {
        guard !leaves.isEmpty else { return "" }

        var currentLevel = leaves

        // Pad to power of 2
        while currentLevel.count & (currentLevel.count - 1) != 0 {
            currentLevel.append(currentLevel.last!)  // Duplicate last
        }

        // Build tree bottom-up
        while currentLevel.count > 1 {
            var nextLevel: [Data] = []
            for i in stride(from: 0, to: currentLevel.count, by: 2) {
                let left = currentLevel[i]
                let right = currentLevel[i + 1]
                let combined = left + right
                let hash = Data(SHA256.hash(data: combined))
                nextLevel.append(hash)
            }
            currentLevel = nextLevel
        }

        return "sha256:\(currentLevel[0].hexString)"
    }

    func generateProof(forIndex index: Int) -> [MerkleProofElement] {
        // Generate inclusion proof for a specific leaf
        var proof: [MerkleProofElement] = []
        var currentIndex = index
        var levelNodes = leaves

        while levelNodes.count > 1 {
            let siblingIndex = currentIndex % 2 == 0 ? currentIndex + 1 : currentIndex - 1
            let isLeft = currentIndex % 2 == 1

            if siblingIndex < levelNodes.count {
                proof.append(MerkleProofElement(
                    hash: "sha256:\(levelNodes[siblingIndex].hexString)",
                    isLeft: isLeft
                ))
            }

            // Move to next level
            var nextLevel: [Data] = []
            for i in stride(from: 0, to: levelNodes.count, by: 2) {
                let combined = levelNodes[i] + levelNodes[min(i + 1, levelNodes.count - 1)]
                nextLevel.append(Data(SHA256.hash(data: combined)))
            }
            levelNodes = nextLevel
            currentIndex = currentIndex / 2
        }

        return proof
    }
}

struct MerkleProofElement: Codable {
    let hash: String
    let isLeft: Bool
}
Enter fullscreen mode Exit fullscreen mode

RFC 3161 Request (ASN.1 DER)

class TSAService {
    private let tsaEndpoint = "https://rfc3161.ai.moda"

    func requestTimestamp(forMerkleRoot merkleRoot: String) async throws -> TSAResponse {
        // Build ASN.1 DER request
        let request = try createTSARequest(merkleRoot: merkleRoot)

        // Send to TSA
        var urlRequest = URLRequest(url: URL(string: tsaEndpoint)!)
        urlRequest.httpMethod = "POST"
        urlRequest.setValue("application/timestamp-query", forHTTPHeaderField: "Content-Type")
        urlRequest.httpBody = request

        let (data, response) = try await URLSession.shared.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw TSAError.requestFailed
        }

        // Parse RFC 3161 response
        return try parseTSAResponse(data)
    }

    private func createTSARequest(merkleRoot: String) throws -> Data {
        // TimeStampReq structure (simplified - use proper ASN.1 library in production)
        // SEQUENCE {
        //   version INTEGER (1),
        //   messageImprint MessageImprint,
        //   reqPolicy OBJECT IDENTIFIER OPTIONAL,
        //   nonce INTEGER OPTIONAL,
        //   certReq BOOLEAN DEFAULT FALSE
        // }

        let hashData = Data(hexString: merkleRoot.replacingOccurrences(of: "sha256:", with: ""))!

        // MessageImprint: SEQUENCE { hashAlgorithm, hashedMessage }
        // SHA-256 OID: 2.16.840.1.101.3.4.2.1
        let sha256OID: [UInt8] = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]

        // Build ASN.1 structure (use proper library like SwiftASN1 in production)
        var request = Data()
        // ... ASN.1 encoding ...

        return request
    }
}
Enter fullscreen mode Exit fullscreen mode

TSA Response Verification

struct AnchorRecord: Codable {
    let anchorId: String
    let merkleRoot: String
    let eventCount: Int
    let anchorTimestamp: String
    let tsaResponse: String  // Base64-encoded RFC 3161 token
    let tsaCertificate: String
}

func verifyAnchor(_ anchor: AnchorRecord) throws -> Bool {
    // 1. Decode TSA token
    let tokenData = Data(base64Encoded: anchor.tsaResponse)!

    // 2. Extract signed content and signature
    let (content, signature, cert) = try parseTimeStampToken(tokenData)

    // 3. Verify signature against TSA certificate
    guard try verifySignature(signature, of: content, with: cert) else {
        throw VerificationError.tsaSignatureInvalid
    }

    // 4. Verify certificate chain to trusted root
    guard try verifyCertificateChain(cert) else {
        throw VerificationError.tsaCertificateInvalid
    }

    // 5. Extract and compare merkle root
    let timestampedHash = try extractMessageImprint(content)
    guard timestampedHash == anchor.merkleRoot else {
        throw VerificationError.merkleRootMismatch
    }

    return true
}
Enter fullscreen mode Exit fullscreen mode

What TSA anchoring proves:

  • ✅ The Merkle root existed at the claimed time
  • ✅ An independent third party attests to this
  • ✅ Backdating is impossible (TSA timestamp is authoritative)

Privacy by Design: ACE (Attested Capture Extension)

CPP can prove a human took the photo—not a bot, not a timer, not an automated system.

But here's the key insight: we prove authentication happened without storing biometric data.

What We Record vs. Don't Record

struct HumanAttestation: Codable, Sendable {
    // ✅ RECORDS
    let verified: Bool          // Authentication succeeded
    let method: String          // "FaceID", "TouchID", "Passcode"
    let verifiedAt: String      // ISO 8601 timestamp
    let captureOffsetMs: Int    // Milliseconds between auth and capture
    let sessionNonce: String    // Cryptographic binding nonce
}

// ❌ DOES NOT RECORD
// - Facial geometry
// - Fingerprint minutiae  
// - Biometric templates
// - Raw sensor data
Enter fullscreen mode Exit fullscreen mode

Implementation

class AttestedCaptureService {
    private var authenticationTime: Date?
    private var sessionNonce: String?

    func beginAttestedCapture() async throws -> Bool {
        // Generate session nonce
        sessionNonce = generateSecureNonce()

        // Request biometric authentication
        let context = LAContext()
        context.localizedReason = "Verify your presence for capture"

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: "Authenticate to create verified capture"
            )

            if success {
                authenticationTime = Date()
                return true
            }
        } catch {
            // Fallback to passcode
            let passcodeSuccess = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "Authenticate to create verified capture"
            )

            if passcodeSuccess {
                authenticationTime = Date()
                return true
            }
        }

        return false
    }

    func createAttestation() -> HumanAttestation? {
        guard let authTime = authenticationTime,
              let nonce = sessionNonce else {
            return nil
        }

        let captureTime = Date()
        let offsetMs = Int(captureTime.timeIntervalSince(authTime) * 1000)

        // Attestation window: must capture within 30 seconds of auth
        guard offsetMs <= 30_000 else {
            return nil
        }

        let context = LAContext()
        let method: String

        if context.biometryType == .faceID {
            method = "FaceID"
        } else if context.biometryType == .touchID {
            method = "TouchID"
        } else {
            method = "Passcode"
        }

        return HumanAttestation(
            verified: true,
            method: method,
            verifiedAt: ISO8601DateFormatter().string(from: authTime),
            captureOffsetMs: offsetMs,
            sessionNonce: nonce
        )
    }

    private func generateSecureNonce() -> String {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        return Data(bytes).base64EncodedString()
    }
}
Enter fullscreen mode Exit fullscreen mode

Privacy guarantees:

  • Biometric data never leaves the Secure Enclave
  • We only record that authentication happened, not how
  • GDPR-compliant by design

Protocol Comparison: The Complete Picture

Feature Matrix

Feature C2PA CPP
Individual file integrity
Edit history tracking
Collection completeness
Deletion detection
External timestamp (mandatory) ✅ (Silver+)
Privacy-first identity ⚠️ Limited
Human presence proof ✅ ACE
Post-quantum readiness 🔮 Planned
Ecosystem maturity ✅✅✅ ⚠️ Emerging

When to Use Which

                    ┌─────────────────────────────────────┐
                    │   Choose Your Protocol              │
                    └──────────────┬──────────────────────┘
                                   │
                                   ▼
                    ┌─────────────────────────────────────┐
                    │ Need to detect deleted evidence?    │
                    └──────────────┬──────────────────────┘
                            │              │
                           YES            NO
                            │              │
                            ▼              ▼
                    ┌──────────┐    ┌─────────────────────┐
                    │   CPP    │    │ Need edit history?  │
                    └──────────┘    └──────────┬──────────┘
                                        │              │
                                       YES            NO
                                        │              │
                                        ▼              ▼
                                 ┌──────────┐    ┌──────────┐
                                 │   C2PA   │    │ Either   │
                                 └──────────┘    └──────────┘
Enter fullscreen mode Exit fullscreen mode

Use Cases

C2PA is ideal for:

  • News organization photo workflows
  • AI-generated content labeling
  • Social media platform verification badges
  • Creative asset attribution

CPP is ideal for:

  • Legal evidence documentation
  • Insurance claim photography
  • Regulatory compliance (MiFID II, EU AI Act)
  • Forensic investigation
  • Whistleblower protection

Interoperability: Using Both

CPP explicitly supports C2PA export:

{
  "c2pa.actions": [
    {
      "action": "c2pa.captured",
      "parameters": {
        "vso.cpp.verification_url": "https://verify.veritaschain.org/cpp/CPP-2026-ABC123XYZ"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This enables:

  1. Internal verification with CPP (forensic-grade)
  2. External distribution with C2PA (ecosystem compatibility)

Getting Started

CPP Resources

C2PA Resources

Conformance Levels (CPP)

Level TSA Human Attestation Completeness Retention
Bronze Optional Optional Required 6 months
Silver Daily Optional Required 2 years
Gold Per-capture Required Required 5+ years

Conclusion

C2PA and CPP aren't competitors—they're solving different problems.

C2PA answers: "What happened to this content?"
CPP answers: "Was this actually captured, and is the collection complete?"

For most media distribution use cases, C2PA's mature ecosystem is the right choice. But when you need forensic-grade provenance—legal proceedings, regulatory audits, insurance claims—CPP provides guarantees that C2PA architecturally cannot.

The Completeness Invariant alone is worth understanding, even if you never implement CPP. The insight that XOR hash sums can detect deletion is applicable far beyond media provenance.


What's Next?

In the next article in this series, I'll cover:

  • Implementing CPP verification in a web app
  • Building a cross-platform proof sharing system
  • Zero-knowledge proofs for privacy-preserving verification

Follow me for updates, and check out the VeriCapture repository for a complete implementation.


Have questions? Drop them in the comments. I'll answer everything about content provenance, cryptographic evidence, or building tamper-proof systems.

Tags: #security #cryptography #ios #opensource #tutorial


Appendix: Quick Reference

Hash Prefixes

Prefix Algorithm
sha256: SHA-256
sha3-256: SHA-3 256
sha384: SHA-384

Signature Prefixes

Prefix Algorithm
es256: ECDSA P-256
ed25519: EdDSA Ed25519
rs256: RSA PKCS#1 v1.5

Event Types

Type Code Description
Capture CPP_CAPTURE Media from sensor
Attested Capture CPP_CAPTURE_ATT With biometric verification
Seal CPP_SEAL Collection finalized
Share CPP_SHARE Shared with link
Delete CPP_DELETE Crypto-shredding

TSA Providers

Provider Endpoint Free Tier
rfc3161.ai.moda https://rfc3161.ai.moda Yes
DigiCert https://timestamp.digicert.com Yes
Sectigo https://timestamp.sectigo.com Yes
FreeTSA https://freetsa.org/tsr Yes

If this article helped you, consider ⭐ starring the CPP specification repo!

Top comments (0)