お断り: 本記事は C2PA Technical Specification v2.3(2026年4月時点)と c2pa-rsc2patool のソースコードを筆者が読解して整理したものです。SDK のバージョンアップにより API や出力形式が変わることがあります。実装や設計判断の根拠として用いる際は、必ずC2PA 公式仕様および各 SDK の最新ドキュメントをご確認ください。本記事に誤りや古くなった箇所を見つけられた場合は、記事末尾のフィードバック枠よりお知らせいただけると助かります。

はじめに

C2PA における「バインディング(binding)」とは、Manifest と当該コンテンツを紐付ける仕組みのことです。仕様書では大きく Hard BindingsSoft Bindings の二系統に分けて議論されており、Manifest に必須な hard binding assertion がコンテンツの「正確な同一性」を保証する一方、soft binding assertion はリフローや再エンコードを経た後でも対応する Manifest を引けるようにする補完的な役割を担います。

本記事は、シリーズ「C2PA 実装入門」 の応用編・ハードバインディング編です。後編にあたるソフトバインディング実装(Adobe Trustmark を使った透かし+c2pa.soft_binding assertion の付与)は ソフトバインディングを Adobe Trustmark で実装する をご覧ください。

Hard Binding と Soft Binding は何が違うか

両者の役割を一覧で整理します。

観点Hard BindingSoft Binding
目的バイナリ単位で Manifest と完全一致するコンテンツであることを保証コンテンツが変換・再エンコードされても Manifest を再発見できるようにする
代表的な assertion ラベルc2pa.hash.data / c2pa.hash.boxes / c2pa.hash.bmffc2pa.soft_binding
アルゴリズムSHA-256 / SHA-384 / SHA-512 などの暗号学的ハッシュ知覚ハッシュ、コンテンツフィンガープリント、電子透かしなど
失敗時の意味コンテンツが Manifest と一致しない(改ざんまたは別ファイル)直接の対応は取れない(あるいは候補が見つからない)
必須性Manifest に1つ必要(Section 11 参照)任意

ハードバインディングは「いま手元にあるバイト列がこの Manifest が署名した対象と完全に同じか」を判定します。1ビットでも異なればハッシュは一致しません。一方ソフトバインディングは、画像をリサイズしたり、動画を別コーデックに再エンコードしたりしても、コンテンツ識別子(指紋や透かし)から元の Manifest を辿れるようにする仕組みです。

仕様書では soft binding を「the relationship between an asset and its manifest that allows the recovery of the manifest from a transformed version of the asset」と説明しています(Section 18)。

どちらをいつ使うか

実運用では片方ではなく、目的に応じて組み合わせて使います。

  • 配信前後でコンテンツが変わらないユースケース(社内アーカイブ、原版管理、原稿の正本性確認など): hard binding だけで十分
  • SNS や CDN 経由で再エンコードされる前提のユースケース(メディア配信、報道写真、生成 AI 画像の出所表示など): hard binding に加えて soft binding を併用し、変換後でも「Manifest Recovery」 で来歴を辿れるようにする

C2PA は Manifest をファイル内に埋め込むのが基本ですが、SNS にアップロードされる過程でメタデータが除去されることは珍しくありません。soft binding と Manifest Repository(外部に Manifest を保存しておく仕組み)を組み合わせると、メタデータが剥がれた配信物からも来歴を復元できる可能性が出てきます。

ハードバインディングの実装

c2pa-rs と c2patool では、署名時に hard binding assertion が自動的に付与されます。明示的に書かなくても、SDK が対象ファイルの形式に応じて適切なアルゴリズム(多くの画像で c2pa.hash.data、BMFF 系コンテナで c2pa.hash.bmff)を選んでハッシュを計算してくれます。第5回で使った EphemeralSigner をそのまま流用し、最小構成の Rust プロジェクトでハッシュ値が実際にどう入るかを確認します。

プロジェクトのセットアップ

Rust と c2patool がインストールされている前提で、一時ディレクトリから始めます。

cd /tmp
mkdir -p c2pa-hard-binding && cd c2pa-hard-binding
cargo init .
cargo add c2pa --features file_io

# サンプル画像をダウンロード(NASA アポロ17号撮影、パブリックドメイン)
curl -sL -o input.jpg https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/earth_apollo17.jpg

最小限の署名コード

第5回と同じく、テスト用証明書は EphemeralSigner が自動生成します。OpenSSL の事前準備は不要です。

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

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

    let signer = EphemeralSigner::new("C2PA Hard Binding Demo")?;
    builder.sign_file(&signer, "input.jpg", "output.jpg")?;
    println!("署名完了: output.jpg");
    Ok(())
}

定義 JSON に書いた assertion は c2pa.actions.v2 だけで、ハッシュ系 assertion は一切含めていません。ハードバインディングは SDK が内部で自動付与することを確認するのが目的です。

rm -f output.jpg
cargo run
# => 署名完了: output.jpg

自動付与されたハッシュを取り出す

c2patool を通常の引数で呼ぶと、c2pa.hash.* 系の内部 assertion は一覧に現れません。詳細モード -d を使うと assertion_store が展開され、その中に c2pa.hash.data が登場します。

c2patool -d output.jpg | jq '.manifests[].assertion_store["c2pa.hash.data"]'

実際の出力(筆者の環境で再現したもの):

{
  "exclusions": [
    {
      "start": 9964,
      "length": 13196
    }
  ],
  "name": "jumbf manifest",
  "alg": "sha256",
  "hash": "t//SV50JIargwLLt889e8iYhsH8n18VqhWoJ5UKHea0=",
  "pad": "AAAAAAAAAAAA"
}

