Get your free and exclusive 80-page Banking Passkey Report
Back to Overview

如何构建数字凭证验证器(开发者指南)

学习如何使用 Next.js、OpenID4VP 和 ISO mDoc 从零开始构建一个数字凭证验证器。这份分步开发者指南将向你展示如何创建一个可以请求、接收和验证移动驾照及其他数字凭证的验证器。

Amine

Created: August 20, 2025

Updated: August 21, 2025

Blog-Post-Header-Image

See the original blog version in English here.

DigitalCredentialsDemo Icon

Want to experience digital credentials in action?

Try Digital Credentials

1. 引言#

在线证明身份一直是一项挑战,导致我们过度依赖密码,并通过不安全的渠道共享敏感文件。这使得企业的身份验证过程变得缓慢、昂贵且容易发生欺诈。数字凭证提供了一种新方法,将数据控制权交还给用户。它们相当于实体 wallet 的数字版本,可以包含从驾照到大学学位的所有内容,但还具备密码学安全、保护隐私和即时验证等额外优势。

本指南为开发者提供了一个实用、分步的教程,用于构建数字凭证的验证器。虽然相关标准已经存在,但关于如何实施的指导却很少。本教程填补了这一空白,向你展示如何使用浏览器的原生数字凭证 API、用于表述协议的 OpenID4VP 以及作为凭证格式的 ISO mDoc(例如,移动驾照)来构建验证器。

最终的成果将是一个简单而实用的 Next.js 应用程序,能够从兼容的移动 wallet 中请求、接收和验证数字凭证。

以下是最终应用程序的快速演示。整个过程包括四个主要步骤:

第 1 步:初始页面 用户进入初始页面,点击“使用数字身份验证”开始流程。

第 2 步:信任提示 浏览器提示用户确认信任。用户点击“继续”以进行下一步。

第 3 步:扫描 QR 码 屏幕上会显示一个 QR 码,用户使用兼容的 wallet 应用程序扫描它。

第 4 步:已解码的凭证 成功验证后,应用程序会显示解码后的凭证数据。

1.1 工作原理#

数字凭证背后的奥秘在于一个简单而强大的“信任三角”模型,其中涉及三个关键角色:

  • 颁发者 (Issuer): 一个受信任的机构(例如,政府机构、大学或银行),它对凭证进行密码学签名并将其颁发给用户。
  • 持有者 (Holder): 用户,他们接收凭证并将其安全地存储在设备上的个人数字 wallet 中。
  • 验证者 (Verifier): 需要检查用户凭证的应用程序或服务。

当用户想要访问某项服务时,他们会从自己的 wallet 中出示凭证。验证者可以立即检查其真实性,而无需直接联系原始的颁发者

1.2 为什么验证者至关重要(以及你为何在此)#

为了让这个去中心化身份生态系统蓬勃发展,验证者的角色至关重要。他们是这个新信任基础设施的守门人,是使用凭证并使其在现实世界中有用武之地的一方。如下图所示,验证者通过向持有者请求、接收和验证凭证来完成信任三角。

如果你是一名开发者,构建一个执行此验证的服务将是开发下一代安全且以用户为中心的应用所需的一项基础技能。本指南旨在引导你完成这个过程。我们将涵盖构建你自己的可验证凭证验证器所需的一切,从核心概念和标准到验证签名和检查凭证状态的逐步实施细节。

想直接看成品? 你可以在 GitHub 上找到本教程的完整项目。随时可以克隆下来亲自尝试: https://github.com/corbado/digital-credentials-example

让我们开始吧。

2. 构建验证器的先决条件#

在开始之前,请确保你具备以下条件:

  1. 对数字凭证和 mdoc 的基本了解
    • 本教程专注于 ISO mDoc 格式(例如,用于移动驾照),不涵盖 W3C 可验证凭证 (VCs) 等其他格式。熟悉 mdoc 的基本概念会很有帮助。
  2. Docker 和 Docker Compose
    • 我们的项目使用 Docker 容器中的 MySQL 数据库来管理 OIDC 会话状态。请确保你已安装并运行这两者。
  3. 选定的协议:OpenID4VP
    • 我们将使用 OpenID4VP (OpenID for Verifiable Presentations) 协议进行凭证交换流程。
  4. 准备好技术栈
    • 使用 TypeScript (Node.js) 进行后端逻辑处理。
    • 使用 Next.js 进行后端(API 路由)和前端(UI)开发。
    • 关键库:用于解析 mdocCBOR 解码库和一个 MySQL 客户端。
  5. 测试凭证和 Wallet
  6. 基本的密码学知识
    • 理解与 mdoc 和 OIDC 流程相关的数字签名和公钥/私钥概念。

