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.pemDatabase 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.
Identity and authentication for your app users. Issues JWTs consumed by the enrollment API.
Managed container runtime for your backend API. Auto-scales with traffic; no VPC required for basic deployments.
Object storage for signed media files and C2PA manifests. Lifecycle rules handle expiry of draft uploads.
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