exclusions は「ハッシュ計算から除外するバイト範囲」で、Manifest 自身が埋め込まれる JUMBF 領域がそこに該当します。Manifest を含めてハッシュを取ると無限ループになるため、署名対象から自分自身の領域を外す設計になっています。この仕組みは仕様書 Section 18.5 “Data Hash” で定義されています。この例では先頭 9,964 バイト目から 13,196 バイトにわたる JUMBF ボックスが除外対象です。

pad(および必要に応じて pad2)は、c2pa.hash.data assertion 自体の CBOR バイナリ長を事前に確保した領域サイズへ合わせるためのゼロ埋めパディングです。Manifest 全体は署名前に JUMBF の領域サイズが決まっており、その中の各 assertion も固定長で配置されるため、ハッシュ計算結果の長さが変わっても全体サイズを変えないよう assertion 末尾に pad を詰めて調整します。c2pa-rs 側の実装は DataHash::pad_to_size にあり、pad だけで収まらない場合は pad2 へ分割する処理まで含まれています。

検証時には、この exclusions を除いた残りバイト列に対して alg で指定されたハッシュ(ここでは SHA-256)を再計算し、hash と一致するかを確認します。1バイトでも異なれば検証失敗です。

なお、入力画像とハッシュアルゴリズム(hash_alg、既定は sha256)が同じであれば、署名を何度やり直しても hashstart は同じ値になります。ハッシュ対象は「exclusions を除いたバイト列」、つまり元の JPEG の画像データ部分そのもので、入力が変わらなければ結果も変わりません。署名鍵・証明書・タイムスタンプ・Claim UUID はすべて Manifest JUMBF 内 = exclusions の中に閉じているので、EphemeralSigner が毎回違う鍵を生成してもハッシュ値には影響しない設計です。start も JPEG 内の Manifest JUMBF ボックスの挿入位置(APP11 マーカー周辺)が構造的に決まるため同一になります。一方、length は Manifest 内のタイムスタンプや Ephemeral 証明書の情報により CBOR のバイト長が前後することがあり、署名ごとに数バイト変動しうる値です。hash_algsha256 から sha384 などに切り替えれば、当然 hash の値(長さと内容)は別物になります。

検証結果でハードバインディングの成立を確認する

c2patoolvalidation_results を見ると、ハッシュ検証が通ったことが success 側に記録されます。

c2patool output.jpg | jq '.validation_results.activeManifest.success[] | select(.code | startswith("assertion."))'

出力:

{
  "code": "assertion.hashedURI.match",
  "url": "self#jumbf=/c2pa/urn:c2pa:.../c2pa.assertions/c2pa.actions.v2",
  "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
}
{
  "code": "assertion.hashedURI.match",
  "url": "self#jumbf=/c2pa/urn:c2pa:.../c2pa.assertions/c2pa.hash.data",
  "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
}
{
  "code": "assertion.dataHash.match",
  "url": "self#jumbf=/c2pa/urn:c2pa:.../c2pa.assertions/c2pa.hash.data",
  "explanation": "data hash valid"
}

assertion.dataHash.match が「data hash valid」を返していれば、exclusions を除いたバイト列の SHA-256 が Manifest 内の hash と一致したことを意味します。試しに署名済み output.jpg を複製して、exclusions の外側(今回の例では 9,964〜23,159 バイト目以外)を 1 バイトだけ書き換えてから再検証してみます。

cp output.jpg tampered.jpg
printf '\xFF' | dd of=tampered.jpg bs=1 seek=50000 count=1 conv=notrunc
c2patool tampered.jpg | jq '.validation_status'

出力:

[
  {
    "code": "signingCredential.untrusted",
    "url": "self#jumbf=/c2pa/urn:c2pa:.../c2pa.signature",
    "explanation": "signing certificate untrusted"
  },
  {
    "code": "assertion.dataHash.mismatch",
    "url": "self#jumbf=/c2pa/urn:c2pa:.../c2pa.assertions/c2pa.hash.data",
    "explanation": "asset hash error, name: jumbf manifest, error: hash verification( Hashes do not match )"
  }
]

先ほどまで success 側にあった assertion.dataHash.match が消え、代わりに assertion.dataHash.mismatchvalidation_status に現れました。たった 1 バイトの改変でも検知できる、というのがハードバインディングの強みです(signingCredential.untrusted は EphemeralSigner のテスト用証明書が Trust List に載っていないことの注意喚起で、ハッシュ検証とは独立です)。

まとめ

ハードバインディングは Manifest 必須の要素で、c2pa-rs の Builder が自動付与してくれるため利用者側の追加作業はほぼありません。それでも内部では、JUMBF 領域を exclusions に登録し、残りのバイト列を hash_alg でハッシュ化して c2pa.hash.data assertion を固定サイズに詰める、という一連の手順がきっちり走っています。バイト単位の完全一致を要求する仕組みなので、配信経路で再エンコードやリサイズが入ると当然壊れる。ここまでが前編です。

再エンコード後でも Manifest を辿れるようにするための補完策がソフトバインディングです。続く後編ソフトバインディングを Adobe Trustmark で実装する では、C2PA 公認レジストリに登録された Adobe Trustmark を使って画像に透かしを焼き付け、c2pa.soft_binding assertion として Manifest に記録する流れを、Python のハンズオンで体験します。

TechThanks では、生成 AI 由来コンテンツやメディア配信における C2PA 導入の支援を行っています。Hard / Soft Binding の組み合わせ設計、Manifest Repository の構築、検証パイプラインの実装まで含めた相談がありましたら、お気軽にお問い合わせください。