接下来,我们将详细介绍这些先决条件,从支撑这个基于 mdoc 的验证器的标准和协议开始。

2.1 协议选择#

我们的验证器是为以下标准构建的:

标准 / 协议描述
W3C VCW3C 可验证凭证数据模型。它定义了数字凭证的标准结构,包括声明、元数据和证明。
SD-JWTJWT 的选择性披露。一种基于 JSON Web Tokens 的 VC 格式,允许持有者选择性地仅披露凭证中的特定声明,从而增强隐私。
ISO mDocISO/IEC 18013-5。移动驾照 (mDL) 和其他移动身份证件的国际标准,定义了离线和在线使用的数据结构和通信协议。
OpenID4VPOpenID for Verifiable Presentations。一个基于 OAuth 2.0 构建的可互操作的表述协议。它定义了验证者如何请求凭证以及持有者的 wallet 如何出示凭证。

在本教程中,我们具体使用:

  • OpenID4VP 作为请求和接收凭证的协议。
  • ISO mDoc 作为凭证格式(例如,用于移动驾照)。

关于范围的说明: 虽然我们简要介绍了 W3C VC 和 SD-JWT 以提供更广泛的背景,但本教程仅通过 OpenID4VP 实现 ISO mDoc 凭证。基于 W3C 的 VC 不在本示例的范围之内。

2.1.1 ISO mDoc(移动文档)#

ISO/IEC 18013-5 mDoc 标准定义了移动文档(如移动驾照,mDL)的结构和编码。mDoc 凭证经过 CBOR 编码、密码学签名,并可以数字方式出示以供验证。我们的验证器将专注于解码和验证这些 mdoc 凭证。

2.1.2 OpenID4VP (OpenID for Verifiable Presentations)#

OpenID4VP 是一个用于请求和出示数字凭证的可互操作协议,建立在 OAuth 2.0 和 OpenID Connect 之上。在此实现中,OpenID4VP 用于:

  • 发起凭证出示流程(通过 QR 码或浏览器 API)
  • 从用户的 wallet 接收 mdoc 凭证
  • 确保安全、有状态且保护隐私的凭证交换

2.2 技术栈选择#

既然我们对标准和协议有了清晰的了解,我们需要选择合适的技术栈来构建我们的验证器。我们的选择旨在实现稳健性、良好的开发者体验以及与现代 Web 生态系统的兼容性。

2.2.1 语言:TypeScript#

我们将为前端和后端代码都使用 TypeScript。作为 JavaScript 的超集,它增加了静态类型,这有助于及早发现错误、提高代码质量,并使复杂应用的维护更容易。在凭证验证这样对安全敏感的场景中,类型安全是一个巨大的优势。

2.2.2 框架:Next.js#

Next.js 是我们的首选框架,因为它为构建全栈应用提供了无缝、集成的体验。

  • 对于前端: 我们将使用 Next.jsReact 来构建用户界面,用户在这里发起验证过程(例如,显示一个 QR 码)。
  • 对于后端: 我们将利用 Next.js API Routes 来创建服务器端端点。这些端点负责创建有效的 OpenID4VP 请求,并作为 redirect_uri 安全地接收和验证来自 CMWallet 的最终响应。

2.2.3 关键库#

我们的实现依赖于一组特定的前端和后端库:

  • next: Next.js 框架,用于后端 API 路由和前端 UI。
  • reactreact-dom: 驱动前端用户界面。
  • cbor-web: 用于将 CBOR 编码的 mdoc 凭证解码为可用的 JavaScript 对象。
  • mysql2: 提供 MySQL 数据库连接,用于存储挑战码和验证会话。
  • uuid: 一个用于生成唯一挑战字符串(nonces)的库。
  • @types/uuid: 用于 UUID 生成的 TypeScript 类型。

关于 openid-client 的说明: 更高级、生产级的验证器可能会使用 openid-client 库直接在后端处理 OpenID4VP 协议,以实现动态 redirect_uri 等功能。在带有 redirect_uri 的服务器驱动的 OpenID4VP 流程中,openid-client 将用于直接解析和验证 vp_token 响应。在本教程中,我们使用一个更简单的、由浏览器介导的流程,不需要它,从而使过程更容易理解。

