From 395f7dfd63e0e313bb9ec2226273a5352235c8ee Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sat, 11 Jan 2025 23:49:47 +0800 Subject: [PATCH] migrate webauthn.js to typescript --- package-lock.json | 8 ++ package.json | 1 + src/global.d.ts | 9 ++ src/lib/common.ts | 2 +- src/lib/{webauthn.js => webauthn.ts} | 123 +++++++++++------- src/views/desktop/UnlockPage.vue | 13 +- .../app/settings/tabs/AppLockSettingTab.vue | 11 +- src/views/mobile/ApplicationLockPage.vue | 11 +- src/views/mobile/UnlockPage.vue | 13 +- 9 files changed, 124 insertions(+), 67 deletions(-) rename src/lib/{webauthn.js => webauthn.ts} (65%) diff --git a/package-lock.json b/package-lock.json index 0750f7ab..ec5d742e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@tsconfig/node22": "^22.0.0", + "@types/cbor-js": "^0.1.1", "@types/crypto-js": "^4.2.2", "@types/git-rev-sync": "^2.0.2", "@types/node": "^22.10.2", @@ -4183,6 +4184,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cbor-js": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@types/cbor-js/-/cbor-js-0.1.1.tgz", + "integrity": "sha512-pfCx/EZC7VNBThwAQ0XvGPOXYm8BUk+gSVonaIGcEKBuqGJHTdcwAGW8WZkdRs/u9n9yOt1pBoPTCS1s8ZYpEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/crypto-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", diff --git a/package.json b/package.json index 726bf998..ce21770e 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@tsconfig/node22": "^22.0.0", + "@types/cbor-js": "^0.1.1", "@types/crypto-js": "^4.2.2", "@types/git-rev-sync": "^2.0.2", "@types/node": "^22.10.2", diff --git a/src/global.d.ts b/src/global.d.ts index 8c5d5f67..51c5cd34 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -21,3 +21,12 @@ interface Window { interface Navigator { browserLanguage?: string; } + +interface Credential { + rawId: ArrayBuffer; + response: { + clientDataJSON: ArrayBuffer; + attestationObject: ArrayBuffer; + userHandle: ArrayBuffer; + }; +} diff --git a/src/lib/common.ts b/src/lib/common.ts index 7e6b8a2f..c070a811 100644 --- a/src/lib/common.ts +++ b/src/lib/common.ts @@ -237,7 +237,7 @@ export function base64encode(arrayBuffer: ArrayBuffer): string | null { return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(arrayBuffer)))); } -export function base64decode(str: string): string | null { +export function base64decode(str: string): string { if (!str) { return ''; } diff --git a/src/lib/webauthn.js b/src/lib/webauthn.ts similarity index 65% rename from src/lib/webauthn.js rename to src/lib/webauthn.ts index 4b0ec64f..a2eb6970 100644 --- a/src/lib/webauthn.js +++ b/src/lib/webauthn.ts @@ -1,5 +1,8 @@ import CBOR from 'cbor-js'; -import logger from './logger.ts'; + +import type { ApplicationLockState } from '@/core/setting.ts'; +import type { UserBasicInfo } from '@/models/user.ts'; + import { isFunction, stringToArrayBuffer, @@ -10,8 +13,36 @@ import { import { generateRandomString } from './misc.ts'; +import logger from './logger.ts'; -const publicKeyCredentialCreationOptionsBaseTemplate = { +interface ClientData { + challenge: string; + crossOrigin: boolean; + origin: string; + type: string; +} + +interface AttestationData { + authData: Uint8Array; + fmt: string; +} + +interface WebAuthnRegisterResponse { + id: string | null; + clientData: ClientData; + publicKey: Uint8Array | null; + rawCredential: Credential; +} + +interface WebAuthnVerifyResponse { + id: string | null; + userName: string; + userSecret: string; + clientData: ClientData; + rawCredential: Credential; +} + +const PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS_BASE_TEMPLATE = { attestation: "none", authenticatorSelection: { authenticatorAttachment: 'platform', @@ -26,7 +57,7 @@ const publicKeyCredentialCreationOptionsBaseTemplate = { timeout: 120000 }; -const publicKeyCredentialRequestOptionsBaseTemplate = { +const PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_BASE_TEMPLATE = { allowCredentials: [{ type: 'public-key' }], @@ -34,37 +65,57 @@ const publicKeyCredentialRequestOptionsBaseTemplate = { timeout: 120000 }; -function isSupported() { +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); } -function isCompletelySupported() { - if (!isSupported()) { +export function isWebAuthnCompletelySupported(): Promise { + if (!isWebAuthnSupported()) { return Promise.resolve(false); } return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } -function registerCredential({ username, secret }, { nickname }) { +export function registerWebAuthnCredential(lockState: ApplicationLockState, userInfo: UserBasicInfo): Promise { if (!window.location || !window.location.hostname) { return Promise.reject({ notSupported: true }); } - if (!isSupported() || !navigator.credentials.create) { + if (!isWebAuthnSupported() || !navigator.credentials.create) { return Promise.reject({ notSupported: true }); } const challenge = generateRandomString(); - const userId = `${username}|${secret}`; // username 32bytes(max) + secret 24bytes = 56bytes(max) + const userId = `${lockState.username}|${lockState.secret}`; // username 32bytes(max) + secret 24bytes = 56bytes(max) - const publicKeyCredentialCreationOptions = Object.assign({}, publicKeyCredentialCreationOptionsBaseTemplate, { + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS_BASE_TEMPLATE, { challenge: stringToArrayBuffer(challenge), rp: { name: window.location.hostname, @@ -72,10 +123,10 @@ function registerCredential({ username, secret }, { nickname }) { }, user: { id: stringToArrayBuffer(userId), - name: username, - displayName: nickname + name: lockState.username, + displayName: userInfo.nickname } - }); + }) as PublicKeyCredentialCreationOptions; logger.debug('webauthn create options', publicKeyCredentialCreationOptions); @@ -91,7 +142,7 @@ function registerCredential({ username, secret }, { nickname }) { if (rawCredential && rawCredential.rawId && clientData && clientData.type === 'webauthn.create' && challengeFromClientData === challenge) { - const ret = { + const ret: WebAuthnRegisterResponse = { id: base64encode(rawCredential.rawId), clientData: clientData, publicKey: publicKey, @@ -109,45 +160,28 @@ function registerCredential({ username, secret }, { nickname }) { }); } -function parseClientData(credential) { - const utf8Decoder = new TextDecoder('utf-8'); - const decodedClientData = utf8Decoder.decode(credential.response.clientDataJSON); - return JSON.parse(decodedClientData); -} - -function parsePublicKeyFromAttestationData(credential) { - const decodedAttestationData = CBOR.decode(credential.response.attestationObject); - 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(); - const publicKeyBytes = authData.slice(55 + credentialIdLength); - - return publicKeyBytes; -} - -function verifyCredential({ username }, credentialId) { +export function verifyWebAuthnCredential(userInfo: UserBasicInfo, credentialId: string): Promise { if (!window.location || !window.location.hostname) { return Promise.reject({ notSupported: true }); } - if (!isSupported() || !navigator.credentials.get) { + if (!isWebAuthnSupported() || !navigator.credentials.get) { return Promise.reject({ notSupported: true }); } const challenge = generateRandomString(); - const publicKeyCredentialRequestOptions = Object.assign({}, publicKeyCredentialRequestOptionsBaseTemplate, { + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_BASE_TEMPLATE, { challenge: stringToArrayBuffer(challenge), rpId: window.location.hostname - }); - publicKeyCredentialRequestOptions.allowCredentials[0].id = stringToArrayBuffer(base64decode(credentialId)); + }) as PublicKeyCredentialRequestOptions; + + if (publicKeyCredentialRequestOptions.allowCredentials && publicKeyCredentialRequestOptions.allowCredentials.length > 0) { + publicKeyCredentialRequestOptions.allowCredentials[0].id = stringToArrayBuffer(base64decode(credentialId)); + } logger.debug('webauthn get options', publicKeyCredentialRequestOptions); @@ -162,8 +196,8 @@ function verifyCredential({ username }, credentialId) { if (rawCredential && rawCredential.rawId && clientData && clientData.type === 'webauthn.get' && challengeFromClientData === challenge && - userIdParts && userIdParts.length === 2 && userIdParts[0] === username) { - const ret = { + userIdParts && userIdParts.length === 2 && userIdParts[0] === userInfo.username) { + const ret: WebAuthnVerifyResponse = { id: base64encode(rawCredential.rawId), userName: userIdParts[0], userSecret: userIdParts[1], @@ -181,10 +215,3 @@ function verifyCredential({ username }, credentialId) { } }); } - -export default { - isSupported, - isCompletelySupported, - registerCredential, - verifyCredential -} diff --git a/src/views/desktop/UnlockPage.vue b/src/views/desktop/UnlockPage.vue index af8b3535..60899e1f 100644 --- a/src/views/desktop/UnlockPage.vue +++ b/src/views/desktop/UnlockPage.vue @@ -119,8 +119,10 @@ import { useExchangeRatesStore } from '@/stores/exchangeRates.ts'; import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts'; import { ThemeType } from '@/core/theme.ts'; -import logger from '@/lib/logger.ts'; -import webauthn from '@/lib/webauthn.js'; +import { + isWebAuthnSupported, + verifyWebAuthnCredential +} from '@/lib/webauthn.ts'; import { unlockTokenByWebAuthn, unlockTokenByPinCode, @@ -128,6 +130,7 @@ import { getWebAuthnCredentialId } from '@/lib/userstate.ts'; import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; +import logger from '@/lib/logger.ts'; export default { data() { @@ -150,7 +153,7 @@ export default { isWebAuthnAvailable() { return this.settingsStore.appSettings.applicationLockWebAuthn && hasWebAuthnConfig() - && webauthn.isSupported(); + && isWebAuthnSupported(); }, isDarkMode() { return this.globalTheme.global.name.value === ThemeType.Dark; @@ -175,14 +178,14 @@ export default { return; } - if (!webauthn.isSupported()) { + if (!isWebAuthnSupported()) { self.$refs.snackbar.showMessage('WebAuth is not supported on this device'); return; } self.verifyingByWebAuthn = true; - webauthn.verifyCredential( + verifyWebAuthnCredential( self.userStore.currentUserBasicInfo, getWebAuthnCredentialId() ).then(({ id, userName, userSecret }) => { diff --git a/src/views/desktop/app/settings/tabs/AppLockSettingTab.vue b/src/views/desktop/app/settings/tabs/AppLockSettingTab.vue index ccbbc5d5..b70e3438 100644 --- a/src/views/desktop/app/settings/tabs/AppLockSettingTab.vue +++ b/src/views/desktop/app/settings/tabs/AppLockSettingTab.vue @@ -66,8 +66,10 @@ import { useSettingsStore } from '@/stores/setting.ts'; import { useUserStore } from '@/stores/user.ts'; import { useTransactionsStore } from '@/stores/transaction.js'; -import logger from '@/lib/logger.ts'; -import webauthn from '@/lib/webauthn.js'; +import { + isWebAuthnCompletelySupported, + registerWebAuthnCredential +} from '@/lib/webauthn.ts'; import { getUserAppLockState, encryptToken, @@ -76,6 +78,7 @@ import { saveWebAuthnConfig, clearWebAuthnConfig } from '@/lib/userstate.ts'; +import logger from '@/lib/logger.ts'; export default { data() { @@ -114,7 +117,7 @@ export default { if (newValue) { self.enablingWebAuthn = true; - webauthn.registerCredential( + registerWebAuthnCredential( getUserAppLockState(), self.userStore.currentUserBasicInfo, ).then(({ id }) => { @@ -150,7 +153,7 @@ export default { }, created() { const self = this; - webauthn.isCompletelySupported().then(result => { + isWebAuthnCompletelySupported().then(result => { self.isSupportedWebAuthn = result; }); }, diff --git a/src/views/mobile/ApplicationLockPage.vue b/src/views/mobile/ApplicationLockPage.vue index fbc06789..9540c296 100644 --- a/src/views/mobile/ApplicationLockPage.vue +++ b/src/views/mobile/ApplicationLockPage.vue @@ -41,8 +41,10 @@ import { useSettingsStore } from '@/stores/setting.ts'; import { useUserStore } from '@/stores/user.ts'; import { useTransactionsStore } from '@/stores/transaction.js'; -import logger from '@/lib/logger.ts'; -import webauthn from '@/lib/webauthn.js'; +import { + isWebAuthnCompletelySupported, + registerWebAuthnCredential +} from '@/lib/webauthn.ts'; import { getUserAppLockState, encryptToken, @@ -51,6 +53,7 @@ import { saveWebAuthnConfig, clearWebAuthnConfig } from '@/lib/userstate.ts'; +import logger from '@/lib/logger.ts'; export default { data() { @@ -88,7 +91,7 @@ export default { if (newValue) { self.$showLoading(); - webauthn.registerCredential( + registerWebAuthnCredential( getUserAppLockState(), self.userStore.currentUserBasicInfo, ).then(({ id }) => { @@ -124,7 +127,7 @@ export default { }, created() { const self = this; - webauthn.isCompletelySupported().then(result => { + isWebAuthnCompletelySupported().then(result => { self.isSupportedWebAuthn = result; }); }, diff --git a/src/views/mobile/UnlockPage.vue b/src/views/mobile/UnlockPage.vue index 2f230a3e..6c893b4f 100644 --- a/src/views/mobile/UnlockPage.vue +++ b/src/views/mobile/UnlockPage.vue @@ -74,8 +74,10 @@ import { useTransactionsStore } from '@/stores/transaction.js'; import { useExchangeRatesStore } from '@/stores/exchangeRates.ts'; import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts'; -import logger from '@/lib/logger.ts'; -import webauthn from '@/lib/webauthn.js'; +import { + isWebAuthnSupported, + verifyWebAuthnCredential +} from '@/lib/webauthn.ts'; import { unlockTokenByWebAuthn, unlockTokenByPinCode, @@ -84,6 +86,7 @@ import { } from '@/lib/userstate.ts'; import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; import { isModalShowing } from '@/lib/ui/mobile.ts'; +import logger from '@/lib/logger.ts'; export default { props: [ @@ -108,7 +111,7 @@ export default { isWebAuthnAvailable() { return this.settingsStore.appSettings.applicationLockWebAuthn && hasWebAuthnConfig() - && webauthn.isSupported(); + && isWebAuthnSupported(); }, currentLanguageCode() { return this.$locale.getCurrentLanguageTag(); @@ -127,14 +130,14 @@ export default { return; } - if (!webauthn.isSupported()) { + if (!isWebAuthnSupported()) { self.$toast('WebAuth is not supported on this device'); return; } self.$showLoading(); - webauthn.verifyCredential( + verifyWebAuthnCredential( self.userStore.currentUserBasicInfo, getWebAuthnCredentialId() ).then(({ id, userName, userSecret }) => {