本文へスキップ
ecdsa.com
← すべての記事

SD-JWT と SD-JWT VC:EUDI ウォレットの選択的開示

署名付き JWT では、それを目にする誰にでもすべてのクレームが見えてしまいます。SD-JWT を使うと、クレデンシャルの保有者は検証者が必要とするクレームだけを開示でき、それでも発行者の署名はすべてのクレームを対象にしています。この記事では、その仕組みをバイト単位で追い、ライブラリと照合済みの動作する発行者・ウォレット・検証者のコードを示し、EU デジタル ID ウォレットの中での位置付けを説明します。

要点:SD-JWT(RFC 9901、2025年11月)は、署名付き JWT の中の秘匿可能なクレームを、1 つずつディスクロージャーのハッシュ値に置き換えます。ディスクロージャーとは、ランダムなソルト、クレーム名、その値からなる小さな JSON 配列を base64url でエンコードしたものです。発行者はハッシュ値に署名し、保有者は後で、署名付き JWT と、自分が選んだディスクロージャーだけを ~ でつないで送ります。検証者はハッシュ値を計算し直し、それぞれが署名付きペイロードに含まれていることを確認します。キーバインディング JWT(typ: kb+jwt)は、クレデンシャルで指定された鍵で署名され、プレゼンテーションを特定の検証者(aud)、特定のリクエスト(nonce)、そして提示したディスクロージャーの組そのもの(sd_hash)に結び付けます。SD-JWT VC はクレデンシャルの層を追加します。typ: dc+sd-jwt、vct に入るクレデンシャルタイプ、ステータス、タイプメタデータです。SD-JWT VC はまだインターネットドラフトですが、すべての EU デジタル ID ウォレットがサポートしなければならない 2 つのクレデンシャル形式の 1 つで、もう 1 つは ISO/IEC 18013-5 の mdoc です。

ディスクロージャーとダイジェスト

オブジェクトのプロパティのディスクロージャーは配列 [salt, name, value]、配列要素のディスクロージャーは [salt, value] です(RFC 9901 §4.2)。これを UTF-8 の JSON として base64url でエンコードします。ダイジェストは JSON に対してではなく、そのエンコード済みの文字列に対して計算した SHA-256 を base64url で表したものです(§4.2.3)。ハッシュは発行者が選んだバイト列そのものを対象にするため、JSON の正規化は必要ありません。同じクレームでも空白の入れ方が違えば別のディスクロージャーになり、ダイジェストも変わります。この 2 点を RFC 自身の例で確認した結果は次のとおりです。

出力:RFC 9901 §4.2.3 の例(1 行目が RFC の値と一致すること)
RFC 9901 §4.2.3 digest:   X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces:    TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM

発行者はダイジェストをペイロード内の _sd 配列に入れ、順序から何もわからないようにソートまたはシャッフルします。さらに、隠したクレームの数が漏れないよう、どのディスクロージャーにも一致しないデコイのダイジェストを追加できます(§4.2.5)。_sd_alg はハッシュ関数を示し、SHA-256 がデフォルトで、実装必須です。ソルトがあるため、ダイジェストは推測できません。ソルトがなければ、検証者はありうるすべての日付について ["birthdate", "1984-01-26"] のハッシュ値を計算し、一致するものを見つけられてしまいます。RFC 9901 は、ソルトごとに 128 ビット以上のランダム値を使い、クレームごとに独立して選ぶことを推奨しています(§9.3)。

伝送時の SD-JWT は <issuer-signed JWT>~<disclosure>~…~<disclosure>~ という形で、キーバインディング付きのプレゼンテーションでは最後の空の部分がキーバインディング JWT に置き換わり、…~<disclosure>~<KB-JWT> となります(§4)。検証者は、末尾の ~ で両者を区別します。

発行者・ウォレット・検証者を 76 行で実装

次のモジュールは、秘匿可能なクレームがすべてトップレベルのプロパティであるフラットなケースを実装しています。すべてのルールの動きを確認するには、これで十分です。入れ子のディスクロージャーや再帰的なディスクロージャーも、内側のオブジェクトに対して同じ手順をたどります(§6)。

