File

src/shared/trust/wallet-attestation.service.ts

Description

Service for verifying wallet attestations. Handles validation of client attestation JWTs, X.509 certificate chains, and status list verification for wallet providers.

Index

Properties
Methods

Constructor

constructor(cryptoService: CryptoService, cryptoImplementationService: CryptoImplementationService, trustStoreService: TrustStoreService, x509ValidationService: X509ValidationService, statusListVerifierService: StatusListVerifierService, configService: ConfigService)
Parameters :
Name Type Optional
cryptoService CryptoService No
cryptoImplementationService CryptoImplementationService No
trustStoreService TrustStoreService No
x509ValidationService X509ValidationService No
statusListVerifierService StatusListVerifierService No
configService ConfigService No

Methods

Private getIssuer
getIssuer(tenantId: string)

Get the OID4VCI issuer instance for a specific tenant. Used for wallet attestation verification.

Parameters :
Name Type Optional Description
tenantId string No

The ID of the tenant.

Returns : Openid4vciIssuer

The OID4VCI issuer instance.

Private Async getThumbprint
getThumbprint(cert: x509.X509Certificate)

Calculate the SHA-256 thumbprint of an X.509 certificate.

Parameters :
Name Type Optional
cert x509.X509Certificate No
Returns : Promise<string>
Private Async validateWalletAttestationStatus
validateWalletAttestationStatus(clientAttestationJwt: string, matchedEntity: MatchedTrustedEntity | null, trustStore: BuiltTrustStore | null)

Validate the status of a wallet attestation JWT if it contains a status claim. The status claim is optional per the spec, so if not present, validation passes. Also verifies that the status list JWT is signed by the revocation certificate from the same trusted entity that issued the wallet attestation.

Parameters :
Name Type Optional Description
clientAttestationJwt string No

The wallet attestation JWT

matchedEntity MatchedTrustedEntity | null No

The matched trusted entity from certificate validation

trustStore BuiltTrustStore | null No

The trust store used for validation

Returns : Promise<void>
Private Async validateWalletProviderCertificate
validateWalletProviderCertificate(clientAttestationJwt: string, trustListUrls: string[])

Validate the wallet provider's X.509 certificate against configured trust lists. Returns the matched entity and trust store for use in status list verification.

Parameters :
Name Type Optional Description
clientAttestationJwt string No

The wallet attestation JWT

trustListUrls string[] No

URLs of trust lists to validate against

Returns : Promise<literal type>

The matched entity and trust store (both null if no trust lists configured)

Private Async verifyStatusListSignature
verifyStatusListSignature(statusListJwt: string, matchedEntity: MatchedTrustedEntity | null, trustStore: BuiltTrustStore | null)

Verify the signature of a status list JWT against the revocation certificate from the matched trusted entity.

Parameters :
Name Type Optional Description
statusListJwt string No

The status list JWT to verify

matchedEntity MatchedTrustedEntity | null No

The matched trusted entity

trustStore BuiltTrustStore | null No

The trust store

Returns : Promise<boolean>

true if the signature is valid and from the trusted revocation cert

Async verifyWalletAttestation
verifyWalletAttestation(tenantId: string, clientAttestation: ClientAttestation | undefined, authorizationServer: string, walletAttestationRequired: boolean, walletProviderTrustLists: string[])

Verify wallet attestation if provided or required. Validates the attestation JWT signature, checks the X.509 certificate chain against configured trust lists, and verifies the status list if present.

Parameters :
Name Type Optional Description
tenantId string No

The tenant ID

clientAttestation ClientAttestation | undefined No

The client attestation from the request

authorizationServer string No

The authorization server URL

walletAttestationRequired boolean No

Whether wallet attestation is required

walletProviderTrustLists string[] No

URLs of trust lists containing trusted wallet providers

Returns : Promise<void>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(WalletAttestationService.name)
import { X509Certificate } from "node:crypto";
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Openid4vciIssuer } from "@openid4vc/openid4vci";
import * as x509 from "@peculiar/x509";
import { decodeProtectedHeader, JWK } from "jose";
import { CryptoService } from "../../crypto/crypto.service";
import { CryptoImplementationService } from "../../crypto/key/crypto-implementation/crypto-implementation.service";
import { StatusListVerifierService } from "./status-list-verifier.service";
import { BuiltTrustStore, TrustStoreService } from "./trust-store.service";
import { ServiceTypeIdentifiers, TrustListSource } from "./types";
import {
    MatchedTrustedEntity,
    X509ValidationService,
} from "./x509-validation.service";

