diff --git a/.env.template b/.env.template index 4fb5630..a1a2e67 100644 --- a/.env.template +++ b/.env.template @@ -15,3 +15,9 @@ USER_PASS_PID_DEMO_PASSWORD=vErY$3cure PRE_AUTHORIZED_CREDENTIAL_ISSUANCE=true PRE_AUTHORIZED_CODE_API_URL=http://localhost:8003/api PRE_AUTHORIZED_CODE_API_BEARER_TOKEN=secret-bearer-token +OPENID4VC_PROFILE=DEFAULT +ABCA_CLIENT_ATTESTATION_MAX_AGE=86400 +ABCA_CLIENT_ATTESTATION_POP_MAX_AGE=60 +ABCA_CHALLENGE_SECRET=replace-with-a-64-len-secret +DPOP_NONCE_SECRET=replace-with-a-32+-len-secret +WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS=http://localhost:8002 diff --git a/README.md b/README.md index 44ea9c3..a50ce1d 100644 --- a/README.md +++ b/README.md @@ -27,27 +27,42 @@ Configuration is loaded from `.env` (see `.env.template`). Values are read via ` Environment variables and how they are used: -| Variable | Purpose | Default / Notes | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `SERVICE_URL` | Full external URL for the AS (scheme/host and optional path prefix). | `http://localhost:6060` if unset. Example: If deployed under `/as`, set it to `https://issuer.example.com/as`. | -| `WALLET_URL` | Redirect URI registered for the wallet client. | `http://localhost:3000` if unset. | -| `OIDC_JWKS_PATH` | Optional override for the OIDC provider signing JWKS path used for ID tokens and JWT authorization responses. | Defaults to `./keys/oidc.jwks.json` | -| `INTROSPECTION_CLIENT` | Client ID allowed to introspect tokens. | If set with `INTROSPECTION_CLIENT_SECRET`, an extra client is registered. | -| `INTROSPECTION_CLIENT_SECRET` | Secret for the introspection client. | Required alongside `INTROSPECTION_CLIENT`. | -| `SCOPES` | Comma-separated list of supported scopes. | Split by `,` and passed to OIDC provider `scopes`. | -| `METADATA_URL` | Credential Issuer metadata URL. | Used during consent to fetch display metadata for requested scopes. | -| `ACCESS_TOKEN_TTL` | Access token TTL (seconds). | Default: `30` | -| `REFRESH_TOKEN_TTL` | Refresh token TTL (seconds). | Default: `2592000` | -| `BASE_PATH` | Optional base path for public, AS/OIDC, and related Express endpoints. | Empty by default. Supports values like `as` or `/as`; normalized to `/as`. | -| `AUTHENTICATOR` | Selected authenticator to load (single value). | Supported values: `user-pass-pid` or `auth-broker`. Defaults to `user-pass-pid` if unset. | -| `USER_PASS_PID_DEMO_USERNAME` | Demo username for the `user-pass-pid` login screen and demo account. | Used only when `AUTHENTICATOR=user-pass-pid`. | -| `USER_PASS_PID_DEMO_PASSWORD` | Demo password shown in `user-pass-pid` login form. | Used only for prefill; authentication does not check password. | -| `AUTH_BROKER_PROVIDER_URL` | External OIDC provider URL for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | -| `AUTH_BROKER_CLIENT_ID` | OIDC client ID for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | -| `AUTH_BROKER_CLIENT_SECRET` | OIDC client secret for the `auth-broker` authenticator. | Optional for public clients. | -| `AUTH_BROKER_SCOPE` | Space-separated scopes sent to external IdP authorize endpoint by `auth-broker`. | Default: `openid profile email`. | -| `AUTH_BROKER_REDIRECT_URI` | Redirect URI handled by wallet-as auth-broker callback route. | Default: `SERVICE_URL + /interaction/authBroker/callback`. | -| `AUTH_BROKER_SKIP_LOGOUT` | Skip external IdP logout even if IdP supports it in metadata in `auth-broker` callback flow. | Optional; set to `true` when logout should be skipped. | +| Variable | Purpose | Default / Notes | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OPENID4VC_PROFILE` | Selects the ecosystem profile across wallet-as. | `DEFAULT` if unset. Supported values: `DEFAULT`, `WEBUILD`; see profiles below. | +| `SERVICE_URL` | Full external URL for the AS (scheme/host and optional path prefix). | `http://localhost:6060` if unset. Example: If deployed under `/as`, set it to `https://issuer.example.com/as`. | +| `WALLET_URL` | Redirect URI registered for the wallet client. | `http://localhost:3000` if unset. | +| `OIDC_JWKS_PATH` | Optional override for the OIDC provider signing JWKS path used for ID tokens and JWT authorization responses. | Defaults to `./keys/oidc.jwks.json` | +| `INTROSPECTION_CLIENT` | Client ID allowed to introspect tokens. | If set with `INTROSPECTION_CLIENT_SECRET`, an extra client is registered. | +| `INTROSPECTION_CLIENT_SECRET` | Secret for the introspection client. | Required alongside `INTROSPECTION_CLIENT`. | +| `SCOPES` | Comma-separated list of supported scopes. | Split by `,` and passed to OIDC provider `scopes`. | +| `METADATA_URL` | Credential Issuer metadata URL. | Used during consent to fetch display metadata for requested scopes. | +| `ACCESS_TOKEN_TTL` | Access token TTL (seconds). | Default: `30` | +| `REFRESH_TOKEN_TTL` | Refresh token TTL (seconds). | Default: `2592000` | +| `BASE_PATH` | Optional base path for public, AS/OIDC, and related Express endpoints. | Empty by default. Supports values like `as` or `/as`; normalized to `/as`. | +| `AUTHENTICATOR` | Selected authenticator to load (single value). | Supported values: `user-pass-pid` or `auth-broker`. Defaults to `user-pass-pid` if unset. | +| `USER_PASS_PID_DEMO_USERNAME` | Demo username for the `user-pass-pid` login screen and demo account. | Used only when `AUTHENTICATOR=user-pass-pid`. | +| `USER_PASS_PID_DEMO_PASSWORD` | Demo password shown in `user-pass-pid` login form. | Used only for prefill; authentication does not check password. | +| `AUTH_BROKER_PROVIDER_URL` | External OIDC provider URL for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | +| `AUTH_BROKER_CLIENT_ID` | OIDC client ID for the `auth-broker` authenticator. | Required when `auth-broker` is enabled. | +| `AUTH_BROKER_CLIENT_SECRET` | OIDC client secret for the `auth-broker` authenticator. | Optional for public clients. | +| `AUTH_BROKER_SCOPE` | Space-separated scopes sent to external IdP authorize endpoint by `auth-broker`. | Default: `openid profile email`. | +| `AUTH_BROKER_REDIRECT_URI` | Redirect URI handled by wallet-as auth-broker callback route. | Default: `SERVICE_URL + /interaction/authBroker/callback`. | +| `AUTH_BROKER_SKIP_LOGOUT` | Skip external IdP logout even if IdP supports it in metadata in `auth-broker` callback flow. | Optional; set to `true` when logout should be skipped. | +| `ABCA_CLIENT_ATTESTATION_MAX_AGE` | Maximum attestation age in seconds under `DEFAULT`; if `iat` is absent, limits remaining validity instead. | Defaults to `86400`. Ignored under `WEBUILD`. | +| `ABCA_CLIENT_ATTESTATION_POP_MAX_AGE` | Maximum PoP age in seconds under `DEFAULT`. Future PoP issuance times are rejected. | Defaults to `60`. Ignored under `WEBUILD`. | +| `ABCA_CHALLENGE_SECRET` | Shared secret for Attestation-Based Client Authentication challenges. | Required: exactly 64 hexadecimal characters encoding 32 random bytes. `yarn setup` in the parent repo generates a random one. Otherwise, use `openssl rand -hex 32` to generate a random value. | +| `DPOP_NONCE_SECRET` | Shared secret used to generate and validate DPoP nonces. | Required: at least 32 characters. `yarn setup` in the parent repo generates a random one. Otherwise, use `openssl rand -hex 16` to generate a random value. | +| `WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS` | Comma-separated trusted origins for WIA status list requests under the WE BUILD profile. | Required at startup only when `OPENID4VC_PROFILE=WEBUILD`. HTTPS required; HTTP loopback allowed outside production. | + +### Profiles + +Set `OPENID4VC_PROFILE` in `.env` to select the ecosystem requirements applied by wallet-as: + +| Value | Behavior | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `DEFAULT` | Default when unset. Applies the configurable attestation and PoP maximum ages, without WE BUILD checks or WIA status-list requests. | +| `WEBUILD` | Adds required wallet fields, WIA lifetime below 24 hours when `iat` is present, WIA/DPoP key equality, and revocation-status checks. | ## Authenticators diff --git a/package.json b/package.json index 37b9123..ffaa361 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@eslint/js": "^9.39.5", "@types/express": "^5.0.6", "@types/node": "^24.10.2", - "@types/oidc-provider": "^9.12.1", + "@types/oidc-provider": "^9.13.0", "@types/supertest": "^6.0.3", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", @@ -41,7 +41,7 @@ "dotenv": "^17.2.3", "express": "^5.2.1", "iovalkey": "^0.3.3", - "oidc-provider": "^9.12.2", + "oidc-provider": "^9.13.3", "openid-client": "^6.8.2", "pug": "^3.0.3", "wallet-common": "git+https://github.com/wwWallet/wallet-common.git#v1.0.0", diff --git a/src/app.ts b/src/app.ts index d1ace67..8387d80 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,10 @@ import { import { dataStoreClient } from './stores/dataStoreClient'; import { createOidcValkeyAdapter } from './stores/OidcValkeyAdapter'; import preAuthorizedCodeHandler from './oid4vci/preAuthorizedCodeHandler'; +import { + getAttestationSignaturePublicKey, + assertAttestationJwtAndPop, +} from './services/attestationBasedClientAuthenticationService'; export function createApp() { const app = express(); @@ -48,7 +52,16 @@ export function createApp() { adapter: createOidcValkeyAdapter(dataStoreClient), clients: oidClients, jwks: config.oidcJwks, - + enabledJWA: { + attestSigningAlgValues: ['ES256'], + dPoPSigningAlgValues: ['ES256'], + }, + clientAuthMethods: [ + 'client_secret_basic', + 'client_secret_post', + 'attest_jwt_client_auth', + 'none', + ], discovery: { ...(config.preAuthorizedCredentialIssuance ? { @@ -73,7 +86,20 @@ export function createApp() { }, }, features: { + attestClientAuth: { + enabled: true, + challengeSecret: config.abcaChallengeSecret, + ack: 'draft-12', + getAttestationSignaturePublicKey, + assertAttestationJwtAndPop, + }, devInteractions: { enabled: false }, + dPoP: { + enabled: true, + nonceSecret: config.dpopNonceSecret, + requireNonce: () => false, + allowReplay: false, + }, introspection: { enabled: true, allowedPolicy: introspectionAllowedPolicy, @@ -148,7 +174,19 @@ export function createApp() { ); routesRoot.post('/token', (req, _res, next) => { - if (req.body?.grant_type === 'urn:ietf:params:oauth:grant-type:pre-authorized_code') { + const hasClientAuthentication = Boolean( + req.body?.client_id || + req.body?.client_secret || + req.body?.client_assertion || + req.headers.authorization || + req.headers['oauth-client-attestation'] || + req.headers['oauth-client-attestation-pop'], + ); + + if ( + req.body?.grant_type === 'urn:ietf:params:oauth:grant-type:pre-authorized_code' && + !hasClientAuthentication + ) { req.body.client_id = '__pre-authorized_code_client__'; } diff --git a/src/config/index.ts b/src/config/index.ts index a39bd1e..794d325 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type * as oidc from 'oidc-provider'; import { z } from 'zod'; +import crypto from 'node:crypto'; dotenv.config(); const serviceUrl = process.env.SERVICE_URL || 'http://localhost:6060'; @@ -41,9 +42,15 @@ const dataStorePassword = process.env.DATA_STORE_PASSWORD || null; if (process.env.NODE_ENV === 'production' && !dataStorePassword) { console.error(`FATAL: Insecure data store found in production.`); + process.exit(1); +} +const dpopNonceSecret = process.env.DPOP_NONCE_SECRET; +if (!dpopNonceSecret || dpopNonceSecret.length < 32) { + console.error(`FATAL: DPOP_NONCE_SECRET must be configured with at least 32 characters.`); process.exit(1); } +const dpopNonceSecretBytes = crypto.createHash('sha256').update(dpopNonceSecret).digest(); const certsDir = path.resolve(process.cwd(), './certs'); let trustedRootCertificates: string[] = []; @@ -99,12 +106,41 @@ function loadOAuth2Clients(): oidc.ClientMetadata[] { try { parsed = JSON.parse(fs.readFileSync(clientsFile, 'utf-8')); } catch (err) { - throw new Error(`Failed to parse OAuth2 clients file '${clientsFile}': ${err}`); + console.error(`FATAL: Failed to parse OAuth2 clients file '${clientsFile}': ${err}`); + process.exit(1); } - return clientsSchema.parse(parsed) as oidc.ClientMetadata[]; } +const abcaChallengeSecretHex = process.env.ABCA_CHALLENGE_SECRET; +if ( + !abcaChallengeSecretHex || + abcaChallengeSecretHex.length !== 64 || + !/^[0-9a-fA-F]{64}$/.test(abcaChallengeSecretHex) +) { + console.error( + `FATAL: ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes).`, + ); + process.exit(1); +} +const abcaChallengeSecret = Buffer.from(abcaChallengeSecretHex, 'hex'); +const openid4vcProfile = z + .enum(['DEFAULT', 'WEBUILD']) + .default('DEFAULT') + .parse(process.env.OPENID4VC_PROFILE); +const weBuildAbcaStatusListAllowedOrigins = ( + process.env.WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS || '' +) + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); +if (openid4vcProfile === 'WEBUILD' && weBuildAbcaStatusListAllowedOrigins.length === 0) { + console.error( + `FATAL: WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD.`, + ); + process.exit(1); +} + export default { serviceUrl: serviceUrl, basePath: basePath, @@ -113,6 +149,7 @@ export default { dataStoreHost: dataStoreHost, dataStorePort: dataStorePort, dataStorePassword: dataStorePassword, + dpopNonceSecret: dpopNonceSecretBytes, oidClients: loadOAuth2Clients(), introspectionClient: process.env.INTROSPECTION_CLIENT || null, introspectionClientSecret: process.env.INTROSPECTION_CLIENT_SECRET || null, @@ -121,6 +158,8 @@ export default { accessToken: Number(process.env.ACCESS_TOKEN_TTL) || 30, refreshToken: Number(process.env.REFRESH_TOKEN_TTL) || 2592000, authorizationCode: Number(process.env.AUTHORIZATION_CODE_TTL) || 60, + clientAttestation: Number(process.env.ABCA_CLIENT_ATTESTATION_MAX_AGE) || 86400, + clientAttestationPoP: Number(process.env.ABCA_CLIENT_ATTESTATION_POP_MAX_AGE) || 60, }, demoUsername: process.env.USER_PASS_PID_DEMO_USERNAME || null, demoPassword: process.env.USER_PASS_PID_DEMO_PASSWORD || null, @@ -142,4 +181,7 @@ export default { process.env.PRE_AUTHORIZED_CREDENTIAL_ISSUANCE === 'true' || false, preAuthorizedCodeApiUrl: process.env.PRE_AUTHORIZED_CODE_API_URL || '', preAuthorizedCodeApiBearerToken: process.env.PRE_AUTHORIZED_CODE_API_BEARER_TOKEN || '', + abcaChallengeSecret, + openid4vcProfile, + weBuildAbcaStatusListAllowedOrigins, }; diff --git a/src/config/oauth2clients.json b/src/config/oauth2clients.json index 050551d..4b205cb 100644 --- a/src/config/oauth2clients.json +++ b/src/config/oauth2clients.json @@ -15,7 +15,7 @@ }, { "client_id": "1233", - "token_endpoint_auth_method": "none", + "token_endpoint_auth_method": "attest_jwt_client_auth", "redirect_uris": ["http://localhost:3000/"], "post_logout_redirect_uris": ["http://localhost:3000/"], "grant_types": ["authorization_code", "refresh_token"], diff --git a/src/oid4vci/preAuthorizedCodeHandler.ts b/src/oid4vci/preAuthorizedCodeHandler.ts index 5e42070..0211f7e 100644 --- a/src/oid4vci/preAuthorizedCodeHandler.ts +++ b/src/oid4vci/preAuthorizedCodeHandler.ts @@ -20,7 +20,7 @@ export default async function preAuthorizedCodeHandler(ctx: any) { validatePreAuthorizedCodeGrant(grant, tx_code); - const client = await provider.Client.find('__pre-authorized_code_client__'); + const client = ctx.oidc.client ?? (await provider.Client.find('__pre-authorized_code_client__')); if (!client) { throw new errors.InvalidClient('Could not find pre-authorized_code client'); } diff --git a/src/services/attestationBasedClientAuthenticationService.ts b/src/services/attestationBasedClientAuthenticationService.ts new file mode 100644 index 0000000..2faa7b1 --- /dev/null +++ b/src/services/attestationBasedClientAuthenticationService.ts @@ -0,0 +1,278 @@ +import { X509Certificate } from 'node:crypto'; +import { inflateSync } from 'node:zlib'; +import * as oidc from 'oidc-provider'; +import { + calculateJwkThumbprint, + decodeProtectedHeader, + exportJWK, + importX509, + jwtVerify, + type JWK, +} from 'jose'; +import config from '../config'; +import { verifyX5C } from 'wallet-common'; + +const MAX_X5C_CERTIFICATES = 5; +const MAX_X5C_CERTIFICATE_LENGTH = 16 * 1024; +const BASE64_CERTIFICATE_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +function parseX5C(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_X5C_CERTIFICATES) { + throw new oidc.errors.InvalidClientAttestation('invalid client attestation x5c chain'); + } + + return value.map((certificate) => { + if ( + typeof certificate !== 'string' || + certificate.length === 0 || + certificate.length > MAX_X5C_CERTIFICATE_LENGTH || + certificate.length % 4 !== 0 || + !BASE64_CERTIFICATE_PATTERN.test(certificate) + ) { + throw new oidc.errors.InvalidClientAttestation('invalid client attestation x5c certificate'); + } + + return certificate; + }); +} + +function certificateToPem(certificate: string): string { + const lines = certificate.match(/.{1,64}/g)?.join('\n') ?? certificate; + return `-----BEGIN CERTIFICATE-----\n${lines}\n-----END CERTIFICATE-----`; +} + +function assertNumericDate(value: unknown, claim: string): number { + if (!Number.isInteger(value) || typeof value !== 'number') { + throw new oidc.errors.InvalidClientAttestation(`${claim} must be an integer NumericDate`); + } + + return value; +} + +export async function getAttestationSignaturePublicKey( + _ctx: oidc.KoaContextWithOIDC, + header: oidc.UnknownObject, + _payload: oidc.UnknownObject, + _client: oidc.Client, +): Promise { + if (header.alg !== 'ES256') { + throw new oidc.errors.InvalidClientAttestation('unsupported client attestation alg'); + } + const certificateChain = parseX5C(header.x5c); + try { + const leafCertificate = new X509Certificate(Buffer.from(certificateChain[0], 'base64')); + if (leafCertificate.ca) { + throw new Error('CA certificates cannot sign Wallet Instance Attestations'); + } + } catch { + throw new oidc.errors.InvalidClientAttestation( + 'invalid client attestation signing certificate', + ); + } + + if (config.trustedRootCertificates.length === 0) { + throw new oidc.errors.InvalidClientAttestation('no Wallet Provider trust anchors configured'); + } + + let trusted: boolean; + try { + trusted = await verifyX5C(certificateChain, config.trustedRootCertificates); + } catch { + trusted = false; + } + if (!trusted) { + throw new oidc.errors.InvalidClientAttestation('untrusted client attestation x5c chain'); + } + + try { + return await exportJWK(await importX509(certificateToPem(certificateChain[0]), header.alg)); + } catch { + throw new oidc.errors.InvalidClientAttestation( + 'invalid client attestation signing certificate', + ); + } +} + +export async function assertAttestationJwtAndPop( + ctx: oidc.KoaContextWithOIDC, + attestation: oidc.JWTVerificationResult, + _pop: oidc.JWTVerificationResult, + _client: oidc.Client, +): Promise { + const payload = attestation.payload; + const now = Math.floor(Date.now() / 1000); + // The provider validates NumericDate types. Draft 10 permits an omitted iat. + const issuedAt = payload.iat; + const expiresAt = assertNumericDate(payload.exp, 'attestation exp'); + if (typeof issuedAt === 'number' && (issuedAt > now || expiresAt <= issuedAt)) { + throw new oidc.errors.InvalidClientAttestation( + 'attestation iat is in the future or exp is not after iat', + ); + } + if (ctx.oidc.route === 'token' && !ctx.get('DPoP')) { + throw new oidc.errors.InvalidClientAttestation('DPoP is required for token requests'); + } + + if (config.openid4vcProfile === 'WEBUILD') { + await assertAttestationJwtAndPopWeBuildProfile(ctx, attestation, _pop, _client); + } else { + if (typeof issuedAt === 'number') { + if (now - issuedAt > config.ttl.clientAttestation) { + throw new oidc.errors.UseFreshAttestation('attestation is too old'); + } + } else if (expiresAt - now > config.ttl.clientAttestation) { + throw new oidc.errors.UseFreshAttestation('attestation exp is too far in the future'); + } + const popIssuedAt = _pop.payload.iat; + if ( + typeof popIssuedAt !== 'number' || + !Number.isFinite(popIssuedAt) || + popIssuedAt > now || + now - popIssuedAt > config.ttl.clientAttestationPoP + ) { + throw new oidc.errors.InvalidClientAttestation('PoP iat is outside the allowed age window'); + } + } +} + +async function assertAttestationJwtAndPopWeBuildProfile( + ctx: oidc.KoaContextWithOIDC, + attestation: oidc.JWTVerificationResult, + _pop: oidc.JWTVerificationResult, + _client: oidc.Client, +): Promise { + const payload = attestation.payload; + for (const name of ['wallet_name', 'wallet_version']) { + if (typeof payload[name] !== 'string' || payload[name].trim().length === 0) { + throw new oidc.errors.InvalidClientAttestation(`invalid ${name}`); + } + } + const certification = payload.wallet_solution_certification_information; + if ( + certification === undefined || + certification === null || + (typeof certification === 'string' && certification.trim().length === 0) + ) { + throw new oidc.errors.InvalidClientAttestation('wallet certification information missing'); + } + + const issuedAt = payload.iat; + const expiresAt = payload.exp as number; + if (typeof issuedAt === 'number' && expiresAt - issuedAt >= 86400) { + throw new oidc.errors.InvalidClientAttestation('invalid WIA lifetime (must be below 24 hours)'); + } + + const dpop = ctx.get('DPoP'); + if (dpop) { + try { + const confirmation = payload.cnf as { jwk: JWK }; + const dpopKey = decodeProtectedHeader(dpop).jwk; + if ( + !dpopKey || + (await calculateJwkThumbprint(dpopKey)) !== (await calculateJwkThumbprint(confirmation.jwk)) + ) { + throw new Error('key mismatch'); + } + } catch { + throw new oidc.errors.InvalidClientAttestation('DPoP key does not match WIA cnf.jwk'); + } + } + + await validateRevocationStatus(attestation); +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +async function validateRevocationStatus(attestation: oidc.JWTVerificationResult): Promise { + const status = asRecord(attestation.payload.client_status); + const reference = asRecord(asRecord(status?.status)?.status_list); + const now = Math.floor(Date.now() / 1000); + if ( + typeof status?.exp !== 'number' || + !Number.isSafeInteger(status.exp) || + status.exp - now < 31 * 86400 || + typeof reference?.uri !== 'string' || + !Number.isSafeInteger(reference.idx) || + (reference.idx as number) < 0 + ) { + throw new oidc.errors.InvalidClientAttestation( + 'invalid client_status or maintenance period below 31 days', + ); + } + + try { + const url = new URL(reference.uri); + if ( + url.username || + url.password || + url.hash || + !config.weBuildAbcaStatusListAllowedOrigins.includes(url.origin) || + (url.protocol !== 'https:' && + !( + process.env.NODE_ENV !== 'production' && + url.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + )) + ) { + throw new Error('status list origin is not allowed'); + } + const response = await fetch(url, { + headers: { Accept: 'application/statuslist+jwt' }, + redirect: 'error', + signal: AbortSignal.timeout(5000), + }); + if (!response.ok || !response.body) throw new Error('status list unavailable'); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > 256 * 1024) { + await reader.cancel(); + throw new Error('status list token too large'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + // Minimal signer policy: the status list must use the WIA signing key. + const verified = await jwtVerify(Buffer.concat(chunks).toString('utf8'), attestation.key, { + algorithms: ['ES256'], + typ: 'statuslist+jwt', + subject: reference.uri, + requiredClaims: ['sub', 'iat', 'status_list'], + }); + const list = asRecord(verified.payload.status_list); + const bits = list?.bits; + if ( + typeof verified.payload.iat !== 'number' || + verified.payload.iat > now || + (verified.payload.ttl !== undefined && + (typeof verified.payload.ttl !== 'number' || + verified.payload.ttl <= 0 || + now >= verified.payload.iat + verified.payload.ttl)) || + typeof bits !== 'number' || + ![1, 2, 4, 8].includes(bits) || + typeof list?.lst !== 'string' || + !/^[A-Za-z0-9_-]+$/.test(list.lst) + ) { + throw new Error('invalid status list payload'); + } + const bytes = inflateSync(Buffer.from(list.lst, 'base64url'), { maxOutputLength: 1024 * 1024 }); + const index = reference.idx as number; + if (index >= bytes.length * (8 / bits)) throw new Error('status index out of bounds'); + const bitOffset = index * bits; + const value = (bytes[Math.floor(bitOffset / 8)] >> (bitOffset % 8)) & ((1 << bits) - 1); + if (value !== 0) throw new Error('WIA is revoked or not valid'); + } catch { + throw new oidc.errors.InvalidClientAttestation('client status could not be verified as valid'); + } +} diff --git a/test/integration/attestationClientAuth.integration.test.ts b/test/integration/attestationClientAuth.integration.test.ts new file mode 100644 index 0000000..fc3690b --- /dev/null +++ b/test/integration/attestationClientAuth.integration.test.ts @@ -0,0 +1,245 @@ +import { execFileSync } from 'node:child_process'; +import { randomUUID, X509Certificate } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { calculateJwkThumbprint, exportJWK, generateKeyPair, importPKCS8, SignJWT } from 'jose'; +import type * as oidc from 'oidc-provider'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const consumeCode = vi.hoisted(() => vi.fn()); +vi.mock('../../src/services/preAuthorizedCodeService', () => ({ + consumePreAuthorizedCode: consumeCode, +})); + +describe('attestation client authentication through the AS', () => { + const issuer = 'https://as.example.test'; + const clientId = `abca-test-${randomUUID()}`; + let directory: string; + let app: ReturnType['app']; + let provider: oidc.Provider; + let config: typeof import('../../src/config').default; + let attesterKey: Awaited>; + let instanceKeys: Awaited>; + let leafBase64: string; + + function openssl(...args: string[]) { + execFileSync('openssl', args, { cwd: directory, stdio: 'pipe' }); + } + + beforeAll(async () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-as-abca-integration-')); + openssl( + 'req', + '-x509', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:P-256', + '-nodes', + '-keyout', + 'root.key', + '-out', + 'root.pem', + '-days', + '2', + '-subj', + '/CN=Test CA', + '-addext', + 'basicConstraints=critical,CA:TRUE', + '-addext', + 'keyUsage=critical,keyCertSign,cRLSign', + ); + openssl( + 'req', + '-new', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:P-256', + '-nodes', + '-keyout', + 'leaf.key', + '-out', + 'leaf.csr', + '-subj', + '/CN=Test Attester', + ); + fs.writeFileSync( + path.join(directory, 'leaf.ext'), + 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n', + ); + openssl( + 'x509', + '-req', + '-in', + 'leaf.csr', + '-CA', + 'root.pem', + '-CAkey', + 'root.key', + '-CAcreateserial', + '-out', + 'leaf.pem', + '-days', + '1', + '-extfile', + 'leaf.ext', + ); + leafBase64 = new X509Certificate( + fs.readFileSync(path.join(directory, 'leaf.pem')), + ).raw.toString('base64'); + attesterKey = await importPKCS8( + fs.readFileSync(path.join(directory, 'leaf.key'), 'utf8'), + 'ES256', + ); + instanceKeys = await generateKeyPair('ES256'); + + vi.stubEnv('SERVICE_URL', issuer); + vi.stubEnv('BASE_PATH', ''); + vi.stubEnv('AUTHENTICATOR', 'user-pass-pid'); + vi.stubEnv('OPENID4VC_PROFILE', 'DEFAULT'); + vi.stubEnv('PRE_AUTHORIZED_CREDENTIAL_ISSUANCE', 'true'); + vi.resetModules(); + config = (await import('../../src/config')).default; + config.trustedRootCertificates = [fs.readFileSync(path.join(directory, 'root.pem'), 'utf8')]; + config.oidClients = [ + { + client_id: clientId, + token_endpoint_auth_method: 'attest_jwt_client_auth', + grant_types: ['urn:ietf:params:oauth:grant-type:pre-authorized_code'], + response_types: [], + redirect_uris: ['https://wallet.example/callback'], + }, + ]; + ({ app, provider } = (await import('../../src/app')).createApp()); + }); + + afterAll(async () => { + if (typeof app !== 'undefined') + await (await import('../../src/stores/dataStoreClient')).dataStoreClient.quit(); + vi.unstubAllEnvs(); + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + }); + + beforeEach(() => { + consumeCode.mockReset().mockResolvedValue({ account_id: 'abca-user', scope: 'openid' }); + config.openid4vcProfile = 'DEFAULT'; + }); + + async function attestation(key = attesterKey) { + return new SignJWT({ cnf: { jwk: await exportJWK(instanceKeys.publicKey) } }) + .setProtectedHeader({ alg: 'ES256', typ: 'oauth-client-attestation+jwt', x5c: [leafBase64] }) + .setSubject(clientId) + .setIssuedAt() + .setExpirationTime('5m') + .sign(key); + } + + async function pop(challenge?: string, audience = issuer) { + return new SignJWT(challenge ? { challenge } : {}) + .setProtectedHeader({ alg: 'ES256', typ: 'oauth-client-attestation-pop+jwt' }) + .setAudience(audience) + .setIssuedAt() + .setJti(randomUUID()) + .sign(instanceKeys.privateKey); + } + + async function tokenRequest(wia: string, proof: string) { + const dpop = await new SignJWT({ htm: 'POST', htu: `${issuer}/token` }) + .setProtectedHeader({ + alg: 'ES256', + typ: 'dpop+jwt', + jwk: await exportJWK(instanceKeys.publicKey), + }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(instanceKeys.privateKey); + return request(app) + .post('/token') + .set('Host', 'as.example.test') + .set('X-Forwarded-Proto', 'https') + .set('OAuth-Client-Attestation', wia) + .set('OAuth-Client-Attestation-PoP', proof) + .set('DPoP', dpop) + .type('form') + .send({ + grant_type: 'urn:ietf:params:oauth:grant-type:pre-authorized_code', + 'pre-authorized_code': 'integration-code', + }); + } + + async function challenge() { + const discovery = await request(app).get('/.well-known/openid-configuration').expect(200); + expect(discovery.body.token_endpoint_auth_methods_supported).toContain( + 'attest_jwt_client_auth', + ); + const response = await request(app) + .post(new URL(discovery.body.challenge_endpoint).pathname) + .expect(200); + expect(response.body.attestation_challenge).toEqual(expect.any(String)); + return response.body.attestation_challenge as string; + } + + it('challenges, then authenticates the attested client and issues its token', async () => { + const wia = await attestation(); + const first = await tokenRequest(wia, await pop()); + expect(first.status).toBe(400); + expect(first.body.error).toBe('use_attestation_challenge'); + expect(consumeCode).not.toHaveBeenCalled(); + const nextChallenge = first.headers['oauth-client-attestation-challenge']; + expect(nextChallenge).toEqual(expect.any(String)); + + const response = await tokenRequest(wia, await pop(nextChallenge)); + expect(response.status).toBe(200); + expect(response.body.access_token).toEqual(expect.any(String)); + expect(consumeCode).toHaveBeenCalledOnce(); + const accessToken = await provider.AccessToken.find(response.body.access_token); + expect(accessToken?.clientId).toBe(clientId); + expect(accessToken?.jkt).toBe( + await calculateJwkThumbprint(await exportJWK(instanceKeys.publicKey)), + ); + }); + + it('rejects an attestation signed by a key different from its trusted certificate', async () => { + const wrongSigner = await generateKeyPair('ES256'); + const response = await tokenRequest( + await attestation(wrongSigner.privateKey), + await pop(await challenge()), + ); + expect(response.status).toBe(401); + expect(response.body.error).toBe('invalid_client'); + expect(consumeCode).not.toHaveBeenCalled(); + }); + + it('rejects a PoP intended for another AS', async () => { + const response = await tokenRequest( + await attestation(), + await pop(await challenge(), 'https://other-as.example'), + ); + expect(response.status).toBe(401); + expect(response.body.error).toBe('invalid_client'); + expect(consumeCode).not.toHaveBeenCalled(); + }); + + it('rejects replay of a successfully authenticated PoP', async () => { + const wia = await attestation(); + const proof = await pop(await challenge()); + const first = await tokenRequest(wia, proof); + expect(first.status).toBe(200); + const replay = await tokenRequest(wia, proof); + expect(replay.status).toBe(401); + expect(replay.body.error).toBe('invalid_client'); + expect(consumeCode).toHaveBeenCalledOnce(); + }); + + it('runs the WE BUILD callback when that profile is selected', async () => { + config.openid4vcProfile = 'WEBUILD'; + const response = await tokenRequest(await attestation(), await pop(await challenge())); + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client_attestation'); + expect(response.body.error_description).toContain('wallet_name'); + expect(consumeCode).not.toHaveBeenCalled(); + }); +}); diff --git a/test/integration/setup.ts b/test/integration/setup.ts index d658576..79a152c 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -6,6 +6,9 @@ const VALKEY_PORT = 6379; const VALKEY_PASSWORD = 'test-secret'; export default async function setup() { + // Deterministic test fixture only; never use this value in a deployment. + process.env.ABCA_CHALLENGE_SECRET = 'ab'.repeat(32); + process.env.DPOP_NONCE_SECRET = 'test-dpop-nonce-secret-0123456789ab'; valkeyContainer = await new GenericContainer('valkey/valkey:latest') .withExposedPorts(VALKEY_PORT) .withCommand(['valkey-server', '--requirepass', VALKEY_PASSWORD]) diff --git a/test/unit/attestationBasedClientAuthenticationService.test.ts b/test/unit/attestationBasedClientAuthenticationService.test.ts new file mode 100644 index 0000000..87323de --- /dev/null +++ b/test/unit/attestationBasedClientAuthenticationService.test.ts @@ -0,0 +1,335 @@ +import { deflateSync } from 'node:zlib'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import type * as oidc from 'oidc-provider'; +import { assertAttestationJwtAndPop } from '../../src/services/attestationBasedClientAuthenticationService'; +import config from '../../src/config'; + +vi.mock('../../src/config', () => ({ + default: { + openid4vcProfile: 'WEBUILD', + ttl: { clientAttestation: 86400, clientAttestationPoP: 60 }, + weBuildAbcaStatusListAllowedOrigins: ['https://status.example'], + }, +})); + +const now = Math.floor(Date.now() / 1000); +const statusUri = 'https://status.example/list'; +let signer: Awaited>; +let confirmation: Awaited>; +let attestation: oidc.JWTVerificationResult; +let ctx: oidc.KoaContextWithOIDC; +let dpop: string; +const fetchMock = vi.fn(); + +async function statusToken(value = 0, bits = 1, key = signer.privateKey) { + return new SignJWT({ + status_list: { bits, lst: deflateSync(Buffer.from([value])).toString('base64url') }, + ttl: 300, + }) + .setProtectedHeader({ alg: 'ES256', typ: 'statuslist+jwt' }) + .setSubject(statusUri) + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(key); +} + +function popVerificationResult(iat: number): oidc.JWTVerificationResult { + return { + protectedHeader: { alg: 'ES256', typ: 'oauth-client-attestation-pop+jwt' }, + key: confirmation.publicKey, + payload: { iat }, + }; +} + +async function run() { + await assertAttestationJwtAndPop(ctx, attestation, popVerificationResult(now), {} as oidc.Client); +} + +describe('WIA validation', () => { + beforeEach(async () => { + config.openid4vcProfile = 'WEBUILD'; + config.ttl.clientAttestation = 86400; + config.ttl.clientAttestationPoP = 60; + vi.useFakeTimers(); + vi.setSystemTime(now * 1000); + signer = await generateKeyPair('ES256'); + confirmation = await generateKeyPair('ES256'); + const jwk = await exportJWK(confirmation.publicKey); + dpop = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk }) + .sign(confirmation.privateKey); + attestation = { + protectedHeader: { alg: 'ES256' }, + key: signer.publicKey, + payload: { + wallet_name: 'Wallet', + wallet_version: '1.0', + wallet_solution_certification_information: {}, + iat: now, + exp: now + 3600, + cnf: { jwk }, + client_status: { + exp: now + 31 * 86400, + status: { status_list: { uri: statusUri, idx: 0 } }, + }, + }, + }; + ctx = { get: () => dpop, oidc: { route: 'token' } } as unknown as oidc.KoaContextWithOIDC; + fetchMock.mockReset().mockResolvedValue(new Response(await statusToken())); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('accepts a valid WIA without attestation aud, jti, or lifecycle tracking', async () => { + await expect(run()).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith( + new URL(statusUri), + expect.objectContaining({ redirect: 'error' }), + ); + }); + + it('skips all WE BUILD checks and status fetching when disabled', async () => { + config.openid4vcProfile = 'DEFAULT'; + delete attestation.payload.wallet_name; + delete attestation.payload.wallet_version; + delete attestation.payload.wallet_solution_certification_information; + delete attestation.payload.client_status; + attestation.payload.exp = now + 86400; + dpop = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', jwk: await exportJWK(signer.publicKey) }) + .sign(signer.privateKey); + + await expect(run()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('still requires DPoP at the token endpoint when the profile is disabled', async () => { + config.openid4vcProfile = 'DEFAULT'; + dpop = ''; + await expect(run()).rejects.toMatchObject({ + error_description: 'DPoP is required for token requests', + }); + }); + + it('still rejects future issuance times when the profile is disabled', async () => { + config.openid4vcProfile = 'DEFAULT'; + attestation.payload.iat = now + 1; + await expect(run()).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + }); + + it('applies the configured attestation maximum age under DEFAULT', async () => { + config.openid4vcProfile = 'DEFAULT'; + config.ttl.clientAttestation = 120; + attestation.payload.iat = now - 121; + await expect(run()).rejects.toMatchObject({ error: 'use_fresh_attestation' }); + attestation.payload.iat = now - 120; + await expect(run()).resolves.toBeUndefined(); + }); + + it('bounds remaining validity when DEFAULT receives an attestation without iat', async () => { + config.openid4vcProfile = 'DEFAULT'; + config.ttl.clientAttestation = 120; + delete attestation.payload.iat; + attestation.payload.exp = now + 121; + await expect(run()).rejects.toMatchObject({ error: 'use_fresh_attestation' }); + attestation.payload.exp = now + 120; + await expect(run()).resolves.toBeUndefined(); + }); + + it('applies the configured PoP maximum age under DEFAULT', async () => { + config.openid4vcProfile = 'DEFAULT'; + config.ttl.clientAttestationPoP = 10; + const validate = (iat: number) => + assertAttestationJwtAndPop(ctx, attestation, popVerificationResult(iat), {} as oidc.Client); + await expect(validate(now - 11)).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + await expect(validate(now + 1)).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + await expect(validate(now - 10)).resolves.toBeUndefined(); + }); + + it('keeps WEBUILD policy independent of the configurable maximum ages', async () => { + config.ttl.clientAttestation = 1; + config.ttl.clientAttestationPoP = 1; + attestation.payload.iat = now - 60; + await expect( + assertAttestationJwtAndPop( + ctx, + attestation, + popVerificationResult(now - 60), + {} as oidc.Client, + ), + ).resolves.toBeUndefined(); + }); + + it.each(['wallet_name', 'wallet_version', 'wallet_solution_certification_information'])( + 'rejects a missing WIA field: %s', + async (field) => { + delete attestation.payload[field]; + await expect(run()).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it.each([{ iat: now + 0.5 }, { iat: now + 1 }, { exp: now }, { exp: now + 86400 }])( + 'rejects invalid temporal claims: %j', + async (claims) => { + Object.assign(attestation.payload, claims); + await expect(run()).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it('accepts an omitted attestation iat', async () => { + delete attestation.payload.iat; + await expect(run()).resolves.toBeUndefined(); + }); + + it('accepts a fractional attestation iat', async () => { + attestation.payload.iat = now - 0.5; + await expect(run()).resolves.toBeUndefined(); + }); + + it('leaves expiration validation to the provider', async () => { + attestation.payload.iat = now - 3600; + attestation.payload.exp = now - 61; + await expect(run()).resolves.toBeUndefined(); + }); + + it('requires DPoP at the token endpoint', async () => { + dpop = ''; + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('DPoP is required'), + }); + }); + + it('allows PAR without a DPoP header', async () => { + dpop = ''; + ctx = { + get: () => dpop, + oidc: { route: 'pushed_authorization_request' }, + } as unknown as oidc.KoaContextWithOIDC; + await expect(run()).resolves.toBeUndefined(); + }); + + it('rejects a different DPoP key', async () => { + dpop = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', jwk: await exportJWK(signer.publicKey) }) + .sign(signer.privateKey); + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('DPoP key does not match'), + }); + }); + + it('rejects malformed DPoP', async () => { + dpop = 'invalid'; + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('DPoP key does not match'), + }); + }); + + it('rejects a missing status reference', async () => { + delete attestation.payload.client_status; + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('invalid client_status'), + }); + }); + + it('requires at least 31 days of status maintenance', async () => { + (attestation.payload.client_status as { exp: number }).exp--; + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('maintenance period below 31 days'), + }); + }); + + it('does not fetch an unconfigured origin', async () => { + attestation.payload.client_status = { + exp: now + 31 * 86400, + status: { status_list: { uri: 'https://internal.example/list', idx: 0 } }, + }; + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('client status could not be verified'), + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([1, 2, 4, 8])('rejects a nonzero status for %i-bit lists', async (bits) => { + fetchMock.mockResolvedValue(new Response(await statusToken(1, bits))); + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('client status could not be verified'), + }); + }); + + it('rejects a list signed by another key', async () => { + fetchMock.mockResolvedValue(new Response(await statusToken(0, 1, confirmation.privateKey))); + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('client status could not be verified'), + }); + }); + + it('checks the indexed entry rather than only the first status', async () => { + attestation.payload.client_status = { + exp: now + 31 * 86400, + status: { status_list: { uri: statusUri, idx: 2 } }, + }; + fetchMock.mockResolvedValue(new Response(await statusToken(1 << 4, 2))); + await expect(run()).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + fetchMock.mockResolvedValue(new Response(await statusToken(1, 2))); + await expect(run()).resolves.toBeUndefined(); + }); + + it.each([ + { sub: 'https://status.example/another-list', iat: now, exp: now + 300 }, + { sub: statusUri, iat: now - 600, exp: now - 1 }, + { sub: statusUri, iat: now + 1, exp: now + 300 }, + ])('rejects invalid status token claims: %j', async (claims) => { + const token = await new SignJWT({ + ...claims, + status_list: { bits: 1, lst: deflateSync(Buffer.from([0])).toString('base64url') }, + }) + .setProtectedHeader({ alg: 'ES256', typ: 'statuslist+jwt' }) + .sign(signer.privateKey); + fetchMock.mockResolvedValue(new Response(token)); + await expect(run()).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + }); + + it('rejects an oversized status token', async () => { + fetchMock.mockResolvedValue(new Response('a'.repeat(256 * 1024 + 1))); + await expect(run()).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + }); + + it('limits decompressed status list size', async () => { + const token = await new SignJWT({ + status_list: { + bits: 1, + lst: deflateSync(Buffer.alloc(1024 * 1024 + 1)).toString('base64url'), + }, + }) + .setProtectedHeader({ alg: 'ES256', typ: 'statuslist+jwt' }) + .setSubject(statusUri) + .setIssuedAt(now) + .sign(signer.privateKey); + fetchMock.mockResolvedValue(new Response(token)); + await expect(run()).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + }); + + it('rejects an out-of-range status index', async () => { + attestation.payload.client_status = { + exp: now + 31 * 86400, + status: { status_list: { uri: statusUri, idx: 8 } }, + }; + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('client status could not be verified'), + }); + }); + + it('fails closed when status retrieval fails', async () => { + fetchMock.mockRejectedValue(new Error('unavailable')); + await expect(run()).rejects.toMatchObject({ + error_description: expect.stringContaining('client status could not be verified'), + }); + }); +}); diff --git a/test/unit/attestationTrustResolution.test.ts b/test/unit/attestationTrustResolution.test.ts new file mode 100644 index 0000000..66046ca --- /dev/null +++ b/test/unit/attestationTrustResolution.test.ts @@ -0,0 +1,214 @@ +import { execFileSync } from 'node:child_process'; +import { X509Certificate } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { importPKCS8, jwtVerify, SignJWT } from 'jose'; +import type * as oidc from 'oidc-provider'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const config = vi.hoisted(() => ({ trustedRootCertificates: [] as string[] })); +vi.mock('../../src/config', () => ({ default: config })); + +import { getAttestationSignaturePublicKey } from '../../src/services/attestationBasedClientAuthenticationService'; + +describe('attestation trust resolution', () => { + let directory: string; + let rootPem: string; + let otherRootPem: string; + let leafPem: string; + let leafKeyPem: string; + let leafBase64: string; + + function openssl(...args: string[]) { + execFileSync('openssl', args, { cwd: directory, stdio: 'pipe' }); + } + + function resolve(x5c: unknown = [leafBase64], alg = 'ES256') { + return getAttestationSignaturePublicKey( + {} as oidc.KoaContextWithOIDC, + { alg, x5c }, + {}, + {} as oidc.Client, + ); + } + + beforeAll(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-as-trust-test-')); + for (const name of ['root', 'other-root']) { + openssl( + 'req', + '-x509', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:P-256', + '-nodes', + '-keyout', + `${name}.key`, + '-out', + `${name}.pem`, + '-days', + '30', + '-subj', + `/CN=${name}`, + '-addext', + 'basicConstraints=critical,CA:TRUE', + '-addext', + 'keyUsage=critical,keyCertSign,cRLSign', + ); + } + openssl( + 'req', + '-new', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:P-256', + '-nodes', + '-keyout', + 'leaf.key', + '-out', + 'leaf.csr', + '-subj', + '/CN=Test Wallet Provider', + ); + fs.writeFileSync( + path.join(directory, 'leaf.ext'), + 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n', + ); + openssl( + 'x509', + '-req', + '-in', + 'leaf.csr', + '-CA', + 'root.pem', + '-CAkey', + 'root.key', + '-CAcreateserial', + '-out', + 'leaf.pem', + '-days', + '1', + '-extfile', + 'leaf.ext', + ); + rootPem = fs.readFileSync(path.join(directory, 'root.pem'), 'utf8'); + otherRootPem = fs.readFileSync(path.join(directory, 'other-root.pem'), 'utf8'); + leafPem = fs.readFileSync(path.join(directory, 'leaf.pem'), 'utf8'); + leafKeyPem = fs.readFileSync(path.join(directory, 'leaf.key'), 'utf8'); + leafBase64 = new X509Certificate(leafPem).raw.toString('base64'); + }); + + afterAll(() => { + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + }); + + beforeEach(() => { + config.trustedRootCertificates = [rootPem]; + }); + + afterEach(() => vi.useRealTimers()); + + it('resolves a trusted leaf to its public JWK and verifies a signed JWT', async () => { + const jwk = await resolve(); + const expected = new X509Certificate(leafPem).publicKey.export({ format: 'jwk' }); + expect(jwk).toMatchObject(expected); + expect(jwk).not.toHaveProperty('d'); + const token = await new SignJWT({ sub: 'wallet' }) + .setProtectedHeader({ alg: 'ES256', typ: 'oauth-client-attestation+jwt', x5c: [leafBase64] }) + .sign(await importPKCS8(leafKeyPem, 'ES256')); + const verified = await jwtVerify(token, (header) => + getAttestationSignaturePublicKey( + {} as oidc.KoaContextWithOIDC, + header, + {}, + {} as oidc.Client, + ), + ); + expect(verified.payload.sub).toBe('wallet'); + expect(verified.key).toHaveProperty('type', 'public'); + }); + + it('accepts the presented chain including its trusted root', async () => { + await expect( + resolve([leafBase64, new X509Certificate(rootPem).raw.toString('base64')]), + ).resolves.toMatchObject({ kty: 'EC', crv: 'P-256' }); + }); + + it.each([ + undefined, + [], + 'not-an-array', + [42], + [''], + ['!!!!'], + ['abc'], + ['AAAA'], + ['A'.repeat(16 * 1024 + 4)], + Array(6).fill('AAAA'), + ])('rejects missing, malformed, or oversized x5c (case %#)', async (x5c) => { + // Explicit undefined must not select resolve's default fixture. + await expect( + getAttestationSignaturePublicKey( + {} as oidc.KoaContextWithOIDC, + { alg: 'ES256', x5c }, + {}, + {} as oidc.Client, + ), + ).rejects.toMatchObject({ error: 'invalid_client_attestation' }); + }); + + it('rejects unsupported signing algorithms', async () => { + await expect(resolve([leafBase64], 'ES384')).rejects.toMatchObject({ + error_description: 'unsupported client attestation alg', + }); + }); + + it('rejects a CA leaf even when that CA is trusted', async () => { + await expect( + resolve([new X509Certificate(rootPem).raw.toString('base64')]), + ).rejects.toMatchObject({ + error_description: 'invalid client attestation signing certificate', + }); + }); + + it('rejects missing trust anchors', async () => { + config.trustedRootCertificates = []; + await expect(resolve()).rejects.toMatchObject({ + error_description: 'no Wallet Provider trust anchors configured', + }); + }); + + it('rejects a chain rooted in an untrusted CA', async () => { + config.trustedRootCertificates = [otherRootPem]; + await expect(resolve()).rejects.toMatchObject({ + error_description: 'untrusted client attestation x5c chain', + }); + }); + + it('rejects a certificate with a tampered issuer signature', async () => { + const tampered = Buffer.from(new X509Certificate(leafPem).raw); + tampered[tampered.length - 1] ^= 1; + await expect(resolve([tampered.toString('base64')])).rejects.toMatchObject({ + error_description: 'untrusted client attestation x5c chain', + }); + }); + + it('rejects an expired leaf', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(new X509Certificate(leafPem).validTo) + 1000)); + await expect(resolve()).rejects.toMatchObject({ + error_description: 'untrusted client attestation x5c chain', + }); + }); + + it('rejects a not-yet-valid leaf', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(new X509Certificate(leafPem).validFrom) - 1000)); + await expect(resolve()).rejects.toMatchObject({ + error_description: 'untrusted client attestation x5c chain', + }); + }); +}); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 5308bd1..751dabb 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -1,20 +1,34 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('config', () => { const originalCwd = process.cwd(); + beforeEach(() => { + // Deterministic test fixture only; never use this value in a deployment. + vi.stubEnv('ABCA_CHALLENGE_SECRET', 'ab'.repeat(32)); + vi.stubEnv('DPOP_NONCE_SECRET', 'test-dpop-nonce-secret-0123456789ab'); + vi.stubEnv('OPENID4VC_PROFILE', 'DEFAULT'); + vi.stubEnv('WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS', 'https://status.example'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + function writeClientsFile(root: string, clients: unknown) { const configDir = path.join(root, 'src/config'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, 'oauth2clients.json'), JSON.stringify(clients), 'utf-8'); } - async function importConfigFromTempProject(clients: unknown) { + async function importConfigFromTempProject(clients: unknown, prepare?: (root: string) => void) { const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-as-config-')); writeClientsFile(tempProject, clients); + prepare?.(tempProject); process.chdir(tempProject); vi.resetModules(); @@ -25,6 +39,24 @@ describe('config', () => { } } + async function expectFatalConfig(loadConfig: () => Promise, message: string | RegExp) { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit:${code}`); + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await expect(loadConfig()).rejects.toThrow('process.exit:1'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleSpy).toHaveBeenCalledWith( + typeof message === 'string' ? message : expect.stringMatching(message), + ); + } finally { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + } + } + it('normalizes BASE_PATH and reads basic env vars', async () => { const originalEnv = process.env; process.env = { @@ -121,6 +153,123 @@ describe('config', () => { ).rejects.toThrow(/redirect_uris/); }); + it.each(['missing', 'malformed'])('exits when the OAuth2 clients file is %s', async (kind) => { + await expectFatalConfig( + () => + importConfigFromTempProject([], (root) => { + const clientsFile = path.join(root, 'src/config/oauth2clients.json'); + if (kind === 'missing') { + fs.unlinkSync(clientsFile); + } else { + fs.writeFileSync(clientsFile, '{', 'utf-8'); + } + }), + /^FATAL: Failed to parse OAuth2 clients file '.*oauth2clients\.json': /, + ); + }); + + it.each([ + [undefined, 'DEFAULT'], + ['DEFAULT', 'DEFAULT'], + ['WEBUILD', 'WEBUILD'], + ])('reads OPENID4VC_PROFILE=%s as %s', async (value, profile) => { + vi.stubEnv('OPENID4VC_PROFILE', value); + const { default: config } = await importConfigFromTempProject([]); + expect(config.openid4vcProfile).toBe(profile); + }); + + it.each(['', 'typo', 'true', 'HAIP', 'ETSI'])( + 'rejects unsupported OPENID4VC_PROFILE: %s', + async (value) => { + vi.stubEnv('OPENID4VC_PROFILE', value); + await expect(importConfigFromTempProject([])).rejects.toThrow(); + }, + ); + + it.each([undefined, '', ' , '])( + 'requires status origins under WEBUILD (case %#)', + async (value) => { + vi.stubEnv('OPENID4VC_PROFILE', 'WEBUILD'); + vi.stubEnv('WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS', value); + await expectFatalConfig( + () => importConfigFromTempProject([]), + 'FATAL: WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS is required when OPENID4VC_PROFILE=WEBUILD.', + ); + }, + ); + + it('does not require status origins under DEFAULT', async () => { + vi.stubEnv('WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS', undefined); + const { default: config } = await importConfigFromTempProject([]); + expect(config.weBuildAbcaStatusListAllowedOrigins).toEqual([]); + }); + + it('reads the WE BUILD status origin list', async () => { + vi.stubEnv('OPENID4VC_PROFILE', 'WEBUILD'); + vi.stubEnv( + 'WEBUILD_ABCA_STATUS_LIST_ALLOWED_ORIGINS', + ' https://one.example, https://two.example, ', + ); + const { default: config } = await importConfigFromTempProject([]); + expect(config.weBuildAbcaStatusListAllowedOrigins).toEqual([ + 'https://one.example', + 'https://two.example', + ]); + }); + + it.each([32, 33])('accepts a %i-character DPoP nonce secret', async (length) => { + const secret = 's'.repeat(length); + vi.stubEnv('DPOP_NONCE_SECRET', secret); + const first = (await importConfigFromTempProject([])).default; + const second = (await importConfigFromTempProject([])).default; + + expect(Buffer.isBuffer(first.dpopNonceSecret)).toBe(true); + expect(first.dpopNonceSecret.length).toBe(32); + expect(first.dpopNonceSecret).toEqual(createHash('sha256').update(secret).digest()); + expect(second.dpopNonceSecret).toEqual(first.dpopNonceSecret); + }); + + it.each([undefined, '', 's'.repeat(31)])( + 'rejects a missing or short DPoP nonce secret (case %#)', + async (secret) => { + vi.stubEnv('DPOP_NONCE_SECRET', secret); + await expectFatalConfig( + () => importConfigFromTempProject([]), + 'FATAL: DPOP_NONCE_SECRET must be configured with at least 32 characters.', + ); + }, + ); + + it('decodes the challenge secret into a stable 32-byte Buffer', async () => { + vi.stubEnv('ABCA_CHALLENGE_SECRET', 'AB'.repeat(32)); + const first = (await importConfigFromTempProject([])).default; + const second = (await importConfigFromTempProject([])).default; + + expect(Buffer.isBuffer(first.abcaChallengeSecret)).toBe(true); + expect(first.abcaChallengeSecret.length).toBe(32); + expect(first.abcaChallengeSecret).toEqual(Buffer.alloc(32, 0xab)); + expect(second.abcaChallengeSecret).toEqual(first.abcaChallengeSecret); + }); + + it.each([ + undefined, + '', + 'ab'.repeat(31), + 'ab'.repeat(33), + 'a'.repeat(63), + 'gg'.repeat(32), + `${'ab'.repeat(31)}a!`, + ` ${'ab'.repeat(32)}`, + `${'ab'.repeat(32)}\n`, + ])('rejects a missing or malformed challenge secret (case %#)', async (secret) => { + vi.stubEnv('ABCA_CHALLENGE_SECRET', secret); + + await expectFatalConfig( + () => importConfigFromTempProject([]), + 'FATAL: ABCA_CHALLENGE_SECRET must contain exactly 64 hexadecimal characters (32 bytes).', + ); + }); + it('does not allow production builds to use an insecure Data Store', async () => { const originalEnv = process.env; process.env = { @@ -129,21 +278,15 @@ describe('config', () => { DATA_STORE_PASSWORD: undefined, }; - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { - throw new Error(`process.exit:${code}`); - }); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.resetModules(); try { - await expect(import('../../src/config')).rejects.toThrow('process.exit:1'); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(consoleSpy).toHaveBeenCalledWith('FATAL: Insecure data store found in production.'); + await expectFatalConfig( + () => import('../../src/config'), + 'FATAL: Insecure data store found in production.', + ); } finally { process.env = originalEnv; - exitSpy.mockRestore(); - consoleSpy.mockRestore(); } }); }); diff --git a/test/unit/introspectionHelpers.test.ts b/test/unit/introspectionHelpers.test.ts index e148e77..f5d9457 100644 --- a/test/unit/introspectionHelpers.test.ts +++ b/test/unit/introspectionHelpers.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('introspectionAllowedPolicy', () => { + beforeEach(() => { + // Deterministic test fixture only; never use this value in a deployment. + vi.stubEnv('ABCA_CHALLENGE_SECRET', 'ab'.repeat(32)); + vi.stubEnv('DPOP_NONCE_SECRET', 'test-dpop-nonce-secret-0123456789ab'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); it('rejects non client_secret_basic auth', async () => { vi.resetModules(); const { introspectionAllowedPolicy } = await import('../../src/util/introspectionHelpers'); diff --git a/yarn.lock b/yarn.lock index 98991ea..a905927 100644 --- a/yarn.lock +++ b/yarn.lock @@ -768,10 +768,10 @@ dependencies: undici-types "~7.18.0" -"@types/oidc-provider@^9.12.1": - version "9.12.1" - resolved "https://registry.yarnpkg.com/@types/oidc-provider/-/oidc-provider-9.12.1.tgz#33b95f8d8ab0e036dd0bf573343c9683af338a1c" - integrity sha512-qRJksCw0I+VloInJxMV/4L4gLL5Qrws9Np9+rW+MZeBKauEz5WqKO99OVCpnj4LKB/o1+utkHoQq1c3GBhuE5g== +"@types/oidc-provider@^9.13.0": + version "9.13.0" + resolved "https://registry.yarnpkg.com/@types/oidc-provider/-/oidc-provider-9.13.0.tgz#3d8037e57e30f3bb153f3f7fc77b7ecb63775db3" + integrity sha512-Ucf4oqIvxHHAM7TRH0mh6Yzixjfx+rBDuCxVBDnMw8ebMxboi4vwDUTG2nIdjRXBYUYcHADP6q7QUghxES7Xig== dependencies: "@types/keygrip" "*" "@types/koa" "*" @@ -2439,11 +2439,16 @@ jose@^5.9.6: resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== -jose@^6.1.3, jose@^6.2.10: +jose@^6.1.3: version "6.2.12" resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.12.tgz#65663e146edd010b98ece83f81737d3aa95fd0d7" integrity sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw== +jose@^6.2.13: + version "6.2.14" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.14.tgz#f275808d68c833735d484a96606e7e5d937bc03e" + integrity sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A== + js-base64@^3.7.6: version "3.7.8" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.8.tgz#af44496bc09fa178ed9c4adf67eb2b46f5c6d2a4" @@ -2836,13 +2841,13 @@ obug@^2.1.1: resolved "https://registry.yarnpkg.com/obug/-/obug-2.2.1.tgz#9c453efeeda822050eb9774242e969c784d64d78" integrity sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q== -oidc-provider@^9.12.2: - version "9.12.2" - resolved "https://registry.yarnpkg.com/oidc-provider/-/oidc-provider-9.12.2.tgz#24137fc1c45e975ebff8bcfac70608703fc4d077" - integrity sha512-UaqeVpeijTocxVbDowPqPKloGjb49bxRSzHfChyI86teR+6xxoiI1V5jv8CHL2AIUmA2rg0xTZcYaC9GguympA== +oidc-provider@^9.13.3: + version "9.13.3" + resolved "https://registry.yarnpkg.com/oidc-provider/-/oidc-provider-9.13.3.tgz#ed3ab01776bccc06a4896c0ae33aa4e49726443e" + integrity sha512-WmNDX9dLeItBmIqGqspmX5u6Cc7VVMrKUw2InX2T16+bwUJ8m/N5rJ3Ed+YKxE5aZqLp4YqwEi6kMJx4Ui5ffw== dependencies: debug "^4.4.3" - jose "^6.2.10" + jose "^6.2.13" koa "^3.2.1" on-finished@^2.4.1: @@ -3544,7 +3549,16 @@ streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: fast-fifo "^1.3.2" text-decoder "^1.1.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3576,7 +3590,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4019,7 +4040,16 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==