Перейти к содержимому
ecdsa.com

SD-JWT VC или mdoc: два формата удостоверений в кошельках

Оба формата позволяют кошельку раскрыть только те атрибуты, которые запрашивает проверяющая сторона, причём атрибуты подписаны издателем и привязаны к ключу на устройстве владельца. Один формат построен на JSON и JOSE, другой — на CBOR и COSE.

SD-JWT VC или mdoc — это вопрос о формате, на котором строятся кошельки цифровых удостоверений, мобильные водительские удостоверения и европейский кошелёк цифровых удостоверений (EU Digital Identity Wallet, EUDI Wallet). SD-JWT (RFC 9901) — это JWT, в котором утверждения (claims) заменены дайджестами с солью, а соответствующие значения передаются рядом в виде отдельных раскрытий (disclosures); SD-JWT VC добавляет к этому типы удостоверений и обнаружение ключей издателя. mdoc (ISO/IEC 18013-5) — это документ CBOR, дайджесты элементов данных которого собраны в объект Mobile Security Object (MSO), подписанный по COSE. Оба формата раскрывают только те атрибуты, которые выбрал владелец, и оба доказывают, что у предъявителя есть ключ устройства.

Коротко о главном

SD-JWT VCmdoc (ISO/IEC 18013-5)
Основная спецификацияRFC 9901 SD-JWT (ноябрь 2025 года); SD-JWT VC (черновик IETF, передан в IESG)ISO/IEC 18013-5:2021; онлайн-предъявление — в ISO/IEC TS 18013-7:2025
КодированиеJSON, base64url, раскрытия через разделитель ~CBOR (RFC 8949)
Подпись издателяJWS, обычно ES256COSE_Sign1, которой подписан объект MSO
Выборочное раскрытиеДайджест с солью для каждого утверждения в _sd; вложенные объекты и элементы массивовДайджест с солью для каждого элемента данных, по пространствам имён; плоские списки элементов
Привязка к владельцуКлюч в cnf; JWT привязки к ключу (Key Binding JWT) с aud, nonce, sd_hashКлюч устройства в MSO; подпись устройства или MAC, вычисленные по транскрипту сеанса
Тип удостоверенияURI в vct с метаданными типаdocType, например org.iso.18013.5.1.mDL
Обнаружение ключа издателя.well-known/jwt-vc-issuer, x5c или DIDx5chain с корневым сертификатом IACA (национальные списки доверия)
ОтзывToken Status List (черновик IETF в очереди RFC Editor)Короткий срок действия, списки статусов в новых профилях
Очное предъявлениеНе определеноУстановление связи с устройством (device engagement) по NFC, BLE и QR-коду
Онлайн-предъявлениеOpenID4VP 1.0 (dc+sd-jwt)OpenID4VP 1.0 (mso_mdoc), приложение C к ISO 18013-7, Digital Credentials API
Замер ниже: выданное удостоверение, затем предъявление 1 из 5 утверждений1 112 байт; 1 159 байт1 046 байт; 895 байт (1 194 в base64url)

Как устроено выборочное раскрытие в каждом формате

В SD-JWT издатель превращает каждое утверждение в раскрытие — кодировку base64url массива [salt, name, value] — и подписывает только дайджесты SHA-256 в массиве _sd внутри JWT. Владелец отправляет JWT и выбранные им раскрытия, разделяя их символом ~. Проверяющая сторона вычисляет хеш каждого раскрытия и проверяет, есть ли этот дайджест в _sd; утверждения без раскрытия остаются скрытыми, а соль не позволяет подобрать их значения по дайджесту. JWT привязки к ключу (Key Binding JWT, typ: kb+jwt), подписанный ключом владельца, содержит aud и nonce проверяющей стороны и sd_hash, вычисленный по точному содержимому предъявления.

В mdoc каждый элемент данных — это IssuerSignedItem (ID дайджеста, случайная соль, идентификатор, значение), закодированный в CBOR и обёрнутый в тег 24. Mobile Security Object содержит дайджесты SHA-256 по пространствам имён, открытый ключ устройства и срок действия, а издатель подписывает этот объект по COSE. Владелец возвращает только выбранные элементы вместе с подписанным издателем MSO, а также подпись устройства, вычисленную по транскрипту сеанса; в OpenID4VP этот транскрипт содержит хеш от идентификатора клиента (client ID) проверяющей стороны, nonce и URI для ответа.

Скрипт ниже собирает оба формата с одинаковым набором из 5 атрибутов, подписывает их ключами P-256 и предъявляет только age_over_18. Сначала он сверяет вычисление дайджеста SD-JWT с примером из RFC 9901, § 4.2.3 (Node.js 26, jose 6.2.13, cbor-x 1.6.0).

Сборка обоих форматов и предъявление одного атрибута
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
import { SignJWT, exportJWK } from "jose";
import { Encoder, Tag } from "cbor-x";