这个技术栈确保了一个稳健、类型安全且可扩展的验证器实现,专注于浏览器的数字凭证 API 和 ISO mDoc 凭证格式。

2.3 获取测试 Wallet 和凭证#

要测试你的验证器,你需要一个能够与浏览器数字凭证 API 交互的移动 wallet。

我们将使用 CMWallet,这是一个强大的、符合 OpenID4VP 标准的 Android 测试 wallet。

如何安装 CMWallet (Android):

  1. 直接在你的 Android 设备上使用上面的链接下载 APK 文件
  2. 打开设备的设置 > 安全
  3. 为你下载文件所用的浏览器启用**“安装未知应用”**。
  4. 在你的“下载”文件夹中找到下载的 APK 文件,然后点击它开始安装。
  5. 按照屏幕上的提示完成安装。
  6. 打开 CMWallet,你会发现它已预装了测试凭证,可用于验证流程。

注意: 只安装来自你信任来源的 APK 文件。提供的链接来自官方项目仓库。

2.4 密码学知识#

在我们深入实现之前,了解支撑可验证凭证的密码学概念至关重要。这正是它们之所以“可验证”和值得信赖的原因。

2.4.1 数字签名:信任的基石#

从核心上讲,可验证凭证是一组由颁发者数字签名的声明(如姓名、出生日期等)。数字签名提供了两个关键保证:

  • 真实性 (Authenticity): 它证明凭证确实是由颁发者创建的,而不是冒名顶替者。
  • 完整性 (Integrity): 它证明凭证自签名以来未被篡改或修改过。

2.4.2 公钥/私钥密码学#

数字签名是使用公钥/私钥密码学(也称为非对称密码学)创建的。以下是它在我们的场景中如何工作:

  1. 颁发者拥有一对密钥: 一个保密的私钥和一个对所有人公开的公钥(通常通过其 DID 文档提供)。
  2. 签名: 当颁发者创建凭证时,他们使用其私钥为该特定凭证数据生成一个唯一的数字签名。
  3. 验证: 当我们的验证器收到凭证时,它使用颁发者公钥来检查签名。如果检查通过,验证器就知道该凭证是真实的且未被篡改。对凭证数据的任何更改都会使签名无效。

关于 DID 的说明: 在本教程中,我们不通过 DID 解析颁发者密钥。在生产环境中,颁发者通常会通过 DID 或其他权威端点公开其公钥,验证者将使用这些公钥进行密码学验证。

2.4.3 作为 JWT 的可验证凭证#

可验证凭证通常格式化为 JSON Web Tokens (JWTs)。JWT 是一种紧凑、URL 安全的方式,用于表示在两方之间传输的声明。一个签名的 JWT(也称为 JWS)由三个部分组成,用点号 (.) 分隔:

  • 头部 (Header): 包含关于令牌的元数据,如使用的签名算法 (alg)。
  • 载荷 (Payload): 包含可验证凭证的实际声明 (vc claim),包括 issuercredentialSubject 等。
  • 签名 (Signature): 由颁发者生成的数字签名,覆盖了头部和载荷。
// JWT 结构示例 [头部].[载荷].[签名]

注意: 基于 JWT 的可验证凭证超出了本博客文章的范围。本实现专注于 ISO mDoc 凭证和 OpenID4VP,而非 W3C 可验证凭证或基于 JWT 的凭证。

2.4.4 可验证表述:证明持有权#

对于验证者来说,仅仅知道一个凭证是有效的还不够;它还需要知道出示凭证的人是合法的持有者。这可以防止有人使用偷来的凭证。

这个问题通过可验证表述 (Verifiable Presentation, VP) 来解决。VP 是一个或多个 VC 的包装器,由持有者自己签名

流程如下:

  1. 验证者要求用户出示一个凭证。
  2. 用户的 wallet 创建一个可验证表述,将所需的凭证捆绑在内,并使用持有者的私钥对整个表述进行签名。
  3. wallet 将这个签了名的 VP 发送给验证者。

然后,我们的验证器必须执行两次独立的签名检查:

  1. 验证凭证: 使用颁发者的公钥检查表述内每个 VC 的签名。(证明凭证是真实的)。
  2. 验证表述: 使用持有者的公钥检查 VP 本身的签名。(证明出示它的人是所有者)。

这种双重检查确保了凭证的真实性和出示人身份的真实性,从而创建了一个稳健而安全的信任模型。

