お断り: 本記事は C2PA Technical Specification v2.3(2026年4月時点)、c2pa-rs および c2patool の公式ドキュメントとソースコードを筆者が読解・整理したものです。SDK やツールのバージョンにより CLI フラグ名や挙動は変わる可能性があります。実装の根拠として用いる際は、必ず各ライブラリの最新ドキュメントおよびC2PA 公式仕様をご確認ください。本記事に誤りや古くなった箇所を見つけられた場合は、記事末尾のフィードバック枠よりお知らせいただけると助かります。

はじめに

C2PA における「信頼(Trust)」のモデルは、仕様書を読むだけでは直感的に把握しにくい側面があります。そこで本記事では実践的なハンズオンを通じて、OpenSSL で独自のプライベート Root CA および EE(エンドエンティティ)証明書を発行し、c2pa-rs による署名から c2patool による真正性検証までの一連の流れを体験します。

本記事のゴールは以下の 2 点です。

  1. 標準の Trust List のみを参照する環境では、自前証明書による署名が signingCredential.untrusted(未信頼の署名証明書)と判定される挙動を確認する
  2. 検証側に自前の Root CA をトラストアンカーとして追加登録することで、全く同一の画像・Manifest が signingCredential.trusted(信頼済み)へと遷移する状態変化を実証する

C2PA の Manifest や Trust List の基本概念については第4回および第7回で整理していますので、併せてご参照ください。

この記事で組み立てる全体像

全体の処理フローは以下の 3 フェーズで進行します。証明書の生成、c2pa-rs による署名、c2patool による多段階検証の構成です。