const sha256 = (b) => createHash("sha256").update(b).digest();
const b64u = (b) => Buffer.from(b).toString("base64url");
const cbor = new Encoder({ useRecords: false, useTag259ForMaps: false, variableMapSize: true, tagUint8Array: false });
const tag24 = (v) => new Tag(cbor.encode(v), 24);

// RFC 9901 §4.2.3 example: digest of the family_name Disclosure
const rfcDisclosure = "WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0";
console.log("RFC 9901 example digest:", b64u(sha256(rfcDisclosure)));

const claims = { given_name: "Erika", family_name: "Mustermann", birth_date: "1984-01-26", age_over_18: true, nationality: "DE" };
const issuer = generateKeyPairSync("ec", { namedCurve: "P-256" });
const holder = generateKeyPairSync("ec", { namedCurve: "P-256" });
const holderJwk = await exportJWK(holder.publicKey);
const nonce = b64u(randomBytes(16));

// SD-JWT VC: one salted Disclosure per claim, their digests signed in _sd
const disclosures = Object.entries(claims).map(([k, v]) => b64u(JSON.stringify([b64u(randomBytes(16)), k, v])));
const jwt = await new SignJWT({
  iss: "https://issuer.example", vct: "https://credentials.example/identity_credential",
  _sd: disclosures.map((d) => b64u(sha256(d))), _sd_alg: "sha-256", cnf: { jwk: holderJwk },
}).setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt" }).sign(issuer.privateKey);
const issued = `${jwt}~${disclosures.join("~")}~`;
const shown = `${jwt}~${disclosures[3]}~`; // reveal age_over_18 only
const kbJwt = await new SignJWT({ aud: "https://verifier.example", nonce, sd_hash: b64u(sha256(shown)) })
  .setProtectedHeader({ alg: "ES256", typ: "kb+jwt" }).setIssuedAt().sign(holder.privateKey);
console.log(`SD-JWT VC: issued ${issued.length} bytes; presentation of 1 of 5 claims ${(shown + kbJwt).length} bytes`);

// mdoc (ISO/IEC 18013-5): one salted IssuerSignedItem per element, digests signed in the MSO
const ns = "org.iso.18013.5.1";
const docType = "org.iso.18013.5.1.mDL";
const items = Object.entries(claims).map(([k, v], i) =>
  tag24({ digestID: i, random: randomBytes(16), elementIdentifier: k, elementValue: v }));
const coseSign1 = (key, payload, detached) => {
  const prot = cbor.encode(new Map([[1, -7]])); // alg: ES256
  const toBeSigned = cbor.encode(["Signature1", prot, Buffer.alloc(0), payload]);
  return [prot, {}, detached ? null : payload, sign("sha256", toBeSigned, { key, dsaEncoding: "ieee-p1363" })];
};
const mso = {
  version: "1.0", digestAlgorithm: "SHA-256", docType,
  valueDigests: { [ns]: new Map(items.map((t, i) => [i, sha256(cbor.encode(t))])) },
  deviceKeyInfo: { deviceKey: new Map([[1, 2], [-1, 1], [-2, Buffer.from(holderJwk.x, "base64url")], [-3, Buffer.from(holderJwk.y, "base64url")]]) },
  validityInfo: { signed: new Tag("2026-10-20T00:00:00Z", 0), validFrom: new Tag("2026-10-20T00:00:00Z", 0), validUntil: new Tag("2027-10-20T00:00:00Z", 0) },
};
const issuerAuth = coseSign1(issuer.privateKey, cbor.encode(tag24(mso)));
const issuedMdoc = cbor.encode({ nameSpaces: { [ns]: items }, issuerAuth });

// Presentation over OpenID4VP: DeviceResponse signed by the device key over the session transcript
const handoverInfo = cbor.encode(["https://verifier.example", nonce, null, "https://verifier.example/response"]);
const transcript = [null, null, ["OpenID4VPHandover", sha256(handoverInfo)]];
const deviceNameSpaces = tag24({});
const deviceAuth = cbor.encode(tag24(["DeviceAuthentication", transcript, docType, deviceNameSpaces]));
const response = cbor.encode({
  version: "1.0", status: 0,
  documents: [{ docType, issuerSigned: { nameSpaces: { [ns]: [items[3]] }, issuerAuth },
    deviceSigned: { nameSpaces: deviceNameSpaces, deviceAuth: { deviceSignature: coseSign1(holder.privateKey, deviceAuth, true) } } }],
});
console.log(`mdoc:      issued ${issuedMdoc.length} bytes; presentation of 1 of 5 elements ${response.length} bytes (${b64u(response).length} as base64url)`);
Результат
RFC 9901 example digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
SD-JWT VC: issued 1112 bytes; presentation of 1 of 5 claims 1159 bytes
mdoc:      issued 1046 bytes; presentation of 1 of 5 elements 895 bytes (1194 as base64url)