注意: W3C VC 生态系统中定义的可验证表述概念超出了本博客文章的范围。这里的可验证表述术语指的是 OpenID4VP 的 vp_token 响应,其行为类似于 W3C VP,但基于 ISO mDoc 语义而非 W3C 的 JSON-LD 签名模型。本指南专注于 ISO mDoc 凭证和 OpenID4VP,而非 W3C 可验证表述或其签名验证。

3. 架构概述#

我们的验证器架构使用浏览器内置的数字凭证 API 作为安全中介,连接我们的 Web 应用程序和用户的移动 CMWallet。这种方法简化了流程,让浏览器处理原生的 QR 码显示和与 wallet 的通信。

  • 前端 (Next.js & React): 一个轻量级的面向用户的网站。它的任务是从我们的后端获取一个请求对象,将其传递给浏览器的 navigator.credentials.get() API,接收结果,并将其转发给我们的后端进行验证。
  • 后端 (Next.js API Routes): 验证器的核心。它为浏览器 API 生成一个有效的请求对象,并公开一个端点以接收来自前端的凭证表述进行最终验证。
  • 浏览器 (Credential API): 协调者。它从我们的前端接收请求对象,理解 openid4vp 协议,并原生生成一个 QR 码。然后它等待 wallet 返回响应。
  • CMWallet (移动应用): 用户的 wallet。它扫描 QR 码,处理请求,获取用户同意,并将签名的响应发送回浏览器。

以下是说明完整且准确流程的序列图:

流程解释:

  1. 启动: 用户在我们的前端点击“验证”按钮。
  2. 请求对象: 前端调用我们的后端 (/api/verify/start),后者生成一个包含查询和 nonce 的请求对象,然后返回它。
  3. 浏览器 API 调用: 前端使用请求对象调用 navigator.credentials.get()
  4. 原生 QR 码: 浏览器看到 openid4vp 协议请求,并原生显示一个 QR 码。此时 .get() 的 promise 处于待定状态。

注意: 这个 QR 码流程在桌面浏览器上发生。在移动浏览器(启用实验性标志的 Android Chrome)上,浏览器可以直接与同一设备上兼容的 wallets 通信,无需扫描 QR 码。要在 Android Chrome 上启用此功能,请导航至 chrome://flags#web-identity-digital-credentials 并将该标志设置为“Enabled”。

  1. 扫描与出示: 用户使用 CMWallet 扫描 QR 码。wallet 获得用户批准后,将可验证表述发送回浏览器。
  2. Promise 解析: 浏览器接收到响应,前端最初的 .get() promise 最终解析,传递表述载荷。
  3. 后端验证: 前端将表述载荷 POST 到我们后端的 /api/verify/finish 端点。后端验证 nonce 和凭证。
  4. 结果: 后端向前台返回最终的成功或失败消息,前端更新 UI。

4. 构建验证器#

现在我们对标准、协议和架构流程有了扎实的理解,可以开始构建我们的验证器了。

跟着做或使用最终代码

我们现在将逐步进行设置和代码实现。如果你想直接跳到成品,可以从我们的 GitHub 仓库克隆完整项目并在本地运行。

git clone https://github.com/corbado/digital-credentials-example.git

4.1 项目设置#

首先,我们将初始化一个新的 Next.js 项目,安装必要的依赖项,并启动我们的数据库。

4.1.1 初始化 Next.js 应用#

打开你的终端,导航到你想要创建项目的目录,然后运行以下命令。我们在这个项目中使用 App Router、TypeScript 和 Tailwind CSS。

npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm

此命令将在你当前目录中搭建一个新的 Next.js 应用程序。

4.1.2 安装依赖#

接下来,我们需要安装用于处理 CBOR 解码、数据库连接和 UUID 生成的库。

npm install cbor-web mysql2 uuid @types/uuid

此命令安装:

  • cbor-web: 用于解码 mdoc 凭证载荷。
  • mysql2: 用于我们数据库的 MySQL 客户端。
  • uuid: 用于生成唯一的挑战字符串。
  • @types/uuid: uuid 库的 TypeScript 类型。

4.1.3 启动数据库#

我们的后端需要一个 MySQL 数据库来存储 OIDC 会话数据,以确保每个验证流程都是安全和有状态的。我们提供了一个 docker-compose.yml 文件来简化这个过程。

如果你已经克隆了仓库,只需运行 docker-compose up -d。如果你是从头开始构建,创建一个名为 docker-compose.yml 的文件,内容如下:

