Back to Developer Portal
Infrastructure Guide

Backend Infrastructure Guide

What your backend needs to support the Trusteddit enrollment flow

Overview

The Trusteddit enrollment flow requires two backend responsibilities: generating opaque signer IDs before enrollment, and issuing verification badges after upload. Both operations touch the Trusteddit API, but your backend acts as the trusted intermediary — it is the only party that can correlate a signer ID with a real user.

This guide covers the signer ID endpoint, badge generation, manifest store integration, TSA configuration, database schema, and AWS architecture. It assumes a Node.js / TypeScript backend deployed on AWS App Runner, but the patterns apply to any HTTP server.

Signer ID Endpoint

POST /api/signer-id

This endpoint is called by the mobile app after the user authenticates. It derives an HMAC-SHA256 opaque token from the authenticated user ID, stores the bidirectional mapping, and returns the opaque token. The mobile app uses this token as the certificate CN in the CSR.

// src/routes/signerId.ts  (Express / Hono / Fastify)
import { createHmac, randomBytes } from 'crypto';
import { db } from '../db';

const HMAC_SECRET = process.env.SIGNER_ID_HMAC_SECRET!; // 32+ random bytes

/**
 * Derive a deterministic opaque signer ID for a given user.
 * The HMAC ensures the same user always gets the same signer ID,
 * enabling idempotent re-enrollment without creating duplicate rows.
 */
function deriveSignerId(userId: string): string {
  const hmac = createHmac('sha256', HMAC_SECRET)
    .update(userId)
    .digest('hex')
    .slice(0, 16); // 64 bits of entropy — sufficient for uniqueness at scale
  return `ph_${hmac}@thephenom.app`;
}

export async function handleSignerIdRequest(req, res) {
  // userId is populated by your Cognito JWT middleware
  const { userId } = req.user;

  const signerId = deriveSignerId(userId);

  // Upsert — safe to call multiple times (e.g. after re-enrollment)
  await db.query(
    `INSERT INTO signer_identities (signer_id, user_id, created_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (signer_id) DO NOTHING`,
    [signerId, userId]
  );

  return res.json({ signerId });
}

Security note: Store SIGNER_ID_HMAC_SECRET in AWS Secrets Manager or Parameter Store. Rotate it only when intentionally invalidating all existing signer IDs (which would require re-enrollment of all devices).

Badge Generation

After a signed media upload passes your internal review, call the Trusteddit badge-signer API to attach a platform verification signature. The badge-signer adds a second C2PA assertion to the manifest using Trusteddit's server-side signing certificate.

// src/services/badge.ts
const BADGE_SIGNER_URL = 'https://badge.trusteddit.com/api/v1/sign';

export interface BadgeResult {
  badgeUrl: string;
  updatedManifest: string; // base64-encoded manifest with badge signature appended
  verificationUrl: string;
}

/**
 * Request a platform verification badge from the Trusteddit badge-signer.
 * Call this only after your platform has reviewed and approved the media.
 *
 * @param mediaId     - Your internal media ID
 * @param manifestBox - The device-signed C2PA manifest from the upload
 * @param mediaHash   - SHA-256 hash of the media file (hex)
 * @param apiKey      - Your Trusteddit platform API key (from dashboard)
 */
export async function requestBadge(
  mediaId: string,
  manifestBox: string,
  mediaHash: string,
  apiKey: string
): Promise<BadgeResult> {
  const response = await fetch(BADGE_SIGNER_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      mediaId,
      manifestBox,
      mediaHash,
      platform: 'thephenom.app',
    }),
  });

  if (!response.ok) {
    const err = await response.json() as { code: string; message: string };
    throw new Error(`Badge signing failed [${err.code}]: ${err.message}`);
  }

  return response.json() as Promise<BadgeResult>;
}

Manifest Store Integration

The Trusteddit manifest store handles C2PA manifest lifecycle: pre-registration before upload, signing after receipt, and finalisation once the badge is attached. This three-step flow ensures the manifest hash is committed before the media file is written.

// src/services/manifestStore.ts
const MANIFEST_STORE_URL = 'https://manifests.trusteddit.com/api/v1';

/**
 * Step 1 — Pre-register: Reserve a manifest slot before the media upload.
 * Returns a manifestId to include in the upload multipart form.
 */
export async function preRegisterManifest(
  signerId: string,
  mimeType: string,
  apiKey: string
): Promise<string> {
  const res = await fetch(`${MANIFEST_STORE_URL}/pre-register`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ signerId, mimeType }),
  });
  const { manifestId } = await res.json() as { manifestId: string };
  return manifestId;
}

/**
 * Step 2 — Sign: Attach the device-signed manifest box to the registered slot.
 */
