お断り: 本記事は C2PA Technical Specification v2.3(2026年4月時点)と c2pa-rs・c2patool のソースコードを筆者が読解して整理したものです。SDK のバージョンアップにより API や出力形式が変わることがあります。実装や設計判断の根拠として用いる際は、必ずC2PA 公式仕様および各 SDK の最新ドキュメントをご確認ください。本記事に誤りや古くなった箇所を見つけられた場合は、記事末尾のフィードバック枠よりお知らせいただけると助かります。
はじめに
C2PA における「バインディング(binding)」は、Manifest と当該コンテンツを紐付ける仕組みのことを指します。仕様書では大きく Hard Bindings と Soft Bindings の 2 系統に分けて議論されていて、Manifest 必須の hard binding assertion が「コンテンツの正確な同一性」を保証する一方で、soft binding assertion はリフローや再エンコードを経たあとでも対応する Manifest を引けるようにする補完的な役割を担います。
本記事は、シリーズ「C2PA 実装入門」 の応用編・ハードバインディング編です。後編にあたるソフトバインディング実装(Adobe Trustmark を使った透かし+c2pa.soft_binding assertion の付与)は ソフトバインディングを Adobe Trustmark で実装する をご覧ください。
Hard Binding と Soft Binding は何が違うか
両者の役割を一覧で並べておきます。
| 観点 | Hard Binding | Soft Binding |
|---|---|---|
| 目的 | バイナリ単位で Manifest と完全一致するコンテンツであることを保証 | コンテンツが変換・再エンコードされても Manifest を再発見できるようにする |
| 代表的な assertion ラベル | c2pa.hash.data / c2pa.hash.boxes / c2pa.hash.bmff | c2pa.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 Repository への問い合わせで来歴を辿れるようにする
C2PA は Manifest をファイル内に埋め込むのが基本路線ですが、SNS にアップロードされる過程でメタデータが落とされることは珍しくありません。soft binding と Manifest Repository(外部に Manifest を保存しておく仕組み)を組み合わせれば、メタデータが剥がれた配信物からでも来歴を復元できる可能性が出てきます。
ハードバインディングの実装
c2pa-rs と c2patool は、署名のときに hard binding assertion をこっそり差し込んでくれます。こちらが何も書かなくても、SDK が対象ファイルの形式に合わせてアルゴリズムを選び、ハッシュまで一緒に計算してくれます(c2pa.hash.data / c2pa.hash.bmff / c2pa.hash.boxes の選択ロジックは当該実装を参照)。第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)が同じであれば、署名を何度やり直しても hash と start は同じ値になります。ハッシュ対象は「exclusions を除いたバイト列」、つまり元の JPEG の画像データ部分そのものなので、入力が変わらなければ結果も変わらない、というカラクリです。署名鍵・証明書・タイムスタンプ・Claim UUID はすべて Manifest JUMBF 内 = exclusions の中に閉じているので、EphemeralSigner が毎回違う鍵を生成してもハッシュ値には影響しない設計になっています。start も JPEG 内の Manifest JUMBF ボックスの挿入位置(APP11 マーカー周辺)が構造的に決まるので同じ位置です。一方の length は Manifest 内のタイムスタンプや Ephemeral 証明書の情報によって CBOR のバイト長が前後することがあり、署名ごとに数バイト揺れる値です。hash_alg を sha256 から sha384 などに切り替えれば、当然 hash の値(長さと内容)は別物になります。
検証結果でハードバインディングの成立を確認する
c2patool の validation_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.mismatch が validation_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 の構築、検証パイプラインの実装まで含めた相談がありましたら、お気軽にお問い合わせください。