sd-jwt.mjs(jose 6.2.13、Node.js 24.22)
import { createHash, randomBytes } from "node:crypto";
import { SignJWT, calculateJwkThumbprint, decodeJwt, importJWK, jwtVerify } from "jose";

const b64url = (text) => Buffer.from(text, "utf8").toString("base64url");
// RFC 9901 §4.2.3: SHA-256 over the ASCII of the base64url Disclosure, then base64url.
export const digest = (disclosure) => createHash("sha256").update(disclosure, "ascii").digest("base64url");
const salt = () => randomBytes(16).toString("base64url"); // 128 bits (§9.3)

// --- Issuer ------------------------------------------------------------------
/** claims: always-visible claims; hidden: claims to make selectively disclosable. */
export async function issue({ issuerKey, holderJwk, claims, hidden, decoys = 2 }) {
  const disclosures = [];
  const sd = [];
  for (const [name, value] of Object.entries(hidden)) {
    const d = b64url(JSON.stringify([salt(), name, value]));
    disclosures.push(d);
    sd.push(digest(d));
  }
  for (let i = 0; i < decoys; i++) sd.push(digest(salt())); // decoy digests (§4.2.5)
  sd.sort(); // order must not reveal which claim is which
  const jwt = await new SignJWT({ ...claims, _sd: sd, _sd_alg: "sha-256", cnf: { jwk: holderJwk } })
    .setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt", kid: "issuer-2026-10" })
    .sign(issuerKey);
  return `${jwt}~${disclosures.join("~")}~`;
}

// --- Holder ------------------------------------------------------------------
export async function present({ sdJwt, reveal, holderKey, aud, nonce }) {
  const [jwt, ...rest] = sdJwt.split("~");
  const chosen = rest.filter(Boolean).filter((d) => reveal.includes(JSON.parse(Buffer.from(d, "base64url"))[1]));
  const presented = `${[jwt, ...chosen].join("~")}~`;
  const kb = await new SignJWT({ aud, nonce, sd_hash: digest(presented) }) // sd_hash: §4.3.1
    .setProtectedHeader({ alg: "ES256", typ: "kb+jwt" })
    .setIssuedAt()
    .sign(holderKey);
  return presented + kb;
}

// --- Verifier ----------------------------------------------------------------
export async function verify(presentation, { issuerJwks, issuer, vct, aud, nonce, maxKbAge = 60 }) {
  const parts = presentation.split("~");
  const kbJwt = parts.pop(); // "" when there is no Key Binding JWT
  if (!kbJwt) throw new Error("key binding required, none presented"); // §7.3 step 2
  const [jwt, ...disclosures] = parts;

  // §7.1 step 2: issuer signature, typ, algorithm, issuer, hash algorithm.
  const { payload } = await jwtVerify(jwt, issuerJwks, { algorithms: ["ES256"], typ: "dc+sd-jwt", issuer });
  if (payload._sd_alg !== "sha-256") throw new Error(`unsupported _sd_alg ${payload._sd_alg}`);
  if (payload.vct !== vct) throw new Error(`unexpected vct ${payload.vct}`);

  // §7.1 steps 3-5 (top-level object properties only, which is all this issuer emits).
  const byDigest = new Map(disclosures.map((d) => [digest(d), d]));
  if (byDigest.size !== disclosures.length) throw new Error("duplicate disclosure");
  if (new Set(payload._sd).size !== payload._sd.length) throw new Error("digest appears twice");
  const { _sd, _sd_alg, ...claims } = payload;
  for (const sdDigest of _sd) {
    const d = byDigest.get(sdDigest);
    if (!d) continue; // undisclosed claim or decoy
    byDigest.delete(sdDigest);
    const decoded = JSON.parse(Buffer.from(d, "base64url"));
    if (!Array.isArray(decoded) || decoded.length !== 3) throw new Error("not an object-property disclosure");
    const [, name, value] = decoded;
    if (name === "_sd" || name === "..." || name in claims) throw new Error(`claim name ${name} not allowed`);
    claims[name] = value;
  }
  if (byDigest.size > 0) throw new Error("disclosure not referenced by the issuer-signed JWT"); // step 5

  // §7.3 step 5: Key Binding JWT signed by the key in cnf, fresh, for us, over exactly this presentation.
  const holderKey = await importJWK(payload.cnf.jwk, "ES256");
  const { payload: kb } = await jwtVerify(kbJwt, holderKey, { algorithms: ["ES256"], typ: "kb+jwt", audience: aud, maxTokenAge: maxKbAge });
  if (kb.nonce !== nonce) throw new Error("key binding nonce mismatch");
  if (kb.sd_hash !== digest(`${parts.join("~")}~`)) throw new Error("sd_hash does not match the presented SD-JWT");
  return { claims, holderThumbprint: await calculateJwkThumbprint(payload.cnf.jwk) };
}