services: mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: digital_credentials MYSQL_USER: app_user MYSQL_PASSWORD: app_password ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 volumes: mysql_data:

这个 Docker Compose 设置还需要一个 SQL 初始化脚本。创建一个名为 sql 的目录,并在其中创建一个名为 init.sql 的文件,内容如下,用于设置必要的表:

-- 如果数据库不存在则创建 CREATE DATABASE IF NOT EXISTS digital_credentials; USE digital_credentials; -- 存储挑战码的表 CREATE TABLE IF NOT EXISTS challenges ( id VARCHAR(36) PRIMARY KEY, challenge VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_challenge (challenge), INDEX idx_expires_at (expires_at) ); -- 存储验证会话的表 CREATE TABLE IF NOT EXISTS verification_sessions ( id VARCHAR(36) PRIMARY KEY, challenge_id VARCHAR(36), status ENUM('pending', 'verified', 'failed', 'expired') DEFAULT 'pending', presentation_data JSON, verified_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, INDEX idx_challenge_id (challenge_id), INDEX idx_status (status) ); -- 存储已验证凭证数据的表 (可选) CREATE TABLE IF NOT EXISTS verified_credentials ( id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36), credential_type VARCHAR(255), issuer VARCHAR(255), subject VARCHAR(255), claims JSON, verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES verification_sessions(id) ON DELETE CASCADE, INDEX idx_session_id (session_id), INDEX idx_credential_type (credential_type) );

一旦两个文件都准备好,在项目根目录打开终端并运行:

docker-compose up -d

此命令将在后台启动一个 MySQL 容器。

4.2 Next.js 应用的架构概述#

我们的 Next.js 应用程序结构清晰,将前端和后端的关注点分离开来,尽管它们属于同一个项目。

  • 前端 (src/app/page.tsx): 一个单一的 React 页面,用于启动验证流程并显示结果。它与浏览器的数字凭证 API 交互。
  • 后端 API 路由 (src/app/api/verify/...):
    • start/route.ts: 生成 OpenID4VP 请求和安全 nonce。
    • finish/route.ts: 从 wallet(通过浏览器)接收表述,验证 nonce,并解码凭证。
  • 库 (src/lib/):
    • database.ts: 管理所有数据库交互(创建挑战、验证会话)。
    • crypto.ts: 处理基于 CBOR 的 mDoc 凭证的解码。

下图展示了内部架构:

4.3 构建前端#

我们的前端设计得非常轻量。其主要职责是作为验证流程面向用户的触发器,并与我们的后端以及浏览器的原生凭证处理功能进行通信。它本身不包含任何复杂的协议逻辑;这些都已委托出去。

具体来说,前端将处理以下任务:

  • 用户交互: 提供一个简单的界面,如“验证”按钮,供用户启动流程。
  • 状态管理: 管理 UI 状态,在验证进行中显示加载指示器,并显示最终的成功或错误消息。
  • 后端通信(请求): 调用 /api/verify/start 并接收一个结构化的 JSON 载荷(protocol, request, state),该载荷精确描述了 wallet 应出示的内容。
  • 浏览器 API 调用: 将该 JSON 对象传递给 navigator.credentials.get(),这将渲染一个原生的 QR 码并等待 wallet 响应。
  • 后端通信(响应): 一旦浏览器 API 返回可验证表述,它会通过 POST 请求将此数据发送到我们的 /api/verify/finish 端点进行最终的服务器端验证。
  • 显示结果: 根据后端的响应更新 UI,通知用户验证是成功还是失败。

核心逻辑在 startVerification 函数中:

// src/app/page.tsx const startVerification = async () => { setLoading(true); setVerificationResult(null); try { // 1. 检查浏览器是否支持该 API if (!navigator.credentials?.get) { throw new Error("浏览器不支持 Credential API。"); } // 2. 向我们的后端请求一个请求对象 const res = await fetch("/api/verify/start"); const { protocol, request } = await res.json(); // 3. 将该对象交给浏览器——这将触发原生 QR 码 const credential = await (navigator.credentials as any).get({ mediation: "required", digital: { requests: [ { protocol, // "openid4vp" data: request, // 包含 dcql_query, nonce 等。 }, ], }, }); // 4. 将来自浏览器的 wallet 响应转发到我们的 finish 端点进行服务器端检查 const verifyRes = await fetch("/api/verify/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (verifyRes.ok && result.verified) { setVerificationResult(`成功: ${result.message}`); } else { throw new Error(result.message || "验证失败。"); } } catch (err) { setVerificationResult(`错误: ${(err as Error).message}`); } finally { setLoading(false); } };

