分かりにくさの原因
Verifiable Credentials(VC)の説明が分かりにくくなる原因は、たいてい1点に集約されます。証明書を「発行する」場面と「見せる」場面が、別のタイミングで、別の相手に対して起きることが飛ばされているのです。
紹介状に当てはめると、次のようになります。
① 発行 — 一度きり
例示大学 ──「この鍵の持ち主は本学の所属者だ」に署名── → 中村
② 提示 — 閲覧申請のたび、何度でも
中村 ──「私は所属者です」── → 機関C
中村 ←── 「では、これに署名してください」(毎回変わる乱数) ──
③ 検証 — 受入先の手元だけで完結
機関C ── 公開鍵を取得 ── → 静的ファイル
機関C ── 署名を確認 ──
紙の紹介状では①と②が一体でした。1枚の紙を渡して、それがそのまま提示になる。VC では分離しています。発行された証明書は自分の端末に置いたまま、提示のたびに新しい署名を作ります。
なぜ分けるのか。理由は次章以降で効いてきますが、ここでは2つだけ挙げておきます。
- 証明書を盗まれても使えない。 提示のときに毎回、本人の秘密鍵で署名する必要があるからです
- 発行元に問い合わせが発生しない。 ③で見に行くのは公開鍵だけで、大学の業務系(在籍を答える API)は関与しません。ただし鍵の置き場所は大学のドメインなので、通信が完全に消えるわけではありません。ここはこの章の最後で実測します
3者が何を持つか
| 秘密鍵 | 公開鍵 | 役割 | |
|---|---|---|---|
| 例示大学(発行者) | 大学だけが保持。渡さない | 全世界に公開 | 主張に署名して証明書を作る |
| 中村(保持者) | 本人の端末だけに保持。大学も持たない | 全世界に公開 | 提示のたびに署名する |
| 機関C(検証者) | 持たない | — | 公開鍵を取得して確認するだけ |
ここが要点です。
- 大学は所属者の秘密鍵を持ちません。 だから大学が中村になりすまして提示することはできません
- 所属者は大学の秘密鍵を持ちません。 だから自分で自分の所属証明を作ることはできません
- 検証者は鍵を一切持ちません。 公開鍵は誰が取得しても構わない情報です
鍵ペアは各人が自分のマシンで生成し、公開鍵だけを送って管理者がリポジトリに置きます。秘密鍵が一箇所に集まらないので、リポジトリ全体が漏れても署名は偽造できません。
言葉について1つ断っておきます。「証明書に署名する」と書くと、すでに署名されたものにさらに署名するように読めますが、そうではありません。署名する対象は「主張」で、署名した結果が証明書です。 順序は 主張 → 署名 → 証明書の完成 になります。
公開しているもの、していないもの
同じ「署名」でも、公開するものとしないものが分かれます。ここを混ぜると、次章以降の話が読めなくなります。
| 中身 | 公開するか | |
|---|---|---|
| DID Document | 公開鍵だけ。署名ではない | する(それが目的) |
追記ログの proof | 鍵の更新履歴に対する署名 | する |
| 資料に対する署名(第5章以降) | 資料の隣に置いて配る前提 | する |
| 所属証明の証明書 | 中村に渡すもの | しない(試作では out/ にあり配信していない) |
| 秘密鍵 | — | しない(keys/ にあり .gitignore 済み) |
署名そのものは手元のマシンで行い、秘密鍵が公開ディレクトリに入る経路はありません。
公開鍵は「認証」をしていない
よくある誤解ですが、公開鍵の置き場所に問い合わせても「この人は本物か」は分かりません。置いてあるのは公開鍵だけで、聞いているのは「この識別子に対応する鍵は何か」です。
認証が起きるのは、その鍵で署名を検証した瞬間です。
なぜ公開鍵だけで検証できるのか
ここは基礎ですが、飛ばすと以降が宙に浮くので一段落使います。
署名を作る操作には秘密鍵が要ります。これは本人にしかできません。一方、その署名が正しいかを確かめる操作は、公開鍵さえあれば誰にでもできます。楕円曲線の署名(この試作は ECDSA / P-256)では、検証側は署名の値とメッセージのハッシュから曲線上の点を計算し直し、それが公開鍵と一致するかを見るだけだからです。秘密の値は1つも要りません。
だから公開鍵は文字どおり公開してよく、検証者は秘密を1つも預からずに判定できます。上の表で検証者の秘密鍵の欄が「持たない」になっているのは、そのためです。
この判定は検証者の手元で完結します。だから配布はサーバレスの静的ファイルで足ります。データベースも認証機構も要りません。
実物を見る
試作1が生成した公開鍵の置き場所(DID Document)です。
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1"
],
"id": "did:web:nakamura196.github.io:did-log",
"verificationMethod": [
{
"id": "did:web:nakamura196.github.io:did-log#key-2",
"type": "JsonWebKey2020",
"controller": "did:web:nakamura196.github.io:did-log",
"publicKeyJwk": {
"kty": "EC",
"x": "WDqGHl1w2d-KvldGSPKlbprOU-7lErt4YhMq6RUeM2I",
"y": "7ke4ACW8Yz2Gbum59NcdHNJJuKUXN68y_1xpRO12s4I",
"crv": "P-256"
}
}
],
"authentication": ["did:web:nakamura196.github.io:did-log#key-2"],
"assertionMethod": ["did:web:nakamura196.github.io:did-log#key-2"],
"name": "Example University"
}
中身は本当にこれだけです。x と y は楕円曲線 P-256 上の点の座標で、この2つが公開鍵の実体です。authentication は「本人であることの証明に使ってよい鍵」、assertionMethod は「主張に署名するのに使ってよい鍵」で、同じ鍵を両方に挙げています。
did:web:nakamura196.github.io:did-log という識別子は、次のように URL に読み替えられます。
did:web:nakamura196.github.io:did-log
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~
ドメイン パス
→ https://nakamura196.github.io/did-log/did.json
特別なサーバは要りません。 GitHub Pages に JSON を1枚置けば動きます。
どうやって発行するか
証明書を作る手順は、実行するとこれだけです。
node scripts/03-issue.mjs nakamura
03 発行 — 例示大学 → 各所属者
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[発行] 中村 覚 宛
VC id urn:uuid:719aa6be-e579-46a0-a584-536a456934ce
名宛人 did:web:nakamura196.github.io:did-log:staff:nakamura
有効期限 2027-03-31
→ out/vc-nakamura.jwt (1200 文字)
何を入力するか
発行に要るものは5つです。
| 入力 | この例での値 | どこから来るか |
|---|---|---|
| 発行者の DID | did:web:nakamura196.github.io:did-log | 設定 |
| 発行者の秘密鍵 | keys/utokyo.private.json | 発行者だけが持つ |
どの鍵で署名したか(kid) | …#key-2 | did.json から読む |
| 名宛人の DID | …:staff:nakamura | 申請者から受け取る |
| 主張の中身 | 所属・役割・利用目的 | ここを決めるのが一番難しい |
コードにするとこうです。
const jwt = await issueVC({
issuerDid: ISSUER.did,
issuerKid, // did.json に載っている鍵の識別子
issuerPrivateJwk: issuerPriv, // 発行者の秘密鍵
subjectDid: h.did, // 名宛人
id: `urn:uuid:${crypto.randomUUID()}`,
expirationDate: '2027-03-31T14:59:59Z',
claims: {
affiliation: { id: ISSUER.did, name: ISSUER.name, nameJa: ISSUER.nameJa },
role: 'Faculty',
purpose: '他機関所蔵資料の閲覧申請',
},
});
技術的に難しいところは1つもありません。 難しいのは claims に何を書くかで、そこは規格が決めてくれない部分です。「所属」だけでよいのか、役割まで要るのか、利用目的を含めるべきか。含めた項目は提示のたびに相手に渡るので、多く書くほど利用者の情報が漏れます。
署名は3行
主張を JSON にしたら、あとは署名するだけです。実装の中核はこれだけでした。
export async function sign(header, payload, privateJwk) {
const key = await importPrivate(privateJwk);
const signingInput = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, signingInput);
return `${signingInput}.${b64u(sig)}`;
}
やっていることは、
- ヘッダと中身をそれぞれ Base64 にして、ドットで繋ぐ
- その文字列全体に秘密鍵で署名する
- 署名も Base64 にして、末尾にドットで繋ぐ
署名の対象が「ヘッダと中身を繋いだ文字列そのもの」なのが要点です。だから1文字でも書き換われば、署名は合わなくなります。
暗号の実装は使っていません。crypto.subtle は Node の標準機能です。外部パッケージはゼロで、この関数が発行のすべてです。
できあがるもの
出来上がるのは、ドットで繋がれた3つの部分です。
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDp3ZWI6bmFrYW11cmExOTYuZ2l0aHViLmlvOmRpZC1sb2cj…
└──────────────── header ────────────────┘└─ payload ─┘└─ signature ─┘
| 部分 | 長さ | 中身 |
|---|---|---|
| header | 106 文字 | {"alg":"ES256","typ":"JWT","kid":"did:web:…:did-log#key-2"} |
| payload | 1,006 文字 | 主張の本体(次の節で展開します) |
| signature | 86 文字(= 64 バイト) | 上2つを繋いだ文字列への署名 |
| ドット2つ | 2 文字 | 区切り |
| 合計 | 1,200 文字 |
kid に「どの鍵で署名したか」が入っているのが効きます。検証者はこれを見て、did.json の中から使うべき鍵を選べます。鍵を更新しても、古い証明書がどの鍵で署名されたかを取り違えません。
発行された証明書
前節の3つの部分のうち、真ん中の payload(1,006 文字)を Base64 から戻したものがこれです。header と signature はここには含まれません。
{
"iss": "did:web:nakamura196.github.io:did-log",
"sub": "did:web:nakamura196.github.io:did-log:staff:nakamura",
"jti": "urn:uuid:719aa6be-e579-46a0-a584-536a456934ce",
"nbf": 1790812800,
"exp": 1806505199,
"vc": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential", "AffiliationCredential"],
"issuer": "did:web:nakamura196.github.io:did-log",
"issuanceDate": "2026-10-01T00:00:00Z",
"expirationDate": "2027-03-31T14:59:59Z",
"credentialSubject": {
"id": "did:web:nakamura196.github.io:did-log:staff:nakamura",
"affiliation": {
"name": "Example University",
"nameJa": "例示大学"
},
"role": "Faculty",
"purpose": "他機関所蔵資料の閲覧申請"
}
}
}
いずれも payload の中の項目です。
| 項目 | 意味 |
|---|---|
iss | 誰が言っているか(発行者) |
sub | 誰についての主張か(名宛人) |
nbf / exp | 有効期間の開始と終了(UNIX 時刻) |
credentialSubject | 主張の中身 |
この JWT は、3つの部分を合わせて 1,200 文字の文字列です。 メールに貼ることも、QR コードにすることも、資料と一緒にファイルとして保存することもできます。ここが後で効いてきます。
どうやって検証するか
提示と検証は2つのコマンドに分かれます。発行と違って、こちらは申請のたびに走ります。
node scripts/04-present.mjs nakamura # 機関Cが乱数を出し、本人が署名する
node scripts/05-verify.mjs nakamura --method=webvh # 機関Cが検証する
① 機関Cが乱数を出す
const challenge = crypto.randomUUID();
writeJson(`challenge-${who}.json`, { challenge, domain: VERIFIER.domain });
これだけです。毎回違う値であることだけが要件で、意味は要りません。
② 本人が、その乱数に署名する
export async function presentVC({ holderDid, holderKid, holderPrivateJwk, vcJwt, challenge, domain }) {
const payload = {
iss: holderDid,
aud: domain, // 誰に見せるためのものか
nonce: challenge, // 機関Cが出した乱数
iat: Math.floor(Date.now() / 1000),
vp: {
type: ['VerifiablePresentation'],
holder: holderDid,
verifiableCredential: [vcJwt], // 証明書を「中に入れて」運ぶ
},
};
return sign({ alg: 'ES256', typ: 'JWT', kid: holderKid }, payload, holderPrivateJwk);
}
証明書そのものは何も書き換えません。 大学が署名したものをそのまま vp の中に入れ、その外側を本人の鍵でもう一度署名します。二重の封筒だと思うと分かりやすいと思います。
┌─ 本人が署名した封筒 (VP) ────────────┐
│ 宛先: institution-c.example.org │
│ 乱数: e5c0e4f5-… │
│ ┌─ 大学が署名した証明書 (VC) ───┐ │
│ │ 中村は本学の Faculty である │ │
│ └───────────────────────────────┘ │
└──────────────────────────────────────┘
③ 機関Cが検証する
const result = await verifyPresentation({
vpJwt,
resolveFn: makeResolver({ method }), // did:key / did:web / did:webvh を差し替える口
expectedChallenge: challenge, // 自分が出した乱数
expectedDomain: domain, // 自分の宛先
revoked, // 失効リスト
});
resolveFn を外から渡しているのが要点で、「公開鍵をどこから取ってくるか」だけが差し替え可能になっています。第3章で3方式を比べますが、変わるのはこの関数だけで、下の検証コードは1行も変わりません。
検証の中身は、外側の封筒から順に開けていくだけです。
// 1. 提示者が本人か — holder の鍵で、封筒の署名を検証
const holderDoc = (await resolveFn(holderDid)).didDocument;
await verify(vpJwt, findKey(holderDoc, header.kid).jwk);
// 2. 乱数と宛先が自分のものか
need(vp.nonce === expectedChallenge, 'challenge の一致');
need(vp.aud === expectedDomain, '宛先の一致');
// 3. 中の証明書が本当に発行者のものか — 発行時点の鍵を取りに行く
const resolved = await resolveFn(issuerDid, { at: vc.issuanceDate });
await verify(vcJwt, findKey(resolved.didDocument, vcHeader.kid).jwk);
// 4. 証明書の名宛人と、いま提示してきた人が同一か
need(vc.credentialSubject.id === holderDid, 'VCの名宛人と提示者の一致');
// 5. 期限と失効
need(!vc.exp || nowSec < vc.exp, '有効期限');
need(!revoked.includes(vc.jti), '失効リスト');
{ at: vc.issuanceDate } が地味に効きます。 「いまの鍵」ではなく「発行当時の鍵」を取りに行くので、大学が鍵を更新しても過去の証明書が生き続けます。第3章の did:webvh はこのためにあります。
実行結果
機関Cの側で検証したときの出力です。
05 検証 — 機関C が 中村 覚 の提示を検証 (発行者を webvh で解決)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✔ 提示者の署名 did:web:…:staff:nakamura の鍵で検証OK
✔ challenge の一致 期待 e5c0e4f5-ea90-44e6-bf2c-7204637526f1 /
実際 e5c0e4f5-ea90-44e6-bf2c-7204637526f1
✔ 宛先の一致 institution-c.example.org
✔ 発行者の署名 did:web:nakamura196.github.io:did-log の鍵で検証OK
✔ VCの名宛人と提示者の一致 did:web:…:staff:nakamura
✔ 有効期限 2027-03-31T14:59:59Z
✔ 失効リスト 該当なし
判定: 受理 例示大学 の Faculty / 有効期限 2027-03-31T14:59:59Z
発行者へ照会していない。鍵は一度取得すればキャッシュでき、以後は発行者が落ちていても検証できる。
ただし鍵の取得自体は発行者のサーバへの通信なので、毎回取りに行くと
「誰の資格情報を検証したか」は知られ得る。
7項目を確認しています。それぞれが何を防いでいるかを見ておきます。
提示者の署名 — 機関Cが出した乱数 e5c0e4f5-… に、中村の秘密鍵で署名させています。証明書を盗んだだけの人はこの署名を作れません。
challenge の一致 — 乱数は申請のたびに変わります。過去の提示を録画して再生する攻撃(リプレイ攻撃)を防ぎます。
宛先の一致 — 機関C宛の提示を、別の機関に転送して使い回すことを防ぎます。
発行者の署名 — 証明書の中身が1文字でも書き換わっていれば、ここで落ちます。
VC の名宛人と提示者の一致 — 他人宛の証明書を拾ってきて自分の署名を添える、という手を防ぎます。
有効期限と失効リスト — 期限切れと、取り消された証明書を弾きます。
何が変わったか
冒頭の2つの性質に戻ります。
受入先は大学に問い合わせていません。 見に行ったのは公開鍵だけで、しかもそれは静的ファイルなのでキャッシュできます。大学のサーバが落ちていても、夜間でも、同じ結果になります。極端に言えば、公開鍵さえ手元か第三者のところに残っていれば、大学が消滅した後でも検証は成立します。
もうひとつの性質(大学は誰がどこに申請したかを知らない)は、このままでは成立しません。 当初この本にはそう書いていましたが、実測したところ誤りでした。
検証が実際にどこへ通信するかを記録すると、こうなります。
GET https://nakamura196.github.io/did-log/staff/nakamura/did.json
GET https://nakamura196.github.io/did-log/did.jsonl
1つめは保持者の公開鍵で、パスに保持者の名前が入っています。2つめは発行者の追記ログです。どちらも大学のサーバにあるので、大学のアクセスログには「機関Cの IP から、中村の証明書を検証しに来た」が残ります。誰の証明書かまで分かってしまいます。
原因は VC ではなく、この試作の作りです。VC のデータモデルは「発行者の公開鍵をどう入手するか」を定めていません。鍵が手元にあれば検証は完全にオフラインで完結します。通信が起きているのは、did:web を選び、しかも検証のたびに取りに行く実装にしたからです(lib/vc.mjs の verifyPresentation() が保持者と発行者の DID Document を順に解決します)。
手当ての方法はあります。
- 鍵をキャッシュする — 鍵は滅多に変わらないので、一度取って長期間使い回せば検証のたびの通信は消えます
did:keyを使う — 識別子そのものが鍵なので、取得自体が発生しません(次章)- 保持者が発行者の DID Document ごと持ち歩く — 証明書に同梱する形
- 失効の確認も同じ問題を持ちます。だから Bitstring Status List は、多数の証明書の失効状態を1本のビット列にまとめる設計になっています。「誰の失効を見たか」を隠すためで、規格がこの問題を意識している証拠でもあります
現行の照会方式と比べると位置づけがはっきりします。電話やメールでの照会では、大学は問い合わせを確実に知ります。毎回取りに行く今回の実装では知り得ます。キャッシュするか did:key を使えば知りません。つまり最悪でも現状と同等で、設計次第で改善できる、という関係です。
正確に言えば、プライバシーは VC を採用すれば自動的に手に入るものではなく、鍵の入手経路まで設計して初めて手に入るものでした。「問い合わせが要らない」と「一切通信しない」は別で、前者は成立していますが、後者は設計と運用の結果です。
VC が「提示」というモデルを採る意義はここにあります。ただしそれは、紙の紹介状が持っていたプライバシーを失わずに済む余地があるということであって、放っておいて保たれるものではありません。
次章では、この「公開鍵をどこに置くか」をもう少し掘ります。ここに、この本の後半につながる問題が隠れています。