export const peek = (jwt) => decodeJwt(jwt);

検証者のルールは、RFC 9901 §7.1 と §7.3 に一対一で対応しています。具体的には、none を拒否し、発行者とその鍵を確認し、_sd_alg を解釈できることを確かめ、各ディスクロージャーをちょうど 1 つのダイジェストに対応付けます。_sd や ... という名前のディスクロージャーと、可視のクレームと衝突するディスクロージャーは拒否し、2 回現れるダイジェストと、どこからも参照されないディスクロージャーも拒否します。最後に、キーバインディング JWT について、cnf の鍵による署名、typ、経過時間、aud、nonce、sd_hash を確認します。§7.3 の手順 1 に注意してください。キーバインディングを必須とするかどうかは検証者のポリシーであり、保有者がたまたま KB-JWT を送ってきたかどうかで決まるものでは決してありません。

実行結果とライブラリとの照合

実行用スクリプトは、5 つの秘匿可能なクレーム(名、姓、生年月日、住所、年齢のしきい値)を持つ身元証明のクレデンシャルを発行し、年齢のしきい値だけをショップに提示します。そのプレゼンテーションを上のモジュールと、OpenWallet Foundation の @sd-jwt/sd-jwt-vc 0.23.0 の両方で検証し、さらに両者に対して 6 種類の改ざんを試します。

出力(node:24.22.0-bookworm-slim)
RFC 9901 §4.2.3 digest:   X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces:    TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
issued SD-JWT: 1438 chars, 5 disclosures, _sd has 7 digests (2 decoys)
presentation: 1385 chars, 1 disclosure
hand-written verifier: {"iss":"https://issuer.example","vct":"https://credentials.example/identity_credential","iat":1792499582,"exp":1824035582,"age_equal_or_over":{"18":true,"21":true,"65":false}}
@sd-jwt/sd-jwt-vc: age_equal_or_over {"18":true,"21":true,"65":false}, given_name undefined, kb verified
disclosure with a changed value        | disclosure not referenced by the issuer-signed JWT | Unreferenced disclosure(s) detected in SD-JWT
same presentation, another verifier    | unexpected "aud" claim value | Verify Error: Invalid Key Binding audience
same presentation, a new nonce         | key binding nonce mismatch | Verify Error: Invalid Nonce
extra disclosure appended              | sd_hash does not match the presented SD-JWT | Invalid sd_hash in Key Binding JWT
key binding JWT removed                | key binding required, none presented | Key Binding JWT not exist
key binding by another key             | signature verification failed | Verify Error: Invalid JWT Signature

両方の検証者が、すべてのケースで同じ結果を返しています。各行からわかることは次のとおりです。

  • 選択的開示が機能している:ショップは age_equal_or_over を受け取りました。given_name のディスクロージャーは送信されていないため、ライブラリの結果では undefined になっています。両方の検証者が同じクレームを復元しました。
  • 保有者はクレームを書き換えられない:年齢のディスクロージャーを変更すると、発行者が署名していないダイジェストになりました。
  • プレゼンテーションは特定の検証者の特定のリクエスト専用:別のオーディエンスにリプレイしても、古い nonce で応答しても、検証に失敗しました。
  • sd_hash が選択を固定する:ウォレットがキーバインディング JWT に署名した後でディスクロージャーを追加すると、検証に失敗しました。
  • ウォレットの鍵がなければクレデンシャルは使えない(検証者がキーバインディングを必須にしている場合):キーバインディング JWT を取り除いたものも、別の鍵で署名し直したものも拒否されました。