export interface ClientAttestation {
    clientAttestationJwt: string;
    clientAttestationPopJwt: string;
}

/**
 * Service for verifying wallet attestations.
 * Handles validation of client attestation JWTs, X.509 certificate chains,
 * and status list verification for wallet providers.
 */
@Injectable()
export class WalletAttestationService {
    private readonly logger = new Logger(WalletAttestationService.name);

    constructor(
        private readonly cryptoService: CryptoService,
        private readonly cryptoImplementationService: CryptoImplementationService,
        private readonly trustStoreService: TrustStoreService,
        private readonly x509ValidationService: X509ValidationService,
        private readonly statusListVerifierService: StatusListVerifierService,
        private readonly configService: ConfigService,
    ) {}

    /**
     * Get the OID4VCI issuer instance for a specific tenant.
     * Used for wallet attestation verification.
     * @param tenantId The ID of the tenant.
     * @returns The OID4VCI issuer instance.
     */
    private getIssuer(tenantId: string): Openid4vciIssuer {
        const callbacks = this.cryptoService.getCallbackContext(tenantId);
        return new Openid4vciIssuer({
            callbacks,
        });
    }

    /**
     * Verify wallet attestation if provided or required.
     * Validates the attestation JWT signature, checks the X.509 certificate chain
     * against configured trust lists, and verifies the status list if present.
     * @param tenantId The tenant ID
     * @param clientAttestation The client attestation from the request
     * @param authorizationServer The authorization server URL
     * @param walletAttestationRequired Whether wallet attestation is required
     * @param walletProviderTrustLists URLs of trust lists containing trusted wallet providers
     * @throws UnauthorizedException if attestation is required but not provided, or if provided but invalid
     */
    async verifyWalletAttestation(
        tenantId: string,
        clientAttestation: ClientAttestation | undefined,
        authorizationServer: string,
        walletAttestationRequired: boolean,
        walletProviderTrustLists: string[],
    ): Promise<void> {
        if (!clientAttestation) {
            if (walletAttestationRequired) {
                throw new UnauthorizedException(
                    "Wallet attestation is required but not provided",
                );
            }
            return;
        }

        try {
            // First verify the attestation JWT structure and PoP
            await this.getIssuer(tenantId).verifyWalletAttestation({
                authorizationServer,
                clientAttestationJwt: clientAttestation.clientAttestationJwt,
                clientAttestationPopJwt:
                    clientAttestation.clientAttestationPopJwt,
                allowedSkewInSeconds:
                    this.configService.getOrThrow<number>("CRYPTO_TOLERANCE"),
            });

            // Then validate the X.509 certificate against trust lists and get the matched entity
            const { matchedEntity, trustStore } =
                await this.validateWalletProviderCertificate(
                    clientAttestation.clientAttestationJwt,
                    walletProviderTrustLists,
                );

            // Check the status list if present in the attestation JWT
            // Pass the matched entity and trust store for signature verification
            await this.validateWalletAttestationStatus(
                clientAttestation.clientAttestationJwt,
                matchedEntity,
                trustStore,
            );

            this.logger.debug("Wallet attestation verified successfully");
        } catch (err) {
            if (err instanceof UnauthorizedException) {
                throw err;
            }
            throw new UnauthorizedException(
                `Wallet attestation verification failed: ${err instanceof Error ? err.message : "Unknown error"}`,
                { cause: err instanceof Error ? err : undefined },
            );
        }
    }

