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:
This 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. "[email protected]"
}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.