もう 1 つ、チェックではなく設計上の教訓として、この実行結果からわかることがあります。ショップが尋ねたのは「18 歳以上か」だけなのに、「65 歳以上ではない」ことまで知ってしまいました。発行者がすべてのしきい値を 1 つのディスクロージャーにまとめたためです。粒度は発行時に決まります。RFC 9901 §6.2 は、address オブジェクトを例に構造化された形式を示しています。各プロパティがそれぞれ独自のディスクロージャーを持ち、オブジェクトにはプロパティごとのダイジェストを並べた _sd 配列が 1 つ置かれます。この方法で発行していれば、ウォレットは age_equal_or_over のうち "18": true だけを開示できます。

選択的開示で隠せないもの

選択的開示が隠すのは値であり、クレデンシャルそのものではありません。1 つの SD-JWT から作ったプレゼンテーションには、どれにも同じ発行者署名、同じ _sd ダイジェスト、同じ cnf の鍵が含まれます。そのため、2 つの検証者が情報を突き合わせれば、一方が年齢だけ、もう一方が郵便番号だけを受け取っていたとしても、同じクレデンシャルを見たことがわかります。RFC 9901 §10.1 はこの点をはっきり述べています。この形式は「匿名クレデンシャルのセキュリティ特性を満たさない」ものであり、検証者からプレゼンテーションを受け取った発行者は、自分が発行したクレデンシャルを必ず識別できます。同じソルト付きハッシュ方式を使う mdoc にも、同じことが当てはまります。

RFC が示す対策はバッチ発行です。発行者は同じクレームを持つ複数のクレデンシャルをウォレットに渡し、ウォレットは検証者ごと、さらにはセッションごとに別のクレデンシャルを使います。これが機能するには、バッチ内の各クレデンシャルが独自のソルトと独自のキーバインディング用の鍵を持つ必要があり、iat や exp などの時刻のクレームはランダム化するか丸めなければなりません。正確なタイムスタンプは、シリアル番号と同じくらい紐付けの手がかりになるからです。バッチ発行は検証者どうしによるプレゼンテーションの紐付けを防ぎますが、発行者自身からプレゼンテーションを隠すことはできません。ステータスの確認も、関連する情報漏れの原因になります。あるクレデンシャルについて発行者に問い合わせる検証者は、そのクレデンシャルがどこで使われているかを発行者に伝えてしまいます。そのため、トークンステータスリストでは、検証者が 1 つのクレデンシャルについて問い合わせる代わりに、多数のクレデンシャルをまとめた圧縮済みのリストをダウンロードできるようになっています。

SD-JWT VC が追加するもの

SD-JWT はコンテナー形式です。SD-JWT ベースの検証可能なクレデンシャルは、これをクレデンシャルに使う方法を定めています。

  • typ: dc+sd-jwt:メディアタイプは application/dc+sd-jwt です。バージョン -06 より前のドラフトでは vc+sd-jwt が使われていましたが、W3C の VC データモデルが使っている vc という名前を避けるために変更されました。初期の実装とやり取りしてきた検証者は、今でも vc+sd-jwt を目にすることがあります。
  • vct(必須):クレデンシャルタイプを表す、衝突耐性のある文字列です。タイプには、スキーマや表示のための機械可読なタイプメタデータを用意できます。
  • iss、cnf(保有者の鍵。キーバインディングを使う場合は必須)、status(トークンステータスリストによる失効)、有効期間に関するクレーム:これらと vct は、選択的に開示可能にしてはいけません。検証者は、誰がクレデンシャルを発行したか、誰に紐付けられているか、まだ有効かどうかを常に確認できなければなりません。

2026年10月20日時点の状況:SD-JWT VC はドラフト -19(2026年8月31日)の段階で、IESG に提出済みですが、まだ RFC にはなっていません。失効に使うトークンステータスリストは承認済みで、RFC エディターのキューに入っています。RFC 9901 自体は発行済みです。

EU デジタル ID ウォレット

改正 eIDAS 規則(規則(EU)2024/1183)は、各加盟国に対し、同規則の中核となる実施法令の発効から 24 か月以内に、少なくとも 1 つの欧州デジタル ID ウォレットを提供することを義務付けています。個人識別データに関する実施規則(EU)2024/2977 を含むこれらの実施法令は、2024年12月4日に公表され、2024年12月24日に発効しました。したがって、期限は 2026年12月24日です。

