한국어 | English
C2PA (Coalition for Content Provenance and Authenticity) 기반으로 파일에 출처 정보(Manifest) 를 서명하고 검증하는 Node.js PoC입니다.
@contentauth/c2pa-nodev0.5.x — Rust(c2pa-rs) 바인딩의 Node.js 래퍼
poc_c2pa/
├── assets/ # 입력 폴더 (고정)
│ ├── jwk.json # Ed25519 JWK 키 파일 (.gitignore 대상)
│ └── sample.jpg # 서명할 원본 파일
├── output/ # 출력 폴더 (고정, 자동 생성, .gitignore 대상)
│ ├── signed_sample.jpg # C2PA 매니페스트가 포함된 파일 (signed_ 접두사)
│ ├── cert_chain.pem # 인증서 체인 (캐싱)
│ └── private_key.pem # 개인키 (캐싱)
├── src/
│ ├── constant.ts # ★ 설정 파일 — 파일명, Assertions, 매니페스트 설정
│ ├── crypto-utils.ts # JWK → PEM 변환 + 인증서 체인 생성
│ ├── sign.ts # 서명 스크립트 (npm run sign)
│ └── verify.ts # 검증 스크립트 (npm run verify)
├── package.json
└── tsconfig.json
npm install요구사항: Node.js ≥ 20,
opensslCLI (macOS/Linux 기본 포함)
assets/jwk.json 파일을 생성합니다:
{
"type": "jwk",
"jwk": {
"kty": "OKP",
"d": "<Base64url 개인키>",
"crv": "Ed25519",
"kid": "<키 ID>",
"x": "<Base64url 공개키>"
}
}
assets/jwk.json은.gitignore에 포함되어 있어 커밋되지 않습니다.
assets/ 폴더에 서명할 파일을 넣고, constant.ts의 INPUT_FILE_NAME을 맞춰주세요.
npm run sign처리 흐름:
assets/jwk.json→ PEM 개인키 변환- OpenSSL로 3단계 인증서 체인 생성 (Root CA → Intermediate CA → Signing Cert)
- 생성된 PEM 파일을
output/에 캐싱 (다음 실행 시 재사용) - C2PA 매니페스트(assertions 포함)를 파일에 서명하여
output/signed_<파일명>생성
npm run verify서명된 파일의 매니페스트를 읽고 assertions, 서명 유효성 등을 출력합니다.
| 스크립트 | 설명 |
|---|---|
npm run sign |
C2PA 서명 실행 |
npm run verify |
서명된 파일 검증 |
npm run clear |
output/ 폴더 삭제 (PEM 캐시 포함) |
npm run test |
clear → sign → verify 한 번에 실행 |
모든 설정은 constant.ts에서 관리합니다. 코드 수정 없이 이 파일만 편집하면 됩니다.
export const INPUT_FILE_NAME = 'sample.jpg'; // assets/ 폴더 내 원본 파일명- 입력:
assets/<INPUT_FILE_NAME> - 출력:
output/signed_<INPUT_FILE_NAME>(자동) - 폴더는
assets/,output/고정이며, 파일명만 변경하면 됩니다. - mimeType은 확장자(
.jpg,.png,.mp4등)에서 자동 추론됩니다.
export const SIGNING_ALGORITHM = 'ed25519' as const;
// EC P-256 키를 사용할 경우 'es256'으로 변경C2PA에서 파일에 임베딩하는 정보 단위를 Assertion이라 합니다. { label, data } 쌍으로 구성됩니다.
CUSTOM_ASSERTIONS 배열에 표준 label을 사용하면 C2PA 규격에 맞는 메타데이터를 넣을 수 있습니다:
// c2pa.actions — 파일에 수행된 작업 기록
{
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 콘텐츠 메타데이터
{
label: 'stds.schema-org.CreativeWork',
data: {
'@type': 'CreativeWork',
'@context': 'https://schema.org',
author: [{ '@type': 'Person', name: 'Author Name' }],
},
}
c2pa.actions는setIntent()호출 시 자동 생성되므로 직접 넣지 않아도 됩니다.stds.schema-org.CreativeWork는 선택 사항이며,@context필드가 필수입니다.
CUSTOM_ASSERTIONS 배열에 원하는 JSON을 자유롭게 추가할 수 있습니다:
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,
},
},
];항목을 추가/수정/삭제하면 다음 npm run sign 시 반영됩니다.
export const CLAIM_GENERATOR = 'newnal-c2pa-poc/1.0';매니페스트를 생성한 소프트웨어 식별값. 이름/버전 형태가 관례이며 내용은 자유입니다. SDK가 내부적으로 c2pa-rs/0.75.6도 자동 추가하므로, 여기에는 우리 앱 이름만 넣으면 됩니다.
export const BUILDER_INTENT = {
create: 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia',
};- 키:
create(새 파일) 또는update(기존 파일 수정) 중 하나 - 값: IPTC Digital Source Type URI — 규격화된 값을 사용해야 합니다
| URI (마지막 부분) | 의미 |
|---|---|
trainedAlgorithmicMedia |
AI가 생성한 콘텐츠 |
algorithmicMedia |
알고리즘 생성 (비AI) |
digitalCapture |
카메라/스캐너로 직접 촬영 |
digitalArt |
사람이 디지털로 제작 |
compositeWithTrainedAlgorithmicMedia |
AI 생성물 합성 |
전체 목록: https://cv.iptc.org/newscodes/digitalsourcetype/
이 값에 따라 c2pa.actions assertion이 자동 생성됩니다.
export const CERT_SUBJECT = 'CN=C2PA PoC Test, O=Newnal';자기서명 인증서 생성 시 사용되는 X.509 Distinguished Name. 검증 결과의 signature_info에 표시됩니다.
| 약어 | 의미 | 예시 |
|---|---|---|
CN |
Common Name | C2PA PoC Test |
O |
Organization | Newnal |
OU |
Org Unit (선택) | AI Team |
C |
Country (선택) | KR |
c2pa-rs SDK 내부에서 metadata라는 단어가 label에 포함되면 JSON-LD 형식으로 강제 검증합니다. 이 경우 데이터에 @context 필드가 없으면 검증(verify)에서 실패합니다.
# ❌ 사용 금지 — 검증 시 "missing field @context" 오류 발생
org.newnal.metadata
com.example.metadata
my.custom.metadata
# ✅ 대체 label 사용
org.newnal.project-info
org.newnal.meta
org.newnal.custom-data
@context필드를 포함하면metadatalabel도 사용 가능하지만, 혼란을 피하기 위해 다른 이름을 권장합니다.
- 최초 서명 시 JWK로부터 인증서 체인과 개인키 PEM을 생성하고
output/에 저장합니다. - 이후 실행에서는 캐싱된 PEM을 재사용합니다.
- 키를 변경한 경우 PEM 캐시를 삭제 후 다시 서명하세요:
npm run clear
npm run sign- 이 PoC는 자체 생성한 인증서 체인(Self-signed Root CA)을 사용합니다.
- 검증 시
verify_trust: false로 설정되어 있어 인증서 신뢰 검증은 건너뜁니다. verify_trust: true로 변경하면 검증 자체는 통과하지만, failure에signingCredential.untrusted("signing certificate untrusted")가 추가됩니다.- 자체 생성한 Root CA가 시스템/C2PA 신뢰 저장소에 등록되어 있지 않기 때문입니다.
- 서명의 무결성(해시, 인증서 체인)은 정상이지만, "이 서명자를 신뢰할 수 있는가"에서 실패합니다.
- 프로덕션 환경에서는 C2PA 인증 CA(Adobe, DigiCert 등)에서 발급한 인증서를 사용해야 이 검증을 통과합니다.
확장자에서 mimeType을 자동 추론합니다 (constant.ts의 MIME_TYPES). 지원 형식:
| 확장자 | mimeType |
|---|---|
.jpg, .jpeg |
image/jpeg |
.png |
image/png |
.webp |
image/webp |
.tiff, .tif |
image/tiff |
.mp4 |
video/mp4 |
.mov |
video/quicktime |
┌──────────────────────────────────────────┐
│ 파일 (JPEG, PNG, MP4 등) │
│ ┌────────────────────────────────────┐ │
│ │ C2PA Manifest Store │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Manifest │ │ │
│ │ │ ├─ claim_generator │ │ │
│ │ │ ├─ signature_info │ │ │
│ │ │ └─ assertions[] │ │ │
│ │ │ ├─ c2pa.actions │ │ │ ← 생성/편집 등 작업 기록 (자동)
│ │ │ ├─ stds.schema-org.* │ │ │ ← Schema.org 메타데이터 (선택)
│ │ │ ├─ org.newnal.* │ │ │ ← 커스텀 데이터 (자유 형식)
│ │ │ └─ c2pa.hash.data │ │ │ ← 파일 무결성 해시 (자동)
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
- Manifest Store: 파일에 임베딩되는 전체 컨테이너
- Manifest: 하나의 서명 단위 (편집될 때마다 새 Manifest가 체이닝)
- Assertions:
{ label, data }형태의 데이터 배열 — 파일에 넣고 싶은 정보를 여기에 담습니다 - Signature: 매니페스트 전체에 대한 전자서명 (변조 감지)
서명 후 파일의 바이너리가 수정되면 c2pa.hash.data 검증이 실패하여 변조가 감지됩니다.
npm run sign
> newnal-poc-c2pa@1.0.0 sign
> tsx src/sign.ts
=== C2PA 서명 시작 ===
📄 입력 파일: /Users/evankim/dev/git/newnal/poc_c2pa/assets/sample.mp4
📁 출력 경로: /Users/evankim/dev/git/newnal/poc_c2pa/output/signed_sample.mp4
🔑 서명키 및 인증서 준비 중...
✅ JWK에서 새로 생성 → output/cert_chain.pem, output/private_key.pem 저장
✅ LocalSigner 생성 완료
📋 매니페스트 구성 중...
✅ Builder intent 설정: {"create":"http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"}
✍️ 서명 중...
✅ 서명 완료!
📊 결과:
원본 크기: 51307.7 KB
서명 파일 크기: 51321.2 KB
C2PA 데이터 크기: 13.5 KB
✅ 서명된 파일: /Users/evankim/dev/git/newnal/poc_c2pa/output/signed_sample.mp4
→ npm run verify 로 검증할 수 있습니다.
npm run verify
> newnal-poc-c2pa@1.0.0 verify
> tsx src/verify.ts
=== C2PA 검증 시작 ===
📄 검증 대상: /Users/evankim/dev/git/newnal/poc_c2pa/output/signed_sample.mp4
📋 매니페스트 스토어 요약:
임베디드 여부: true
🔖 활성 매니페스트:
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"
}
]
}
--- 전체 매니페스트 JSON (디버깅용) ---
{
"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"
}