React Native / Expo Integration Guide
Per-install C2PA signing for mobile applications
Overview
This guide walks through integrating Trusteddit PKI into a React Native or Expo application to provide per-install C2PA content credentials. Every app install generates its own ECDSA P-256 key pair in the device secure enclave, enrolls for a short-lived certificate (90 days) from the Trusteddit CA, and uses that certificate to sign all captured media before upload.
Verifying applications (browsers, social platforms, news readers) can inspect the C2PA manifest embedded in the media and resolve the certificate chain to the C2PA Trust List, displaying a provenance badge to the viewer.
Privacy Model
The certificate common name (CN) is an opaque identifier in the format:
ph_a3f92c1d8b4e7f06@thephenom.appThis token is derived via HMAC-SHA256 over the user's internal ID using a secret held only by your app backend. It cannot be reversed to a real user identity by any external party: not by Trusteddit, not by verifying applications, not by the C2PA Trust List.
Only your backend holds the signer_id → user_id mapping. This architecture is GDPR-compliant by construction: the certificate contains no personal data, and the mapping stays within your data boundary.
Enrollment & Signing Flow
Dependencies
# Install required packages
npx expo install expo-crypto expo-secure-store
npm install react-native-quick-crypto @peculiar/x509
# iOS: rebuild native modules
npx expo prebuild --platform iosexpo-crypto
Cryptographic primitives (ECDSA key generation)
expo-secure-store
Secure, hardware-backed key storage
react-native-quick-crypto
Web Crypto API polyfill for CSR generation
@peculiar/x509
X.509 certificate and CSR construction (DER/PEM)
Step 1: Key Pair Generation
Generate an ECDSA P-256 key pair and persist the private key in the device secure enclave via expo-secure-store.
// src/lib/deviceKey.ts
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
const PRIVATE_KEY_ALIAS = 'trusteddit.device.privkey';
const PUBLIC_KEY_ALIAS = 'trusteddit.device.pubkey';
/**
* Generate an ECDSA P-256 key pair and store the private key
* in the device secure enclave. Returns the public key as a
* base64url-encoded DER SubjectPublicKeyInfo blob.
*/
export async function generateDeviceKeyPair(): Promise<string> {
const keyPair = await Crypto.generateKeyPairAsync(
Crypto.CryptoAlgorithm.ECDSA,
{
extractable: false, // private key cannot be exported
namedCurve: 'P-256',
}
);
// Export public key as SPKI (DER) for CSR construction
const publicKeyDer = await Crypto.exportKeyAsync(
Crypto.CryptoKeyFormat.SubjectPublicKeyInfo,
keyPair.publicKey
);
const publicKeyB64 = Buffer.from(publicKeyDer).toString('base64url');
// Persist keys – private key stored in Secure Enclave
await SecureStore.setItemAsync(PRIVATE_KEY_ALIAS, JSON.stringify(keyPair.privateKey));
await SecureStore.setItemAsync(PUBLIC_KEY_ALIAS, publicKeyB64);
return publicKeyB64;
}
/**
* Retrieve the stored public key. Returns null if not yet enrolled.
*/
export async function getStoredPublicKey(): Promise<string | null> {
return SecureStore.getItemAsync(PUBLIC_KEY_ALIAS);
}Step 2: Request Signer ID
Call your app backend to obtain an opaque signer ID for the authenticated user. The backend derives this token via HMAC and stores the mapping: see the Infrastructure Guide for the backend implementation.
// src/lib/enrollment.ts
import { Auth } from 'aws-amplify';
/**
* Retrieve an opaque signer ID from the app backend.
* The backend derives this via HMAC-SHA256(secret, userId).
*/
export async function requestSignerId(): Promise<string> {
const session = await Auth.currentSession();
const jwt = session.getIdToken().getJwtToken();
const response = await fetch('https://api.thephenom.app/api/signer-id', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Signer ID request failed: ${response.status}`);
}
const { signerId } = await response.json() as { signerId: string };
return signerId; // e.g. "ph_a3f92c1d8b4e7f06@thephenom.app"
}Step 3: Build the CSR
Construct a PKCS#10 Certificate Signing Request using the opaque signer ID as the certificate Common Name. The CSR is signed with the on-device private key.
// src/lib/csr.ts
import * as x509 from '@peculiar/x509';
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
const PRIVATE_KEY_ALIAS = 'trusteddit.device.privkey';
/**
* Build a DER-encoded CSR with the opaque signer ID as the CN.
* The CSR is self-signed with the on-device ECDSA P-256 private key.
*/
export async function buildCSR(
signerId: string,
publicKeyDerB64: string
): Promise<string> {
const privateKeyRaw = await SecureStore.getItemAsync(PRIVATE_KEY_ALIAS);
if (!privateKeyRaw) throw new Error('No device private key found');
const privateKey = JSON.parse(privateKeyRaw) as CryptoKey;
// Reconstruct the public key from stored DER
const publicKeyDer = Buffer.from(publicKeyDerB64, 'base64url');
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${signerId}`,
keys: {
privateKey,
publicKey: await crypto.subtle.importKey(
'spki',
publicKeyDer,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify']
),
},
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
extensions: [
// Mark as non-CA end-entity
new x509.BasicConstraintsExtension(false, undefined, true),
],
});
// Return PEM-encoded CSR for transmission
return csr.toString('pem');
}Step 4: Enroll with Trusteddit
Submit the CSR and Cognito JWT to the Trusteddit enrollment API. On success, the API returns a signed certificate and the full Trusteddit CA chain. Store both in secure storage for signing operations.
// src/lib/enrollment.ts (continued)
import * as SecureStore from 'expo-secure-store';
import { Auth } from 'aws-amplify';
const CERT_ALIAS = 'trusteddit.device.cert';
const CHAIN_ALIAS = 'trusteddit.device.chain';
const ENROLL_URL = 'https://enroll.trusteddit.com/api/v1/enroll';
export interface EnrollmentResponse {
certificate: string; // PEM-encoded device certificate
chain: string[]; // PEM-encoded CA chain (intermediate → root)
expiresAt: string; // ISO 8601 expiry of the issued certificate
}
/**
* Submit the CSR to the Trusteddit enrollment API.
* Stores the returned certificate and chain in secure storage.
*/
export async function enrollDevice(csrPem: string): Promise<EnrollmentResponse> {
const session = await Auth.currentSession();
const jwt = session.getIdToken().getJwtToken();
const response = await fetch(ENROLL_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ csr: csrPem }),
});
if (!response.ok) {
const error = await response.json() as { code: string; message: string };
throw new Error(`Enrollment failed [${error.code}]: ${error.message}`);
}
const result = await response.json() as EnrollmentResponse;
// Persist certificate and chain for future signing operations
await SecureStore.setItemAsync(CERT_ALIAS, result.certificate);
await SecureStore.setItemAsync(CHAIN_ALIAS, JSON.stringify(result.chain));
return result;
}Step 5: Sign Captured Media
After capture, build a C2PA manifest embedding the device certificate and sign it with the on-device private key before the file is written to disk or queued for upload.
// src/lib/signing.ts
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
const PRIVATE_KEY_ALIAS = 'trusteddit.device.privkey';
const CERT_ALIAS = 'trusteddit.device.cert';
const CHAIN_ALIAS = 'trusteddit.device.chain';
export interface C2PAManifest {
/** The signed C2PA JUMBF box as a base64-encoded string */
manifestBox: string;
/** ISO 8601 timestamp of the signing operation */
signedAt: string;
}
/**
* Sign a media file with the on-device certificate.
*
* @param mediaUri - File URI of the captured media (video/image)
* @param mimeType - MIME type of the media, e.g. "video/mp4"
* @returns A C2PA manifest box to embed in the file prior to upload
*/
export async function signMedia(
mediaUri: string,
mimeType: string
): Promise<C2PAManifest> {
const [privateKeyRaw, certPem, chainRaw] = await Promise.all([
SecureStore.getItemAsync(PRIVATE_KEY_ALIAS),
SecureStore.getItemAsync(CERT_ALIAS),
SecureStore.getItemAsync(CHAIN_ALIAS),
]);
if (!privateKeyRaw || !certPem || !chainRaw) {
throw new Error('Device not enrolled. Call enrollDevice() first.');
}
const privateKey = JSON.parse(privateKeyRaw) as CryptoKey;
const chain = JSON.parse(chainRaw) as string[];
// Compute SHA-256 hash of the media file for inclusion in the manifest
const fileHash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
mediaUri, // production: read file bytes
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
// Build the C2PA claim structure
const claim = {
claim_generator: 'com.thephenom.app/1.0',
format: mimeType,
assertions: [
{ label: 'c2pa.hash.data', data: { alg: 'sha256', hash: fileHash } },
],
signature_info: {
alg: 'ES256',
cert: certPem,
chain,
tsa_url: 'https://tsa.trusteddit.com/rfc3161',
},
};
// Sign the canonical JSON representation of the claim
const claimBytes = new TextEncoder().encode(JSON.stringify(claim));
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
claimBytes
);
const manifestBox = Buffer.from(JSON.stringify({
claim,
signature: Buffer.from(signature).toString('base64url'),
})).toString('base64');
return { manifestBox, signedAt: new Date().toISOString() };
}Step 6: Upload & Receive Authorship Badge
Upload the signed media to your backend. The backend verifies the C2PA manifest, calls the Trusteddit badge-signer API to add a platform verification signature, and returns the badge for display in-app.
// src/lib/upload.ts
import { Auth } from 'aws-amplify';
import { C2PAManifest } from './signing';
export interface UploadResult {
mediaId: string;
badgeUrl: string; // URL of the rendered authorship badge image
badgeManifest: string; // JSON-encoded C2PA manifest including badge signature
verificationUrl: string; // Public URL for verifiers (e.g. contentcredentials.org)
}
/**
* Upload a signed media file plus its C2PA manifest to the app backend.
* Returns the media ID and authorship badge on success.
*/
export async function uploadSignedMedia(
mediaUri: string,
mimeType: string,
manifest: C2PAManifest
): Promise<UploadResult> {
const session = await Auth.currentSession();
const jwt = session.getIdToken().getJwtToken();
const formData = new FormData();
formData.append('file', {
uri: mediaUri,
name: 'media',
type: mimeType,
} as unknown as Blob);
formData.append('manifestBox', manifest.manifestBox);
formData.append('signedAt', manifest.signedAt);
const response = await fetch('https://api.thephenom.app/api/media/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${jwt}` },
body: formData,
});
if (!response.ok) {
const err = await response.json() as { message: string };
throw new Error(`Upload failed: ${err.message}`);
}
return response.json() as Promise<UploadResult>;
}Two-Signature Model
Every piece of authenticated media carries two independent C2PA signatures. Verifying applications resolve both and present them to the viewer.
| Signature | Signing Key | Issued By | Proves | Applied At |
|---|---|---|---|---|
| Device Cert Signature | On-device ECDSA P-256 private key | Trusteddit CA | This device captured this media | Capture time, on-device |
| Badge-Signer Signature | Trusteddit badge-signer (server-side) | Trusteddit badge-signer API | Platform verified and authenticated the media | Upload time, server-side |
The device certificate signature proves creation provenance: this specific device captured this specific media. The badge-signer signature proves platform verification: your platform reviewed the media and the device certificate chain before publication.