한국어 | English
A Node.js PoC for signing and verifying provenance information (Manifest) on files based on C2PA (Coalition for Content Provenance and Authenticity).
@contentauth/c2pa-nodev0.5.x — Node.js wrapper for the Rust (c2pa-rs) bindings
poc_c2pa/
├── assets/ # Input folder (fixed)
│ ├── jwk.json # Ed25519 JWK key file (.gitignore target)
│ └── sample.jpg # Original file to sign
├── output/ # Output folder (fixed, auto-created, .gitignore target)
│ ├── signed_sample.jpg # File with embedded C2PA manifest (signed_ prefix)
│ ├── cert_chain.pem # Certificate chain (cached)
│ └── private_key.pem # Private key (cached)
├── src/
│ ├── constant.ts # ★ Config file — file name, assertions, manifest settings
│ ├── crypto-utils.ts # JWK → PEM conversion + certificate chain generation
│ ├── sign.ts # Signing script (npm run sign)
│ └── verify.ts # Verification script (npm run verify)
├── package.json
└── tsconfig.json
npm installRequirements: Node.js ≥ 20,
opensslCLI (included by default on macOS/Linux)
Create an assets/jwk.json file:
{
"type": "jwk",
"jwk": {
"kty": "OKP",
"d": "<Base64url private key>",
"crv": "Ed25519",
"kid": "<Key ID>",
"x": "<Base64url public key>"
}
}
assets/jwk.jsonis included in.gitignoreand will not be committed.
Place the file to sign in the assets/ folder and set INPUT_FILE_NAME in constant.ts accordingly.
npm run signProcessing flow:
assets/jwk.json→ PEM private key conversion- Generate 3-level certificate chain via OpenSSL (Root CA → Intermediate CA → Signing Cert)
- Cache generated PEM files in
output/(reused on subsequent runs) - Sign C2PA manifest (including assertions) into the file and output
output/signed_<filename>
npm run verifyReads the manifest from the signed file and outputs assertions, signature validity, etc.
| Script | Description |
|---|---|
npm run sign |
Run C2PA signing |
npm run verify |
Verify signed file |
npm run clear |
Delete output/ folder (including PEM cache) |
npm run test |
Run clear → sign → verify all at once |
All settings are managed in constant.ts. Just edit this file — no code changes needed.
export const INPUT_FILE_NAME = 'sample.jpg'; // Original filename in assets/ folder- Input:
assets/<INPUT_FILE_NAME> - Output:
output/signed_<INPUT_FILE_NAME>(automatic) - Folders are fixed as
assets/andoutput/— just change the filename. - mimeType is automatically inferred from the extension (
.jpg,.png,.mp4, etc.).
export const SIGNING_ALGORITHM = 'ed25519' as const;
// Change to 'es256' if using an EC P-256 keyIn C2PA, the unit of information embedded in a file is called an Assertion. Each consists of a { label, data } pair.
Using standard labels in the CUSTOM_ASSERTIONS array allows you to add C2PA-compliant metadata:
// c2pa.actions — Record of actions performed on the file
{
label: 'c2pa.actions',
data: {
actions: [{
action: 'c2pa.created',
digitalSourceType: 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia',
softwareAgent: 'MyApp/1.0',
}],
},
}
// stds.schema-org.CreativeWork — Schema.org content metadata
{
label: 'stds.schema-org.CreativeWork',
data: {
'@type': 'CreativeWork',
'@context': 'https://schema.org',
author: [{ '@type': 'Person', name: 'Author Name' }],
},
}
c2pa.actionsis automatically generated whensetIntent()is called, so you don't need to add it manually.stds.schema-org.CreativeWorkis optional, but the@contextfield is required.
You can freely add any JSON to the CUSTOM_ASSERTIONS array:
export const CUSTOM_ASSERTIONS = [
{
label: 'org.newnal.project-info',
data: {
projectId: 'proj-abc-123',
version: '1.0.0',
tags: ['ai-generated', 'test', 'poc'],
createdBy: { name: 'Test User', role: 'developer' },
},
},
{
label: 'org.newnal.training-info',
data: {
modelName: 'gpt-4',
modelVersion: '2025-01',
prompt: 'Generate a sample image',
temperature: 0.7,
seed: 42,
},
},
];Changes (add/modify/delete) will be reflected on the next npm run sign.
export const CLAIM_GENERATOR = 'newnal-c2pa-poc/1.0';Software identifier for the manifest creator. The name/version format is conventional, but the content is arbitrary. The SDK automatically appends c2pa-rs/0.75.6 internally, so just put your app name here.
export const BUILDER_INTENT = {
create: 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia',
};- Key: Either
create(new file) orupdate(modifying existing file) - Value: IPTC Digital Source Type URI — must use standardized values
| URI (last segment) | Meaning |
|---|---|
trainedAlgorithmicMedia |
AI-generated content |
algorithmicMedia |
Algorithm-generated (non-AI) |
digitalCapture |
Captured directly by camera/scanner |
digitalArt |
Digitally created by a person |
compositeWithTrainedAlgorithmicMedia |
Composite with AI-generated content |
Full list: https://cv.iptc.org/newscodes/digitalsourcetype/
The c2pa.actions assertion is automatically generated based on this value.
export const CERT_SUBJECT = 'CN=C2PA PoC Test, O=Newnal';X.509 Distinguished Name used when generating self-signed certificates. Displayed in the signature_info of verification results.
| Abbreviation | Meaning | Example |
|---|---|---|
CN |
Common Name | C2PA PoC Test |
O |
Organization | Newnal |
OU |
Org Unit (optional) | AI Team |
C |
Country (optional) | KR |
The c2pa-rs SDK internally enforces JSON-LD format validation when the word metadata is included in a label. If the data doesn't contain a @context field, verification will fail.
# ❌ Avoid — causes "missing field @context" error during verification
org.newnal.metadata
com.example.metadata
my.custom.metadata
# ✅ Use alternative labels
org.newnal.project-info
org.newnal.meta
org.newnal.custom-data
Labels containing
metadatacan still be used if you include a@contextfield, but using different names is recommended to avoid confusion.
- On the first signing, a certificate chain and private key PEM are generated from the JWK and saved in
output/. - Subsequent runs reuse the cached PEM files.
- If you change the key, delete the PEM cache and re-sign:
npm run clear
npm run sign- This PoC uses a self-generated certificate chain (Self-signed Root CA).
- Verification is configured with
verify_trust: false, so certificate trust verification is skipped. - Changing to
verify_trust: truewill pass verification itself, but addssigningCredential.untrusted("signing certificate untrusted") as a failure.- This is because the self-generated Root CA is not registered in the system/C2PA trust store.
- Signature integrity (hash, certificate chain) is valid, but "can this signer be trusted?" fails.
- In a production environment, certificates issued by C2PA-certified CAs (Adobe, DigiCert, etc.) are required to pass this verification.
mimeType is automatically inferred from the file extension (MIME_TYPES in constant.ts). Supported formats:
| Extension | mimeType |
|---|---|
.jpg, .jpeg |
image/jpeg |
.png |
image/png |
.webp |
image/webp |
.tiff, .tif |
image/tiff |
.mp4 |
video/mp4 |
.mov |
video/quicktime |
┌──────────────────────────────────────────┐
│ File (JPEG, PNG, MP4, etc.) │
│ ┌────────────────────────────────────┐ │
│ │ C2PA Manifest Store │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Manifest │ │ │
│ │ │ ├─ claim_generator │ │ │
│ │ │ ├─ signature_info │ │ │
│ │ │ └─ assertions[] │ │ │
│ │ │ ├─ c2pa.actions │ │ │ ← Action records (auto)
│ │ │ ├─ stds.schema-org.* │ │ │ ← Schema.org metadata (optional)
│ │ │ ├─ org.newnal.* │ │ │ ← Custom data (free format)
│ │ │ └─ c2pa.hash.data │ │ │ ← File integrity hash (auto)
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
- Manifest Store: The overall container embedded in the file
- Manifest: A single signing unit (a new Manifest is chained each time the file is edited)
- Assertions: Array of
{ label, data }pairs — put the information you want to embed here - Signature: Digital signature over the entire manifest (tamper detection)
If the file binary is modified after signing, c2pa.hash.data verification will fail, detecting tampering.
npm run sign
> newnal-poc-c2pa@1.0.0 sign
> tsx src/sign.ts
=== C2PA Signing Start ===
📄 Input file: /Users/evankim/dev/git/newnal/poc_c2pa/assets/sample.mp4
📁 Output path: /Users/evankim/dev/git/newnal/poc_c2pa/output/signed_sample.mp4
🔑 Preparing signing key and certificates...
✅ Newly generated from JWK → output/cert_chain.pem, output/private_key.pem saved
✅ LocalSigner created
📋 Composing manifest...
✅ Builder intent set: {"create":"http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"}
✍️ Signing...
✅ Signing complete!
📊 Results:
Original size: 51307.7 KB
Signed file size: 51321.2 KB
C2PA data size: 13.5 KB
✅ Signed file: /Users/evankim/dev/git/newnal/poc_c2pa/output/signed_sample.mp4
→ Verify with: npm run verify
npm run verify
> newnal-poc-c2pa@1.0.0 verify
> tsx src/verify.ts
=== C2PA Verification Start ===
📄 Verification target: /Users/evankim/dev/git/newnal/poc_c2pa/output/signed_sample.mp4
📋 Manifest store summary:
Embedded: true
🔖 Active manifest:
Claim Generator: N/A
Title: N/A
Format: N/A
📝 Assertions (4):
[c2pa.hash.bmff.v3]
{
"exclusions": [
{
"xpath": "/uuid",
"length": null,
"data": [
{
"offset": 8,
"value": "2P7D1hsOSDySl1goh37EgQ=="
}
],
"subset": null,
"version": null,
"flags": null,
"exact": null
},
{
"xpath": "/ftyp",
"length": null,
"data": null,
"subset": null,
"version": null,
"flags": null,
"exact": null
},
{
"xpath": "/mfra",
"length": null,
"data": null,
"subset": null,
"version": null,
"flags": null,
"exact": null
}
],
"alg": "sha256",
"hash": "f1t2PWcXvoBo+TlwpR07Eufq0fN+kRi9wC8yUmLK+cA=",
"name": "jumbf manifest"
}
[org.newnal.project-info]
{
"tags": [
"ai-generated",
"test",
"poc"
],
"version": "1.0.0",
"createdBy": {
"name": "Test User",
"role": "developer"
},
"projectId": "proj-abc-123"
}
[org.newnal.training-info]
{
"seed": 42,
"prompt": "Generate a sample image",
"modelName": "gpt-4",
"temperature": 0.7,
"modelVersion": "2025-01"
}
[c2pa.actions.v2]
{
"actions": [
{
"action": "c2pa.created",
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
}
]
}
--- Full Manifest JSON (for debugging) ---
{
"active_manifest": "urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf",
"manifests": {
"urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf": {
"claim_generator_info": [
{
"name": "c2pa-rs",
"version": "0.75.6",
"org.contentauth.c2pa_rs": "0.75.6"
}
],
"instance_id": "xmp:iid:2d527de9-9ed6-4f8a-97d6-ea1179e1865e",
"assertions": [
{
"label": "c2pa.hash.bmff.v3",
"data": {
"exclusions": [
{
"xpath": "/uuid",
"length": null,
"data": [
{
"offset": 8,
"value": "2P7D1hsOSDySl1goh37EgQ=="
}
],
"subset": null,
"version": null,
"flags": null,
"exact": null
},
{
"xpath": "/ftyp",
"length": null,
"data": null,
"subset": null,
"version": null,
"flags": null,
"exact": null
},
{
"xpath": "/mfra",
"length": null,
"data": null,
"subset": null,
"version": null,
"flags": null,
"exact": null
}
],
"alg": "sha256",
"hash": "f1t2PWcXvoBo+TlwpR07Eufq0fN+kRi9wC8yUmLK+cA=",
"name": "jumbf manifest"
},
"created": true
},
{
"label": "org.newnal.project-info",
"data": {
"tags": [
"ai-generated",
"test",
"poc"
],
"version": "1.0.0",
"createdBy": {
"name": "Test User",
"role": "developer"
},
"projectId": "proj-abc-123"
}
},
{
"label": "org.newnal.training-info",
"data": {
"seed": 42,
"prompt": "Generate a sample image",
"modelName": "gpt-4",
"temperature": 0.7,
"modelVersion": "2025-01"
}
},
{
"label": "c2pa.actions.v2",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
}
]
}
}
],
"signature_info": {
"alg": "Ed25519",
"issuer": "Newnal",
"common_name": "C2PA PoC Test",
"cert_serial_number": "84608593711801546977401573933771680074522597368"
},
"label": "urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf",
"claim_version": 2
}
},
"validation_results": {
"activeManifest": {
"success": [
{
"code": "claimSignature.insideValidity",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.signature",
"explanation": "claim signature valid"
},
{
"code": "claimSignature.validated",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.signature",
"explanation": "claim signature valid"
},
{
"code": "assertion.hashedURI.match",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.assertions/c2pa.hash.bmff.v3",
"explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
},
{
"code": "assertion.hashedURI.match",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.assertions/org.newnal.project-info",
"explanation": "hashed uri matched: self#jumbf=c2pa.assertions/org.newnal.project-info"
},
{
"code": "assertion.hashedURI.match",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.assertions/org.newnal.training-info",
"explanation": "hashed uri matched: self#jumbf=c2pa.assertions/org.newnal.training-info"
},
{
"code": "assertion.hashedURI.match",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.assertions/c2pa.actions.v2",
"explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
},
{
"code": "assertion.bmffHash.match",
"url": "self#jumbf=/c2pa/urn:c2pa:d8ead370-32d0-4e92-a490-b38959784adf/c2pa.assertions/c2pa.hash.bmff.v3",
"explanation": "BMFF hash valid"
}
],
"informational": [],
"failure": []
}
},
"validation_state": "Valid"
}