该函数展示了前端逻辑的四个关键步骤:检查 API 支持、从后端获取请求、调用浏览器 API,以及将结果发回进行验证。文件的其余部分是标准的 React 样板代码,用于状态和 UI 渲染,你可以在 GitHub 仓库中查看。

为什么使用 digitalmediation: 'required'#

你可能会注意到我们对 navigator.credentials.get() 的调用与简单的示例有所不同。这是因为我们严格遵守官方的 W3C 数字凭证 API 规范

  • digital 成员: 该规范要求所有数字凭证请求都嵌套在 digital 对象内。这为该 API 提供了一个清晰、标准化的命名空间,将其与其他凭证类型(如 passwordfederated)区分开来,并允许未来的扩展而不会产生冲突。

  • mediation: 'required' 这个选项是一个至关重要的安全和用户体验功能。它强制要求用户必须主动与提示进行交互(例如,生物识别扫描、PIN 输入或同意屏幕)来批准凭证请求。没有它,网站可能会在后台悄悄访问凭证,这会带来严重的隐私风险。通过要求中介,我们确保用户始终掌控一切,并对每笔交易给予明确的同意。

4.4 构建后端端点#

React UI 准备就绪后,我们现在需要两个 API 路由来在服务器上完成繁重的工作:

  1. /api/verify/start – 构建一个 OpenID4VP 请求,在 MySQL 中持久化一个一次性挑战码,并将所有内容交还给浏览器。
  2. /api/verify/finish – 接收 wallet 响应,验证挑战码,验证并解码凭证,最后向 UI 返回一个简洁的 JSON 结果。

4.4.1 /api/verify/start: 生成 OpenID4VP 请求#

// src/app/api/verify/start/route.ts import { NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { createChallenge, cleanupExpiredChallenges } from "@/lib/database"; export async function GET() { // 1️⃣ 创建一个短期的随机 nonce (挑战码) const challenge = uuidv4(); const challengeId = uuidv4(); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); await createChallenge(challengeId, challenge, expiresAt); cleanupExpiredChallenges().catch(console.error); // 2️⃣ 构建一个描述我们*想要什么*的 DCQL 查询 const dcqlQuery = { credentials: [ { id: "cred1", format: "mso_mdoc", meta: { doctype_value: "eu.europa.ec.eudi.pid.1" }, claims: [ { path: ["eu.europa.ec.eudi.pid.1", "family_name"] }, { path: ["eu.europa.ec.eudi.pid.1", "given_name"] }, { path: ["eu.europa.ec.eudi.pid.1", "birth_date"] }, ], }, ], }; // 3️⃣ 返回一个浏览器可以传递给 navigator.credentials.get() 的对象 return NextResponse.json({ protocol: "openid4vp", // 告诉浏览器使用哪个 wallet 协议 request: { dcql_query: dcqlQuery, // 要出示什么 nonce: challenge, // 防止重放攻击 response_type: "vp_token", response_mode: "dc_api", // wallet 会直接 POST 到 /finish }, state: { credential_type: "mso_mdoc", // 保留以供后续检查 nonce: challenge, challenge_id: challengeId, }, }); }

关键参数

nonce密码学挑战,用于绑定请求和响应(防止重放攻击)。• dcql_query – 一个描述我们所需确切声明的对象。在本指南中,我们使用一个受数字凭证查询语言最新草案启发的 dcql_query 结构,尽管这尚未成为最终标准。• state – wallet 返回的任意 JSON,以便我们查找数据库记录。

4.4.2 数据库辅助函数#

src/lib/database.ts 文件封装了对挑战码和验证会话的基本 MySQL 操作(插入、读取、标记为已使用)。将此逻辑保留在单个模块中,便于以后更换数据存储。


4.5 /api/verify/finish: 验证并解码表述#