Дайджест совпадает со значением, приведённым в RFC 9901. Размеры получены на минимальном примере без сертификатов издателя: в реальных удостоверениях добавляются цепочка сертификатов x5c или x5chain (часто 1–2 КБ), сведения о статусе и дополнительные атрибуты, что примерно одинаково сказывается на обоих форматах. Тем не менее закономерность сохраняется. Предъявление SD-JWT — это полный JWT издателя со всеми дайджестами плюс одно раскрытие и JWT привязки к ключу, а предъявление mdoc отбрасывает невыбранные элементы и сохраняет MSO, поэтому чем больше атрибутов скрыто, тем компактнее предъявление mdoc.

Где используется каждый формат

Европейский регламент о цифровых удостоверениях (European Digital Identity) обязывает государства-члены предоставить кошельки к декабрю 2026 года, а частные доверяющие стороны, которые должны применять строгую аутентификацию пользователей, например банки, — принимать их к декабрю 2027 года; документ «Архитектура и эталонная модель» (Architecture and Reference Framework, ARF) требует, чтобы поставщики выдавали данные идентификации личности и в формате mdoc, и в формате SD-JWT VC. Мобильные водительские удостоверения во всём мире соответствуют ISO/IEC 18013-5, а Управление транспортной безопасности США (TSA) и многие штаты США принимают их на пунктах контроля.

В вебе Chrome 141 и Safari 26 в 2025 году выпустили поддержку W3C Digital Credentials API, который позволяет странице запросить удостоверение у системного кошелька. Chrome принимает запросы OpenID4VP, поэтому запросить можно оба формата; Safari поддерживает только протокол из приложения C к ISO 18013-7 (org-iso-mdoc), поэтому проверяющие стороны в вебе, которые хотят обслуживать пользователей iPhone, должны принимать mdoc. Спецификация OpenID4VP 1.0, окончательная с июля 2025 года, определяет форматы dc+sd-jwt и mso_mdoc и язык запросов DCQL для обоих.

Практические различия для разработчиков

  • Инструменты: SD-JWT — это JSON и JOSE, поэтому достаточно любой библиотеки для JWT и примерно сотни строк кода для обработки раскрытий; инспектор SD-JWT декодирует такие токены. Для mdoc нужны CBOR, COSE и модель данных ISO; инспектор mdoc и декодер COSE показывают эти структуры.
  • Модель данных: SD-JWT поддерживает вложенные объекты и элементы массивов, которые можно раскрывать по отдельности; mdoc использует плоские пространства имён из элементов с идентификаторами, определёнными в ISO, например age_over_18.
  • Несвязываемость: оба формата используют одну и ту же подпись издателя и одни и те же дайджесты во всех предъявлениях, поэтому проверяющие стороны, действуя сообща, могут связать предъявления между собой. Кошельки снижают этот риск так: удостоверения выдаются пакетами, и каждое используется только один раз.
  • Доверие: mdoc опирается на цепочки сертификатов издателя вплоть до национальных корневых сертификатов (IACA), что подходит государственным издателям. SD-JWT VC одинаково поддерживает и обнаружение ключей через веб, и X.509.
  • Статус стандартов: RFC 9901, ISO/IEC 18013-5 и OpenID4VP 1.0 окончательно приняты. SD-JWT VC и список статусов токенов (Token Status List) — черновики IETF на завершающих стадиях (октябрь 2026 года), поэтому зафиксируйте версию черновика, которую реализуете.

Что когда выбирать

  • Государственные удостоверения личности, водительские удостоверения, очная проверка возраста: mdoc.
  • Проверка в вебе, которая уже сейчас должна работать на iPhone: mdoc через Digital Credentials API.
  • Данные идентификации личности для EUDI Wallet: оба формата, как того требует эталонная модель ARF.
  • Дипломы, профессиональные лицензии, удостоверения сотрудников или членов организаций, выдаваемые онлайн: SD-JWT VC.
  • Системы, уже построенные на OAuth и OpenID Connect: SD-JWT VC через OpenID4VCI и OpenID4VP.
  • Подписанные утверждения в JSON без выборочного раскрытия: достаточно обычного JWT или проверяемого удостоверения.

Заметки о миграции

  1. Реализуйте OpenID4VP 1.0 с DCQL: тогда любой из двух форматов можно запросить с одной и той же структурой запроса.
  2. Проверяйте всё, что требуют стандарты: подпись издателя и цепочку доверия, дайджесты каждого раскрытого элемента, срок действия или статус, а также привязку к владельцу по вашим собственным nonce и аудитории; без проверки привязки удостоверение превращается в bearer-токен, который любой может предъявить повторно.
  3. Зафиксируйте версии черновиков: запишите, какие черновики SD-JWT VC и списка статусов вы реализуете, и следите за изменениями, пока они не станут RFC.
  4. Запрашивайте минимум: запрашивайте age_over_18, а не дату рождения, если вопрос именно в этом; оба формата это поддерживают.
  5. Спланируйте управление сертификатами: проверяющим сторонам mdoc нужны национальные списки доверия с корневыми сертификатами IACA, проверяющим сторонам SD-JWT VC — метаданные издателей или проверка сертификатов.

Источники

Все сравнения