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:
- How C2PA and CPP work under the hood
- When to use each protocol
- How to implement key cryptographic patterns
- 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) │
└─────────────────────────────────────┘
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"]
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
)
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
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
4. Trust List Gatekeeping
Your certificate → Intermediate CA → Root CA → C2PA Trust List
↑
Controlled by consortium
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
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 │
└────────────────────────────────────────────────────────────┘
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"
}
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)
}
}
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)"
}
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())"
}
}
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
}
Chain integrity:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Event N-1 │ │ Event N │ │ Event N+1 │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ EventHash ──┼────>│ PrevHash │ │ PrevHash │
│ │ │ EventHash ──┼────>│ │
│ Signature │ │ Signature │ │ Signature │
└─────────────┘ └─────────────┘ └─────────────┘
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ₙ
}
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
}
}
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
}
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) │
└──────────────────────────────────────────┘
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
}
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
}
}
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
}
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
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()
}
}
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 │
└──────────┘ └──────────┘
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"
}
}
]
}
This enables:
- Internal verification with CPP (forensic-grade)
- External distribution with C2PA (ecosystem compatibility)
Getting Started
CPP Resources
- Specification: github.com/veritaschain/cpp-spec
- VAP Framework: github.com/veritaschain/vap-spec
- Reference Implementation: VeriCapture (iOS)
C2PA Resources
- Specification: c2pa.org/specifications
- Rust SDK: github.com/contentauth/c2pa-rs
-
Python SDK:
pip install c2pa-python
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)