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:

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
AuthUser authenticates. Cognito JWT obtained.
2
Signer IDApp requests opaque signer ID from your backend.
3
Key GenECDSA P-256 key pair generated in secure storage.
4
CSRCertificate Signing Request built with signer ID as CN.
5
EnrollCSR + JWT sent to enroll.trusteddit.com.
6
SignDevice cert signs captured media (C2PA manifest).
7
BadgeUpload 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. "[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.

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