migrate webauthn.js to typescript

This commit is contained in:
MaysWind
2025-01-11 23:49:47 +08:00
parent b166f6ff56
commit 395f7dfd63
9 changed files with 124 additions and 67 deletions
+8
View File
@@ -39,6 +39,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.0", "@tsconfig/node22": "^22.0.0",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2", "@types/git-rev-sync": "^2.0.2",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
@@ -4183,6 +4184,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/crypto-js": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
+1
View File
@@ -48,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.0", "@tsconfig/node22": "^22.0.0",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2", "@types/git-rev-sync": "^2.0.2",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
+9
View File
@@ -21,3 +21,12 @@ interface Window {
interface Navigator { interface Navigator {
browserLanguage?: string; browserLanguage?: string;
} }
interface Credential {
rawId: ArrayBuffer;
response: {
clientDataJSON: ArrayBuffer;
attestationObject: ArrayBuffer;
userHandle: ArrayBuffer;
};
}
+1 -1
View File
@@ -237,7 +237,7 @@ export function base64encode(arrayBuffer: ArrayBuffer): string | null {
return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(arrayBuffer)))); 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) { if (!str) {
return ''; return '';
} }
+75 -48
View File
@@ -1,5 +1,8 @@
import CBOR from 'cbor-js'; 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 { import {
isFunction, isFunction,
stringToArrayBuffer, stringToArrayBuffer,
@@ -10,8 +13,36 @@ import {
import { import {
generateRandomString generateRandomString
} from './misc.ts'; } 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", attestation: "none",
authenticatorSelection: { authenticatorSelection: {
authenticatorAttachment: 'platform', authenticatorAttachment: 'platform',
@@ -26,7 +57,7 @@ const publicKeyCredentialCreationOptionsBaseTemplate = {
timeout: 120000 timeout: 120000
}; };
const publicKeyCredentialRequestOptionsBaseTemplate = { const PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_BASE_TEMPLATE = {
allowCredentials: [{ allowCredentials: [{
type: 'public-key' type: 'public-key'
}], }],
@@ -34,37 +65,57 @@ const publicKeyCredentialRequestOptionsBaseTemplate = {
timeout: 120000 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 return !!window.PublicKeyCredential
&& !!navigator.credentials && !!navigator.credentials
&& isFunction(window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable); && isFunction(window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable);
} }
function isCompletelySupported() { export function isWebAuthnCompletelySupported(): Promise<boolean> {
if (!isSupported()) { if (!isWebAuthnSupported()) {
return Promise.resolve(false); return Promise.resolve(false);
} }
return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} }
function registerCredential({ username, secret }, { nickname }) { export function registerWebAuthnCredential(lockState: ApplicationLockState, userInfo: UserBasicInfo): Promise<WebAuthnRegisterResponse> {
if (!window.location || !window.location.hostname) { if (!window.location || !window.location.hostname) {
return Promise.reject({ return Promise.reject({
notSupported: true notSupported: true
}); });
} }
if (!isSupported() || !navigator.credentials.create) { if (!isWebAuthnSupported() || !navigator.credentials.create) {
return Promise.reject({ return Promise.reject({
notSupported: true notSupported: true
}); });
} }
const challenge = generateRandomString(); 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), challenge: stringToArrayBuffer(challenge),
rp: { rp: {
name: window.location.hostname, name: window.location.hostname,
@@ -72,10 +123,10 @@ function registerCredential({ username, secret }, { nickname }) {
}, },
user: { user: {
id: stringToArrayBuffer(userId), id: stringToArrayBuffer(userId),
name: username, name: lockState.username,
displayName: nickname displayName: userInfo.nickname
} }
}); }) as PublicKeyCredentialCreationOptions;
logger.debug('webauthn create options', publicKeyCredentialCreationOptions); logger.debug('webauthn create options', publicKeyCredentialCreationOptions);
@@ -91,7 +142,7 @@ function registerCredential({ username, secret }, { nickname }) {
if (rawCredential && rawCredential.rawId && if (rawCredential && rawCredential.rawId &&
clientData && clientData.type === 'webauthn.create' && challengeFromClientData === challenge) { clientData && clientData.type === 'webauthn.create' && challengeFromClientData === challenge) {
const ret = { const ret: WebAuthnRegisterResponse = {
id: base64encode(rawCredential.rawId), id: base64encode(rawCredential.rawId),
clientData: clientData, clientData: clientData,
publicKey: publicKey, publicKey: publicKey,
@@ -109,45 +160,28 @@ function registerCredential({ username, secret }, { nickname }) {
}); });
} }
function parseClientData(credential) { export function verifyWebAuthnCredential(userInfo: UserBasicInfo, credentialId: string): Promise<WebAuthnVerifyResponse> {
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) {
if (!window.location || !window.location.hostname) { if (!window.location || !window.location.hostname) {
return Promise.reject({ return Promise.reject({
notSupported: true notSupported: true
}); });
} }
if (!isSupported() || !navigator.credentials.get) { if (!isWebAuthnSupported() || !navigator.credentials.get) {
return Promise.reject({ return Promise.reject({
notSupported: true notSupported: true
}); });
} }
const challenge = generateRandomString(); const challenge = generateRandomString();
const publicKeyCredentialRequestOptions = Object.assign({}, publicKeyCredentialRequestOptionsBaseTemplate, { const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_BASE_TEMPLATE, {
challenge: stringToArrayBuffer(challenge), challenge: stringToArrayBuffer(challenge),
rpId: window.location.hostname rpId: window.location.hostname
}); }) as PublicKeyCredentialRequestOptions;
publicKeyCredentialRequestOptions.allowCredentials[0].id = stringToArrayBuffer(base64decode(credentialId));
if (publicKeyCredentialRequestOptions.allowCredentials && publicKeyCredentialRequestOptions.allowCredentials.length > 0) {
publicKeyCredentialRequestOptions.allowCredentials[0].id = stringToArrayBuffer(base64decode(credentialId));
}
logger.debug('webauthn get options', publicKeyCredentialRequestOptions); logger.debug('webauthn get options', publicKeyCredentialRequestOptions);
@@ -162,8 +196,8 @@ function verifyCredential({ username }, credentialId) {
if (rawCredential && rawCredential.rawId && if (rawCredential && rawCredential.rawId &&
clientData && clientData.type === 'webauthn.get' && challengeFromClientData === challenge && clientData && clientData.type === 'webauthn.get' && challengeFromClientData === challenge &&
userIdParts && userIdParts.length === 2 && userIdParts[0] === username) { userIdParts && userIdParts.length === 2 && userIdParts[0] === userInfo.username) {
const ret = { const ret: WebAuthnVerifyResponse = {
id: base64encode(rawCredential.rawId), id: base64encode(rawCredential.rawId),
userName: userIdParts[0], userName: userIdParts[0],
userSecret: userIdParts[1], userSecret: userIdParts[1],
@@ -181,10 +215,3 @@ function verifyCredential({ username }, credentialId) {
} }
}); });
} }
export default {
isSupported,
isCompletelySupported,
registerCredential,
verifyCredential
}
+8 -5
View File
@@ -119,8 +119,10 @@ import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts'; import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
import { ThemeType } from '@/core/theme.ts'; import { ThemeType } from '@/core/theme.ts';
import logger from '@/lib/logger.ts'; import {
import webauthn from '@/lib/webauthn.js'; isWebAuthnSupported,
verifyWebAuthnCredential
} from '@/lib/webauthn.ts';
import { import {
unlockTokenByWebAuthn, unlockTokenByWebAuthn,
unlockTokenByPinCode, unlockTokenByPinCode,
@@ -128,6 +130,7 @@ import {
getWebAuthnCredentialId getWebAuthnCredentialId
} from '@/lib/userstate.ts'; } from '@/lib/userstate.ts';
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
import logger from '@/lib/logger.ts';
export default { export default {
data() { data() {
@@ -150,7 +153,7 @@ export default {
isWebAuthnAvailable() { isWebAuthnAvailable() {
return this.settingsStore.appSettings.applicationLockWebAuthn return this.settingsStore.appSettings.applicationLockWebAuthn
&& hasWebAuthnConfig() && hasWebAuthnConfig()
&& webauthn.isSupported(); && isWebAuthnSupported();
}, },
isDarkMode() { isDarkMode() {
return this.globalTheme.global.name.value === ThemeType.Dark; return this.globalTheme.global.name.value === ThemeType.Dark;
@@ -175,14 +178,14 @@ export default {
return; return;
} }
if (!webauthn.isSupported()) { if (!isWebAuthnSupported()) {
self.$refs.snackbar.showMessage('WebAuth is not supported on this device'); self.$refs.snackbar.showMessage('WebAuth is not supported on this device');
return; return;
} }
self.verifyingByWebAuthn = true; self.verifyingByWebAuthn = true;
webauthn.verifyCredential( verifyWebAuthnCredential(
self.userStore.currentUserBasicInfo, self.userStore.currentUserBasicInfo,
getWebAuthnCredentialId() getWebAuthnCredentialId()
).then(({ id, userName, userSecret }) => { ).then(({ id, userName, userSecret }) => {
@@ -66,8 +66,10 @@ import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts'; import { useUserStore } from '@/stores/user.ts';
import { useTransactionsStore } from '@/stores/transaction.js'; import { useTransactionsStore } from '@/stores/transaction.js';
import logger from '@/lib/logger.ts'; import {
import webauthn from '@/lib/webauthn.js'; isWebAuthnCompletelySupported,
registerWebAuthnCredential
} from '@/lib/webauthn.ts';
import { import {
getUserAppLockState, getUserAppLockState,
encryptToken, encryptToken,
@@ -76,6 +78,7 @@ import {
saveWebAuthnConfig, saveWebAuthnConfig,
clearWebAuthnConfig clearWebAuthnConfig
} from '@/lib/userstate.ts'; } from '@/lib/userstate.ts';
import logger from '@/lib/logger.ts';
export default { export default {
data() { data() {
@@ -114,7 +117,7 @@ export default {
if (newValue) { if (newValue) {
self.enablingWebAuthn = true; self.enablingWebAuthn = true;
webauthn.registerCredential( registerWebAuthnCredential(
getUserAppLockState(), getUserAppLockState(),
self.userStore.currentUserBasicInfo, self.userStore.currentUserBasicInfo,
).then(({ id }) => { ).then(({ id }) => {
@@ -150,7 +153,7 @@ export default {
}, },
created() { created() {
const self = this; const self = this;
webauthn.isCompletelySupported().then(result => { isWebAuthnCompletelySupported().then(result => {
self.isSupportedWebAuthn = result; self.isSupportedWebAuthn = result;
}); });
}, },
+7 -4
View File
@@ -41,8 +41,10 @@ import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts'; import { useUserStore } from '@/stores/user.ts';
import { useTransactionsStore } from '@/stores/transaction.js'; import { useTransactionsStore } from '@/stores/transaction.js';
import logger from '@/lib/logger.ts'; import {
import webauthn from '@/lib/webauthn.js'; isWebAuthnCompletelySupported,
registerWebAuthnCredential
} from '@/lib/webauthn.ts';
import { import {
getUserAppLockState, getUserAppLockState,
encryptToken, encryptToken,
@@ -51,6 +53,7 @@ import {
saveWebAuthnConfig, saveWebAuthnConfig,
clearWebAuthnConfig clearWebAuthnConfig
} from '@/lib/userstate.ts'; } from '@/lib/userstate.ts';
import logger from '@/lib/logger.ts';
export default { export default {
data() { data() {
@@ -88,7 +91,7 @@ export default {
if (newValue) { if (newValue) {
self.$showLoading(); self.$showLoading();
webauthn.registerCredential( registerWebAuthnCredential(
getUserAppLockState(), getUserAppLockState(),
self.userStore.currentUserBasicInfo, self.userStore.currentUserBasicInfo,
).then(({ id }) => { ).then(({ id }) => {
@@ -124,7 +127,7 @@ export default {
}, },
created() { created() {
const self = this; const self = this;
webauthn.isCompletelySupported().then(result => { isWebAuthnCompletelySupported().then(result => {
self.isSupportedWebAuthn = result; self.isSupportedWebAuthn = result;
}); });
}, },
+8 -5
View File
@@ -74,8 +74,10 @@ import { useTransactionsStore } from '@/stores/transaction.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.ts'; import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts'; import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
import logger from '@/lib/logger.ts'; import {
import webauthn from '@/lib/webauthn.js'; isWebAuthnSupported,
verifyWebAuthnCredential
} from '@/lib/webauthn.ts';
import { import {
unlockTokenByWebAuthn, unlockTokenByWebAuthn,
unlockTokenByPinCode, unlockTokenByPinCode,
@@ -84,6 +86,7 @@ import {
} from '@/lib/userstate.ts'; } from '@/lib/userstate.ts';
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
import { isModalShowing } from '@/lib/ui/mobile.ts'; import { isModalShowing } from '@/lib/ui/mobile.ts';
import logger from '@/lib/logger.ts';
export default { export default {
props: [ props: [
@@ -108,7 +111,7 @@ export default {
isWebAuthnAvailable() { isWebAuthnAvailable() {
return this.settingsStore.appSettings.applicationLockWebAuthn return this.settingsStore.appSettings.applicationLockWebAuthn
&& hasWebAuthnConfig() && hasWebAuthnConfig()
&& webauthn.isSupported(); && isWebAuthnSupported();
}, },
currentLanguageCode() { currentLanguageCode() {
return this.$locale.getCurrentLanguageTag(); return this.$locale.getCurrentLanguageTag();
@@ -127,14 +130,14 @@ export default {
return; return;
} }
if (!webauthn.isSupported()) { if (!isWebAuthnSupported()) {
self.$toast('WebAuth is not supported on this device'); self.$toast('WebAuth is not supported on this device');
return; return;
} }
self.$showLoading(); self.$showLoading();
webauthn.verifyCredential( verifyWebAuthnCredential(
self.userStore.currentUserBasicInfo, self.userStore.currentUserBasicInfo,
getWebAuthnCredentialId() getWebAuthnCredentialId()
).then(({ id, userName, userSecret }) => { ).then(({ id, userName, userSecret }) => {