Back to Developer Portal
Mobile Integration

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

Opaque Signer IDs

The certificate common name (CN) is an opaque identifier in the format:

ph_a3f92c1d8b4e7f06@thephenom.app

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

1
Auth: User authenticates. Cognito JWT obtained.
2
Signer ID: App requests opaque signer ID from your backend.
3
Key Gen: ECDSA P-256 key pair generated in secure storage.
4
CSR: Certificate Signing Request built with signer ID as CN.
5
Enroll: CSR + JWT sent to enroll.trusteddit.com.
6
Sign: Device cert signs captured media (C2PA manifest).
7
Badge: Upload signed media; backend issues verification badge.
Auth
Signer ID
Key Gen
CSR
Enroll
Sign
Badge

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 ios

expo-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.

SignatureSigning KeyIssued ByProvesApplied At
Device Cert SignatureOn-device ECDSA P-256 private keyTrusteddit CAThis device captured this mediaCapture time, on-device
Badge-Signer SignatureTrusteddit badge-signer (server-side)Trusteddit badge-signer APIPlatform verified and authenticated the mediaUpload 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.

Next: Infrastructure Guide