Aprende a construir un emisor de credenciales verificables W3C utilizando el protocolo OpenID4VCI. Esta guía paso a paso te muestra cómo crear una aplicación Next.js que emite credenciales firmadas criptográficamente y compatibles con billeteras digitales
Amine
Created: August 20, 2025
Updated: August 21, 2025
See the original blog version in English here.
Las credenciales digitales son una forma poderosa de probar la identidad y las afirmaciones de manera segura y preservando la privacidad. Pero, ¿cómo obtienen los usuarios estas credenciales en primer lugar? Aquí es donde el papel del Emisor se vuelve crucial. Un Emisor es una entidad de confianza, como una agencia gubernamental, una universidad o un banco, responsable de crear y distribuir credenciales firmadas digitalmente a los usuarios.
Esta guía ofrece un tutorial completo y paso a paso para construir un Emisor de credenciales digitales. Nos centraremos en el protocolo OpenID for Verifiable Credential Issuance (OpenID4VCI), un estándar moderno que define cómo los usuarios pueden obtener credenciales de un Emisor y almacenarlas de forma segura en sus billeteras digitales.
El resultado final será una aplicación funcional de Next.js que puede:
Recent Articles
📝
Cómo construir un verificador de credenciales digitales (Guía para desarrolladores)
📝
Cómo construir un emisor de credenciales digitales (Guía para desarrolladores)
📖
Clave residente de WebAuthn: credenciales descubribles como passkeys
🔑
Acceso con tarjetas físicas y Passkeys: Guía técnica
🔑
MFA obligatoria y transición a Passkeys: Buenas prácticas
Antes de continuar, es importante aclarar la distinción entre dos conceptos relacionados pero diferentes:
Credenciales digitales (término general): Es una categoría amplia que engloba cualquier forma digital de credenciales, certificados o atestaciones. Pueden incluir certificados digitales simples, insignias digitales básicas o cualquier credencial almacenada electrónicamente que pueda o no tener características de seguridad criptográfica.
Credenciales verificables (VCs - Estándar W3C): Es un tipo específico de credencial digital que sigue el estándar del Modelo de Datos de Credenciales Verificables del W3C. Las credenciales verificables son credenciales firmadas criptográficamente, a prueba de manipulaciones y que respetan la privacidad, y que pueden ser verificadas de forma independiente. Incluyen requisitos técnicos específicos como:
En esta guía, estamos construyendo específicamente un emisor de credenciales verificables que sigue el estándar W3C, no solo un sistema de credenciales digitales cualquiera. El protocolo OpenID4VCI que estamos usando está diseñado específicamente para emitir credenciales verificables, y el formato JWT-VC que implementaremos es un formato compatible con W3C para credenciales verificables.
La magia detrás de las credenciales digitales reside en un modelo simple pero poderoso de "triángulo de confianza" que involucra a tres actores clave:
El flujo de emisión es el primer paso en este ecosistema. El Emisor valida la información del usuario y le proporciona una credencial. Una vez que el Titular tiene esta credencial en su billetera, puede presentarla a un Verificador para probar su identidad o sus afirmaciones, completando así el triángulo.
Aquí un vistazo rápido a la aplicación final en acción:
Paso 1: Ingreso de datos del usuario El usuario completa un formulario con su información personal para solicitar una nueva credencial.
Paso 2: Generación de la oferta de credencial La aplicación genera una oferta de credencial segura, que se muestra como un código QR y un código preautorizado.
Paso 3: Interacción con la billetera El usuario escanea el código QR con una billetera compatible (por ejemplo, Sphereon Wallet) e introduce un PIN para autorizar la emisión.
Paso 4: Credencial emitida La billetera recibe y almacena la nueva credencial digital emitida, lista para su uso futuro.
Antes de sumergirnos en el código, cubramos los conocimientos y herramientas fundamentales que necesitaremos. Esta guía asume que tienes una familiaridad básica con los conceptos de desarrollo web, pero los siguientes prerrequisitos son esenciales para construir un emisor de credenciales.
Nuestro emisor se basa en un conjunto de estándares abiertos que garantizan la interoperabilidad entre billeteras y servicios de emisión. Para este tutorial, nos centraremos en lo siguiente:
Estándar / Protocolo | Descripción |
---|---|
OpenID4VCI | OpenID for Verifiable Credential Issuance. Este es el protocolo central que usaremos. Define un flujo estándar sobre cómo un usuario (a través de su billetera) puede solicitar y recibir una credencial de un Emisor. |
JWT-VC | Credenciales verificables basadas en JWT. El formato de la credencial que emitiremos. Es un estándar del W3C que codifica las credenciales verificables como JSON Web Tokens (JWT), haciéndolas compactas y amigables para la web. |
ISO mDoc | ISO/IEC 18013-5. El estándar internacional para las licencias de conducir móviles (mDL). Aunque emitimos un JWT-VC, las claims dentro de él están estructuradas para ser compatibles con el modelo de datos mDoc (por ejemplo, eu.europa.ec.eudi.pid.1 ). |
OAuth 2.0 | El marco de autorización subyacente utilizado por OpenID4VCI. Implementaremos un flujo de pre-authorized_code , que es un tipo de concesión específico diseñado para una emisión de credenciales segura y fácil de usar. |
OpenID4VCI admite dos flujos de autorización principales para emitir credenciales:
Flujo de código preautorizado: En este flujo, el Emisor genera un código de corta
duración y de un solo uso (pre-authorized_code
) que está disponible de inmediato para
el usuario. La billetera del usuario puede luego intercambiar este código directamente
por una credencial. Este flujo es ideal para escenarios donde el usuario ya está
autenticado y presente en el sitio web del Emisor, ya que
proporciona una experiencia de emisión instantánea y fluida sin redirecciones.
Flujo de código de autorización: Este es el flujo estándar de
OAuth 2.0, donde el usuario es redirigido a un servidor de
autorización para otorgar su consentimiento. Después de la aprobación, el servidor
envía un authorization_code
de vuelta a una redirect_uri
registrada. Este flujo es
más adecuado para aplicaciones de terceros que inician el proceso de emisión en nombre
del usuario.
Para este tutorial, usaremos el flujo de pre-authorized_code
. Elegimos este enfoque
porque es más simple y proporciona una experiencia de usuario más directa para nuestro
caso de uso específico: un usuario que solicita directamente una credencial desde el
propio sitio web del Emisor. Elimina la necesidad de redirecciones
complejas y registro de clientes, lo que facilita la comprensión e implementación de la
lógica central de emisión.
Esta combinación de estándares nos permite construir un emisor compatible con una amplia gama de billeteras digitales y garantiza un proceso seguro y estandarizado para el usuario.
Para construir nuestro emisor, utilizaremos la misma pila tecnológica robusta y moderna que usamos para el verificador, asegurando una experiencia de desarrollador consistente y de alta calidad.
Usaremos TypeScript tanto para nuestro código de frontend como de backend. Su tipado estático es invaluable en una aplicación crítica para la seguridad como un emisor, ya que ayuda a prevenir errores comunes y mejora la calidad y mantenibilidad general del código.
Next.js es nuestro framework de elección porque proporciona una experiencia fluida e integrada para construir aplicaciones full-stack.
Nuestra implementación se basará en algunas librerías clave para manejar tareas específicas:
pre-authorized_code
.Para probar tu emisor, necesitarás una billetera móvil que admita el protocolo OpenID4VCI. Para este tutorial, recomendamos la Sphereon Wallet, que está disponible tanto para Android como para iOS.
Cómo instalar Sphereon Wallet:
Emitir una credencial es una operación crítica para la seguridad que se basa en conceptos criptográficos fundamentales para garantizar la confianza y la autenticidad.
En su núcleo, una credencial verificable es un conjunto de afirmaciones que ha sido firmado digitalmente por el Emisor. Esta firma proporciona dos garantías:
Las firmas digitales se crean utilizando criptografía de clave pública/privada. Así es como funciona:
En nuestra implementación, generaremos un par de claves de Curva Elíptica (EC) y usaremos
el algoritmo ES256
para firmar el JWT-VC. La clave pública se incrusta en el DID del
Emisor (did:web
), permitiendo que cualquier Verificador la descubra y valide la firma de
la credencial. Nota: La claim aud
(audiencia) se omite intencionadamente en nuestros
JWTs, ya que la credencial está diseñada para ser de propósito general y no estar
vinculada a una billetera específica. Si deseas restringir el uso a una audiencia
particular, incluye una claim aud
y configúrala en consecuencia.
Nuestra aplicación de emisor está construida como un proyecto
full-stack de Next.js, con una clara
separación entre la lógica del frontend y del backend. Esta arquitectura nos permite crear
una experiencia de usuario fluida mientras manejamos todas las operaciones críticas para
la seguridad en el servidor. Importante: Las tablas verification_sessions
y
verified_credentials
incluidas en el SQL no son necesarias para este emisor, pero se
incluyen para que esté completo.
src/app/issue/page.tsx
): Una única página de
React que permite a los usuarios introducir sus datos para
solicitar una credencial. Realiza llamadas a la API de nuestro backend para iniciar el
proceso de emisión.src/app/api/issue/...
): Un conjunto de endpoints del
lado del servidor que implementan el protocolo OpenID4VCI.
/.well-known/openid-credential-issuer
: Un endpoint de metadatos público. Esta es
la primera URL que una billetera comprobará para descubrir las capacidades del
emisor, incluyendo su servidor de autorización, endpoint de token, endpoint de
credencial y los tipos de credenciales que ofrece./.well-known/openid-configuration
: Un endpoint de descubrimiento estándar de
OpenID Connect. Aunque está estrechamente relacionado con el anterior, este endpoint
sirve para una configuración más amplia relacionada con OIDC y a menudo es necesario
para la interoperabilidad con clientes OpenID estándar./.well-known/did.json
: El documento DID de nuestro emisor. Al usar el método
did:web
, este archivo se utiliza para publicar las claves públicas del emisor, que
los verificadores pueden usar para validar las firmas de las credenciales que emite.authorize/route.ts
: Crea un pre-authorized_code
y una oferta de credencial.token/route.ts
: Intercambia el pre-authorized_code
por un
token de acceso.credential/route.ts
: Emite el JWT-VC final, firmado criptográficamente.schemas/pid/route.ts
: Expone el esquema JSON para la credencial PID. Esto permite
que cualquier consumidor de la credencial entienda su estructura y tipos de datos.src/lib/
):
database.ts
: Gestiona todas las interacciones con la base de datos, como el
almacenamiento de códigos de autorización y claves del emisor.crypto.ts
: Maneja todas las operaciones criptográficas, incluyendo la generación
de claves y la firma de JWT.Aquí hay un diagrama que ilustra el flujo de emisión:
Ahora que tenemos una comprensión sólida de los estándares, protocolos y arquitectura, podemos empezar a construir nuestro emisor.
Sigue los pasos o usa el código final
Ahora repasaremos la configuración y la implementación del código paso a paso. Si prefieres ir directamente al producto terminado, puedes clonar el proyecto completo desde nuestro repositorio de GitHub y ejecutarlo localmente.
git clone https://github.com/corbado/digital-credentials-example.git
Primero, inicializaremos un nuevo proyecto de Next.js, instalaremos las dependencias necesarias y pondremos en marcha nuestra base de datos.
Abre tu terminal, navega al directorio donde quieres crear tu proyecto y ejecuta el siguiente comando. Estamos usando el App Router, TypeScript y Tailwind CSS para este proyecto.
npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm
Este comando crea un esqueleto de una nueva aplicación Next.js en tu directorio actual.
A continuación, necesitamos instalar las librerías que manejarán los JWTs, las conexiones a la base de datos y la generación de UUIDs.
npm install jose mysql2 uuid @types/uuid
Este comando instala:
jose
: Para firmar y verificar JSON Web Tokens (JWTs).mysql2
: El cliente de MySQL para nuestra base
de datos.uuid
: Para generar cadenas de desafío únicas.@types/uuid
: Tipos de TypeScript para la librería uuid
.Nuestro backend requiere una base de datos MySQL
para almacenar códigos de autorización, sesiones de emisión y claves del emisor. Hemos
incluido un archivo docker-compose.yml
para facilitar esto.
Si has clonado el repositorio, simplemente puedes ejecutar docker-compose up -d
. Si
estás construyendo desde cero, crea un archivo llamado docker-compose.yml
con el
siguiente contenido:
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:
Esta configuración de Docker Compose también requiere un script de inicialización SQL.
Crea un directorio llamado sql
y dentro de él, un archivo llamado init.sql
con el
siguiente contenido para configurar las tablas necesarias tanto para el verificador como
para el emisor:
-- Crear la base de datos si no existe CREATE DATABASE IF NOT EXISTS digital_credentials; USE digital_credentials; -- Tabla para almacenar desafíos 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) ); -- Tabla para almacenar sesiones de verificación 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) ); -- Tabla para almacenar datos de credenciales verificadas (opcional) 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) ); -- TABLAS DEL EMISOR -- Tabla para almacenar códigos de autorización en el flujo OpenID4VCI CREATE TABLE IF NOT EXISTS authorization_codes ( id VARCHAR(36) PRIMARY KEY, code VARCHAR(255) NOT NULL UNIQUE, client_id VARCHAR(255), scope VARCHAR(255), code_challenge VARCHAR(255), code_challenge_method VARCHAR(50), redirect_uri TEXT, user_pin VARCHAR(10), expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_code (code), INDEX idx_expires_at (expires_at) ); -- Tabla para almacenar sesiones de emisión CREATE TABLE IF NOT EXISTS issuance_sessions ( id VARCHAR(36) PRIMARY KEY, authorization_code_id VARCHAR(36), access_token VARCHAR(255), token_type VARCHAR(50) DEFAULT 'Bearer', expires_in INT DEFAULT 3600, c_nonce VARCHAR(255), c_nonce_expires_at TIMESTAMP, status ENUM('pending', 'authorized', 'credential_issued', 'expired', 'failed') DEFAULT 'pending', user_data JSON, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE, INDEX idx_access_token (access_token), INDEX idx_c_nonce (c_nonce), INDEX idx_status (status) ); -- Tabla para almacenar credenciales emitidas CREATE TABLE IF NOT EXISTS issued_credentials ( id VARCHAR(36) PRIMARY KEY, session_id VARCHAR(36), credential_id VARCHAR(255), credential_type VARCHAR(255) DEFAULT 'jwt_vc', doctype VARCHAR(255) DEFAULT 'eu.europa.ec.eudi.pid.1', credential_data LONGTEXT, -- mDoc codificado en Base64 credential_claims JSON, issuer_did VARCHAR(255), subject_id VARCHAR(255), issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP, revoked BOOLEAN DEFAULT FALSE, revoked_at TIMESTAMP NULL, FOREIGN KEY (session_id) REFERENCES issuance_sessions(id) ON DELETE CASCADE, INDEX idx_credential_id (credential_id), INDEX idx_session_id (session_id), INDEX idx_doctype (doctype), INDEX idx_subject_id (subject_id), INDEX idx_issued_at (issued_at) ); -- Tabla para almacenar claves del emisor (simplificada para la demo) CREATE TABLE IF NOT EXISTS issuer_keys ( id VARCHAR(36) PRIMARY KEY, key_id VARCHAR(255) NOT NULL UNIQUE, key_type VARCHAR(50) NOT NULL, -- 'EC', 'RSA' algorithm VARCHAR(50) NOT NULL, -- 'ES256', 'RS256', etc. public_key TEXT NOT NULL, -- formato JWK private_key TEXT NOT NULL, -- formato JWK (cifrado en producción) is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_key_id (key_id), INDEX idx_is_active (is_active) );
Una vez que ambos archivos estén en su lugar, abre tu terminal en la raíz del proyecto y ejecuta:
docker-compose up -d
Este comando iniciará un contenedor de MySQL en segundo plano, listo para que nuestra aplicación lo use.
Antes de construir los endpoints de la API, vamos a crear las librerías compartidas que manejarán la lógica de negocio principal. Este enfoque mantiene nuestras rutas de API limpias y centradas en manejar las solicitudes HTTP, mientras que el trabajo complejo se delega a estos módulos.
src/lib/database.ts
)#Este archivo es la única fuente de verdad para todas las interacciones con la base de
datos. Utiliza la librería mysql2
para conectarse a nuestro contenedor de MySQL y
proporciona un conjunto de funciones exportadas para crear, leer y actualizar registros en
nuestras tablas. Esta capa de abstracción hace que nuestro código sea más modular y fácil
de mantener.
Crea el archivo src/lib/database.ts
con el siguiente contenido:
// src/lib/database.ts import mysql from "mysql2/promise"; // Configuración de la conexión a la base de datos const dbConfig = { host: process.env.DATABASE_HOST || "localhost", port: parseInt(process.env.DATABASE_PORT || "3306"), user: process.env.DATABASE_USER || "app_user", password: process.env.DATABASE_PASSWORD || "app_password", database: process.env.DATABASE_NAME || "digital_credentials", timezone: "+00:00", }; let connection: mysql.Connection | null = null; export async function getConnection(): Promise<mysql.Connection> { if (!connection) { connection = await mysql.createConnection(dbConfig); } return connection; } // Funciones de Data-Access-Object (DAO) para cada tabla // ... (ej., createChallenge, getChallenge, createAuthorizationCode, etc.)
Nota: Por brevedad, se ha omitido la lista completa de funciones DAO. Puedes encontrar el código completo en el repositorio del proyecto. Este archivo incluye funciones para gestionar desafíos, sesiones de verificación, códigos de autorización, sesiones de emisión y claves de emisor.
src/lib/crypto.ts
)#Este archivo maneja todas las operaciones criptográficas críticas para la seguridad.
Utiliza la librería jose
para generar pares de claves y firmar JSON Web Tokens (JWTs).
Generación de claves La función generateIssuerKeyPair
crea un nuevo par de claves de
Curva Elíptica que se utilizará para firmar credenciales. La clave pública se exporta en
formato JSON Web Key (JWK) para que pueda publicarse en nuestro documento did.json
.
// src/lib/crypto.ts import { generateKeyPair, exportJWK, SignJWT } from "jose"; export async function generateIssuerKeyPair(keyId: string, issuerDid: string) { const { publicKey, privateKey } = await generateKeyPair("ES256", { crv: "P-256", extractable: true, }); const publicKeyJWK = await exportJWK(publicKey); publicKeyJWK.kid = keyId; // Asignar un ID de clave único // ... (exportación de la clave privada y otra configuración) return { publicKey, privateKey, publicKeyJWK /* ... */ }; }
Creación de credenciales JWT La función createJWTVerifiableCredential
es el núcleo
del proceso de emisión. Toma las afirmaciones del usuario, el par de claves del emisor y
otros metadatos, y los utiliza para crear un JWT-VC firmado.
// src/lib/crypto.ts export async function createJWTVerifiableCredential( claims: MDocClaims, issuerKeyPair: IssuerKeyPair, subjectId: string, audience: string, ): Promise<string> { const now = Math.floor(Date.now() / 1000); const oneYear = 365 * 24 * 60 * 60; const vcPayload = { // El DID del emisor iss: issuerKeyPair.issuerDid, // El DID del sujeto (titular) sub: subjectId, // La hora en que se emitió la credencial (iat) y cuándo expira (exp) iat: now, exp: now + oneYear, // El modelo de datos de Credencial Verificable vc: { "@context": [ "https://www.w3.org/2018/credentials/v1", "https://europa.eu/eudi/pid/v1", ], type: ["VerifiableCredential", "eu.europa.ec.eudi.pid.1"], issuer: issuerKeyPair.issuerDid, issuanceDate: new Date(now * 1000).toISOString(), credentialSubject: { id: subjectId, ...claims, }, }, }; // Firmar el payload con la clave privada del emisor return await new SignJWT(vcPayload) .setProtectedHeader({ alg: issuerKeyPair.algorithm, kid: issuerKeyPair.keyId, typ: "JWT", }) .sign(issuerKeyPair.privateKey); }
Esta función construye el payload del JWT de acuerdo con el Modelo de Datos de Credenciales Verificables del W3C y lo firma con la clave privada del emisor, produciendo una credencial verificable segura.
Nuestra aplicación Next.js está estructurada para separar responsabilidades entre el frontend y el backend, aunque forman parte del mismo proyecto. Esto se logra aprovechando el App Router tanto para las páginas de la interfaz de usuario como para los endpoints de la API.
Frontend (src/app/issue/page.tsx
): Un único componente de página de
React que define la interfaz de usuario para la ruta /issue
.
Maneja la entrada del usuario y se comunica con nuestra API de backend.
Rutas de API del backend (src/app/api/...
):
.well-known/.../route.ts
): Estas rutas exponen endpoints de
metadatos públicos que permiten a las billeteras y otros clientes descubrir las
capacidades y las claves públicas del emisor.issue/.../route.ts
): Estos endpoints implementan la lógica central de
OpenID4VCI, incluyendo la creación de ofertas de credenciales, la emisión de tokens
y la firma de la credencial final.schemas/pid/route.ts
): Esta ruta sirve el esquema JSON para la
credencial, definiendo su estructura.Librería (src/lib/
): Este directorio contiene lógica reutilizable compartida en
todo el backend.
database.ts
: Gestiona todas las interacciones con la base de datos, abstrayendo
las consultas SQL.crypto.ts
: Maneja todas las operaciones criptográficas, como la generación de
claves y la firma de JWT.Esta clara separación hace que la aplicación sea modular y más fácil de mantener.
Nota: La función generateIssuerDid()
debe devolver un did:web
válido que coincida
con tu dominio de emisor. Cuando se despliegue, el archivo .well-known/did.json
debe
servirse a través de HTTPS en ese dominio para que los verificadores puedan validar las
credenciales.
Nuestro frontend es una única página de React que proporciona un formulario simple para que los usuarios soliciten una nueva credencial digital. Sus responsabilidades son:
La lógica central se maneja en la función handleSubmit
, que se activa cuando el usuario
envía el formulario.
// src/app/issue/page.tsx const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); setCredentialOffer(null); try { // 1. Validar campos requeridos if (!userData.given_name || !userData.family_name || !userData.birth_date) { throw new Error("Por favor, rellena todos los campos requeridos"); } // 2. Solicitar una oferta de credencial al backend const response = await fetch("/api/issue/authorize", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ user_data: userData, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error( errorData.error_description || "Fallo al crear la oferta de credencial", ); } // 3. Establecer la oferta de credencial en el estado para mostrar el código QR const result = await response.json(); setCredentialOffer(result); } catch (err) { const errorMessage = (err as Error).message || "Ocurrió un error desconocido"; setError(errorMessage); } finally { setLoading(false); } };
Esta función realiza tres acciones clave:
POST
a nuestro endpoint /api/issue/authorize
con los datos
del usuario.El resto del archivo contiene código estándar de React para renderizar el formulario y mostrar el código QR. Puedes ver el archivo completo en el repositorio del proyecto.
Antes de construir la API del backend, necesitamos configurar nuestro entorno y establecer
los endpoints de descubrimiento. Estos archivos .well-known
son cruciales para que las
billeteras encuentren nuestro emisor y entiendan cómo interactuar con él.
Crea un archivo llamado .env.local
en la raíz de tu proyecto y añade la siguiente línea.
Esta URL debe ser accesible públicamente para que una billetera móvil pueda llegar a ella.
Para el desarrollo local, puedes usar un servicio de túnel como
ngrok para exponer tu localhost
.
NEXT_PUBLIC_BASE_URL=http://localhost:3000
Las billeteras descubren las capacidades de un emisor consultando URLs estándar
.well-known
. Necesitamos crear tres de estos endpoints.
1. Metadatos del emisor (/.well-known/openid-credential-issuer
)
Este es el archivo de descubrimiento principal para OpenID4VCI. Le dice a la billetera todo lo que necesita saber sobre el emisor, incluyendo sus endpoints, los tipos de credenciales que ofrece y los algoritmos criptográficos compatibles.
Crea el archivo src/app/.well-known/openid-credential-issuer/route.ts
:
// src/app/.well-known/openid-credential-issuer/route.ts import { NextResponse } from "next/server"; export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const issuerMetadata = { // El identificador único del emisor. issuer: baseUrl, // La URL del servidor de autorización. Para simplificar, nuestro emisor es su propio servidor de autorización. authorization_servers: [baseUrl], // La URL del emisor de la credencial. credential_issuer: baseUrl, // El endpoint al que la billetera hará POST para recibir la credencial real. credential_endpoint: `${baseUrl}/api/issue/credential`, // El endpoint donde la billetera intercambia un código de autorización por un token de acceso. token_endpoint: `${baseUrl}/api/issue/token`, // El endpoint para el flujo de autorización (no se usa en nuestro flujo preautorizado, pero es una buena práctica incluirlo). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Indica soporte para el flujo de código preautorizado sin requerir autenticación del cliente. pre_authorized_grant_anonymous_access_supported: true, // Información legible por humanos sobre el emisor. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // Una lista de los tipos de credenciales que este emisor puede emitir. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // El formato de la credencial (ej., jwt_vc, mso_mdoc). format: "jwt_vc", // El tipo de documento específico, conforme a los estándares ISO mDoc. doctype: "eu.europa.ec.eudi.pid.1", // El scope de OAuth 2.0 asociado con este tipo de credencial. scope: "eu.europa.ec.eudi.pid.1", // Métodos que la billetera puede usar para probar la posesión de su clave. cryptographic_binding_methods_supported: ["jwk"], // Algoritmos de firma que el emisor soporta para esta credencial. credential_signing_alg_values_supported: ["ES256"], // Tipos de prueba de posesión que la billetera puede usar. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Propiedades de visualización para la credencial. display: [ { name: "Corbado Credential Issuer", locale: "en-US", logo: { uri: `${baseUrl}/logo.png`, alt_text: "EU Digital Identity", }, background_color: "#003399", text_color: "#FFFFFF", }, ], // Una lista de las claims (atributos) en la credencial. claims: { "eu.europa.ec.eudi.pid.1": { given_name: { mandatory: true, display: [{ name: "Given Name", locale: "en-US" }], }, family_name: { mandatory: true, display: [{ name: "Family Name", locale: "en-US" }], }, birth_date: { mandatory: true, display: [{ name: "Date of Birth", locale: "en-US" }], }, }, }, }, }, // Métodos de autenticación soportados por el token endpoint. 'none' significa cliente público. token_endpoint_auth_methods_supported: ["none"], // Métodos de desafío de código PKCE soportados. code_challenge_methods_supported: ["S256"], // Tipos de concesión de OAuth 2.0 que el emisor soporta. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], }; return NextResponse.json(issuerMetadata, { headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", Expires: "0", }, }); }
2. Configuración de OpenID (/.well-known/openid-configuration
)
Este es un documento de descubrimiento estándar de OIDC que proporciona un conjunto más amplio de detalles de configuración.
Crea el archivo src/app/.well-known/openid-configuration/route.ts
:
// src/app/.well-known/openid-configuration/route.ts import { NextResponse } from "next/server"; export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const openidConfiguration = { // El identificador único del emisor. credential_issuer: baseUrl, // El endpoint al que la billetera hará POST para recibir la credencial real. credential_endpoint: `${baseUrl}/api/issue/credential`, // El endpoint para el flujo de autorización. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // El endpoint donde la billetera intercambia un código de autorización por un token de acceso. token_endpoint: `${baseUrl}/api/issue/token`, // Una lista de los tipos de credenciales que este emisor puede emitir. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { format: "jwt_vc", scope: "eu.europa.ec.eudi.pid.1", cryptographic_binding_methods_supported: ["jwk"], credential_signing_alg_values_supported: ["ES256", "ES384", "ES512"], proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, }, }, // Tipos de concesión de OAuth 2.0 que el emisor soporta. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Indica soporte para el flujo de código preautorizado. pre_authorized_grant_anonymous_access_supported: true, // Métodos de desafío de código PKCE soportados. code_challenge_methods_supported: ["S256"], // Métodos de autenticación soportados por el token endpoint. token_endpoint_auth_methods_supported: ["none"], // Scopes de OAuth 2.0 que el emisor soporta. scopes_supported: ["eu.europa.ec.eudi.pid.1"], }; return NextResponse.json(openidConfiguration, { headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", Expires: "0", }, }); }
3. Documento DID (/.well-known/did.json
)
Este archivo publica la clave pública del emisor utilizando el método did:web
,
permitiendo a cualquiera verificar la firma de las credenciales emitidas por él.
Crea el archivo src/app/.well-known/did.json/route.ts
:
// src/app/.well-known/did.json/route.ts import { NextResponse } from "next/server"; import { getActiveIssuerKey } from "../../../lib/database"; import { generateIssuerDid } from "../../../lib/crypto"; export async function GET() { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { return NextResponse.json( { error: "No se encontró una clave de emisor activa" }, { status: 404 }, ); } const publicKeyJWK = JSON.parse(issuerKey.public_key); const didId = generateIssuerDid(); const didDocument = { // El contexto define el vocabulario utilizado en el documento. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // La URI del DID, que es el identificador único del emisor. id: didId, // El controlador del DID, que es la entidad que controla el DID. Aquí, es el propio emisor. controller: didId, // Una lista de claves públicas que se pueden usar para verificar las firmas del emisor. verificationMethod: [ { // Un identificador único para la clave, dentro del ámbito del DID. id: `${didId}#${issuerKey.key_id}`, // El tipo de la clave. type: "JsonWebKey2020", // El DID del controlador de la clave. controller: didId, // La clave pública en formato JWK. publicKeyJwk: publicKeyJWK, }, ], // Especifica qué claves se pueden usar para la autenticación (probar el control del DID). authentication: [`${didId}#${issuerKey.key_id}`], // Especifica qué claves se pueden usar para crear credenciales verificables. assertionMethod: [`${didId}#${issuerKey.key_id}`], // Una lista de servicios proporcionados por el sujeto del DID, como el endpoint del emisor. service: [ { id: `${didId}#openid-credential-issuer`, type: "OpenIDCredentialIssuer", serviceEndpoint: `${baseUrl}/.well-known/openid-credential-issuer`, }, ], }; return NextResponse.json(didDocument, { headers: { "Content-Type": "application/did+json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", Expires: "0", }, }); }
¿Por qué no usar caché? Notarás que estos tres endpoints devuelven cabeceras que
evitan agresivamente el almacenamiento en caché (Cache-Control: no-cache
,
Pragma: no-cache
, Expires: 0
). Esta es una práctica de seguridad crítica para los
documentos de descubrimiento. Las configuraciones del emisor pueden cambiar; por
ejemplo, una clave criptográfica podría ser rotada. Si una billetera o un cliente
almacenara en caché una versión antigua del archivo did.json
o
openid-credential-issuer
, no podría validar nuevas credenciales o interactuar con
endpoints actualizados. Al forzar a los clientes a obtener una copia nueva en cada
solicitud, nos aseguramos de que siempre tengan la información más actualizada.
La última pieza de nuestra infraestructura pública es el endpoint del esquema de la credencial. Esta ruta sirve un esquema JSON que define formalmente la estructura, los tipos de datos y las restricciones de la credencial PID que estamos emitiendo. Las billeteras y los verificadores pueden usar este esquema para validar el contenido de la credencial.
Crea el archivo src/app/api/schemas/pid/route.ts
con el siguiente contenido:
// src/app/api/schemas/pid/route.ts import { NextResponse } from "next/server"; export async function GET() { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://example.com/schemas/pid", // Reemplaza con tu dominio real title: "Credencial PID", description: "Un esquema para una Credencial Verificable que representa un Documento de Identificación Personal (PID).", type: "object", properties: { credentialSubject: { type: "object", properties: { given_name: { type: "string" }, family_name: { type: "string" }, birth_date: { type: "string", format: "date" }, // ... otras propiedades del sujeto de la credencial }, required: ["given_name", "family_name", "birth_date"], }, // ... otras propiedades de nivel superior de una Credencial Verificable }, }; return NextResponse.json(schema, { headers: { "Content-Type": "application/schema+json", "Access-Control-Allow-Origin": "*", // Permitir solicitudes de origen cruzado }, }); }
Nota: El esquema JSON para una credencial PID puede ser bastante grande y detallado. Por brevedad, el esquema completo ha sido truncado. Puedes encontrar el archivo completo en el repositorio del proyecto.
Con el frontend en su lugar, ahora necesitamos la lógica del lado del servidor para
manejar el flujo de OpenID4VCI. Empezaremos con el primer endpoint que el frontend llama:
/api/issue/authorize
.
/api/issue/authorize
: Crear la oferta de credencial#Este endpoint es responsable de tomar los datos del usuario, generar un código seguro de
un solo uso y construir una credential_offer
que la billetera del usuario pueda
entender.
Aquí está la lógica central:
// src/app/api/issue/authorize/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { createAuthorizationCode } from "@/lib/database"; export async function POST(request: NextRequest) { try { const body = await request.json(); const { user_data } = body; // 1. Validar los datos del usuario if ( !user_data || !user_data.given_name || !user_data.family_name || !user_data.birth_date ) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 2. Generar un código preautorizado y un PIN const code = uuidv4(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutos const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // PIN de 4 dígitos // 3. Almacenar el código y los datos del usuario await createAuthorizationCode(uuidv4(), code, expiresAt); // Nota: Esto utiliza un almacén en memoria solo para fines de demostración. // En producción, persiste los datos de forma segura en una base de datos con una expiración adecuada. if (!(global as any).userDataStore) (global as any).userDataStore = new Map(); (global as any).userDataStore.set(code, user_data); if (!(global as any).txCodeStore) (global as any).txCodeStore = new Map(); (global as any).txCodeStore.set(code, txCode); // 4. Crear el objeto de oferta de credencial const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const credentialOffer = { // El identificador del emisor, que es su URL base. credential_issuer: baseUrl, // Un array de tipos de credenciales que el emisor está ofreciendo. credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"], // Especifica los tipos de concesión que la billetera puede usar. grants: { // Estamos usando el flujo de código preautorizado. "urn:ietf:params:oauth:grant-type:pre-authorized_code": { // El código de un solo uso que la billetera intercambiará por un token. "pre-authorized_code": code, // Indica que el usuario debe introducir un PIN (tx_code) para canjear el código. user_pin_required: true, }, }, }; // 5. Crear la URI completa de la oferta de credencial (un deep link para billeteras) const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent( JSON.stringify(credentialOffer), )}`; // La respuesta final al frontend. return NextResponse.json({ // El deep link para el código QR. credential_offer_uri: credentialOfferUri, // El código preautorizado en crudo, para mostrarlo o para entrada manual. pre_authorized_code: code, // El PIN de 4 dígitos que el usuario debe introducir en su billetera. tx_code: txCode, }); } catch (error) { console.error("Error de autorización:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }
Pasos clave en este endpoint:
pre-authorized_code
único (un UUID) y un tx_code
de 4
dígitos (PIN) para una capa extra de seguridad.pre-authorized_code
se almacena en la base de datos con un
tiempo de expiración corto. Los datos del usuario y el PIN se almacenan en memoria,
vinculados al código.credential_offer
según la especificación de
OpenID4VCI. Este objeto le dice a la billetera dónde está el emisor, qué credenciales
ofrece y el código necesario para obtenerlas.openid-credential-offer://...
) y la devuelve al frontend, junto con el tx_code
para que el usuario lo vea./api/issue/token
: Intercambiar el código por un token#Una vez que el usuario escanea el código QR e introduce su PIN, la billetera realiza una
solicitud POST
a este endpoint. Su trabajo es validar el pre-authorized_code
y el
user_pin
(PIN), y si son válidos, emitir un token de acceso de
corta duración.
Crea el archivo src/app/api/issue/token/route.ts
con el siguiente contenido:
// src/app/api/issue/token/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getAuthorizationCode, markAuthorizationCodeAsUsed, createIssuanceSession, } from "@/lib/database"; export async function POST(request: NextRequest) { try { const formData = await request.formData(); const grant_type = formData.get("grant_type") as string; const code = formData.get("pre-authorized_code") as string; const user_pin = formData.get("user_pin") as string; // 1. Validar el tipo de concesión if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") { return NextResponse.json( { error: "unsupported_grant_type" }, { status: 400 }, ); } // 2. Validar el código preautorizado const authCode = await getAuthorizationCode(code); if (!authCode) { return NextResponse.json( { error: "invalid_grant", error_description: "Código inválido o expirado", }, { status: 400 }, ); } // 3. Validar el PIN (tx_code) const expectedTxCode = (global as any).txCodeStore?.get(code); if (expectedTxCode !== user_pin) { return NextResponse.json( { error: "invalid_grant", error_description: "PIN inválido" }, { status: 400 }, ); } // 4. Generar token de acceso y c_nonce const accessToken = uuidv4(); const cNonce = uuidv4(); const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutos // 5. Crear una nueva sesión de emisión const userData = (global as any).userDataStore?.get(code); await createIssuanceSession( uuidv4(), authCode.id, accessToken, cNonce, cNonceExpiresAt, userData, ); // 6. Marcar el código como usado y limpiar los datos temporales await markAuthorizationCodeAsUsed(code); (global as any).txCodeStore?.delete(code); (global as any).userDataStore?.delete(code); // 7. Devolver la respuesta del token de acceso return NextResponse.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600, // 1 hora c_nonce: cNonce, c_nonce_expires_in: 300, // 5 minutos }); } catch (error) { console.error("Error en el endpoint del token:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }
Pasos clave en este endpoint:
pre-authorized_code
correcto.pre-authorized_code
exista en la base de datos,
no haya expirado y no se haya utilizado antes.user_pin
de la billetera con el tx_code
que almacenamos
anteriormente para asegurar que el usuario autorizó la transacción.access_token
seguro y un c_nonce
(nonce de credencial),
que es un valor de un solo uso para prevenir ataques de repetición en el endpoint de la
credencial.issuance_sessions
en la base de datos,
vinculando el token de acceso a los datos del usuario.pre-authorized_code
como usado.access_token
y el c_nonce
a la billetera./api/issue/credential
: Emitir la credencial firmada#Este es el endpoint final y más importante. La billetera utiliza el token de acceso que
recibió del endpoint /token
para realizar una solicitud POST
autenticada a esta ruta.
El trabajo de este endpoint es realizar la validación final, crear la credencial firmada
criptográficamente y devolverla a la billetera.
Crea el archivo src/app/api/issue/credential/route.ts
con el siguiente contenido:
// src/app/api/issue/credential/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getIssuanceSessionByToken, updateIssuanceSession, createIssuedCredential, getActiveIssuerKey, } from "@/lib/database"; import { createJWTVerifiableCredential, importIssuerKeyPair, generateIssuerDid, } from "@/lib/crypto"; export async function POST(request: NextRequest) { try { // 1. Validar el token Bearer const authHeader = request.headers.get("authorization"); const accessToken = authHeader?.substring(7); const session = await getIssuanceSessionByToken(accessToken); if (!session) { return NextResponse.json({ error: "invalid_token" }, { status: 401 }); } // 2. Obtener los datos del usuario de la sesión const userData = session.user_data; if (!userData) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 3. Obtener la clave de emisor activa const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { // En una aplicación real, tendrías un sistema de gestión de claves más robusto. // Para esta demostración, podemos generar una clave sobre la marcha si no existe. // Esta parte se omite por brevedad, pero está en el repositorio. return NextResponse.json( { error: "server_error", error_description: "Fallo al obtener la clave del emisor", }, { status: 500 }, ); } // 4. Crear el JWT-VC const issuerDid = generateIssuerDid(); const keyPair = await importIssuerKeyPair( issuerKey.key_id, issuerKey.public_key, issuerKey.private_key, issuerDid, ); const subjectId = `did:example:${uuidv4()}`; const credentialData = await createJWTVerifiableCredential( userData, keyPair, subjectId, process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000", ); // 5. Almacenar la credencial emitida en la base de datos await createIssuedCredential(/* ... detalles de la credencial ... */); await updateIssuanceSession(session.id, "credential_issued"); // 6. Devolver la credencial firmada return NextResponse.json({ format: "jwt_vc", credential: credentialData, c_nonce: uuidv4(), // Un nuevo nonce para solicitudes posteriores c_nonce_expires_in: 300, }); } catch (error) { console.error("Error en el endpoint de la credencial:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }
Pasos clave en este endpoint:
Bearer
válido en la cabecera
Authorization
y lo utiliza para buscar la sesión de emisión activa.createJWTVerifiableCredential
de src/lib/crypto.ts
para construir y firmar el JWT-VC.Ahora tienes una implementación completa de extremo a extremo de un emisor de credenciales digitales. A continuación, te explicamos cómo ejecutarlo localmente y qué necesitas considerar para llevarlo de una prueba de concepto a una aplicación lista para producción.
Clona el repositorio:
git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
Instala las dependencias:
npm install
Inicia la base de datos: Asegúrate de que Docker esté en ejecución, luego inicia el contenedor de MySQL:
docker-compose up -d
Configura el entorno y ejecuta el túnel: Este es el paso más crítico para las
pruebas locales. Dado que tu billetera móvil necesita conectarse a tu máquina de
desarrollo a través de Internet, debes exponer tu servidor local con una URL HTTPS
pública. Usaremos ngrok
para esto.
a. Inicia ngrok:
ngrok http 3000
b. Copia la URL HTTPS de la salida de
ngrok (por ejemplo,
https://random-string.ngrok.io
). c. Crea un archivo .env.local
y establece la
URL:
NEXT_PUBLIC_BASE_URL=https://<tu-url-de-ngrok>
Ejecuta la aplicación:
npm run dev
Abre tu navegador en http://localhost:3000/issue
. Ahora puedes rellenar el
formulario, y el código QR generado apuntará correctamente a tu URL pública de ngrok,
permitiendo que tu billetera móvil se conecte y reciba la credencial.
ngrok
#Los protocolos de credenciales digitales se construyen con la seguridad como máxima
prioridad. Por esta razón, las billeteras casi siempre se negarán a conectarse a un emisor
a través de una conexión insegura (http://
). Todo el proceso se basa en una conexión
HTTPS segura, que es habilitada por un certificado SSL.
Un servicio de túnel como ngrok
resuelve ambos problemas creando una URL HTTPS segura y
pública (con un certificado SSL válido) que reenvía todo el tráfico a tu servidor de
desarrollo local. Las billeteras requieren HTTPS y se negarán a conectarse a endpoints
inseguros (http://
). Esta es una herramienta esencial para probar cualquier servicio web
que necesite interactuar con dispositivos móviles o webhooks externos.
Este ejemplo se centra intencionadamente en el flujo de emisión principal para que sea fácil de entender. Los siguientes temas se consideran fuera de alcance:
revoked
para uso futuro,
no se proporciona lógica de revocación aquí.pre-authorized_code
. Una implementación completa del flujo de authorization_code
requeriría una pantalla de consentimiento del usuario y una lógica de
OAuth 2.0 más compleja.¡Eso es todo! Con unas pocas páginas de código, ahora tenemos un emisor de credenciales digitales completo y de extremo a extremo que:
pre-authorized_code
.Aunque esta guía proporciona una base sólida, un emisor listo para producción requeriría características adicionales como una gestión robusta de claves, almacenamiento persistente en lugar de almacenes en memoria, revocación de credenciales y un endurecimiento de la seguridad completo. La compatibilidad de las billeteras también varía; se recomienda Sphereon Wallet para las pruebas, pero es posible que otras billeteras no admitan el flujo preautorizado tal como se implementa aquí. Sin embargo, los componentes básicos y el flujo de interacción seguirían siendo los mismos. Siguiendo estos patrones, puedes construir un emisor seguro e interoperable para cualquier tipo de credencial digital.
Aquí tienes algunos de los recursos, especificaciones y herramientas clave utilizados o referenciados en este tutorial:
Repositorio del proyecto:
Especificaciones clave:
did:web
Method: El método DID
utilizado para la clave pública de nuestro emisor.Herramientas:
Librerías:
Related Articles
Table of Contents