import CBOR from 'cbor-js'; import type { ApplicationLockState } from '@/core/setting.ts'; import type { UserBasicInfo } from '@/models/user.ts'; import { isFunction, stringToArrayBuffer, arrayBufferToString, base64encode, base64decode } from './common.ts'; import { generateRandomString } from './misc.ts'; import logger from './logger.ts'; interface ClientData { readonly challenge: string; readonly crossOrigin: boolean; readonly origin: string; readonly type: string; } interface AttestationData { readonly authData: Uint8Array; readonly fmt: string; } interface WebAuthnRegisterResponse { readonly id: string; readonly clientData: ClientData; readonly publicKey: Uint8Array | null; readonly rawCredential: Credential; } interface WebAuthnVerifyResponse { readonly id: string; readonly userName: string; readonly userSecret: string; readonly clientData: ClientData; readonly rawCredential: Credential; } const PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS_BASE_TEMPLATE = { attestation: "none", authenticatorSelection: { authenticatorAttachment: 'platform', requireResidentKey: false, userVerification: "discouraged" }, pubKeyCredParams: [ // https://www.iana.org/assignments/cose/cose.xhtml#algorithms {type: "public-key", alg: -7}, // ECDSA w/ SHA-256 {type: "public-key", alg: -257}, // RSASSA-PKCS1-v1_5 using SHA-256 ], timeout: 120000 }; const PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_BASE_TEMPLATE = { allowCredentials: [{ type: 'public-key' }], userVerification: "discouraged", timeout: 120000 }; function parseClientData(credential: Credential): ClientData | null { const utf8Decoder = new TextDecoder('utf-8'); const decodedClientData = utf8Decoder.decode(credential.response.clientDataJSON); return JSON.parse(decodedClientData) as ClientData; } function parsePublicKeyFromAttestationData(credential: Credential): Uint8Array { const decodedAttestationData = CBOR.decode(credential.response.attestationObject) as AttestationData; const authData = decodedAttestationData.authData; const dataView = new DataView(new ArrayBuffer(2)); const idLenBytes = authData.slice(53, 55); idLenBytes.forEach((value, index) => dataView.setUint8(index, value)); const credentialIdLength = dataView.getUint16(0); const publicKeyBytes = authData.slice(55 + credentialIdLength); return publicKeyBytes; } export function isWebAuthnSupported(): boolean { return !!window.PublicKeyCredential && !!navigator.credentials && isFunction(window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable); } export function isWebAuthnCompletelySupported(): Promise { if (!isWebAuthnSupported()) { return Promise.resolve(false); } return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } export function registerWebAuthnCredential(lockState: ApplicationLockState, userInfo: UserBasicInfo): Promise { if (!window.location || !window.location.hostname) { return Promise.reject({ notSupported: true }); } if (!isWebAuthnSupported() || !navigator.credentials.create) { return Promise.reject({ notSupported: true }); } const challenge = generateRandomString(); const userId = `${lockState.username}|${lockState.secret}`; // username 32bytes(max) + secret 24bytes = 56bytes(max) const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS_BASE_TEMPLATE, { challenge: stringToArrayBuffer(challenge), rp: { name: window.location.hostname, id: window.location.hostname }, user: { id: stringToArrayBuffer(userId), name: lockState.username, displayName: userInfo.nickname } }) as PublicKeyCredentialCreationOptions; logger.debug('webauthn create options', publicKeyCredentialCreationOptions); return navigator.credentials.create({ publicKey: publicKeyCredentialCreationOptions }).then(rawCredential => { const clientData = rawCredential ? parseClientData(rawCredential) : null; const publicKey = rawCredential ? parsePublicKeyFromAttestationData(rawCredential) : null; const challengeFromClientData = clientData && clientData.challenge ? base64decode(clientData.challenge) : null; logger.debug('webauthn create raw response', rawCredential); if (rawCredential && rawCredential.rawId && clientData && clientData.type === 'webauthn.create' && challengeFromClientData === challenge) { const ret: WebAuthnRegisterResponse = { id: base64encode(rawCredential.rawId), clientData: clientData, publicKey: publicKey, rawCredential: rawCredential }; logger.debug('webauthn create response', ret); return ret; } else { return Promise.reject({ invalid: true }); } }); } export function verifyWebAuthnCredential(userInfo: UserBasicInfo, credentialId: string): Promise { if (!window.location || !window.location.hostname) { return Promise.reject({ notSupported: true }); } if (!isWebAuthnSupported() || !navigator.credentials.get) { return Promise.reject({ notSupported: true }); } const challenge = generateRandomString(); const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_BASE_TEMPLATE, { challenge: stringToArrayBuffer(challenge), rpId: window.location.hostname }) as PublicKeyCredentialRequestOptions; if (publicKeyCredentialRequestOptions.allowCredentials && publicKeyCredentialRequestOptions.allowCredentials.length > 0) { publicKeyCredentialRequestOptions.allowCredentials[0].id = stringToArrayBuffer(base64decode(credentialId)); } logger.debug('webauthn get options', publicKeyCredentialRequestOptions); return navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions }).then(rawCredential => { const clientData = rawCredential ? parseClientData(rawCredential) : null; const challengeFromClientData = clientData && clientData.challenge ? base64decode(clientData.challenge) : null; const userIdParts = rawCredential && rawCredential.response && rawCredential.response.userHandle ? arrayBufferToString(rawCredential.response.userHandle).split('|') : null; logger.debug('webauthn get raw response', rawCredential); if (rawCredential && rawCredential.rawId && clientData && clientData.type === 'webauthn.get' && challengeFromClientData === challenge && userIdParts && userIdParts.length === 2 && userIdParts[0] === userInfo.username) { const ret: WebAuthnVerifyResponse = { id: base64encode(rawCredential.rawId), userName: userIdParts[0], userSecret: userIdParts[1], clientData: clientData, rawCredential: rawCredential }; logger.debug('webauthn get response', ret); return ret; } else { return Promise.reject({ invalid: true }); } }); }