// src/app/api/verify/finish/route.ts import { NextResponse, NextRequest } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getChallenge, markChallengeAsUsed, createVerificationSession, updateVerificationSession, } from "@/lib/database"; import { decodeDigitalCredential, decodeAllNamespaces } from "@/lib/crypto"; export async function POST(request: NextRequest) { const body = await request.json(); // 1️⃣ 提取可验证表述的各个部分 const vpTokenMap = body.vp_token ?? body.data?.vp_token; const state = body.state; const mdocToken = vpTokenMap?.cred1; // 我们在 dcqlQuery 中请求了这个 ID if (!vpTokenMap || !state || !mdocToken) { return NextResponse.json( { verified: false, message: "响应格式错误" }, { status: 400 }, ); } // 2️⃣ 一次性挑战码验证 const stored = await getChallenge(state.nonce); if (!stored) { return NextResponse.json( { verified: false, message: "无效或已过期的挑战码" }, { status: 400 }, ); } const sessionId = uuidv4(); await createVerificationSession(sessionId, stored.id); // 3️⃣ (伪) 密码学检查 – 在生产环境中替换为真实的 mDL 验证 // 在真实应用中,你会使用专门的库来对 mdoc 签名 // 进行完整的密码学验证,比对颁发者的公钥。 const isValid = mdocToken.length > 0; if (!isValid) { await updateVerificationSession(sessionId, "failed", { reason: "mdoc 验证失败", }); return NextResponse.json( { verified: false, message: "凭证验证失败" }, { status: 400 }, ); } // 4️⃣ 将移动驾照 (mdoc) 载荷解码为人类可读的 JSON const decoded = await decodeDigitalCredential(mdocToken); const readable = decodeAllNamespaces(decoded)["eu.europa.ec.eudi.pid.1"]; await markChallengeAsUsed(state.nonce); await updateVerificationSession(sessionId, "verified", { readable }); return NextResponse.json({ verified: true, message: "mdoc 凭证验证成功!", credentialData: readable, sessionId, }); }

wallet 响应中的重要字段

vp_token – 存放 wallet 返回的每个凭证的 map。在我们的演示中,我们提取 vp_token.cred1。• state – 我们在 /start 中提供的 blob 的回显;包含 nonce,以便我们查找数据库记录。• mdocToken – 一个 Base64URL 编码的 CBOR 结构,代表 ISO mDoc。

4.6 解码 mdoc 凭证#

当验证器从浏览器收到一个 mdoc 凭证时,它是一个包含 CBOR 编码二进制数据的 Base64URL 字符串。为了提取实际的声明,finish 端点使用 src/lib/crypto.ts 中的辅助函数执行多步解码过程。

4.6.1 第 1 步:Base64URL 和 CBOR 解码#

decodeDigitalCredential 函数处理从编码字符串到可用对象的转换:

// src/lib/crypto.ts export async function decodeDigitalCredential(encodedCredential: string) { // 1. 将 Base64URL 转换为标准 Base64 const base64UrlToBase64 = (input: string) => { let base64 = input.replace(/-/g, "+").replace(/_/g, "/"); const pad = base64.length % 4; if (pad) base64 += "=".repeat(4 - pad); return base64; }; const base64 = base64UrlToBase64(encodedCredential); // 2. 将 Base64 解码为二进制 const binaryString = atob(base64); const byteArray = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); // 3. 解码 CBOR const decoded = await cbor.decodeFirst(byteArray); return decoded; }
  • Base64URL 转 Base64: 将凭证从 Base64URL 转换为标准 Base64 编码。
  • Base64 转二进制: 将 Base64 字符串解码为二进制字节数组。
  • CBOR 解码: 使用 cbor-web 库将二进制数据解码为结构化的 JavaScript 对象。

4.6.2 第 2 步:提取命名空间声明#

decodeAllNamespaces 函数进一步处理解码后的 CBOR 对象,以从相关命名空间中提取实际声明:

// src/lib/crypto.ts export function decodeAllNamespaces(jsonObj) { const decoded = {}; try { jsonObj.documents.forEach((doc, idx) => { // 1) issuerSigned.nameSpaces: const issuerNS = doc.issuerSigned?.nameSpaces || {}; Object.entries(issuerNS).forEach(([nsName, entries]) => { if (!decoded[nsName]) decoded[nsName] = {}; (entries as any[]).forEach((entry) => { const bytes = Uint8Array.from(entry.value); const decodedEntry = cbor.decodeFirstSync(bytes); Object.assign(decoded[nsName], decodedEntry); }); }); // 2) deviceSigned.nameSpaces (如果存在): const deviceNS = doc.deviceSigned?.nameSpaces; if (deviceNS?.value?.data) { const bytes = Uint8Array.from(deviceNS.value); decoded[`deviceSigned_ns_${idx}`] = cbor.decodeFirstSync(bytes); } }); } catch (e) { console.error(e); } return decoded; }
  • 遍历所有文档 在解码后的凭证中。
  • 解码每个命名空间(例如,eu.europa.ec.eudi.pid.1)以提取实际的声明值(如姓名、出生日期等)。
  • 处理颁发者签名和设备签名的命名空间(如果存在)。

