Коротко. SD-JWT (RFC 9901, ноябрь 2025 года) заменяет каждое скрываемое утверждение (claim) подписанного JWT хешем раскрытия (disclosure) — небольшого JSON-массива в кодировке base64url из случайной соли, имени утверждения и его значения. Издатель подписывает хеши; позже владелец отправляет подписанный JWT и только те раскрытия, которые выбрал сам, соединив их символом ~. Проверяющая сторона заново вычисляет хеши и проверяет, что каждый из них есть в подписанной полезной нагрузке. JWT привязки к ключу (key binding JWT, typ: kb+jwt), подписанный ключом, который указан в удостоверении, привязывает предъявление к одной проверяющей стороне (aud), одному запросу (nonce) и именно этому набору раскрытий (sd_hash). SD-JWT VC добавляет уровень удостоверений: typ: dc+sd-jwt, тип удостоверения в vct, статус и метаданные типа. SD-JWT VC пока остаётся Internet-Draft, но это один из двух форматов удостоверений, которые обязан поддерживать каждый европейский кошелёк цифровых удостоверений (EUDI Wallet), наряду с mdoc по ISO/IEC 18013-5.
Раскрытия и дайджесты
Раскрытие для свойства объекта — это массив [salt, name, value], для элемента массива — [salt, value] (RFC 9901, § 4.2). Массив записывается как JSON в UTF-8 и кодируется в base64url, а дайджест — это SHA-256 от закодированной строки, а не от JSON, тоже в base64url (§ 4.2.3). Хеш покрывает ровно те байты, которые выбрал издатель, поэтому канонизация JSON не нужна: то же утверждение, записанное с другими пробелами, — это другое раскрытие с другим дайджестом. Оба момента проверены на примере из самого RFC:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
Издатель помещает дайджесты в массив _sd полезной нагрузки, отсортировав или перемешав их, чтобы порядок ничего не сообщал, и может добавить фиктивные (decoy) дайджесты, которым не соответствует ни одно раскрытие (§ 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).
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 поддерживается; сопоставить каждое раскрытие ровно с одним дайджестом; отклонить раскрытия с именем _sd или ..., а также раскрытия, имя которых совпадает с именем видимого утверждения; отклонить дайджест, который встречается дважды, и раскрытие, на которое ничто не ссылается; затем проверить подпись JWT привязки к ключу ключом из cnf, его typ, возраст, aud, nonce и sd_hash. Обратите внимание на шаг 1 из § 7.3: требовать ли привязку к ключу, решает политика проверяющей стороны, и это решение никогда не зависит от того, прислал ли владелец KB-JWT.
Запуск и перекрёстная проверка
Тестовый скрипт выдаёт удостоверение личности с 5 скрываемыми утверждениями (имя, фамилия, дата рождения, адрес и возрастные пороги), предъявляет магазину только возрастные пороги, проверяет предъявление модулем выше и библиотекой @sd-jwt/sd-jwt-vc 0.23.0 от OpenWallet Foundation, а затем проверяет обе реализации на 6 манипуляциях:
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 привязки к ключу или с JWT привязки, подписанным другим ключом, было отклонено.
И ещё одно, что показывает запуск, — это урок проектирования, а не проверка: магазин спросил «старше 18», а заодно узнал и «не старше 65», потому что издатель упаковал все пороги в одно раскрытие. Степень детализации задаётся при выдаче. В RFC 9901, § 6.2 структурированная форма показана на объекте address: каждое свойство получает своё раскрытие, а в объекте остаётся один массив _sd с дайджестом для каждого свойства. При такой выдаче age_equal_or_over позволил бы кошельку раскрыть только "18": true.
Что выборочное раскрытие не скрывает
Выборочное раскрытие скрывает значения, но не само удостоверение. Каждое предъявление одного SD-JWT несёт одну и ту же подпись издателя, одни и те же дайджесты _sd и один и тот же ключ cnf, поэтому две проверяющие стороны, сравнив свои данные, могут понять, что видели одно и то же удостоверение, даже если одна получила только возраст, а другая — только почтовый индекс. RFC 9901, § 10.1 говорит об этом прямо: формат «не обладает свойствами безопасности анонимных удостоверений», а издатель, к которому предъявления вернулись от проверяющей стороны, всегда может узнать своё удостоверение. То же относится к mdoc, где используется тот же подход с хешами с солью.
Способ смягчения, который описывает RFC, — пакетная выдача (batch issuance): издатель выдаёт кошельку несколько удостоверений с одинаковыми утверждениями, и кошелёк использует другое удостоверение для каждой проверяющей стороны или даже для каждой сессии. Чтобы это работало, у каждого удостоверения в пакете должны быть свои соли и свой ключ привязки, а утверждения о времени, такие как iat и exp, должны быть рандомизированы или округлены: точные метки времени связывают предъявления так же надёжно, как серийный номер. Пакетная выдача не даёт проверяющим сторонам связать предъявления между собой, но не может скрыть предъявление от самого издателя. Проверки статуса — родственный источник утечки: проверяющая сторона, которая спрашивает издателя об одном удостоверении, сообщает ему, где это удостоверение используется. Поэтому список статусов токенов (Token Status List) позволяет проверяющим сторонам скачивать сжатый список, который охватывает множество удостоверений, а не спрашивать об одном.
Что добавляет SD-JWT VC
SD-JWT — это контейнер. Спецификация проверяемых удостоверений на основе SD-JWT (SD-JWT-based Verifiable Credentials) определяет, как использовать его для удостоверений:
typ: dc+sd-jwt, медиатипapplication/dc+sd-jwt. До редакции -06 черновик использовалvc+sd-jwt; значение сменили, чтобы не занимать имяvc, которое использует W3C Verifiable Credentials Data Model. Проверяющие стороны, которые имели дело с ранними реализациями, ещё могут встретитьvc+sd-jwt.vct, обязательное утверждение: тип удостоверения, строка, стойкая к коллизиям. У типов могут быть машиночитаемые метаданные типа (type metadata) для схем и отображения.iss,cnf(ключ владельца, обязателен, если используется привязка к ключу),status(отзыв через список статусов токенов) и утверждения о сроке действия. Эти утверждения, как иvct, не должны допускать выборочного раскрытия: проверяющая сторона всегда должна видеть, кто выдал удостоверение, к кому оно привязано и действительно ли оно ещё.
Статус на 20 октября 2026 года: SD-JWT VC находится в редакции -19 (31 августа 2026 года), передан в IESG и пока не стал RFC. Черновик Token Status List, который SD-JWT VC использует для отзыва, одобрен и находится в очереди RFC Editor. Сам RFC 9901 уже опубликован.
Европейский кошелёк цифровых удостоверений
Обновлённый регламент eIDAS (Регламент (ЕС) 2024/1183) обязывает каждое государство-член ЕС предоставить хотя бы один европейский кошелёк цифровых удостоверений (European Digital Identity Wallet) в течение 24 месяцев после вступления в силу основных исполнительных актов к регламенту. Эти акты, в том числе Исполнительный регламент (ЕС) 2024/2977 об идентификационных данных лица, были опубликованы 4 декабря 2024 года и вступили в силу 24 декабря 2024 года, поэтому крайний срок — 24 декабря 2026 года.
Техническая сторона описана в документе Architecture and Reference Framework (ARF), версия 3.0.0 которого вышла 23 июля 2026 года (сам ARF носит информационный характер, юридически обязательный текст — исполнительные акты). В его главе 5 сказано:
- Кошельки поддерживают два формата удостоверений: mdoc по ISO/IEC 18013-5 и SD-JWT VC. Поддержка W3C VCDM 2.0 необязательна и предусмотрена только для неквалифицированных аттестаций.
- Удостоверения mdoc предъявляются очно по ISO/IEC 18013-5 и удалённо по ISO/IEC 18013-7 или по OpenID for Verifiable Presentations с профилем HAIP. Удостоверения SD-JWT VC предъявляются только удалённо, по OpenID4VP и HAIP: их «нельзя использовать для очного предъявления».
- Для идентификационных данных лица (person identification data, PID) используются тип документа mdoc
eu.europa.ec.eudi.pid.1и тип SD-JWT VCurn:eudi:pid:1, а национальные типы, напримерurn:eudi:pid:de:1, находятся в том же пространстве имён (PID Rulebook).
Связанные с кошельком протоколы OpenID уже имеют финальные версии: OpenID4VP 1.0 (июль 2025 года), OpenID4VCI 1.0 (сентябрь 2025 года) и HAIP 1.0 (декабрь 2025 года) — профиль SD-JWT VC и mdoc для сценариев с высоким уровнем доверия.
SD-JWT VC и mdoc: сравнение
ARF называет два механизма выборочного раскрытия «концептуально идентичными»: оба подписывают дайджесты отдельных атрибутов, вычисленные с солью. Различается всё, что вокруг них. В столбце mdoc используются имена структур из стандарта ISO, который не находится в свободном доступе; библиотека с открытым исходным кодом @auth0/mdl использует те же имена в своих типах.
| SD-JWT VC | ISO/IEC 18013-5 mdoc | |
|---|---|---|
| Кодирование | JSON, JWS, base64url | CBOR, COSE |
| Скрытый атрибут | раскрытие [salt, name, value] | IssuerSignedItem с полями random, digestID, elementIdentifier, elementValue |
| Где хранятся дайджесты | массивы _sd в JWT, подписанном издателем | valueDigests для каждого пространства имён в Mobile Security Object (MSO), который подписывает издатель |
| Привязка к владельцу | JWT привязки к ключу, в формате необязателен | ключ устройства в MSO и аутентификация устройства, обязательны |
| Доверие к издателю | ключ издателя, например через x5c или метаданные издателя | цепочка сертификатов издателя до выдающего органа |
| Предъявление в кошельке EUDI | только удалённое (OpenID4VP + HAIP) | очное (NFC, BLE, QR) и удалённое |
| Происхождение | рабочая группа OAuth в IETF | стандарт мобильных водительских удостоверений |
Для проверяющей стороны поддержка кошелька означает приём обоих форматов, а на практике — по библиотеке на каждый: разбор mdoc и проверку COSE не стоит писать самостоятельно.
Типичные ошибки
- Хеширование декодированного JSON вместо строки base64url. Ни один дайджест не совпадёт; пример из RFC выше — готовый тест.
- Короткие или повторно используемые соли. Из-за них скрытые значения можно подобрать или связать между утверждениями.
- Отсутствие KB-JWT понимается как «привязка к ключу не нужна». Решает политика проверяющей стороны, а не предъявление.
- Нет проверки
audиnonceв JWT привязки к ключу. Тогда любое предъявление, которое владелец когда-либо делал, можно повторно отправить вам. - Слишком крупные раскрытия. Одно раскрытие на весь объект раскрывает объект целиком; проектируйте структуру под вопросы, которые будут задавать проверяющие стороны.
- Игнорирование
status. Удостоверение может быть подписано, не просрочено и при этом отозвано.
Источники
- RFC 9901: выборочное раскрытие для JSON Web Token, § 4, § 6, § 7, § 9
- draft-ietf-oauth-sd-jwt-vc, проверяемые удостоверения на основе SD-JWT (SD-JWT-based Verifiable Credentials), редакция -19
- draft-ietf-oauth-status-list, список статусов токенов (Token Status List)
- Регламент (ЕС) 2024/1183 и Исполнительный регламент (ЕС) 2024/2977
- EUDI Wallet Architecture and Reference Framework, v3.0.0, глава 5; PID Rulebook
- OpenID4VP 1.0, OpenID4VCI 1.0, HAIP 1.0
- @sd-jwt/sd-jwt-vc и @auth0/mdl