分かりにくさの原因

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"
}

中身は本当にこれだけです。xy は楕円曲線 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つです。

入力この例での値どこから来るか
発行者の DIDdid:web:nakamura196.github.io:did-log設定
発行者の秘密鍵keys/utokyo.private.json発行者だけが持つ
どの鍵で署名したか(kid…#key-2did.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)}`;
}

やっていることは、

  1. ヘッダと中身をそれぞれ Base64 にして、ドットで繋ぐ
  2. その文字列全体に秘密鍵で署名する
  3. 署名も Base64 にして、末尾にドットで繋ぐ

署名の対象が「ヘッダと中身を繋いだ文字列そのもの」なのが要点です。だから1文字でも書き換われば、署名は合わなくなります。

暗号の実装は使っていません。crypto.subtle は Node の標準機能です。外部パッケージはゼロで、この関数が発行のすべてです。

できあがるもの

出来上がるのは、ドットで繋がれた3つの部分です。

eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDp3ZWI6bmFrYW11cmExOTYuZ2l0aHViLmlvOmRpZC1sb2cj…
└──────────────── header ────────────────┘└─ payload ─┘└─ signature ─┘
部分長さ中身
header106 文字{"alg":"ES256","typ":"JWT","kid":"did:web:…:did-log#key-2"}
payload1,006 文字主張の本体(次の節で展開します)
signature86 文字(= 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.mjsverifyPresentation() が保持者と発行者の DID Document を順に解決します)。

手当ての方法はあります。

  • 鍵をキャッシュする — 鍵は滅多に変わらないので、一度取って長期間使い回せば検証のたびの通信は消えます
  • did:key を使う — 識別子そのものが鍵なので、取得自体が発生しません(次章)
  • 保持者が発行者の DID Document ごと持ち歩く — 証明書に同梱する形
  • 失効の確認も同じ問題を持ちます。だから Bitstring Status List は、多数の証明書の失効状態を1本のビット列にまとめる設計になっています。「誰の失効を見たか」を隠すためで、規格がこの問題を意識している証拠でもあります

現行の照会方式と比べると位置づけがはっきりします。電話やメールでの照会では、大学は問い合わせを確実に知ります。毎回取りに行く今回の実装では知り得ます。キャッシュするか did:key を使えば知りません。つまり最悪でも現状と同等で、設計次第で改善できる、という関係です。

正確に言えば、プライバシーは VC を採用すれば自動的に手に入るものではなく、鍵の入手経路まで設計して初めて手に入るものでした。「問い合わせが要らない」と「一切通信しない」は別で、前者は成立していますが、後者は設計と運用の結果です。

VC が「提示」というモデルを採る意義はここにあります。ただしそれは、紙の紹介状が持っていたプライバシーを失わずに済む余地があるということであって、放っておいて保たれるものではありません。

次章では、この「公開鍵をどこに置くか」をもう少し掘ります。ここに、この本の後半につながる問題が隠れています。