// Copyright (c) RoochNetwork // SPDX-License-Identifier: Apache-2.0 import { Bytes } from '../types/index.js' import { sha256, concatBytes, bytesToString, stringToBytes } from '../utils/index.js' import { bcs } from '../bcs/index.js' /** * Session signing envelope types */ export enum SigningEnvelope { RawTxHash = 0x00, BitcoinMessageV0 = 0x01, WebAuthnV0 = 0x02, } export class WebauthnEnvelopeData { authenticator_data: Uint8Array client_data_json: Uint8Array constructor(authenticator_data: Uint8Array, client_data_json: Uint8Array) { this.authenticator_data = authenticator_data this.client_data_json = client_data_json } encode(): Bytes { return WebauthnEnvelopeDataSchema.serialize({ authenticator_data: this.authenticator_data, client_data_json: this.client_data_json, }).toBytes() } } export const WebauthnEnvelopeDataSchema = bcs.struct('WebauthnEnvelopeData', { authenticator_data: bcs.vector(bcs.u8()), client_data_json: bcs.vector(bcs.u8()), }) /** * WebAuthn assertion data parsed from AuthenticatorAssertionResponse */ export interface WebAuthnAssertionData { signature: Bytes // Original DER signature rawSignature: Bytes // Canonicalized raw signature authenticatorData: Bytes clientDataJSON: Bytes credentialId?: string } /** * WebAuthn utilities for parsing and validating WebAuthn data */ export class WebAuthnUtils { /** * Parse AuthenticatorAssertionResponse into standardized data */ static parseAssertionResponse(response: AuthenticatorAssertionResponse): WebAuthnAssertionData { const signature = new Uint8Array(response.signature) const authenticatorData = new Uint8Array(response.authenticatorData) const clientDataJSON = new Uint8Array(response.clientDataJSON) return { signature, rawSignature: this.derToRaw(signature), authenticatorData, clientDataJSON, } } /** * Validate that the challenge in clientDataJSON matches the expected txHash * The challenge should be the URL-safe base64 encoding of the txHash (WebAuthn standard) */ static validateChallenge(clientDataJSON: Bytes, txHash: Bytes): boolean { try { // Parse client data JSON const clientData = JSON.parse(new TextDecoder().decode(clientDataJSON)) // Decode the challenge from URL-safe base64 (WebAuthn uses base64url encoding) const actualChallenge = this.decodeBase64Url(clientData.challenge) // Compare byte arrays manually if (actualChallenge.length !== txHash.length) { console.error('[WebAuthnUtils.validateChallenge] Length mismatch:', { actualLength: actualChallenge.length, expectedLength: txHash.length, }) return false } const isMatch = actualChallenge.every((byte, index) => byte === txHash[index]) if (!isMatch) { console.error('[WebAuthnUtils.validateChallenge] Challenge mismatch:', { actualChallengeHex: Array.from(actualChallenge) .map((b) => b.toString(16).padStart(2, '0')) .join(''), expectedTxHashHex: Array.from(txHash) .map((b) => b.toString(16).padStart(2, '0')) .join(''), }) } return isMatch } catch (error) { console.error('[WebAuthnUtils.validateChallenge] Error during validation:', error) return false } } /** * Encode Uint8Array to URL-safe base64 string (base64url) * WebAuthn uses base64url encoding (RFC 4648) */ static toUrlSafeBase64(bytes: Bytes): string { return bytesToString('base64url', bytes) } /** * Decode base64url string to Uint8Array with proper padding handling * WebAuthn often omits padding characters, so we need to add them back */ private static decodeBase64Url(base64url: string): Uint8Array { try { // First try direct decoding return stringToBytes('base64url', base64url) } catch (error) { console.log('[WebAuthnUtils.decodeBase64Url] Direct decode failed, trying with padding:', { original: base64url, error: (error as Error).message, }) // If direct decoding fails, try adding padding let paddedBase64url = base64url const padding = base64url.length % 4 if (padding === 2) { paddedBase64url += '==' } else if (padding === 3) { paddedBase64url += '=' } console.log('[WebAuthnUtils.decodeBase64Url] Trying with padding:', { original: base64url, padded: paddedBase64url, }) try { return stringToBytes('base64url', paddedBase64url) } catch (paddingError) { // If base64url still fails, try converting to standard base64 console.log('[WebAuthnUtils.decodeBase64Url] base64url failed, trying standard base64') const standardBase64 = paddedBase64url.replace(/-/g, '+').replace(/_/g, '/') return stringToBytes('base64', standardBase64) } } } /** * Compute WebAuthn verification message: authenticator_data || SHA256(client_data_json) */ static computeVerificationMessage(authenticatorData: Bytes, clientDataJSON: Bytes): Bytes { const clientDataHash = sha256(clientDataJSON) return concatBytes(authenticatorData, clientDataHash) } /** * Convert DER signature to raw format and canonicalize (low-S form) */ private static derToRaw(der: Bytes): Bytes { // Parse DER sequence: 0x30 len 0x02 lenR R 0x02 lenS S let offset = 0 if (der[offset++] !== 0x30) throw new Error('Invalid DER signature') const seqLen = der[offset++] if (seqLen + 2 !== der.length) { // For signatures longer than 127 bytes, length encoding could be multi-byte // but for ECDSA signatures this is typically not the case } if (der[offset++] !== 0x02) throw new Error('Invalid DER signature') const rLen = der[offset++] let r = der.slice(offset, offset + rLen) offset += rLen if (der[offset++] !== 0x02) throw new Error('Invalid DER signature') const sLen = der[offset++] let s = der.slice(offset, offset + sLen) // Strip leading zero padding if (r.length === 33 && r[0] === 0x00) { r = r.slice(1) } if (s.length === 33 && s[0] === 0x00) { s = s.slice(1) } // Canonicalize S to low-S form (for secp256r1) const SECP256R1_N = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551') const HALF_N = SECP256R1_N >> BigInt(1) let sBig = BigInt( '0x' + Array.from(s) .map((b) => b.toString(16).padStart(2, '0')) .join(''), ) if (sBig > HALF_N) { sBig = SECP256R1_N - sBig // Convert back to bytes let sHex = sBig.toString(16) if (sHex.length % 2 === 1) sHex = '0' + sHex s = new Uint8Array(sHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))) } // Pad to 32 bytes const rPad = new Uint8Array(32) rPad.set(r, 32 - r.length) const sPad = new Uint8Array(32) sPad.set(s, 32 - s.length) const raw = new Uint8Array(64) raw.set(rPad, 0) raw.set(sPad, 32) return raw } }