技術面はアーキテクチャおよび参照フレームワーク(ARF)に記述されており、そのバージョン 3.0.0 は 2026年7月23日にリリースされました(ARF 自体は参考文書であり、拘束力を持つのは実施法令の本文です)。その第 5 章には、次のように書かれています。

  • ウォレットは、ISO/IEC 18013-5 の mdoc と SD-JWT VC の 2 つのクレデンシャル形式をサポートします。W3C VCDM 2.0 は任意で、非適格のアテステーションにのみ使えます。
  • mdoc クレデンシャルは、近接での提示では ISO/IEC 18013-5 で、リモートでの提示では ISO/IEC 18013-7、または HAIP プロファイルを適用した OpenID for Verifiable Presentations で提示されます。SD-JWT VC クレデンシャルはリモートでのみ、OpenID4VP と HAIP で提示され、「近接でのプレゼンテーションには使用できない」とされています。
  • 個人識別データ(PID)は、mdoc のドキュメントタイプ eu.europa.ec.eudi.pid.1 と SD-JWT VC のタイプ urn:eudi:pid:1 を使い、urn:eudi:pid:de:1 のような国別のタイプも同じ名前空間に置かれます(PID ルールブック)。

その周辺の OpenID プロトコルは最終仕様になっています。OpenID4VP 1.0(2025年7月)、OpenID4VCI 1.0(2025年9月)、そして SD-JWT VC と mdoc を高保証の用途向けに規定するプロファイルである HAIP 1.0(2025年12月)です。

SD-JWT VC と mdoc の比較

ARF は、2 つの選択的開示の仕組みを「概念的には同一」と呼んでいます。どちらも、個々の属性のソルト付きダイジェストに署名します。違いは、その周りのすべてにあります。mdoc の列では ISO 規格の構造名を使っています。この規格は無償では公開されていませんが、オープンソースの @auth0/mdl ライブラリが同じ名前を型として公開しています。

SD-JWT VCISO/IEC 18013-5 mdoc
エンコーディングJSON、JWS、base64urlCBOR、COSE
秘匿される属性ディスクロージャー [salt, name, value]random、digestID、elementIdentifier、elementValue を持つ IssuerSignedItem
ダイジェストの置き場所発行者署名付き JWT 内の _sd 配列発行者が署名するモバイルセキュリティオブジェクト(MSO)内の、名前空間ごとの valueDigests
保有者への紐付けキーバインディング JWT(この形式では任意)MSO 内のデバイス鍵とデバイス認証(必須)
発行者の信頼発行者の鍵(例:x5c や発行者メタデータ経由)発行機関に至る発行者の証明書チェーン
EUDI ウォレットでの提示リモートのみ(OpenID4VP + HAIP)近接(NFC、BLE、QR)とリモート
策定元IETF の OAuth ワーキンググループモバイル運転免許証の規格

検証者にとって、ウォレットに対応するとは両方を受け付けることであり、実際にはそれぞれにライブラリが必要になります。mdoc の解析や COSE の検証は、自前で実装すべきものではありません。

よくある間違い

  1. base64url 文字列ではなく、デコードした JSON をハッシュ化する:すべてのダイジェストが一致しなくなります。上の RFC の例がそのテストになります。
  2. 短いソルトや再利用したソルト:隠した値が推測可能になったり、クレームをまたいで紐付け可能になったりします。
  3. KB-JWT がないことを「キーバインディング不要」と解釈する:決めるのは検証者のポリシーであり、プレゼンテーションではありません。
  4. キーバインディング JWT の aud と nonce を確認しない:そうすると、保有者が過去に作ったプレゼンテーションは、どれでも自分のサービスにリプレイできてしまいます。
  5. 粒度の粗いディスクロージャー:オブジェクトごとに 1 つのディスクロージャーでは、オブジェクト全体が開示されます。検証者が尋ねる質問に合わせて構造を設計してください。
  6. status を無視する:クレデンシャルは、署名が正しく、有効期限内であっても、失効していることがあります。

出典