export async function signManifest(
  manifestId: string,
  manifestBox: string,
  apiKey: string
): Promise<void> {
  await fetch(`${MANIFEST_STORE_URL}/${manifestId}/sign`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ manifestBox }),
  });
}

/**
 * Step 3 — Finalize: Attach the badge signature and mark the manifest complete.
 * After this call, the manifest is publicly resolvable by verifying applications.
 */
export async function finalizeManifest(
  manifestId: string,
  badgeManifest: string,
  apiKey: string
): Promise<string> {
  const res = await fetch(`${MANIFEST_STORE_URL}/${manifestId}/finalize`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ badgeManifest }),
  });
  const { verificationUrl } = await res.json() as { verificationUrl: string };
  return verificationUrl;
}

TSA Configuration

Trusteddit operates an RFC 3161 Time-Stamp Authority at tsa.trusteddit.com. Including a timestamp in the C2PA manifest proves the signing time independently of the device clock, which is critical for forensic admissibility.

// Include in the C2PA claim (mobile, Step 3 / Step 5)
const claim = {
  // ...
  signature_info: {
    alg: 'ES256',
    cert: certPem,
    chain,
    // RFC 3161 TSA endpoint — Trusteddit issues RFC 3161 tokens
    tsa_url: 'https://tsa.trusteddit.com/rfc3161',
  },
};

// The C2PA SDK / @peculiar/x509 will call the TSA endpoint
// automatically when tsa_url is present in signature_info.
// The returned timestamp token is embedded in the manifest CMS structure.

// To verify a timestamp token independently:
// openssl ts -verify -in timestamp.tsr -data claim.json \
//   -CAfile trusteddit-tsa-root.pem

Database Schema

The signer_identities table is the sole location where opaque signer IDs are linked to real user identities. Restrict access to this table strictly — treat it as a secrets store.

-- PostgreSQL schema

CREATE TABLE signer_identities (
  -- The opaque token used as certificate CN
  -- Format: ph_<16-char hex>@thephenom.app
  signer_id    TEXT        PRIMARY KEY,

  -- Your internal user ID (Cognito sub or DB primary key)
  user_id      TEXT        NOT NULL,

  -- UTC creation timestamp
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  -- Tracks the currently active enrollment for this signer ID.
  -- A signer may re-enroll (e.g. after certificate expiry);
  -- only the most recent certificate serial is stored here.
  cert_serial  TEXT,
  cert_expires TIMESTAMPTZ,

  -- Optional: last known device platform for auditing
  platform     TEXT
);

-- Index for reverse-lookup: given a user_id, find their signer_id(s)
CREATE INDEX idx_signer_identities_user_id ON signer_identities (user_id);

-- Row-level security: only the API role may read this table
ALTER TABLE signer_identities ENABLE ROW LEVEL SECURITY;

CREATE POLICY api_only ON signer_identities
  USING (current_user = 'api_service_role');

AWS Architecture

A reference architecture for hosting the backend API that supports the Trusteddit enrollment flow. All components run in a single AWS region.

Cognito User Pool

Identity and authentication for your app users. Issues JWTs consumed by the enrollment API.

App Runner

Managed container runtime for your backend API. Auto-scales with traffic; no VPC required for basic deployments.

S3

Object storage for signed media files and C2PA manifests. Lifecycle rules handle expiry of draft uploads.

RDS / PostgreSQL

Persistent store for the signer_identities mapping table. Use RDS Proxy for connection pooling in serverless environments.

# Simplified architecture (AWS CDK / Pulumi pseudocode)

Cognito User Pool
  ├── App Client (mobile)     → issues ID tokens (JWT)
  └── Triggers                → post-confirmation Lambda (optional)

App Runner Service            → your Node.js API
  ├── Routes:
  │     POST /api/signer-id   → derive & store signer ID
  │     POST /api/media/upload → receive signed media + manifest
  │     GET  /api/media/:id   → fetch media with badge
  ├── IAM Role:
  │     secretsmanager:GetSecretValue (SIGNER_ID_HMAC_SECRET)
  │     s3:PutObject (media bucket)
  │     rds-db:connect (PostgreSQL)
  └── Environment:
        COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID
        SIGNER_ID_HMAC_SECRET (from Secrets Manager)
        TRUSTEDDIT_PLATFORM_API_KEY (from Secrets Manager)
        DATABASE_URL

S3 Bucket (media)
  ├── Lifecycle: abort incomplete uploads after 24h
  ├── Encryption: SSE-S3 (or SSE-KMS for regulated workloads)
  └── Block public access: true (presigned URLs for retrieval)

RDS PostgreSQL (or Aurora Serverless v2)
  └── Tables: signer_identities, media_uploads
Mobile Integration GuideNext: API Reference