示例输出#

经过这些步骤后,finish 端点会获得一个包含 mdoc 声明的人类可读对象,例如:

{ "family_name": "Doe", "given_name": "John", "birth_date": "1990-01-01" }

这个过程确保了验证器能够安全可靠地从 mdoc 凭证中提取所需信息,以供显示和进一步处理。

4.7 在 UI 中呈现结果#

finish 端点向前台返回一个最小化的 JSON 对象:

{ "verified": true, "message": "mdoc 凭证验证成功!", "credentialData": { "family_name": "Doe", "given_name": "John", "birth_date": "1990-01-01" } }

前端在 startVerification() 中接收此响应,并简单地将其持久化在 React 状态中,这样我们就可以渲染一个漂亮的确认卡片或显示单个声明——例如,“欢迎,John Doe (出生于 1990-01-01)!”。

5. 运行验证器及后续步骤#

你现在拥有一个完整、可工作的验证器,它使用了浏览器的原生凭证处理功能。以下是如何在本地运行它,以及如何将其从概念验证阶段推进到生产就绪的应用程序。

5.1 如何运行示例#

  1. 克隆仓库:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. 安装依赖:

    npm install
  3. 启动数据库: 确保你的机器上正在运行 Docker,然后启动 MySQL 容器:

    docker-compose up -d
  4. 运行应用程序:

    npm run dev

    在浏览器中打开 http://localhost:3000,你应该能看到验证器的 UI。现在你可以使用你的 CMWallet 扫描 QR 码并完成验证流程。

5.2 后续步骤:从演示到生产#

本教程为验证器提供了基础构建模块。要使其生产就绪,你需要实现几个附加功能:

  • 完整的密码学验证: 当前的实现使用了一个占位符检查 (mdocToken.length > 0)。在真实场景中,你必须对 mdoc 签名进行完整的密码学验证,比对颁发者的公钥(例如,通过解析他们的 DID 或获取他们的公钥证书)。关于 DID 解析标准,请参考 W3C DID 解析规范

  • 颁发者撤销检查: 凭证可能会在到期日之前被颁发者撤销。生产环境的验证器必须通过查询颁发者提供的撤销列表或状态端点来检查凭证的状态。W3C 可验证凭证状态列表为凭证撤销列表提供了标准。

  • 稳健的错误处理与安全: 添加全面的错误处理、输入验证、API 端点的速率限制,并确保所有通信都通过 HTTPS (TLS) 进行,以保护传输中的数据。OWASP API 安全指南提供了全面的 API 安全最佳实践。

  • 支持多种凭证类型: 如果你期望接收的不仅仅是欧洲数字身份 (EUDI) PID 凭证,就需要扩展逻辑来处理不同的 doctype 值和凭证格式。W3C 可验证凭证数据模型提供了全面的 VC 格式规范。

5.3 本教程未涵盖的范围#

本示例有意专注于核心的浏览器介导流程,以便于理解。以下主题被认为超出了范围:

  • 生产就绪的安全性: 该验证器仅用于教育目的,缺乏在实际环境中所需的加固措施。
  • W3C 可验证凭证: 本教程仅专注于移动驾照的 ISO mDoc 格式。它不涵盖其他流行格式,如 JWT-VC 或带有链接数据证明 (LD-Proofs) 的 VC。
  • 高级 OpenID4VP 流程: 我们没有实现更复杂的 OpenID4VP 功能,例如使用 redirect_uri 的 wallet 到后端直接通信或动态客户端注册。

通过在此基础上构建并融入这些后续步骤,你可以开发出一个强大而安全的验证器,能够在自己的应用程序中信任和验证数字凭证。

结论#

就是这样!我们用不到 250 行的 TypeScript 代码,就实现了一个端到端的验证器,它能够:

  1. 为浏览器的凭证 API 发布一个请求。
  2. 让任何兼容的 wallet 提供一个可验证表述。
  3. 在服务器上验证该表述。
  4. 实时更新 UI。

在生产环境中,你会用完整的 ISO 18013-5 检查替换占位符验证,添加颁发者撤销查询、速率限制、审计日志,当然还有端到端的 TLS——但核心构建模块完全相同。

资源#

以下是本教程中使用或引用的一些关键资源、规范和工具:

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start Free Trial

Share this article


LinkedInTwitterFacebook