    /**
     * Validate the wallet provider's X.509 certificate against configured trust lists.
     * Returns the matched entity and trust store for use in status list verification.
     * @param clientAttestationJwt The wallet attestation JWT
     * @param trustListUrls URLs of trust lists to validate against
     * @returns The matched entity and trust store (both null if no trust lists configured)
     * @throws UnauthorizedException if certificate is not trusted
     */
    private async validateWalletProviderCertificate(
        clientAttestationJwt: string,
        trustListUrls: string[],
    ): Promise<{
        matchedEntity: MatchedTrustedEntity | null;
        trustStore: BuiltTrustStore | null;
    }> {
        if (trustListUrls.length === 0) {
            // No trust lists configured - accept any valid attestation
            this.logger.warn(
                "No wallet provider trust lists configured - accepting attestation without certificate validation",
            );
            return { matchedEntity: null, trustStore: null };
        }

        // Extract X.509 certificate chain from JWT header
        const header = decodeProtectedHeader(clientAttestationJwt);
        const x5c = header.x5c;

        if (!x5c || x5c.length === 0) {
            throw new UnauthorizedException(
                "Wallet attestation JWT does not contain X.509 certificate chain (x5c header)",
            );
        }

        // Build trust list source from configured URLs
        const trustListSource: TrustListSource = {
            lotes: trustListUrls.map((url) => ({ url })),
            acceptedServiceTypes: [ServiceTypeIdentifiers.WalletProvider],
        };

        // Fetch and build the trust store
        const trustStore =
            await this.trustStoreService.getTrustStore(trustListSource);

        if (trustStore.entities.length === 0) {
            throw new UnauthorizedException(
                "No trusted wallet providers found in configured trust lists",
            );
        }

        // Parse the certificate chain
        const certChain = this.x509ValidationService.parseX5c(x5c);
        const leaf = certChain[0];

        // Build and validate the certificate path against trust anchors
        const trustAnchors = this.x509ValidationService.parseTrustAnchors(
            trustStore.entities.flatMap((e) => e.services),
        );

        try {
            const path = await this.x509ValidationService.buildPath(
                leaf,
                certChain,
                trustAnchors,
            );

            // Check if the path matches any trusted entity (using WalletProvider service type)
            const match =
                await this.x509ValidationService.pathMatchesTrustedEntities(
                    path,
                    trustStore.entities,
                    "leaf",
                    ServiceTypeIdentifiers.WalletProvider,
                );

            if (!match) {
                throw new UnauthorizedException(
                    "Wallet provider certificate is not trusted - no matching entity in trust list",
                );
            }

            this.logger.debug(
                `Wallet attestation validated against trusted entity: ${match.entity.entityId ?? "unknown"}`,
            );

            return { matchedEntity: match, trustStore };
        } catch (err) {
            if (err instanceof UnauthorizedException) {
                throw err;
            }
            throw new UnauthorizedException(
                `Certificate chain validation failed: ${err instanceof Error ? err.message : "Unknown error"}`,
            );
        }
    }

    /**
     * Validate the status of a wallet attestation JWT if it contains a status claim.
     * The status claim is optional per the spec, so if not present, validation passes.
     * Also verifies that the status list JWT is signed by the revocation certificate
     * from the same trusted entity that issued the wallet attestation.
     * @param clientAttestationJwt The wallet attestation JWT
     * @param matchedEntity The matched trusted entity from certificate validation
     * @param trustStore The trust store used for validation
     * @throws UnauthorizedException if the attestation has been revoked or suspended
     */
    private async validateWalletAttestationStatus(
        clientAttestationJwt: string,
        matchedEntity: MatchedTrustedEntity | null,
        trustStore: BuiltTrustStore | null,
    ): Promise<void> {
        try {
            // Get the status entry from the JWT
            const statusEntry =
                this.statusListVerifierService.getStatusEntryFromJwt(
                    clientAttestationJwt,
                );

            // No status claim in JWT - this is allowed per spec
            if (!statusEntry) {
                this.logger.debug(
                    "Wallet attestation does not contain status claim - skipping status check",
                );
                return;
            }

            // Fetch the status list JWT
            const statusListJwt =
                await this.statusListVerifierService.getStatusListJwt(
                    statusEntry.uri,
                );

            // Verify the status list JWT signature against the revocation cert
            const signatureValid = await this.verifyStatusListSignature(
                statusListJwt,
                matchedEntity,
                trustStore,
            );

            if (!signatureValid) {
                throw new UnauthorizedException(
                    "Status list JWT signature verification failed - not signed by trusted revocation certificate",
                );
            }

            // Now check the actual status value
            const statusResult =
                await this.statusListVerifierService.checkStatus(
                    statusEntry.uri,
                    statusEntry.idx,
                );

            // Check if the status indicates the attestation is valid
            if (!statusResult.isValid) {
                this.logger.warn(
                    `Wallet attestation status check failed: ${statusResult.description}`,
                );
                throw new UnauthorizedException(
                    `Wallet attestation is not valid: ${statusResult.description}`,
                );
            }

            this.logger.debug(
                `Wallet attestation status verified: ${statusResult.description}`,
            );
        } catch (err) {
            if (err instanceof UnauthorizedException) {
                throw err;
            }
            // Log the error but don't fail - status checking is optional
            // and network issues shouldn't block valid attestations
            this.logger.warn(
                `Failed to check wallet attestation status: ${err instanceof Error ? err.message : "Unknown error"}`,
            );
        }
    }