flowchart TD subgraph S1["Step 1: 画像を用意"] IMG["input.jpg
(素の画像)"] end subgraph S2["Step 2: 証明書を作る"] CA["Root CA
ca.pem / ca.key"] EE["EE 証明書
ee.pem / ee.key"] CA -->|発行| EE end subgraph S3["Step 3: 署名"] SIGN["c2pa-rs で署名"] OUT["output.jpg
(Manifest 入り)"] SIGN --> OUT end subgraph S45["Step 4-5: 検証"] VER{"c2patool で
チェーン照合"} NG["untrusted"] OK["trusted"] VER -->|アンカー無し| NG VER -->|ca.pem をアンカーに登録| OK end IMG --> SIGN EE -->|chain.pem として渡す| SIGN OUT --> VER CA -->|--trust_anchors| VER

登場する用語を先に整理

本記事では OpenSSL、c2pa-rs、c2patool を横断して操作するため、公開鍵暗号基盤(PKI)や X.509 証明書に関連する専門用語が頻出します。主要な用語の概要と登場箇所をあらかじめ整理します。

用語概要登場箇所
openssl暗号鍵と X.509 証明書を生成・管理する CLI ツール。ecparam / req / x509 サブコマンドを使用Step 2 全般
Root CA(ルート認証局)信頼の起点となる自己署名証明書。下位の証明書に対して正当性を保証する署名権限を持つca.pem。Step 2 で生成、Step 5 で登録
EE 証明書(End-Entity)証明書チェーンの末端に位置し、実際の署名者(主体)に紐づく証明書。下位証明書への署名権限は持たないee.pem。C2PA のアセット署名者に対応
証明書チェーンEE 証明書から Root CA に至る階層的な証明書の束。検証側はチェーンを上位へ遡航して検証するchain.pem(= ee.pem + ca.pem
CSR(Certificate Signing Request)EE 側が公開鍵と識別情報を CA に提出し、証明書発行を求める署名要求データee.csr(OpenSSL が生成する中間ファイル)
PEM証明書や暗号鍵を Base64 テキストで表現したフォーマット(-----BEGIN...-----各種 .pem ファイル
ECDSA P-256(prime256v1)NIST 定義の楕円曲線を用いたデジタル署名アルゴリズム。C2PA における標準的な署名方式鍵生成時に指定
keyUsage証明書の基本的な暗号用途を制約する X.509v3 拡張。アセット署名には digitalSignature が必須v3_ee.cnf
extendedKeyUsage(EKU)keyUsage をさらに細分化し、特定のアプリケーション用途を指定する OID 拡張v3_ee.cnf
Claim Signing 用 OIDC2PA の Claim 署名専用であることを示す識別子 1.3.6.1.4.1.311.76.59.1.9EKU に指定
Claim Signing 用 EKU上記 OID を EKU に格納した状態。これが無いと c2pa-rs が署名時点で証明書を拒否するEE 証明書の前提条件
トラストアンカー検証側が「この認証局から連なるチェーンは無条件に信頼する」と事前に定めた起点証明書--trust_anchors=ca.pem
C2PA Trust ListC2PA アライアンスが公式に管理する適合ルート認証局のリスト(PEM 形式)Step 5 での併用例

この表の中で、直接設定を記述するのは ca.pem(Root CA)と ee.pem(EE 証明書)の 2 枚のみで、残りはそこから派生して生成されるファイルです。

事前準備

使うツールは以下のとおりです。執筆時点の手元環境のバージョンを記載しておきます。

  • Rust 1.94.0
  • OpenSSL 3.6.2(自前 CA の発行用)
  • c2patool 0.26.15(trust サブコマンドと --trust_anchors オプションを使う)

c2patool は cargo で入れるのが一番ラクです。

cargo install c2patool
c2patool --version
# => c2patool 0.26.15

作業ディレクトリを切っておきます。以降のコマンドはとくに断りがなければ、この /tmp/c2pa-trust-anchor/ をカレントにして実行する前提です。パスが .. や相対になっている箇所も、ここが起点だと思って読んでください。

cd /tmp
mkdir -p c2pa-trust-anchor && cd c2pa-trust-anchor
pwd
# => /tmp/c2pa-trust-anchor

Step 1: サンプル画像を用意

NASA アポロ17号のパブリックドメイン画像を拝借してきます。第5回と同じく、c2pa-rs リポジトリのテストフィクスチャから取ってくるのが手軽です。

curl -sL -o input.jpg \
  https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/earth_apollo17.jpg

まだ素の JPEG なので、念のため確認しておきます。

c2patool input.jpg
# => Error: No claim found

Manifest が入っていないことをここで見ておくと、後で署名後との差分がクリアになります。

Step 2: 自前の Root CA と EE 証明書を OpenSSL で発行

C2PA の署名処理に用いる EE 証明書には、仕様上満たすべき厳格な技術要件があります。第7回で整理した条件のうち、自前環境の構築時に必須となる要件は以下の通りです。

  • EE 証明書の keyUsagedigitalSignature を含めること
  • EE 証明書の extendedKeyUsage に Claim Signing 用 OID(1.3.6.1.4.1.311.76.59.1.9)を含めること
  • Root CA の basicConstraintscritical, CA:TRUE とすること
  • 署名アルゴリズムとして ECDSA P-256(prime256v1)を採用すること

Claim Signing 用 EKU が欠落している場合、c2pa-rs は署名実行時に CertificateProfileError(InvalidCertificate) を返して処理を中断します。規格適合上、極めて重要なポイントです。

設定ファイルを作成する

まず Root CA 用と EE 用の OpenSSL 設定ファイルを作ります。

cat > v3_ca.cnf <<'EOF'
[req]
distinguished_name = dn
x509_extensions    = v3_ca
prompt             = no

[dn]
CN = TechThanks Test Root CA
O  = TechThanks Test

[v3_ca]
basicConstraints = critical, CA:TRUE
keyUsage         = critical, keyCertSign, cRLSign
EOF
cat > v3_ee.cnf <<'EOF'
basicConstraints   = CA:FALSE
keyUsage           = critical, digitalSignature
extendedKeyUsage   = 1.3.6.1.4.1.311.76.59.1.9
EOF
余談: この 2 つの cnf ファイルは何を宣言しているのか

設定ファイルの各行は「これから作る証明書にどんな情報・拡張を載せるか」を OpenSSL に伝える設計書です。-config / -extfile で読み込まれ、証明書発行時に X509v3 extensions として焼き込まれます。

v3_ca.cnf — Root CA 用

[req] セクションは openssl req コマンドの動作設定です。

  • distinguished_name = dn: Subject(発行先の名前)を [dn] セクションから読む
  • x509_extensions = v3_ca: -x509 で自己署名証明書を作るときの拡張を [v3_ca] から読む
  • prompt = no: 対話プロンプトを出さずファイルの値をそのまま使う

[dn] セクションに書いた CNO がそのまま Subject DN になります。自己署名なので、これが Issuer にもコピーされます。

[v3_ca] セクションが Root CA たる所以の本体です。

  • basicConstraints = critical, CA:TRUE: 「この証明書は CA である」と宣言。他の証明書に署名できる
  • keyUsage = critical, keyCertSign, cRLSign: 鍵の用途を制限。keyCertSign は証明書への署名、cRLSign は失効リストへの署名

v3_ee.cnf — EE(署名者)証明書用

こちらはセクションヘッダ無しでベタ書き。openssl x509 -req -extfile v3_ee.cnf で読むときは、ファイル全体が拡張セットとして解釈されます。

  • basicConstraints = CA:FALSE: 「この証明書は CA じゃない」= チェーンの末端。これより下には署名できない
  • keyUsage = critical, digitalSignature: データへの署名用途専用。keyCertSign が無いので他の証明書には署名不可
  • extendedKeyUsage = 1.3.6.1.4.1.311.76.59.1.9: C2PA Claim Signing 専用の OID。これが無いと c2pa-rs が署名時点で CertificateProfileError を返して通してくれない

EE は「署名するだけの実働メンバー」、CA は「部下に権限を渡す管理職」、と役割が明確に分離されています。

critical フラグの意味

critical を付けた拡張は、検証ソフトが「この拡張の意味を知らない」場合に証明書全体を拒否します。付けないと無視されて通過してしまうので、信頼の根幹になる basicConstraintskeyUsage には必ず付けておきます。

なぜ Root 側と EE 側で設定ファイルを分けるのか

OpenSSL のコマンド体系では、Root CA 作成は openssl req -x509 -config v3_ca.cnf、EE 証明書発行は openssl x509 -req -extfile v3_ee.cnf で、読み込む設定形式が違います。req[req][dn][v3_ca] という参照構造、x509 -extfile は「拡張だけ書いたファイル」をそのまま読むため、ファイルを分けて書いています。

実際に焼き込まれた結果を確認

cnf に書いた内容がそのまま証明書の拡張領域に入っていることを、作成後に確認できます。

openssl x509 -in ca.pem -noout -text | grep -A2 "X509v3"
# X509v3 Basic Constraints: critical
#     CA:TRUE
# X509v3 Key Usage: critical
#     Certificate Sign, CRL Sign

openssl x509 -in ee.pem -noout -text | grep -A1 "X509v3"
# X509v3 Basic Constraints:
#     CA:FALSE
# X509v3 Key Usage: critical
#     Digital Signature
# X509v3 Extended Key Usage:
#     1.3.6.1.4.1.311.76.59.1.9

Root CA の鍵と自己署名証明書を作る

# Root CA 秘密鍵(ECDSA P-256)
openssl ecparam -name prime256v1 -genkey -noout -out ca.key

# Root CA 自己署名証明書(10年有効)
openssl req -new -x509 -key ca.key -out ca.pem -days 3650 -config v3_ca.cnf

これで「TechThanks 自前のルート CA」ができました。

EE 証明書を CA に発行してもらう

EE 側の鍵を作って CSR を出し、先ほどの Root CA で署名してもらいます。

# EE 秘密鍵
openssl ecparam -name prime256v1 -genkey -noout -out ee.key

# CSR
openssl req -new -key ee.key -out ee.csr \
  -subj "/CN=TechThanks Test Signer/O=TechThanks Test"

# CA 署名で EE 証明書を発行
openssl x509 -req -in ee.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
  -out ee.pem -days 365 -extfile v3_ee.cnf

c2pa-rs の create_signer::from_files には、EE 証明書と Root CA 証明書を連結した「証明書チェーン PEM」を渡します。連結順序は EE 証明書を先頭、上位の Root CA を末尾とします。

cat ee.pem ca.pem > chain.pem

証明書の拡張属性を確認します。

openssl x509 -in ee.pem -noout -ext extendedKeyUsage
# => X509v3 Extended Key Usage:
#        1.3.6.1.4.1.311.76.59.1.9

Claim Signing EKU が正しく組み込まれていることが確認できます。

ここまでで生成した鍵ペア・証明書・署名要求の関係を下図に示します。EE 側は自身の鍵ペアから公開鍵を抽出して ee.csr(署名要求)を作成し、Root CA に提出します。Root CA は受け取った CSR の公開鍵に指定の拡張属性を付与した上で、自身の秘密鍵で電子署名を行い ee.pem を発行します。つまり ee.pem は EE が単独で生成するものではなく、認証局(CA)による信頼の付与を経て発行される成果物です。最後に EE 側が発行された ee.pem と公開されている ca.pem を連結して chain.pem を構成します。

flowchart TD subgraph EEside["EE 署名者(申請する側)"] EEKEY[("ee.key
鍵ペア(秘密鍵+公開鍵)")] CSR["ee.csr
署名要求
(公開鍵+Subject+EE自己署名)"] EEKEY -->|公開鍵を載せて
EE秘密鍵で所有証明| CSR end subgraph CAside["Root CA(発行する側)"] CAKEY[("ca.key
鍵ペア(秘密鍵+公開鍵)")] CAPEM["ca.pem
自己署名証明書"] CAKEY -->|公開鍵を載せて
秘密鍵で自己署名| CAPEM end CSR -->|①CAに提出| EEPEM["ee.pem
EE 証明書
(CSRの公開鍵+CAの署名)"] CAKEY -->|②CA秘密鍵で署名して発行| EEPEM EEPEM --> CHAIN["chain.pem
= ee.pem + ca.pem"] CAPEM --> CHAIN

この時点で /tmp/c2pa-trust-anchor/ はこういう状態です。

tree
# .
# ├── ca.key          # Root CA の秘密鍵
# ├── ca.pem          # Root CA 証明書(← これを後で --trust_anchors に渡す)
# ├── ca.srl          # openssl x509 -CAcreateserial が作るシリアル管理ファイル
# ├── chain.pem       # ee.pem + ca.pem を連結したチェーン(署名時に渡す)
# ├── ee.csr          # EE の証明書署名要求
# ├── ee.key          # EE の秘密鍵(← これも署名時に渡す)
# ├── ee.pem          # Root CA に署名された EE 証明書
# ├── input.jpg       # 署名対象のサンプル画像
# ├── v3_ca.cnf
# └── v3_ee.cnf

これ以降のステップで主役になるのは ca.pem(検証側のトラストアンカー)、chain.pemee.key(署名側が使う)、そして input.jpg の 4 つです。

Step 3: c2pa-rs で自前の証明書を使って署名

Rust のプロジェクトを /tmp/c2pa-trust-anchor/signer/ に切ります。証明書や画像は親ディレクトリに置いたままにして、コード側から ../ で参照する構成にすると、ファイルが二重管理にならずに済みます。

# /tmp/c2pa-trust-anchor/ にいる前提
cargo new signer
cd signer
cargo add c2pa --features file_io
cargo add serde_json

src/main.rs を以下の内容に差し替えます。chain.pem / ee.key / input.jpg はひとつ上の階層にあるので、パスを ../ 付きで指定しています。

// src/main.rs
use c2pa::{create_signer, Builder, SigningAlg};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = Builder::default()
        .with_definition(r#"{
            "title": "my_photo.jpg",
            "claim_generator_info": [{
                "name": "TechThanksTrustAnchorDemo",
                "version": "1.0.0"
            }],
            "assertions": [{
                "label": "c2pa.actions.v2",
                "data": {
                    "actions": [{
                        "action": "c2pa.created",
                        "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
                    }]
                },
                "created": true
            }]
        }"#)?;

    // 自前のチェーン PEM と EE 秘密鍵で署名者を作る
    let signer = create_signer::from_files(
        "../chain.pem",
        "../ee.key",
        SigningAlg::Es256,
        Some("http://timestamp.digicert.com".to_string()),
    )?;

    builder.sign_file(&*signer, "../input.jpg", "../output.jpg")?;
    println!("署名完了: ../output.jpg");
    Ok(())
}

第5回で使った EphemeralSigner との違いは、create_signer::from_files で自前の PEM と鍵を渡しているところだけです。署名者を差し替えれば、Builder 側のコードは同じまま動きます。

ビルドして署名します。

cargo run
# => 署名完了: ../output.jpg

署名が終わったところで、作業ディレクトリ全体を見直しておきます(ビルド成果物の signer/target/ は大きいので除外)。

cd ..   # /tmp/c2pa-trust-anchor/ に戻る
tree -I target
# .
# ├── ca.key
# ├── ca.pem
# ├── ca.srl
# ├── chain.pem
# ├── ee.csr
# ├── ee.key
# ├── ee.pem
# ├── input.jpg          # 署名前の元画像
# ├── output.jpg         # 自前 EE 証明書で署名した画像
# ├── signer
# │   ├── Cargo.lock
# │   ├── Cargo.toml
# │   └── src
# │       └── main.rs
# ├── v3_ca.cnf
# └── v3_ee.cnf

以降の c2patool の検証コマンドも、この /tmp/c2pa-trust-anchor/ をカレントにしたまま実行します。

Step 4: c2patool で検証(トラストアンカーなし)

まずはトラストアンカーを何も指定せず、素の状態で c2patool に食わせます。--detailed を付けると validation_results が JSON で返ってくるので、そこを抜き出します。

c2patool output.jpg --detailed | jq '.validation_results'

validation_results の中身はだいたいこんな形になります(抜粋)。

{
  "activeManifest": {
    "success": [
      { "code": "claimSignature.insideValidity" },
      { "code": "claimSignature.validated" },
      { "code": "assertion.hashedURI.match" },
      { "code": "assertion.dataHash.match" }
    ],
    "informational": [
      { "code": "timeStamp.untrusted" }
    ],
    "failure": [
      {
        "code": "signingCredential.untrusted",
        "explanation": "signing certificate untrusted"
      }
    ]
  }
}

claimSignature.insideValidityassertion.hashedURI.match は成功しており、Manifest 自体の暗号学的整合性は保たれています。一方で signingCredential.untrustedfailure 配列に含まれています。これは、アセットのハッシュや署名ダイジェスト自体は数学的に完全であるものの、署名に用いられた証明書が検証側の信頼するルート認証局に紐づいていないことを示しています。

この状態が「暗号としては正当だが、認証局として未信頼」という評価です。

Step 5: 自前 Root CA をトラストアンカーに登録

C2PA 仕様の Section 14.4.1 C2PA Signers では、検証側は C2PA 公式の Trust List を含めた上で、独自のトラストアンカーを追加定義することが認められています。ここではその規定に沿い、自前で生成した ca.pem を検証側のトラストアンカーとして追加します。

検証側が実行する信頼性判定のメカニズムを下図に示します。output.jpg 内に埋め込まれた EE 証明書から Root CA までのチェーンを遡航し、そのルート証明書が指定されたトラストアンカー群の中に存在するかどうかで最終判定が分岐します。

flowchart TD subgraph inside["output.jpg 内の Claim Signature"] EEIN["EE 証明書
(ee.pem)"] CAIN["Root CA 証明書
(ca.pem)"] EEIN -->|チェーンをたどる| CAIN end CAIN --> MATCH{"Root がアンカーに
登録されているか?"} ANCHOR[("--trust_anchors
で渡した PEM")] ANCHOR --> MATCH MATCH -->|一致| OK["signingCredential.trusted"] MATCH -->|未登録| NG["signingCredential.untrusted"]

c2patool の trust サブコマンドでは、--trust_anchors オプションでトラストアンカーの PEM ファイルを指定できます(環境変数 C2PATOOL_TRUST_ANCHORS でも指定可能です)。複数の Root CA を信頼対象とする場合は、PEM を連結した単一ファイルを渡します。

--detailed フラグを付与して、同じディレクトリにある ca.pem をトラストアンカーに指定して実行します。

c2patool output.jpg --detailed trust --trust_anchors=ca.pem \
  | jq '.validation_results'
{
  "activeManifest": {
    "success": [
      { "code": "signingCredential.trusted",
        "explanation": "signing certificate trusted, found in System trust anchors" },
      { "code": "claimSignature.insideValidity" },
      { "code": "claimSignature.validated" },
      { "code": "assertion.hashedURI.match" },
      { "code": "assertion.dataHash.match" }
    ],
    "informational": [
      { "code": "timeStamp.untrusted" }
    ],
    "failure": []
  }
}

failure 配列が空になり、signingCredential.trustedsuccess に記録されました。注目すべきは、同一の output.jpg に対して画像ファイル自体には一切手を加えず、検証側のトラストアンカー設定を変更しただけで判定結果が遷移した点です。これが C2PA における「検証者主体の信頼評価モデル」の本質です。

環境変数による設定

CI/CD パイプラインや常駐検証サーバーにおいては、CLI オプションの代わりに環境変数で指定する構成が有効です。

# /tmp/c2pa-trust-anchor/ をカレントにした状態で
export C2PATOOL_TRUST_ANCHORS="$(pwd)/ca.pem"
c2patool output.jpg --detailed trust \
  | jq '.validation_results.activeManifest.success[] | select(.code=="signingCredential.trusted")'
# => { "code": "signingCredential.trusted", ... }

C2PA 公式の Trust List と自前 CA を併用したい場合は、公式 PEM と自前 PEM を結合したファイルを生成し、それを --trust_anchors に指定します。

# /tmp/c2pa-trust-anchor/ にいることを確認
pwd
# => /tmp/c2pa-trust-anchor

curl -sL -o c2pa-trust-list.pem \
  https://raw.githubusercontent.com/c2pa-org/conformance-public/refs/heads/main/trust-list/C2PA-TRUST-LIST.pem
cat c2pa-trust-list.pem ca.pem > anchors.pem

# 連結できたか軽く確認(Trust List の枚数 + 自前 Root CA 1 枚になっていれば OK)
grep -c "BEGIN CERTIFICATE" anchors.pem

c2patool output.jpg --detailed trust --trust_anchors=anchors.pem \
  | jq '.validation_results'

実務運用においては、自社プライベート CA のライフサイクルを内部管理しつつ、公式 Trust List の更新を定期的に同期取得してマージする運用設計が一般的です。

想定されるユースケース

自前のプライベート CA をトラストアンカーとして組み込む構成は、以下のような実践シナリオで威力を発揮します。

  • 社内専用のワークフローやメディア管理基盤において先行導入を進めたい場合。公式 Trust List への登録審査を待たずに、検証側システム(社内 CMS やアセットレビュー環境)をプライベート CA 基準で直ちに本稼働させることが可能
  • パブリック CA から C2PA 証明書を購入・調達する前段として、PoC やステージング環境で Trusted 判定を含む E2E のパイプライン挙動を完全に検証したい場合
  • 公式 Trust List とは独立して、特定の提携パートナー企業との間でのみ相互信頼関係を結び、アセット流通の閉域検証を行いたい場合

一方で、一般の Web 空間へ広く公開し不特定多数のユーザー環境で検証させるアセットについては、各プラットフォームが保持する公式 Trust List に登録された正規 CA の証明書で署名する必要があります。

まとめ

本記事では、自前で発行した Root CA を c2patool のトラストアンカーに追加することで、同一アセットの検証判定が signingCredential.untrusted から signingCredential.trusted へと変化する実態をコードとコマンドで検証しました。要点を整理します。

  • 署名側(c2pa-rs)では、EE 証明書の EKU に C2PA Claim Signing OID(1.3.6.1.4.1.311.76.59.1.9)を正しく組み込む必要がある
  • 検証側(c2patool)では、--trust_anchors オプションまたは環境変数を用いて、信頼すべきルート認証局の PEM を動的に注入できる
  • 「信頼(Trusted)」の可否はアセット内に固定された情報ではなく、コンテンツを検証・消費する側の信頼ポリシーによって決定される

C2PA の信頼モデルは、「どの認証局を正当と認めるか」を検証システム側が主体的に決定・変更できる柔軟なアーキテクチャの上に成り立っています。この構造を理解しておくことで、社内向けプライベート CA の運用からパブリック CA による広域運用まで、要件に応じた最適なシステム設計が可能となります。

参考リンク


TechThanks は Content Credentials の実装支援に取り組んでいます。C2PA の署名基盤構築や検証パイプラインの設計、自前 CA 運用の設計までお気軽にご相談ください。