diff --git a/package-lock.json b/package-lock.json index a6010318..3f2448a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3152,6 +3152,11 @@ "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", "dev": true }, + "cbor-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cbor-js/-/cbor-js-0.1.0.tgz", + "integrity": "sha1-yAzmEg84fo+qdDcN/aIdlluPx/k=" + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", diff --git a/package.json b/package.json index ae504c92..93ca5fc5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "axios": "^0.21.0", + "cbor-js": "^0.1.0", "core-js": "^3.6.5", "crypto-js": "^4.0.0", "framework7": "^5.7.14", diff --git a/src/consts/licenses.js b/src/consts/licenses.js index 509872fc..0221eec3 100644 --- a/src/consts/licenses.js +++ b/src/consts/licenses.js @@ -165,6 +165,12 @@ const licenses = [ url: 'https://github.com/brix/crypto-js', licenseUrl: 'https://github.com/brix/crypto-js/blob/develop/LICENSE' }, + { + name: 'cbor-js', + copyright: 'Copyright (c) 2014 Patrick Gansterer ', + url: 'https://github.com/paroga/cbor-js', + licenseUrl: 'https://github.com/paroga/cbor-js/blob/master/LICENSE' + }, { name: 'js-cookie', copyright: 'Copyright (c) 2018 Copyright 2018 Klaus Hartl, Fagner Brack, GitHub Contributors', diff --git a/src/lib/settings.js b/src/lib/settings.js index 1d451a67..4f02f51f 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -6,6 +6,7 @@ const serverSettingsCookieKey = 'ACP_SETTINGS'; const defaultSettings = { lang: 'en', applicationLock: false, + applicationLockWebAuthn: false, autoUpdateExchangeRatesData: true, thousandsSeparator: true, currencyDisplayMode: 'code', // or 'none' or 'name' @@ -76,6 +77,8 @@ export default { setLanguage: value => setOption('lang', value), isEnableApplicationLock: () => getOption('applicationLock'), setEnableApplicationLock: value => setOption('applicationLock', value), + isEnableApplicationLockWebAuthn: () => getOption('applicationLockWebAuthn'), + setEnableApplicationLockWebAuthn: value => setOption('applicationLockWebAuthn', value), isAutoUpdateExchangeRatesData: () => getOption('autoUpdateExchangeRatesData'), setAutoUpdateExchangeRatesData: value => setOption('autoUpdateExchangeRatesData', value), isEnableThousandsSeparator: () => getOption('thousandsSeparator'), diff --git a/src/lib/userstate.js b/src/lib/userstate.js index 1abc3084..9fa5d21e 100644 --- a/src/lib/userstate.js +++ b/src/lib/userstate.js @@ -6,7 +6,9 @@ import utils from './utils.js'; const APP_LOCK_SECRET_BASE_STRING_PREFIX = 'LAB_LOCK_SECRET_'; const tokenLocalStorageKey = 'lab_user_token'; +const webauthnConfigLocalStorageKey = 'lab_user_webauthn_config'; const userInfoLocalStorageKey = 'lab_user_info'; + const tokenSessionStorageKey = 'lab_user_session_token'; const appLockSecretSessionStorageKey = 'lab_user_app_lock_secret'; @@ -36,6 +38,11 @@ function getUserInfo() { return JSON.parse(data); } +function getUserAppLockSecret() { + const currentSecret = sessionStorage.getItem(appLockSecretSessionStorageKey); + return currentSecret; +} + function isUserLogined() { return !!localStorage.getItem(tokenLocalStorageKey); } @@ -52,7 +59,42 @@ function isUserUnlocked() { return !!sessionStorage.getItem(appLockSecretSessionStorageKey) && !!sessionStorage.getItem(tokenSessionStorageKey); } -function unlockToken(pinCode) { +function getWebAuthnCredentialId() { + const webauthnConfigData = localStorage.getItem(webauthnConfigLocalStorageKey); + const webauthnConfig = JSON.parse(webauthnConfigData); + + return webauthnConfig.credentialId; +} + +function saveWebAuthnConfig(credentialId) { + const webAuthnConfig = { + credentialId: credentialId + }; + + localStorage.setItem(webauthnConfigLocalStorageKey, JSON.stringify(webAuthnConfig)); +} + +function clearWebAuthnConfig() { + localStorage.removeItem(webauthnConfigLocalStorageKey); +} + +function unlockTokenByWebAuthn(credentialId, userSecret) { + const webauthnConfigData = localStorage.getItem(webauthnConfigLocalStorageKey); + const webauthnConfig = JSON.parse(webauthnConfigData); + + if (webauthnConfig.credentialId !== credentialId) { + return false; + } + + const encryptedToken = localStorage.getItem(tokenLocalStorageKey); + const secret = userSecret; + const token = getDecryptedToken(encryptedToken, secret); + + sessionStorage.setItem(appLockSecretSessionStorageKey, secret); + sessionStorage.setItem(tokenSessionStorageKey, token); +} + +function unlockTokenByPinCode(pinCode) { const encryptedToken = localStorage.getItem(tokenLocalStorageKey); const secret = getAppLockSecret(pinCode); const token = getDecryptedToken(encryptedToken, secret); @@ -127,9 +169,14 @@ function clearTokenAndUserInfo() { export default { getToken, getUserInfo, + getUserAppLockSecret, isUserLogined, isUserUnlocked, - unlockToken, + getWebAuthnCredentialId, + saveWebAuthnConfig, + clearWebAuthnConfig, + unlockTokenByWebAuthn, + unlockTokenByPinCode, encryptToken, decryptToken, isCorrectPinCode, diff --git a/src/lib/utils.js b/src/lib/utils.js index 639d9c1b..1640436d 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -1,3 +1,4 @@ +import CryptoJS from "crypto-js"; import uaParser from 'ua-parser-js'; import accountConstants from '../consts/account.js'; @@ -29,6 +30,27 @@ function isBoolean(val) { return typeof(val) === 'boolean'; } +function base64encode(arrayBuffer) { + if (!arrayBuffer || arrayBuffer.length === 0) { + return null; + } + + return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))); +} + +function arrayBufferToString(arrayBuffer) { + return String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); +} + +function stringToArrayBuffer(str){ + return Uint8Array.from(str, c => c.charCodeAt(0)).buffer; +} + +function generateRandomString() { + const baseString = 'lab_' + Math.round(new Date().getTime() / 1000) + '_' + Math.random(); + return CryptoJS.SHA256(baseString).toString(); +} + function parseUserAgent(ua) { const uaParseRet = uaParser(ua); @@ -129,6 +151,10 @@ export default { isString, isNumber, isBoolean, + base64encode, + arrayBufferToString, + stringToArrayBuffer, + generateRandomString, parseUserAgent, getCategorizedAccounts, getAccountByAccountId, diff --git a/src/lib/webauthn.js b/src/lib/webauthn.js new file mode 100644 index 00000000..a299776b --- /dev/null +++ b/src/lib/webauthn.js @@ -0,0 +1,169 @@ +import CBOR from 'cbor-js'; +import utils from './utils.js'; + +const PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS_TEMPLATE = { + attestation: "none", + authenticatorSelection: { + authenticatorAttachment: 'platform', + requireResidentKey: false, + userVerification: "required" + }, + pubKeyCredParams: [ + // https://www.iana.org/assignments/cose/cose.xhtml#algorithms + {type: "public-key", alg: -7}, // ECDSA w/ SHA-256 + {type: "public-key", alg: -35}, // ECDSA w/ SHA-384 + {type: "public-key", alg: -36}, // ECDSA w/ SHA-512 + {type: "public-key", alg: -257}, // RSASSA-PKCS1-v1_5 using SHA-256 + {type: "public-key", alg: -258}, // RSASSA-PKCS1-v1_5 using SHA-384 + {type: "public-key", alg: -259}, // RSASSA-PKCS1-v1_5 using SHA-512 + {type: "public-key", alg: -37}, // RSASSA-PSS w/ SHA-256 + {type: "public-key", alg: -38}, // RSASSA-PSS w/ SHA-384 + {type: "public-key", alg: -39}, // RSASSA-PSS w/ SHA-512 + {type: "public-key", alg: -8} // EdDSA + ], + timeout: 1800000 +}; + +const PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_TEMPLATE = { + allowCredentials: [{ + type: 'public-key', + transports: ['internal'] + }], + userVerification: "required", + timeout: 1800000 +} + +function isSupported() { + return !!window.PublicKeyCredential && !!navigator.credentials; +} + +function isCompletelySupported() { + if (!isSupported()) { + return Promise.resolve(false); + } + + return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +} + +function registerCredential({ username, nickname }, userSecret) { + if (!window.location || !window.location.hostname || !document.title) { + return Promise.reject({ + notSupported: true + }); + } + + if (!isSupported() || !navigator.credentials.create) { + return Promise.reject({ + notSupported: true + }); + } + + const challenge = utils.generateRandomString(); + const publicKeyCredentialCreationOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS_TEMPLATE, { + challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), + rp: { + name: document.title, + id: window.location.hostname, + }, + user: { + id: Uint8Array.from(userSecret, c => c.charCodeAt(0)), + name: username, + displayName: nickname, + } + }); + + 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 ? atob(clientData.challenge) : null; + + if (rawCredential && rawCredential.rawId && + clientData && clientData.type === 'webauthn.create' && challengeFromClientData === challenge) { + + return { + id: utils.base64encode(rawCredential.rawId), + clientData: clientData, + publicKey: publicKey, + rawCredential: rawCredential + }; + } else { + return Promise.reject({ + invalid: true + }); + } + }); +} + +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(credentialId) { + if (!window.location || !window.location.hostname) { + return Promise.reject({ + notSupported: true + }); + } + + if (!isSupported() || !navigator.credentials.get) { + return Promise.reject({ + notSupported: true + }); + } + + const challenge = utils.generateRandomString(); + const publicKeyCredentialRequestOptions = Object.assign({}, PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS_TEMPLATE, { + challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), + rpId: window.location.hostname + }); + publicKeyCredentialRequestOptions.allowCredentials[0].id = Uint8Array.from(atob(credentialId), c=>c.charCodeAt(0)).buffer; + + return navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions + }).then(rawCredential => { + const clientData = rawCredential ? parseClientData(rawCredential) : null; + const challengeFromClientData = clientData && clientData.challenge ? atob(clientData.challenge) : null; + + if (rawCredential && rawCredential.rawId && + rawCredential.response && rawCredential.response.userHandle && + clientData && clientData.type === 'webauthn.get' && challengeFromClientData === challenge) { + + return { + id: utils.base64encode(rawCredential.rawId), + userSecret: utils.arrayBufferToString(rawCredential.response.userHandle), + clientData: clientData, + rawCredential: rawCredential + }; + } else { + return Promise.reject({ + invalid: true + }); + } + }); +} + +export default { + isSupported, + isCompletelySupported, + registerCredential, + verifyCredential +} diff --git a/src/locales/en.js b/src/locales/en.js index 37184a48..9830fc89 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -415,6 +415,12 @@ export default { 'PIN Code': 'PIN Code', 'Please input a new PIN code. PIN code would encrypt your local data, so you need input this PIN code when you launch this app. If this PIN code is lost, you should re-login.': 'Please input a new PIN code. PIN code would encrypt your local data, so you need input this PIN code when you launch this app. If this PIN code is lost, you should re-login.', 'Please enter your current PIN code when disable application lock': 'Please enter your current PIN code when disable application lock', + 'Face ID / Touch ID': 'Face ID / Touch ID', + 'You have enabled Face ID/Touch ID successfully': 'You have enabled Face ID/Touch ID successfully', + 'This device does not support Face ID/Touch ID': 'This device does not support Face ID/Touch ID', + 'Failed to enable Face ID/Touch ID': 'Failed to enable Face ID/Touch ID', + 'User has canceled or this device does not support Face ID/Touch ID': 'User has canceled or this device does not support Face ID/Touch ID', + 'Failed to authenticate by Face ID/Touch ID': 'Failed to authenticate by Face ID/Touch ID', 'Exchange Rates Data': 'Exchange Rates Data', 'Base Currency': 'Base Currency', 'Last Updated': 'Last Updated', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index 542de3a8..8f52aee6 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -415,6 +415,12 @@ export default { 'PIN Code': 'PIN码', 'Please input a new PIN code. PIN code would encrypt your local data, so you need input this PIN code when you launch this app. If this PIN code is lost, you should re-login.': '请输入一个新的PIN码,PIN码会加密你的本地数据,所以您每次打开应用时都需要输入PIN码。如果您忘记了PIN码,您需要重新登录。', 'Please enter your current PIN code when disable application lock': '禁用应用锁时需要输入当前的PIN码', + 'Face ID / Touch ID': '面容ID / 指纹ID', + 'You have enabled Face ID/Touch ID successfully': '您已经成功开启面容ID / 指纹ID', + 'This device does not support Face ID/Touch ID': '当前设备不支持 Face ID 或 Touch ID', + 'Failed to enable Face ID/Touch ID': '启用 Face ID 或 Touch ID 失败', + 'User has canceled or this device does not support Face ID/Touch ID': '用户已取消或当前设备不支持 Face ID 或 Touch ID', + 'Failed to authenticate by Face ID/Touch ID': '使用 Face ID / Touch ID 认证失败', 'Exchange Rates Data': '汇率数据', 'Base Currency': '基准货币', 'Last Updated': '最后更新', diff --git a/src/mobile-main.js b/src/mobile-main.js index 8346a3f4..afe09d13 100644 --- a/src/mobile-main.js +++ b/src/mobile-main.js @@ -24,6 +24,7 @@ import settings from './lib/settings.js'; import services from './lib/services.js'; import userstate from './lib/userstate.js'; import exchangeRates from './lib/exchangeRates.js'; +import webauthn from './lib/webauthn.js'; import utils from './lib/utils.js'; import currencyFilter from './filters/currency.js'; import accountIconFilter from './filters/accountIcon.js'; @@ -50,6 +51,7 @@ Vue.prototype.$constants = { account: account, }; Vue.prototype.$utilities = utils; +Vue.prototype.$webauthn = webauthn; Vue.prototype.$settings = settings; Vue.prototype.$getDefaultLanguage = getDefaultLanguage; Vue.prototype.$getAllLanguages = getAllLanguages; diff --git a/src/views/mobile/ApplicationLock.vue b/src/views/mobile/ApplicationLock.vue index 805e4b4d..72b20b52 100644 --- a/src/views/mobile/ApplicationLock.vue +++ b/src/views/mobile/ApplicationLock.vue @@ -9,6 +9,10 @@ + + {{ $t('Face ID / Touch ID') }} + + {{ $t('Disable') }} @@ -75,7 +79,9 @@ export default { data() { return { + isSupportedWebAuthn: false, isEnableApplicationLock: this.$settings.isEnableApplicationLock(), + isEnableApplicationLockWebAuthn: this.$settings.isEnableApplicationLockWebAuthn(), currentPinCodeForEnable: '', currentPinCodeForDisable: '', showInputPinCodeSheetForEnable: false, @@ -88,8 +94,51 @@ export default { }, currentPinCodeForDisableValid() { return this.currentPinCodeForDisable && this.currentPinCodeForDisable.length === 6; + }, + }, + watch: { + isEnableApplicationLockWebAuthn: function (newValue) { + const self = this; + + if (newValue) { + self.$showLoading(); + + self.$webauthn.registerCredential( + self.$user.getUserInfo(), + self.$user.getUserAppLockSecret(), + ).then(({ id }) => { + self.$hideLoading(); + + self.$user.saveWebAuthnConfig(id); + self.$settings.setEnableApplicationLockWebAuthn(true); + self.$toast('You have enabled Face ID/Touch ID successfully'); + }).catch(({ notSupported, invalid }) => { + self.$hideLoading(); + + if (notSupported) { + self.$toast('This device does not support Face ID/Touch ID'); + } else if (invalid) { + self.$toast('Failed to enable Face ID/Touch ID'); + } else { + self.$toast('User has canceled or this device does not support Face ID/Touch ID'); + } + + self.isEnableApplicationLockWebAuthn = false; + self.$settings.setEnableApplicationLockWebAuthn(false); + self.$user.clearWebAuthnConfig(); + }); + } else { + self.$settings.setEnableApplicationLockWebAuthn(false); + self.$user.clearWebAuthnConfig(); + } } }, + created() { + const self = this; + self.$webauthn.isCompletelySupported().then(result => { + self.isSupportedWebAuthn = result; + }); + }, methods: { enable(pinCode) { if (this.$settings.isEnableApplicationLock()) { @@ -111,6 +160,10 @@ export default { this.$settings.setEnableApplicationLock(true); this.isEnableApplicationLock = true; + this.$settings.setEnableApplicationLockWebAuthn(false); + this.$user.clearWebAuthnConfig(); + this.isEnableApplicationLockWebAuthn = false; + this.showInputPinCodeSheetForEnable = false; }, disable(pinCode) { @@ -133,6 +186,10 @@ export default { this.$settings.setEnableApplicationLock(false); this.isEnableApplicationLock = false; + this.$settings.setEnableApplicationLockWebAuthn(false); + this.$user.clearWebAuthnConfig(); + this.isEnableApplicationLockWebAuthn = false; + this.showInputPinCodeSheetForDisable = false; } } diff --git a/src/views/mobile/Unlock.vue b/src/views/mobile/Unlock.vue index 192458b8..d7ae762a 100644 --- a/src/views/mobile/Unlock.vue +++ b/src/views/mobile/Unlock.vue @@ -1,7 +1,7 @@