    /**
     * Verify the signature of a status list JWT against the revocation certificate
     * from the matched trusted entity.
     * @param statusListJwt The status list JWT to verify
     * @param matchedEntity The matched trusted entity
     * @param trustStore The trust store
     * @returns true if the signature is valid and from the trusted revocation cert
     */
    private async verifyStatusListSignature(
        statusListJwt: string,
        matchedEntity: MatchedTrustedEntity | null,
        trustStore: BuiltTrustStore | null,
    ): Promise<boolean> {
        try {
            // Parse the status list JWT header
            const header = decodeProtectedHeader(statusListJwt);
            const x5c = header.x5c;

            // Extract public key from x5c and verify signature
            if (!x5c || x5c.length === 0) {
                // If no x5c in status list, we can't verify against trust chain
                // Accept if no trust validation is configured
                if (!matchedEntity) {
                    return true;
                }
                this.logger.warn(
                    "Status list JWT missing x5c header - cannot verify against trust chain",
                );
                return false;
            }

            // Verify the JWT signature
            const cert = new X509Certificate(Buffer.from(x5c[0], "base64"));
            const publicKey = cert.publicKey.export({ format: "jwk" }) as JWK;
            const crypto =
                this.cryptoImplementationService.getCryptoFromJwk(publicKey);
            const verifier = await crypto.getVerifier(publicKey);

            // Split JWT and verify
            const [headerB64, payloadB64, signatureB64] =
                statusListJwt.split(".");
            const data = `${headerB64}.${payloadB64}`;

            const sigOk = await verifier(data, signatureB64)
                .then(() => true)
                .catch((e) => {
                    this.logger.debug(
                        `Status list JWT signature invalid: ${e?.message ?? e}`,
                    );
                    return false;
                });

            if (!sigOk) return false;

            // If no entity was matched (no trust list configured), accept if signature is valid
            if (!matchedEntity || !trustStore) {
                return true;
            }

            // Check if the matched entity has a revocation certificate
            if (!matchedEntity.revocationCert) {
                this.logger.warn(
                    `TrustedEntity ${matchedEntity.entity.entityId ?? "unknown"} ` +
                        `has no revocation certificate configured - accepting status list`,
                );
                return true;
            }

            // Build and verify the status list's certificate chain
            const presented = this.x509ValidationService.parseX5c(x5c);
            const leaf = presented[0];

            // Get all certs for path building
            const allCerts = trustStore.entities.flatMap((e) =>
                e.services.map((s) => ({ certValue: s.certValue })),
            );
            const anchors =
                this.x509ValidationService.parseTrustAnchors(allCerts);

            let path: x509.X509Certificate[];
            try {
                path = await this.x509ValidationService.buildPath(
                    leaf,
                    presented,
                    anchors,
                );
            } catch (e: any) {
                this.logger.debug(
                    `Status list chain build failed: ${e?.message ?? e}`,
                );
                return false;
            }

            // Get thumbprints for comparison
            const statusLeafThumb = await this.getThumbprint(presented[0]);
            const statusEndThumb = await this.getThumbprint(path.at(-1)!);

            // Check if the status list is signed by the revocation cert from the same entity
            const revocationThumb = matchedEntity.revocationThumbprint!;
            const revocationIsCa = this.x509ValidationService.isCaCert(
                matchedEntity.revocationCert,
            );

            let statusMatchesRevocation = false;

            if (revocationIsCa) {
                // Revocation cert is CA: path must terminate at this cert
                statusMatchesRevocation = revocationThumb === statusEndThumb;
            } else {
                // Revocation cert is pinned (non-CA): leaf must match
                statusMatchesRevocation = revocationThumb === statusLeafThumb;
            }

            if (!statusMatchesRevocation) {
                this.logger.warn(
                    `Status list is NOT signed by the revocation certificate from the same TrustedEntity. ` +
                        `Entity: ${matchedEntity.entity.entityId ?? "unknown"}, ` +
                        `Expected revocation cert: ${revocationThumb}, ` +
                        `Status list leaf cert: ${statusLeafThumb}, ` +
                        `Status list end cert: ${statusEndThumb}`,
                );
                return false;
            }

            this.logger.debug(
                `Status list verified against revocation cert from entity: ${matchedEntity.entity.entityId ?? "unknown"}`,
            );
            return true;
        } catch (e: any) {
            this.logger.error(
                `Error verifying status list signature: ${e?.message ?? e}`,
            );
            return false;
        }
    }

    /**
     * Calculate the SHA-256 thumbprint of an X.509 certificate.
     */
    private async getThumbprint(cert: x509.X509Certificate): Promise<string> {
        const thumbBuffer = await cert.getThumbprint("SHA-256");
        return Buffer.from(thumbBuffer).toString("hex").toLowerCase();
    }
}

results matching ""

    No results matching ""