From 0bece197a867266530c4a8963af3ac12dfe44776 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 15:12:52 +0100 Subject: [PATCH 01/31] build(yarn): update react-icons package to v5.2.1 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 2af78344..ead9fa46 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "react-confetti": "^6.1.0", "react-dom": "^18.2.0", "react-i18next": "^12.2.0", - "react-icons": "^4.7.1", + "react-icons": "^5.2.1", "react-loader-spinner": "^5.3.4", "react-redux": "^8.0.5", "react-router-dom": "^6.8.2", diff --git a/yarn.lock b/yarn.lock index 10df8bff..acf70586 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10775,10 +10775,10 @@ react-i18next@^12.2.0: "@babel/runtime" "^7.20.6" html-parse-stringify "^3.0.1" -react-icons@^4.7.1: - version "4.7.1" - resolved "https://registry.npmjs.org/react-icons/-/react-icons-4.7.1.tgz" - integrity sha512-yHd3oKGMgm7zxo3EA7H2n7vxSoiGmHk5t6Ou4bXsfcgWyhfDKMpyKfhHR6Bjnn63c+YXBLBPUql9H4wPJM6sXw== +react-icons@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.2.1.tgz#28c2040917b2a2eda639b0f797bff1888e018e4a" + integrity sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw== react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" From 283adf0d77c1ae72692b2f5ca95effa79fad19e0 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 17:37:19 +0100 Subject: [PATCH 02/31] feat: add passkey service to get and fetch a passkey --- .../IAuthenticationExtensionsClientOutputs.ts | 9 ++ src/common/types/IPRFExtensionOutput.ts | 9 ++ src/common/types/IPRFExtensionResults.ts | 5 + src/common/types/index.ts | 3 + src/extension/enums/ErrorCodeEnum.ts | 3 + .../errors/PasskeyNotSupportedError.ts | 10 ++ src/extension/errors/index.ts | 1 + .../services/PasskeyService/PasskeyService.ts | 110 ++++++++++++++++++ .../services/PasskeyService/index.ts | 2 + .../types/ICreatePasskeyOptions.ts | 9 ++ .../types/ICreatePasskeyResult.ts | 6 + .../services/PasskeyService/types/index.ts | 2 + src/extension/types/index.ts | 1 + src/extension/types/passkeys/IPasskey.ts | 12 ++ src/extension/types/passkeys/index.ts | 1 + 15 files changed, 183 insertions(+) create mode 100644 src/common/types/IAuthenticationExtensionsClientOutputs.ts create mode 100644 src/common/types/IPRFExtensionOutput.ts create mode 100644 src/common/types/IPRFExtensionResults.ts create mode 100644 src/extension/errors/PasskeyNotSupportedError.ts create mode 100644 src/extension/services/PasskeyService/PasskeyService.ts create mode 100644 src/extension/services/PasskeyService/index.ts create mode 100644 src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts create mode 100644 src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts create mode 100644 src/extension/services/PasskeyService/types/index.ts create mode 100644 src/extension/types/passkeys/IPasskey.ts create mode 100644 src/extension/types/passkeys/index.ts diff --git a/src/common/types/IAuthenticationExtensionsClientOutputs.ts b/src/common/types/IAuthenticationExtensionsClientOutputs.ts new file mode 100644 index 00000000..6440b296 --- /dev/null +++ b/src/common/types/IAuthenticationExtensionsClientOutputs.ts @@ -0,0 +1,9 @@ +// types +import IPRFExtensionOutput from './IPRFExtensionOutput'; + +interface IAuthenticationExtensionsClientOutputs + extends AuthenticationExtensionsClientOutputs { + prf?: IPRFExtensionOutput; +} + +export default IAuthenticationExtensionsClientOutputs; diff --git a/src/common/types/IPRFExtensionOutput.ts b/src/common/types/IPRFExtensionOutput.ts new file mode 100644 index 00000000..07641a8a --- /dev/null +++ b/src/common/types/IPRFExtensionOutput.ts @@ -0,0 +1,9 @@ +// types +import IPRFExtensionResults from './IPRFExtensionResults'; + +interface IPRFExtensionOutput { + enabled?: boolean; + results?: IPRFExtensionResults; +} + +export default IPRFExtensionOutput; diff --git a/src/common/types/IPRFExtensionResults.ts b/src/common/types/IPRFExtensionResults.ts new file mode 100644 index 00000000..e333be2f --- /dev/null +++ b/src/common/types/IPRFExtensionResults.ts @@ -0,0 +1,5 @@ +interface IPRFExtensionResults { + first: ArrayBuffer; +} + +export default IPRFExtensionResults; diff --git a/src/common/types/index.ts b/src/common/types/index.ts index cc58a252..bea996d2 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -1,7 +1,10 @@ +export type { default as IAuthenticationExtensionsClientOutputs } from './IAuthenticationExtensionsClientOutputs'; export type { default as IBaseOptions } from './IBaseOptions'; export type { default as IClientInformation } from './IClientInformation'; export type { default as IClientRequestMessage } from './IClientRequestMessage'; export type { default as IClientResponseMessage } from './IClientResponseMessage'; export type { default as ILogger } from './ILogger'; export type { default as ILogLevel } from './ILogLevel'; +export type { default as IPRFExtensionOutput } from './IPRFExtensionOutput'; +export type { default as IPRFExtensionResults } from './IPRFExtensionResults'; export type { default as TProviderMessages } from './TProviderMessages'; diff --git a/src/extension/enums/ErrorCodeEnum.ts b/src/extension/enums/ErrorCodeEnum.ts index 2c0b2926..a1699f18 100644 --- a/src/extension/enums/ErrorCodeEnum.ts +++ b/src/extension/enums/ErrorCodeEnum.ts @@ -35,6 +35,9 @@ enum ErrorCodeEnum { ScreenCaptureError = 7000, ScreenCaptureNotAllowedError = 7001, ScreenCaptureNotFoundError = 7002, + + // passkey + PasskeyNotSupportedError = 8000, } export default ErrorCodeEnum; diff --git a/src/extension/errors/PasskeyNotSupportedError.ts b/src/extension/errors/PasskeyNotSupportedError.ts new file mode 100644 index 00000000..56290e34 --- /dev/null +++ b/src/extension/errors/PasskeyNotSupportedError.ts @@ -0,0 +1,10 @@ +// enums +import { ErrorCodeEnum } from '../enums'; + +// errors +import BaseExtensionError from './BaseExtensionError'; + +export default class PasskeyNotSupportedError extends BaseExtensionError { + public readonly code = ErrorCodeEnum.PasskeyNotSupportedError; + public readonly name = 'PasskeyNotSupportedError'; +} diff --git a/src/extension/errors/index.ts b/src/extension/errors/index.ts index 8225c92b..42bc951d 100644 --- a/src/extension/errors/index.ts +++ b/src/extension/errors/index.ts @@ -16,6 +16,7 @@ export { default as NotAZeroBalanceError } from './NotAZeroBalanceError'; export { default as NotEnoughMinimumBalanceError } from './NotEnoughMinimumBalanceError'; export { default as OfflineError } from './OfflineError'; export { default as ParsingError } from './ParsingError'; +export { default as PasskeyNotSupportedError } from './PasskeyNotSupportedError'; export { default as PrivateKeyAlreadyExistsError } from './PrivateKeyAlreadyExistsError'; export { default as ReadABIContractError } from './ReadABIContractError'; export { default as ScreenCaptureError } from './ScreenCaptureError'; diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts new file mode 100644 index 00000000..3e65aa6b --- /dev/null +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -0,0 +1,110 @@ +import { encode as encodeHex } from '@stablelib/hex'; +import { randomBytes } from 'tweetnacl'; + +// errors +import { PasskeyNotSupportedError } from '@extension/errors'; + +// types +import type { + IAuthenticationExtensionsClientOutputs, + IBaseOptions, + ILogger, +} from '@common/types'; +import type { ICreatePasskeyOptions, ICreatePasskeyResult } from './types'; + +export default class PasskeyService { + // private variables + private logger: ILogger | null; + + constructor({ logger }: IBaseOptions) { + this.logger = logger || null; + } + + /** + * public static functions + */ + + /** + * + * @param {ICreatePasskeyOptions} options - the device ID and an optional logger. + * @returns {Promise} + * @throws {PasskeyNotSupportedError} if the browser does not support WebAuthn or the authenticator does not support + * the PRF extension. + * @public + * @static + */ + public static async createPasskey({ + deviceID, + logger, + }: ICreatePasskeyOptions): Promise { + const _functionName = 'createPasskey'; + const salt = randomBytes(32); + const credential = await navigator.credentials.create({ + publicKey: { + challenge: randomBytes(32), + rp: { + name: 'Kibisis Web Extension', + }, + user: { + id: new TextEncoder().encode(deviceID), + name: deviceID, + displayName: 'Kibisis Passkey', + }, + pubKeyCredParams: [ + { alg: -8, type: 'public-key' }, // Ed25519 + { alg: -7, type: 'public-key' }, // ES256 + { alg: -257, type: 'public-key' }, // RS256 + ], + authenticatorSelection: { + userVerification: 'required', + }, + extensions: { + // @ts-ignore + prf: { + eval: { + first: salt, + }, + }, + }, + }, + }); + let _error: string; + let extensionResults: IAuthenticationExtensionsClientOutputs; + + if (!credential) { + _error = 'browser does not support webauthn'; + + logger?.error(`${PasskeyService.name}#${_functionName}: ${_error}`); + + throw new PasskeyNotSupportedError(_error); + } + + extensionResults = ( + credential as PublicKeyCredential + ).getClientExtensionResults(); + + // if the prf is not present or the not enabled, the browser does not support the prf extension + if (!extensionResults.prf?.enabled) { + _error = 'authenticator does not support the prf extension for webauthn'; + + logger?.error(`${PasskeyService.name}#${_functionName}: ${_error}`); + + throw new PasskeyNotSupportedError(_error); + } + + return { + credential, + salt: encodeHex(salt), + }; + } + + /** + * Convenience function that simply checks if the browser supports public key WebAuthn. + * @returns {boolean} true of the browser supports public key WebAuthn, false otherwise. + * @public + * @static + */ + public static isSupported(): boolean { + return !!window?.PublicKeyCredential; + } +} diff --git a/src/extension/services/PasskeyService/index.ts b/src/extension/services/PasskeyService/index.ts new file mode 100644 index 00000000..eeae80bc --- /dev/null +++ b/src/extension/services/PasskeyService/index.ts @@ -0,0 +1,2 @@ +export { default } from './PasskeyService'; +export * from './types'; diff --git a/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts b/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts new file mode 100644 index 00000000..6c54946d --- /dev/null +++ b/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts @@ -0,0 +1,9 @@ +// types +import type { ILogger } from '@common/types'; + +interface ICreatePasskeyOptions { + deviceID: string; + logger?: ILogger; +} + +export default ICreatePasskeyOptions; diff --git a/src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts b/src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts new file mode 100644 index 00000000..68a9200b --- /dev/null +++ b/src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts @@ -0,0 +1,6 @@ +interface ICreatePasskeyResult { + credential: Credential; + salt: string; +} + +export default ICreatePasskeyResult; diff --git a/src/extension/services/PasskeyService/types/index.ts b/src/extension/services/PasskeyService/types/index.ts new file mode 100644 index 00000000..5266d79d --- /dev/null +++ b/src/extension/services/PasskeyService/types/index.ts @@ -0,0 +1,2 @@ +export type { default as ICreatePasskeyOptions } from './ICreatePasskeyOptions'; +export type { default as ICreatePasskeyResult } from './ICreatePasskeyResult'; diff --git a/src/extension/types/index.ts b/src/extension/types/index.ts index dfc749ca..480bf457 100644 --- a/src/extension/types/index.ts +++ b/src/extension/types/index.ts @@ -20,6 +20,7 @@ export * from './modals'; export * from './networks'; export * from './notifications'; export * from './password-lock'; +export * from './passkeys'; export * from './private-key'; export * from './redux'; export * from './registration'; diff --git a/src/extension/types/passkeys/IPasskey.ts b/src/extension/types/passkeys/IPasskey.ts new file mode 100644 index 00000000..6aeb7ce9 --- /dev/null +++ b/src/extension/types/passkeys/IPasskey.ts @@ -0,0 +1,12 @@ +/** + * @property {string} id - the hexadecimal encoded ID of the passkey. + * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. + * @property {string[]} transports - the transports of the passkey that were determined at creation. + */ +interface IPasskey { + id: string; + salt: string; + transports: string[]; +} + +export default IPasskey; diff --git a/src/extension/types/passkeys/index.ts b/src/extension/types/passkeys/index.ts new file mode 100644 index 00000000..a55c548a --- /dev/null +++ b/src/extension/types/passkeys/index.ts @@ -0,0 +1 @@ +export type { default as IPasskey } from './IPasskey'; From d5442921c49b4570752576d1ae19d92eb69d23e2 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 18:44:34 +0100 Subject: [PATCH 03/31] feat: add functionaility to fetch key material from passkey --- src/extension/enums/ErrorCodeEnum.ts | 2 + src/extension/errors/PasskeyCreationError.ts | 10 ++ .../errors/UnableToFetchPasskeyError.ts | 17 ++ src/extension/errors/index.ts | 2 + src/extension/pages/PasskeyPage/index.ts | 1 + .../services/PasskeyService/PasskeyService.ts | 161 +++++++++++++----- .../types/ICreatePasskeyOptions.ts | 5 +- .../types/ICreatePasskeyResult.ts | 6 - .../types/IFetchPasskeyKeyMaterialOptions.ts | 9 + .../services/PasskeyService/types/index.ts | 4 +- src/extension/types/passkeys/IPasskey.ts | 12 -- .../types/passkeys/IPasskeyCredential.ts | 12 ++ src/extension/types/passkeys/index.ts | 2 +- .../utils/encryptBytes/encryptBytes.ts | 2 +- 14 files changed, 180 insertions(+), 65 deletions(-) create mode 100644 src/extension/errors/PasskeyCreationError.ts create mode 100644 src/extension/errors/UnableToFetchPasskeyError.ts create mode 100644 src/extension/pages/PasskeyPage/index.ts delete mode 100644 src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts create mode 100644 src/extension/services/PasskeyService/types/IFetchPasskeyKeyMaterialOptions.ts delete mode 100644 src/extension/types/passkeys/IPasskey.ts create mode 100644 src/extension/types/passkeys/IPasskeyCredential.ts diff --git a/src/extension/enums/ErrorCodeEnum.ts b/src/extension/enums/ErrorCodeEnum.ts index a1699f18..568c0f5c 100644 --- a/src/extension/enums/ErrorCodeEnum.ts +++ b/src/extension/enums/ErrorCodeEnum.ts @@ -38,6 +38,8 @@ enum ErrorCodeEnum { // passkey PasskeyNotSupportedError = 8000, + PasskeyCreationError = 8001, + UnableToFetchPasskeyError = 8002, } export default ErrorCodeEnum; diff --git a/src/extension/errors/PasskeyCreationError.ts b/src/extension/errors/PasskeyCreationError.ts new file mode 100644 index 00000000..acca4b0d --- /dev/null +++ b/src/extension/errors/PasskeyCreationError.ts @@ -0,0 +1,10 @@ +// enums +import { ErrorCodeEnum } from '../enums'; + +// errors +import BaseExtensionError from './BaseExtensionError'; + +export default class PasskeyCreationError extends BaseExtensionError { + public readonly code = ErrorCodeEnum.PasskeyCreationError; + public readonly name = 'PasskeyCreationError'; +} diff --git a/src/extension/errors/UnableToFetchPasskeyError.ts b/src/extension/errors/UnableToFetchPasskeyError.ts new file mode 100644 index 00000000..0ae13b77 --- /dev/null +++ b/src/extension/errors/UnableToFetchPasskeyError.ts @@ -0,0 +1,17 @@ +// enums +import { ErrorCodeEnum } from '../enums'; + +// errors +import BaseExtensionError from './BaseExtensionError'; + +export default class UnableToFetchPasskeyError extends BaseExtensionError { + public readonly code = ErrorCodeEnum.UnableToFetchPasskeyError; + public readonly id: string; + public readonly name = 'UnableToFetchPasskeyError'; + + constructor(id: string, message?: string) { + super(message || `unable to fetch passkey "${id}"`); + + this.id = id; + } +} diff --git a/src/extension/errors/index.ts b/src/extension/errors/index.ts index 42bc951d..8466846c 100644 --- a/src/extension/errors/index.ts +++ b/src/extension/errors/index.ts @@ -16,10 +16,12 @@ export { default as NotAZeroBalanceError } from './NotAZeroBalanceError'; export { default as NotEnoughMinimumBalanceError } from './NotEnoughMinimumBalanceError'; export { default as OfflineError } from './OfflineError'; export { default as ParsingError } from './ParsingError'; +export { default as PasskeyCreationError } from './PasskeyCreationError'; export { default as PasskeyNotSupportedError } from './PasskeyNotSupportedError'; export { default as PrivateKeyAlreadyExistsError } from './PrivateKeyAlreadyExistsError'; export { default as ReadABIContractError } from './ReadABIContractError'; export { default as ScreenCaptureError } from './ScreenCaptureError'; export { default as ScreenCaptureNotAllowedError } from './ScreenCaptureNotAllowedError'; export { default as ScreenCaptureNotFoundError } from './ScreenCaptureNotFoundError'; +export { default as UnableToFetchPasskeyError } from './UnableToFetchPasskeyError'; export { default as UnknownError } from './UnknownError'; diff --git a/src/extension/pages/PasskeyPage/index.ts b/src/extension/pages/PasskeyPage/index.ts new file mode 100644 index 00000000..ac8527dd --- /dev/null +++ b/src/extension/pages/PasskeyPage/index.ts @@ -0,0 +1 @@ +export { default } from './PasskeyPage'; diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index 3e65aa6b..24860452 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -1,8 +1,12 @@ -import { encode as encodeHex } from '@stablelib/hex'; +import { encode as encodeHex, decode as decodeHex } from '@stablelib/hex'; import { randomBytes } from 'tweetnacl'; // errors -import { PasskeyNotSupportedError } from '@extension/errors'; +import { + PasskeyCreationError, + PasskeyNotSupportedError, + UnableToFetchPasskeyError, +} from '@extension/errors'; // types import type { @@ -10,7 +14,11 @@ import type { IBaseOptions, ILogger, } from '@common/types'; -import type { ICreatePasskeyOptions, ICreatePasskeyResult } from './types'; +import type { IPasskeyCredential } from '@extension/types'; +import type { + ICreatePasskeyCredentialOptions, + IFetchPasskeyKeyMaterialOptions, +} from './types'; export default class PasskeyService { // private variables @@ -27,61 +35,67 @@ export default class PasskeyService { /** * * @param {ICreatePasskeyOptions} options - the device ID and an optional logger. - * @returns {Promise} + * @returns {Promise} a promise that resolves to a created passkey credential. * @throws {PasskeyNotSupportedError} if the browser does not support WebAuthn or the authenticator does not support * the PRF extension. * @public * @static */ - public static async createPasskey({ + public static async createPasskeyCredential({ deviceID, logger, - }: ICreatePasskeyOptions): Promise { + }: ICreatePasskeyCredentialOptions): Promise { const _functionName = 'createPasskey'; const salt = randomBytes(32); - const credential = await navigator.credentials.create({ - publicKey: { - challenge: randomBytes(32), - rp: { - name: 'Kibisis Web Extension', - }, - user: { - id: new TextEncoder().encode(deviceID), - name: deviceID, - displayName: 'Kibisis Passkey', - }, - pubKeyCredParams: [ - { alg: -8, type: 'public-key' }, // Ed25519 - { alg: -7, type: 'public-key' }, // ES256 - { alg: -257, type: 'public-key' }, // RS256 - ], - authenticatorSelection: { - userVerification: 'required', - }, - extensions: { - // @ts-ignore - prf: { - eval: { - first: salt, + let _error: string; + let credential: PublicKeyCredential | null; + let extensionResults: IAuthenticationExtensionsClientOutputs; + + try { + credential = (await navigator.credentials.create({ + publicKey: { + authenticatorSelection: { + userVerification: 'discouraged', + }, + challenge: randomBytes(32), + extensions: { + // @ts-ignore + prf: { + eval: { + first: salt, + }, }, }, + pubKeyCredParams: [ + { alg: -8, type: 'public-key' }, // Ed25519 + { alg: -7, type: 'public-key' }, // ES256 + { alg: -257, type: 'public-key' }, // RS256 + ], + rp: { + name: 'Kibisis Web Extension', + }, + user: { + id: new TextEncoder().encode(deviceID), + name: deviceID, + displayName: 'Kibisis Passkey', + }, }, - }, - }); - let _error: string; - let extensionResults: IAuthenticationExtensionsClientOutputs; + })) as PublicKeyCredential | null; + } catch (error) { + logger?.error(`${PasskeyService.name}#${_functionName}:`, error); + + throw new PasskeyCreationError(error.message); + } if (!credential) { - _error = 'browser does not support webauthn'; + _error = 'failed to create a passkey'; logger?.error(`${PasskeyService.name}#${_functionName}: ${_error}`); - throw new PasskeyNotSupportedError(_error); + throw new PasskeyCreationError(_error); } - extensionResults = ( - credential as PublicKeyCredential - ).getClientExtensionResults(); + extensionResults = credential.getClientExtensionResults(); // if the prf is not present or the not enabled, the browser does not support the prf extension if (!extensionResults.prf?.enabled) { @@ -93,11 +107,78 @@ export default class PasskeyService { } return { - credential, + id: encodeHex(new Uint8Array(credential.rawId)), salt: encodeHex(salt), + transports: ( + credential.response as AuthenticatorAttestationResponse + ).getTransports() as AuthenticatorTransport[], }; } + /** + * + * @param credential + * @param logger + */ + public static async fetchPasskeyKeyMaterial({ + credential, + logger, + }: IFetchPasskeyKeyMaterialOptions): Promise { + const _functionName = 'fetchPasskey'; + let _error: string; + let _credential: PublicKeyCredential | null; + let extensionResults: IAuthenticationExtensionsClientOutputs; + + try { + _credential = (await navigator.credentials.get({ + publicKey: { + allowCredentials: [ + { + id: decodeHex(credential.id), + transports: credential.transports, + type: 'public-key', + }, + ], + challenge: randomBytes(32), + extensions: { + // @ts-ignore + prf: { + eval: { + first: decodeHex(credential.salt), + }, + }, + }, + userVerification: 'discouraged', + }, + })) as PublicKeyCredential | null; + } catch (error) { + logger?.error(`${PasskeyService.name}#${_functionName}:`, error); + + throw new UnableToFetchPasskeyError(credential.id, error.message); + } + + if (!_credential) { + _error = `failed to fetch passkey "${credential.id}"`; + + logger?.error(`${PasskeyService.name}#${_functionName}: ${_error}`); + + throw new UnableToFetchPasskeyError(credential.id, _error); + } + + extensionResults = _credential.getClientExtensionResults(); + + // if the prf is not present or not results, the browser does not support the prf extension + if (!extensionResults.prf?.results) { + _error = 'authenticator does not support the prf extension for webauthn'; + + logger?.error(`${PasskeyService.name}#${_functionName}: ${_error}`); + + throw new PasskeyNotSupportedError(_error); + } + + return new Uint8Array(extensionResults.prf.results.first); + } + /** * Convenience function that simply checks if the browser supports public key WebAuthn. * @returns {boolean} true of the browser supports public key WebAuthn, false otherwise. diff --git a/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts b/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts index 6c54946d..68a6e5dd 100644 --- a/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts +++ b/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts @@ -1,9 +1,8 @@ // types -import type { ILogger } from '@common/types'; +import type { IBaseOptions } from '@common/types'; -interface ICreatePasskeyOptions { +interface ICreatePasskeyOptions extends IBaseOptions { deviceID: string; - logger?: ILogger; } export default ICreatePasskeyOptions; diff --git a/src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts b/src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts deleted file mode 100644 index 68a9200b..00000000 --- a/src/extension/services/PasskeyService/types/ICreatePasskeyResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -interface ICreatePasskeyResult { - credential: Credential; - salt: string; -} - -export default ICreatePasskeyResult; diff --git a/src/extension/services/PasskeyService/types/IFetchPasskeyKeyMaterialOptions.ts b/src/extension/services/PasskeyService/types/IFetchPasskeyKeyMaterialOptions.ts new file mode 100644 index 00000000..a1d00c7b --- /dev/null +++ b/src/extension/services/PasskeyService/types/IFetchPasskeyKeyMaterialOptions.ts @@ -0,0 +1,9 @@ +// types +import type { IBaseOptions } from '@common/types'; +import type { IPasskeyCredential } from '@extension/types'; + +interface IFetchPasskeyKeyMaterialOptions extends IBaseOptions { + credential: IPasskeyCredential; +} + +export default IFetchPasskeyKeyMaterialOptions; diff --git a/src/extension/services/PasskeyService/types/index.ts b/src/extension/services/PasskeyService/types/index.ts index 5266d79d..eae81916 100644 --- a/src/extension/services/PasskeyService/types/index.ts +++ b/src/extension/services/PasskeyService/types/index.ts @@ -1,2 +1,2 @@ -export type { default as ICreatePasskeyOptions } from './ICreatePasskeyOptions'; -export type { default as ICreatePasskeyResult } from './ICreatePasskeyResult'; +export type { default as ICreatePasskeyCredentialOptions } from './ICreatePasskeyOptions'; +export type { default as IFetchPasskeyKeyMaterialOptions } from './IFetchPasskeyKeyMaterialOptions'; diff --git a/src/extension/types/passkeys/IPasskey.ts b/src/extension/types/passkeys/IPasskey.ts deleted file mode 100644 index 6aeb7ce9..00000000 --- a/src/extension/types/passkeys/IPasskey.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @property {string} id - the hexadecimal encoded ID of the passkey. - * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. - * @property {string[]} transports - the transports of the passkey that were determined at creation. - */ -interface IPasskey { - id: string; - salt: string; - transports: string[]; -} - -export default IPasskey; diff --git a/src/extension/types/passkeys/IPasskeyCredential.ts b/src/extension/types/passkeys/IPasskeyCredential.ts new file mode 100644 index 00000000..7337c5c6 --- /dev/null +++ b/src/extension/types/passkeys/IPasskeyCredential.ts @@ -0,0 +1,12 @@ +/** + * @property {string} id - the hexadecimal encoded ID of the passkey. + * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. + * @property {AuthenticatorTransport[]} transports - the transports of the passkey that were determined at creation. + */ +interface IPasskeyCredentials { + id: string; + salt: string; + transports: AuthenticatorTransport[]; +} + +export default IPasskeyCredentials; diff --git a/src/extension/types/passkeys/index.ts b/src/extension/types/passkeys/index.ts index a55c548a..1238b65f 100644 --- a/src/extension/types/passkeys/index.ts +++ b/src/extension/types/passkeys/index.ts @@ -1 +1 @@ -export type { default as IPasskey } from './IPasskey'; +export type { default as IPasskeyCredential } from './IPasskeyCredential'; diff --git a/src/extension/utils/encryptBytes/encryptBytes.ts b/src/extension/utils/encryptBytes/encryptBytes.ts index 91540e54..c4a7a6f8 100644 --- a/src/extension/utils/encryptBytes/encryptBytes.ts +++ b/src/extension/utils/encryptBytes/encryptBytes.ts @@ -36,7 +36,7 @@ export default async function encryptBytes( try { encryptedData = secretbox(data, nonce, derivedKey); } catch (error) { - logger?.debug(`${_functionName}(): ${error.message}`); + logger?.debug(`${_functionName}: ${error.message}`); throw new EncryptionError(error.message); } From 6df3b18e732845e88aebc86ea319493299e6c27b Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 18:55:42 +0100 Subject: [PATCH 04/31] chore: squash --- .../services/PasskeyService/PasskeyService.ts | 18 +++++++++++++----- .../types/passkeys/IPasskeyCredential.ts | 4 ++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index 24860452..a46ffaca 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -33,9 +33,12 @@ export default class PasskeyService { */ /** - * + * Registers a passkey with the authenticator and returns the credentials that are used to fetch the key material to derive an + * encryption key. NOTE: this requires PRF extension support and will throw an error if the authenticator does not + * support it. * @param {ICreatePasskeyOptions} options - the device ID and an optional logger. * @returns {Promise} a promise that resolves to a created passkey credential. + * @throws {PasskeyCreationError} if the public key credentials failed to be created on the authenticator. * @throws {PasskeyNotSupportedError} if the browser does not support WebAuthn or the authenticator does not support * the PRF extension. * @public @@ -116,11 +119,16 @@ export default class PasskeyService { } /** - * - * @param credential - * @param logger + * Fetches the key material from the authenticator that is used to derive the encryption key. + * @param {IFetchPasskeyKeyMaterialOptions} options - passkey credentials and a logger. + * @returns {Promise} a promise that resolves to the key material used to derive an encryption key. + * @throws {UnableToFetchPasskeyError} if the authenticator did not return the public key credentials. + * @throws {PasskeyNotSupportedError} if the browser does not support WebAuthn or the authenticator does not support + * the PRF extension. + * @public + * @static */ - public static async fetchPasskeyKeyMaterial({ + public static async fetchKeyMaterialFromPasskey({ credential, logger, }: IFetchPasskeyKeyMaterialOptions): Promise { diff --git a/src/extension/types/passkeys/IPasskeyCredential.ts b/src/extension/types/passkeys/IPasskeyCredential.ts index 7337c5c6..5ad0b920 100644 --- a/src/extension/types/passkeys/IPasskeyCredential.ts +++ b/src/extension/types/passkeys/IPasskeyCredential.ts @@ -3,10 +3,10 @@ * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. * @property {AuthenticatorTransport[]} transports - the transports of the passkey that were determined at creation. */ -interface IPasskeyCredentials { +interface IPasskeyCredential { id: string; salt: string; transports: AuthenticatorTransport[]; } -export default IPasskeyCredentials; +export default IPasskeyCredential; From 59f34b12941d05dd5ded57e4e0b238493137a2f4 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 19:10:19 +0100 Subject: [PATCH 05/31] feat: add functionality to save, remove and fetch passkey credential from storage --- src/extension/constants/Keys.ts | 1 + .../services/PasskeyService/PasskeyService.ts | 56 +++++++++++++++++-- .../PasskeyService/types/INewOptions.ts | 11 ++++ .../services/PasskeyService/types/index.ts | 1 + .../types/storage/IStorageItemTypes.ts | 2 + 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/extension/services/PasskeyService/types/INewOptions.ts diff --git a/src/extension/constants/Keys.ts b/src/extension/constants/Keys.ts index c298d4a1..6a303ea3 100644 --- a/src/extension/constants/Keys.ts +++ b/src/extension/constants/Keys.ts @@ -7,6 +7,7 @@ export const EVENT_QUEUE_ITEM_KEY: string = 'event_queue'; export const NETWORK_TRANSACTION_PARAMS_ITEM_KEY_PREFIX: string = 'network_transaction_params_'; export const NEWS_KEY: string = 'news'; +export const PASSKEY_CREDENTIAL_KEY: string = 'passkey_credential'; export const PASSWORD_LOCK_ITEM_KEY: string = 'password_lock'; export const PASSWORD_TAG_ITEM_KEY: string = 'password_tag'; export const PRIVATE_KEY_ITEM_KEY_PREFIX: string = 'private_key_'; diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index a46ffaca..afc3db03 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -1,6 +1,9 @@ import { encode as encodeHex, decode as decodeHex } from '@stablelib/hex'; import { randomBytes } from 'tweetnacl'; +// constants +import { PASSKEY_CREDENTIAL_KEY } from '@extension/constants'; + // errors import { PasskeyCreationError, @@ -8,24 +11,29 @@ import { UnableToFetchPasskeyError, } from '@extension/errors'; +// services +import StorageManager from '@extension/services/StorageManager'; + // types import type { IAuthenticationExtensionsClientOutputs, - IBaseOptions, ILogger, } from '@common/types'; import type { IPasskeyCredential } from '@extension/types'; import type { ICreatePasskeyCredentialOptions, IFetchPasskeyKeyMaterialOptions, + INewOptions, } from './types'; export default class PasskeyService { // private variables - private logger: ILogger | null; + private readonly logger: ILogger | null; + private readonly storageManager: StorageManager; - constructor({ logger }: IBaseOptions) { - this.logger = logger || null; + constructor(options?: INewOptions) { + this.logger = options?.logger || null; + this.storageManager = options?.storageManager || new StorageManager(); } /** @@ -196,4 +204,44 @@ export default class PasskeyService { public static isSupported(): boolean { return !!window?.PublicKeyCredential; } + + /** + * public functions + */ + + /** + * Fetches the passkey credential from storage. + * @returns {Promise} a promise that resolves to the passkey credential or null if no + * passkey credential exists in storage. + * @public + */ + public async fetchFromStorage(): Promise { + return await this.storageManager.getItem( + PASSKEY_CREDENTIAL_KEY + ); + } + + /** + * Removes the stored passkey credential. + * @public + */ + public async removeFromStorage(): Promise { + return await this.storageManager.remove(PASSKEY_CREDENTIAL_KEY); + } + + /** + * Saves the credential to storage. This will overwrite the current stored credential. + * @param {IPasskeyCredential} credential - the credential to save. + * @returns {Promise} a promise that resolves to the saved credential. + * @public + */ + public async saveToStorage( + credential: IPasskeyCredential + ): Promise { + await this.storageManager.setItems({ + [PASSKEY_CREDENTIAL_KEY]: credential, + }); + + return credential; + } } diff --git a/src/extension/services/PasskeyService/types/INewOptions.ts b/src/extension/services/PasskeyService/types/INewOptions.ts new file mode 100644 index 00000000..820ca496 --- /dev/null +++ b/src/extension/services/PasskeyService/types/INewOptions.ts @@ -0,0 +1,11 @@ +// services +import StorageManager from '@extension/services/StorageManager'; + +// types +import type { IBaseOptions } from '@common/types'; + +interface INewOptions extends IBaseOptions { + storageManager?: StorageManager; +} + +export default INewOptions; diff --git a/src/extension/services/PasskeyService/types/index.ts b/src/extension/services/PasskeyService/types/index.ts index eae81916..208a24f3 100644 --- a/src/extension/services/PasskeyService/types/index.ts +++ b/src/extension/services/PasskeyService/types/index.ts @@ -1,2 +1,3 @@ export type { default as ICreatePasskeyCredentialOptions } from './ICreatePasskeyOptions'; export type { default as IFetchPasskeyKeyMaterialOptions } from './IFetchPasskeyKeyMaterialOptions'; +export type { default as INewOptions } from './INewOptions'; diff --git a/src/extension/types/storage/IStorageItemTypes.ts b/src/extension/types/storage/IStorageItemTypes.ts index bb4ea801..fbe6be80 100644 --- a/src/extension/types/storage/IStorageItemTypes.ts +++ b/src/extension/types/storage/IStorageItemTypes.ts @@ -5,6 +5,7 @@ import type { IAccount, IActiveAccountDetails } from '../accounts'; import type { IARC0072Asset, IARC0200Asset, IStandardAsset } from '../assets'; import type { TEvents } from '../events'; import type { IAppWindow } from '../layout'; +import type { IPasskeyCredential } from '../passkeys'; import type { IPasswordLock } from '../password-lock'; import type { IPasswordTag, IPrivateKey } from '../private-key'; import type { ITransactionParams } from '../networks'; @@ -29,6 +30,7 @@ type IStorageItemTypes = | IGeneralSettings | TEvents[] | INewsItem[] + | IPasskeyCredential | IPasswordLock | IPasswordTag | IPrivacySettings From 7a7901ea423602e1c5631dfe624379cc780324dd Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 21:31:34 +0100 Subject: [PATCH 06/31] feat: add functionality to decrypt/encrypt bytes from passkey input key material --- .../services/PasskeyService/PasskeyService.ts | 124 +++++++++++++++++- .../PasskeyService/constants/Algorithms.ts | 3 + .../PasskeyService/constants/Sizes.ts | 4 + .../PasskeyService/constants/index.ts | 2 + .../types/IDecryptBytesOptions.ts | 11 ++ .../types/IEncryptBytesOptions.ts | 11 ++ .../types/IGenerateEncryptionKeyOptions.ts | 6 + .../services/PasskeyService/types/index.ts | 3 + .../types/passkeys/IPasskeyCredential.ts | 3 + 9 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 src/extension/services/PasskeyService/constants/Algorithms.ts create mode 100644 src/extension/services/PasskeyService/constants/Sizes.ts create mode 100644 src/extension/services/PasskeyService/constants/index.ts create mode 100644 src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts create mode 100644 src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts create mode 100644 src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index afc3db03..912397cc 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -3,6 +3,15 @@ import { randomBytes } from 'tweetnacl'; // constants import { PASSKEY_CREDENTIAL_KEY } from '@extension/constants'; +import { + CHALLENGE_BYTE_SIZE, + DERIVATION_KEY_ALGORITHM, + DERIVATION_KEY_HASH_ALGORITHM, + ENCRYPTION_KEY_ALGORITHM, + ENCRYPTION_KEY_BIT_SIZE, + INITIALIZATION_VECTOR_BYTE_SIZE, + SALT_BYTE_SIZE, +} from './constants'; // errors import { @@ -22,7 +31,10 @@ import type { import type { IPasskeyCredential } from '@extension/types'; import type { ICreatePasskeyCredentialOptions, + IDecryptBytesOptions, + IEncryptBytesOptions, IFetchPasskeyKeyMaterialOptions, + IGenerateEncryptionKeyOptions, INewOptions, } from './types'; @@ -36,6 +48,48 @@ export default class PasskeyService { this.storageManager = options?.storageManager || new StorageManager(); } + /** + * private static functions + */ + + /** + * Generates an encryption key that can be used to decrypt/encrypt bytes. This function imports the key using the + * input key material rom the passkey. + * @param {IGenerateEncryptionKeyOptions} options - the device ID and input key material from the passkey. + * @returns {Promise} a promise that resolves to an encryption key that can be used to decrypt/encrypt + * some bytes. + * @private + * @static + */ + private static async _generateEncryptionKey({ + deviceID, + inputKeyMaterial, + }: IGenerateEncryptionKeyOptions): Promise { + const derivationKey = await crypto.subtle.importKey( + 'raw', + inputKeyMaterial, + DERIVATION_KEY_ALGORITHM, + false, + ['deriveKey'] + ); + + return await crypto.subtle.deriveKey( + { + name: DERIVATION_KEY_ALGORITHM, + info: new TextEncoder().encode(deviceID), // + salt: new Uint8Array(), // use an empty salt + hash: DERIVATION_KEY_HASH_ALGORITHM, + }, + derivationKey, + { + name: ENCRYPTION_KEY_ALGORITHM, + length: ENCRYPTION_KEY_BIT_SIZE, + }, + false, + ['decrypt', 'encrypt'] + ); + } + /** * public static functions */ @@ -57,7 +111,7 @@ export default class PasskeyService { logger, }: ICreatePasskeyCredentialOptions): Promise { const _functionName = 'createPasskey'; - const salt = randomBytes(32); + const salt = randomBytes(SALT_BYTE_SIZE); let _error: string; let credential: PublicKeyCredential | null; let extensionResults: IAuthenticationExtensionsClientOutputs; @@ -119,6 +173,9 @@ export default class PasskeyService { return { id: encodeHex(new Uint8Array(credential.rawId)), + initializationVector: encodeHex( + randomBytes(INITIALIZATION_VECTOR_BYTE_SIZE) + ), salt: encodeHex(salt), transports: ( credential.response as AuthenticatorAttestationResponse @@ -126,6 +183,67 @@ export default class PasskeyService { }; } + /** + * Decrypts some previously encrypted bytes using the input key material fetched from a passkey. + * @param {IDecryptBytesOptions} options - the encrypted bytes, the initialization vector created at the passkey + * creation, the device ID and the input key material fetched from the passkey. + * @returns {Promise} a promise that resolves to the decrypted bytes. + * @public + * @static + */ + public static async decryptBytes({ + deviceID, + encryptedBytes, + initializationVector, + inputKeyMaterial, + }: IDecryptBytesOptions): Promise { + const encryptionKey = await PasskeyService._generateEncryptionKey({ + deviceID, + inputKeyMaterial, + }); + const decryptedBytes = await crypto.subtle.decrypt( + { + name: ENCRYPTION_KEY_ALGORITHM, + iv: initializationVector, + }, + encryptionKey, + encryptedBytes + ); + + return new Uint8Array(decryptedBytes); + } + + /** + * Encrypts some arbitrary bytes using the input key material fetched from a passkey. This function uses the AES-GCM + * algorithm to encrypt the bytes. + * @param {IEncryptBytesOptions} options - the bytes to encrypt, the initialization vector created at the passkey + * creation, the device ID and the input key material fetched from the passkey. + * @returns {Promise} a promise that resolves to the encrypted bytes. + * @public + * @static + */ + public static async encryptBytes({ + bytes, + deviceID, + initializationVector, + inputKeyMaterial, + }: IEncryptBytesOptions): Promise { + const encryptionKey = await PasskeyService._generateEncryptionKey({ + deviceID, + inputKeyMaterial, + }); + const encryptedBytes = await crypto.subtle.encrypt( + { + name: ENCRYPTION_KEY_ALGORITHM, + iv: initializationVector, + }, + encryptionKey, + bytes + ); + + return new Uint8Array(encryptedBytes); + } + /** * Fetches the key material from the authenticator that is used to derive the encryption key. * @param {IFetchPasskeyKeyMaterialOptions} options - passkey credentials and a logger. @@ -136,7 +254,7 @@ export default class PasskeyService { * @public * @static */ - public static async fetchKeyMaterialFromPasskey({ + public static async fetchInputKeyMaterialFromPasskey({ credential, logger, }: IFetchPasskeyKeyMaterialOptions): Promise { @@ -155,7 +273,7 @@ export default class PasskeyService { type: 'public-key', }, ], - challenge: randomBytes(32), + challenge: randomBytes(CHALLENGE_BYTE_SIZE), extensions: { // @ts-ignore prf: { diff --git a/src/extension/services/PasskeyService/constants/Algorithms.ts b/src/extension/services/PasskeyService/constants/Algorithms.ts new file mode 100644 index 00000000..2a8694c4 --- /dev/null +++ b/src/extension/services/PasskeyService/constants/Algorithms.ts @@ -0,0 +1,3 @@ +export const DERIVATION_KEY_ALGORITHM = 'HKDF'; +export const DERIVATION_KEY_HASH_ALGORITHM = 'SHA-256'; +export const ENCRYPTION_KEY_ALGORITHM = 'AES-GCM'; diff --git a/src/extension/services/PasskeyService/constants/Sizes.ts b/src/extension/services/PasskeyService/constants/Sizes.ts new file mode 100644 index 00000000..c587b76f --- /dev/null +++ b/src/extension/services/PasskeyService/constants/Sizes.ts @@ -0,0 +1,4 @@ +export const CHALLENGE_BYTE_SIZE = 32; +export const ENCRYPTION_KEY_BIT_SIZE = 256; +export const INITIALIZATION_VECTOR_BYTE_SIZE = 12; +export const SALT_BYTE_SIZE = 32; diff --git a/src/extension/services/PasskeyService/constants/index.ts b/src/extension/services/PasskeyService/constants/index.ts new file mode 100644 index 00000000..17de392f --- /dev/null +++ b/src/extension/services/PasskeyService/constants/index.ts @@ -0,0 +1,2 @@ +export * from './Algorithms'; +export * from './Sizes'; diff --git a/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts b/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts new file mode 100644 index 00000000..6bbbd616 --- /dev/null +++ b/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts @@ -0,0 +1,11 @@ +// types +import type { IBaseOptions } from '@common/types'; + +interface IDecryptBytesOptions extends IBaseOptions { + deviceID: string; + encryptedBytes: Uint8Array; + initializationVector: Uint8Array; + inputKeyMaterial: Uint8Array; +} + +export default IDecryptBytesOptions; diff --git a/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts b/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts new file mode 100644 index 00000000..c06e97ae --- /dev/null +++ b/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts @@ -0,0 +1,11 @@ +// types +import type { IBaseOptions } from '@common/types'; + +interface IEncryptBytesOptions extends IBaseOptions { + bytes: Uint8Array; + deviceID: string; + initializationVector: Uint8Array; + inputKeyMaterial: Uint8Array; +} + +export default IEncryptBytesOptions; diff --git a/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts b/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts new file mode 100644 index 00000000..2355fc1e --- /dev/null +++ b/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts @@ -0,0 +1,6 @@ +interface IGenerateEncryptionKeyOptions { + deviceID: string; + inputKeyMaterial: Uint8Array; +} + +export default IGenerateEncryptionKeyOptions; diff --git a/src/extension/services/PasskeyService/types/index.ts b/src/extension/services/PasskeyService/types/index.ts index 208a24f3..526206f4 100644 --- a/src/extension/services/PasskeyService/types/index.ts +++ b/src/extension/services/PasskeyService/types/index.ts @@ -1,3 +1,6 @@ export type { default as ICreatePasskeyCredentialOptions } from './ICreatePasskeyOptions'; +export type { default as IDecryptBytesOptions } from './IDecryptBytesOptions'; +export type { default as IEncryptBytesOptions } from './IEncryptBytesOptions'; export type { default as IFetchPasskeyKeyMaterialOptions } from './IFetchPasskeyKeyMaterialOptions'; +export type { default as IGenerateEncryptionKeyOptions } from './IGenerateEncryptionKeyOptions'; export type { default as INewOptions } from './INewOptions'; diff --git a/src/extension/types/passkeys/IPasskeyCredential.ts b/src/extension/types/passkeys/IPasskeyCredential.ts index 5ad0b920..32c2d458 100644 --- a/src/extension/types/passkeys/IPasskeyCredential.ts +++ b/src/extension/types/passkeys/IPasskeyCredential.ts @@ -1,10 +1,13 @@ /** * @property {string} id - the hexadecimal encoded ID of the passkey. + * @property {string} initializationVector - a hexadecimal encoded initialization vector used in the derivation of the + * encryption key. * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. * @property {AuthenticatorTransport[]} transports - the transports of the passkey that were determined at creation. */ interface IPasskeyCredential { id: string; + initializationVector: string; salt: string; transports: AuthenticatorTransport[]; } From 22eb9082602d784ccbbd70a1bdbec34668a9fc47 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 21:56:35 +0100 Subject: [PATCH 07/31] feat: add passkeys store --- src/extension/apps/background/App.tsx | 2 + src/extension/apps/main/App.tsx | 2 + src/extension/enums/StoreNameEnum.ts | 1 + .../features/passkeys/enums/ThunkEnum.ts | 7 ++ .../features/passkeys/enums/index.ts | 1 + src/extension/features/passkeys/index.ts | 5 ++ src/extension/features/passkeys/slice.ts | 71 +++++++++++++++++++ .../passkeys/thunks/fetchFromStorageThunk.ts | 28 ++++++++ .../features/passkeys/thunks/index.ts | 3 + .../passkeys/thunks/removeFromStorageThunk.ts | 25 +++++++ .../passkeys/thunks/saveToStorageThunk.ts | 29 ++++++++ .../features/passkeys/types/IState.ts | 15 ++++ .../features/passkeys/types/index.ts | 1 + .../passkeys/utils/getInitialState.ts | 10 +++ .../features/passkeys/utils/index.ts | 1 + .../types/states/IBackgroundRootState.ts | 2 + src/extension/types/states/IMainRootState.ts | 2 + 17 files changed, 205 insertions(+) create mode 100644 src/extension/features/passkeys/enums/ThunkEnum.ts create mode 100644 src/extension/features/passkeys/enums/index.ts create mode 100644 src/extension/features/passkeys/index.ts create mode 100644 src/extension/features/passkeys/slice.ts create mode 100644 src/extension/features/passkeys/thunks/fetchFromStorageThunk.ts create mode 100644 src/extension/features/passkeys/thunks/index.ts create mode 100644 src/extension/features/passkeys/thunks/removeFromStorageThunk.ts create mode 100644 src/extension/features/passkeys/thunks/saveToStorageThunk.ts create mode 100644 src/extension/features/passkeys/types/IState.ts create mode 100644 src/extension/features/passkeys/types/index.ts create mode 100644 src/extension/features/passkeys/utils/getInitialState.ts create mode 100644 src/extension/features/passkeys/utils/index.ts diff --git a/src/extension/apps/background/App.tsx b/src/extension/apps/background/App.tsx index eb02383c..67e23f5c 100644 --- a/src/extension/apps/background/App.tsx +++ b/src/extension/apps/background/App.tsx @@ -14,6 +14,7 @@ import { reducer as eventsReducer } from '@extension/features/events'; import { reducer as layoutReducer } from '@extension/features/layout'; import { reducer as messagesReducer } from '@extension/features/messages'; import { reducer as networksReducer } from '@extension/features/networks'; +import { reducer as passkeysReducer } from '@extension/features/passkeys'; import { reducer as passwordLockReducer } from '@extension/features/password-lock'; import { reducer as sessionsReducer } from '@extension/features/sessions'; import { reducer as settingsReducer } from '@extension/features/settings'; @@ -35,6 +36,7 @@ const App: FC = ({ i18next, initialColorMode }: IAppProps) => { layout: layoutReducer, messages: messagesReducer, networks: networksReducer, + passkeys: passkeysReducer, passwordLock: passwordLockReducer, sessions: sessionsReducer, settings: settingsReducer, diff --git a/src/extension/apps/main/App.tsx b/src/extension/apps/main/App.tsx index e40d8ea7..b8ec3cb0 100644 --- a/src/extension/apps/main/App.tsx +++ b/src/extension/apps/main/App.tsx @@ -38,6 +38,7 @@ import { reducer as messagesReducer } from '@extension/features/messages'; import { reducer as networksReducer } from '@extension/features/networks'; import { reducer as newsReducer } from '@extension/features/news'; import { reducer as notificationsReducer } from '@extension/features/notifications'; +import { reducer as passkeysReducer } from '@extension/features/passkeys'; import { reducer as passwordLockReducer } from '@extension/features/password-lock'; import { reducer as reKeyAccountReducer } from '@extension/features/re-key-account'; import { reducer as removeAssetsReducer } from '@extension/features/remove-assets'; @@ -187,6 +188,7 @@ const App: FC = ({ i18next, initialColorMode }: IAppProps) => { networks: networksReducer, news: newsReducer, notifications: notificationsReducer, + passkeys: passkeysReducer, passwordLock: passwordLockReducer, reKeyAccount: reKeyAccountReducer, removeAssets: removeAssetsReducer, diff --git a/src/extension/enums/StoreNameEnum.ts b/src/extension/enums/StoreNameEnum.ts index a543478d..469b1099 100644 --- a/src/extension/enums/StoreNameEnum.ts +++ b/src/extension/enums/StoreNameEnum.ts @@ -9,6 +9,7 @@ enum StoreNameEnum { Networks = 'networks', News = 'news', Notifications = 'notifications', + Passkeys = 'passkeys', PasswordLock = 'password-lock', Register = 'register', ReKeyAccount = 're-key-account', diff --git a/src/extension/features/passkeys/enums/ThunkEnum.ts b/src/extension/features/passkeys/enums/ThunkEnum.ts new file mode 100644 index 00000000..5d459504 --- /dev/null +++ b/src/extension/features/passkeys/enums/ThunkEnum.ts @@ -0,0 +1,7 @@ +enum ThunkEnum { + FetchFromStorage = 'passkeys/fetchFromStorage', + RemoveFromStorage = 'passkeys/removeFromStorage', + SaveToStorage = 'passkeys/saveToStorage', +} + +export default ThunkEnum; diff --git a/src/extension/features/passkeys/enums/index.ts b/src/extension/features/passkeys/enums/index.ts new file mode 100644 index 00000000..14ab6bbd --- /dev/null +++ b/src/extension/features/passkeys/enums/index.ts @@ -0,0 +1 @@ +export { default as ThunkEnum } from './ThunkEnum'; diff --git a/src/extension/features/passkeys/index.ts b/src/extension/features/passkeys/index.ts new file mode 100644 index 00000000..dd1694da --- /dev/null +++ b/src/extension/features/passkeys/index.ts @@ -0,0 +1,5 @@ +export * from './enums'; +export * from './slice'; +export * from './thunks'; +export * from './types'; +export * from './utils'; diff --git a/src/extension/features/passkeys/slice.ts b/src/extension/features/passkeys/slice.ts new file mode 100644 index 00000000..0c07e8ab --- /dev/null +++ b/src/extension/features/passkeys/slice.ts @@ -0,0 +1,71 @@ +import { createSlice, PayloadAction, Reducer } from '@reduxjs/toolkit'; + +// enums +import { StoreNameEnum } from '@extension/enums'; + +// thunks +import { + fetchFromStorageThunk, + removeFromStorageThunk, + saveToStorageThunk, +} from './thunks'; + +// types +import type { IPasskeyCredential } from '@extension/types'; +import type { IState } from './types'; + +// utils +import { getInitialState } from './utils'; + +const slice = createSlice({ + extraReducers: (builder) => { + /** fetch from storage **/ + builder.addCase( + fetchFromStorageThunk.fulfilled, + (state: IState, action: PayloadAction) => { + state.credential = action.payload; + state.fetching = false; + } + ); + builder.addCase(fetchFromStorageThunk.pending, (state: IState) => { + state.fetching = true; + }); + builder.addCase(fetchFromStorageThunk.rejected, (state: IState) => { + state.fetching = false; + }); + /** remove from storage **/ + builder.addCase(removeFromStorageThunk.fulfilled, (state: IState) => { + state.credential = null; + state.saving = false; + }); + builder.addCase(removeFromStorageThunk.pending, (state: IState) => { + state.saving = true; + }); + builder.addCase(removeFromStorageThunk.rejected, (state: IState) => { + state.saving = false; + }); + /** save to storage **/ + builder.addCase( + saveToStorageThunk.fulfilled, + (state: IState, action: PayloadAction) => { + state.credential = action.payload; + state.saving = false; + } + ); + builder.addCase(saveToStorageThunk.pending, (state: IState) => { + state.saving = true; + }); + builder.addCase(saveToStorageThunk.rejected, (state: IState) => { + state.saving = false; + }); + }, + initialState: getInitialState(), + name: StoreNameEnum.Passkeys, + reducers: { + noop: () => { + return; + }, + }, +}); + +export const reducer: Reducer = slice.reducer; diff --git a/src/extension/features/passkeys/thunks/fetchFromStorageThunk.ts b/src/extension/features/passkeys/thunks/fetchFromStorageThunk.ts new file mode 100644 index 00000000..3f4c9d5b --- /dev/null +++ b/src/extension/features/passkeys/thunks/fetchFromStorageThunk.ts @@ -0,0 +1,28 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; + +// types +import type { + IBaseAsyncThunkConfig, + IPasskeyCredential, +} from '@extension/types'; + +const fetchFromStorageThunk: AsyncThunk< + IPasskeyCredential | null, // return + void, // args + IBaseAsyncThunkConfig +> = createAsyncThunk( + ThunkEnum.FetchFromStorage, + async () => { + const passkeyService = new PasskeyService(); + + return await passkeyService.fetchFromStorage(); + } +); + +export default fetchFromStorageThunk; diff --git a/src/extension/features/passkeys/thunks/index.ts b/src/extension/features/passkeys/thunks/index.ts new file mode 100644 index 00000000..6074f0bb --- /dev/null +++ b/src/extension/features/passkeys/thunks/index.ts @@ -0,0 +1,3 @@ +export { default as fetchFromStorageThunk } from './fetchFromStorageThunk'; +export { default as removeFromStorageThunk } from './removeFromStorageThunk'; +export { default as saveToStorageThunk } from './saveToStorageThunk'; diff --git a/src/extension/features/passkeys/thunks/removeFromStorageThunk.ts b/src/extension/features/passkeys/thunks/removeFromStorageThunk.ts new file mode 100644 index 00000000..a10978cf --- /dev/null +++ b/src/extension/features/passkeys/thunks/removeFromStorageThunk.ts @@ -0,0 +1,25 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; + +// types +import type { IBaseAsyncThunkConfig } from '@extension/types'; + +const removeFromStorageThunk: AsyncThunk< + void, // return + void, // args + IBaseAsyncThunkConfig +> = createAsyncThunk( + ThunkEnum.RemoveFromStorage, + async () => { + const passkeyService = new PasskeyService(); + + return await passkeyService.removeFromStorage(); + } +); + +export default removeFromStorageThunk; diff --git a/src/extension/features/passkeys/thunks/saveToStorageThunk.ts b/src/extension/features/passkeys/thunks/saveToStorageThunk.ts new file mode 100644 index 00000000..a8617671 --- /dev/null +++ b/src/extension/features/passkeys/thunks/saveToStorageThunk.ts @@ -0,0 +1,29 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; + +// types +import type { + IBaseAsyncThunkConfig, + IPasskeyCredential, +} from '@extension/types'; + +const saveToStorageThunk: AsyncThunk< + IPasskeyCredential, // return + IPasskeyCredential, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IPasskeyCredential, + IPasskeyCredential, + IBaseAsyncThunkConfig +>(ThunkEnum.SaveToStorage, async (credential) => { + const passkeyService = new PasskeyService(); + + return await passkeyService.saveToStorage(credential); +}); + +export default saveToStorageThunk; diff --git a/src/extension/features/passkeys/types/IState.ts b/src/extension/features/passkeys/types/IState.ts new file mode 100644 index 00000000..a224e31c --- /dev/null +++ b/src/extension/features/passkeys/types/IState.ts @@ -0,0 +1,15 @@ +// types +import type { IPasskeyCredential } from '@extension/types'; + +/** + * @property {boolean} fetching - whether the credential is being fetched. + * @property {IPasskeyCredential | null} credential - the passkey credential. + * @property {boolean} saving - whether the credential is being saved. + */ +interface IState { + fetching: boolean; + credential: IPasskeyCredential | null; + saving: boolean; +} + +export default IState; diff --git a/src/extension/features/passkeys/types/index.ts b/src/extension/features/passkeys/types/index.ts new file mode 100644 index 00000000..bf812279 --- /dev/null +++ b/src/extension/features/passkeys/types/index.ts @@ -0,0 +1 @@ +export type { default as IState } from './IState'; diff --git a/src/extension/features/passkeys/utils/getInitialState.ts b/src/extension/features/passkeys/utils/getInitialState.ts new file mode 100644 index 00000000..554f44a4 --- /dev/null +++ b/src/extension/features/passkeys/utils/getInitialState.ts @@ -0,0 +1,10 @@ +// types +import type { IState } from '../types'; + +export default function getInitialState(): IState { + return { + fetching: false, + credential: null, + saving: false, + }; +} diff --git a/src/extension/features/passkeys/utils/index.ts b/src/extension/features/passkeys/utils/index.ts new file mode 100644 index 00000000..85e2c689 --- /dev/null +++ b/src/extension/features/passkeys/utils/index.ts @@ -0,0 +1 @@ +export { default as getInitialState } from './getInitialState'; diff --git a/src/extension/types/states/IBackgroundRootState.ts b/src/extension/types/states/IBackgroundRootState.ts index e3f59c1a..f6b6c54e 100644 --- a/src/extension/types/states/IBackgroundRootState.ts +++ b/src/extension/types/states/IBackgroundRootState.ts @@ -2,6 +2,7 @@ import type { IState as IAccountsState } from '@extension/features/accounts'; import type { IState as IEventsState } from '@extension/features/events'; import type { IState as INetworksState } from '@extension/features/networks'; +import type { IState as IPasskeysState } from '@extension/features/passkeys'; import type { IState as IPasswordLockState } from '@extension/features/password-lock'; import type { IState as ISessionsState } from '@extension/features/sessions'; import type { IState as ISettingsState } from '@extension/features/settings'; @@ -14,6 +15,7 @@ interface IBackgroundRootState extends IBaseRootState { accounts: IAccountsState; events: IEventsState; networks: INetworksState; + passkeys: IPasskeysState; passwordLock: IPasswordLockState; sessions: ISessionsState; settings: ISettingsState; diff --git a/src/extension/types/states/IMainRootState.ts b/src/extension/types/states/IMainRootState.ts index 10d933fe..42230ada 100644 --- a/src/extension/types/states/IMainRootState.ts +++ b/src/extension/types/states/IMainRootState.ts @@ -6,6 +6,7 @@ import type { IState as IEventsState } from '@extension/features/events'; import type { IState as INetworksState } from '@extension/features/networks'; import type { IState as INewsState } from '@extension/features/news'; import type { IState as INotificationsState } from '@extension/features/notifications'; +import type { IState as IPasskeysState } from '@extension/features/passkeys'; import type { IState as IPasswordLockState } from '@extension/features/password-lock'; import type { IState as IReKeyAccountState } from '@extension/features/re-key-account'; import type { IState as IRemoveAssetsState } from '@extension/features/remove-assets'; @@ -25,6 +26,7 @@ interface IMainRootState extends IBaseRootState { networks: INetworksState; news: INewsState; notifications: INotificationsState; + passkeys: IPasskeysState; passwordLock: IPasswordLockState; reKeyAccount: IReKeyAccountState; removeAssets: IRemoveAssetsState; From 3bc51dd35f608a0c63b7e1a1e5a19dd58daa0057 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sat, 6 Jul 2024 22:04:19 +0100 Subject: [PATCH 08/31] feat: add passkeys selectors and fetch from storage on load --- src/extension/apps/background/Root.tsx | 2 ++ src/extension/apps/main/Root.tsx | 2 ++ src/extension/selectors/passkeys/index.ts | 3 +++ .../passkeys/useSelectPasskeysCredential.ts | 15 +++++++++++++++ .../passkeys/useSelectPasskeysFetching.ts | 10 ++++++++++ .../selectors/passkeys/useSelectPasskeysSaving.ts | 10 ++++++++++ 6 files changed, 42 insertions(+) create mode 100644 src/extension/selectors/passkeys/index.ts create mode 100644 src/extension/selectors/passkeys/useSelectPasskeysCredential.ts create mode 100644 src/extension/selectors/passkeys/useSelectPasskeysFetching.ts create mode 100644 src/extension/selectors/passkeys/useSelectPasskeysSaving.ts diff --git a/src/extension/apps/background/Root.tsx b/src/extension/apps/background/Root.tsx index b8fc5b1e..9b1ea55e 100644 --- a/src/extension/apps/background/Root.tsx +++ b/src/extension/apps/background/Root.tsx @@ -8,6 +8,7 @@ import LoadingPage from '@extension/components/LoadingPage'; import { fetchAccountsFromStorageThunk } from '@extension/features/accounts'; import { handleNewEventByIdThunk } from '@extension/features/events'; import { closeCurrentWindowThunk } from '@extension/features/layout'; +import { fetchFromStorageThunk as fetchPasskeyCredentialFromStorageThunk } from '@extension/features/passkeys'; import { fetchSessionsThunk } from '@extension/features/sessions'; import { fetchSettingsFromStorageThunk } from '@extension/features/settings'; import { fetchStandardAssetsFromStorageThunk } from '@extension/features/standard-assets'; @@ -47,6 +48,7 @@ const Root: FC = () => { return; } + dispatch(fetchPasskeyCredentialFromStorageThunk()); dispatch(fetchSystemInfoFromStorageThunk()); dispatch(fetchSettingsFromStorageThunk()); dispatch(fetchSessionsThunk()); diff --git a/src/extension/apps/main/Root.tsx b/src/extension/apps/main/Root.tsx index 9dedb1c4..b1d12133 100644 --- a/src/extension/apps/main/Root.tsx +++ b/src/extension/apps/main/Root.tsx @@ -27,6 +27,7 @@ import { } from '@extension/features/networks'; import { fetchFromStorageThunk as fetchNewsFromStorageThunk } from '@extension/features/news'; import { setShowingConfetti } from '@extension/features/notifications'; +import { fetchFromStorageThunk as fetchPasskeyCredentialFromStorageThunk } from '@extension/features/passkeys'; import { reset as resetReKeyAccount } from '@extension/features/re-key-account'; import { reset as resetRemoveAssets } from '@extension/features/remove-assets'; import { reset as resetSendAsset } from '@extension/features/send-assets'; @@ -98,6 +99,7 @@ const Root: FC = () => { useEffect(() => { dispatch(fetchSystemInfoFromStorageThunk()); dispatch(fetchSettingsFromStorageThunk()); + dispatch(fetchPasskeyCredentialFromStorageThunk()); dispatch(fetchSessionsThunk()); dispatch(fetchStandardAssetsFromStorageThunk()); dispatch(fetchARC0072AssetsFromStorageThunk()); diff --git a/src/extension/selectors/passkeys/index.ts b/src/extension/selectors/passkeys/index.ts new file mode 100644 index 00000000..05241ca7 --- /dev/null +++ b/src/extension/selectors/passkeys/index.ts @@ -0,0 +1,3 @@ +export { default as useSelectPasskeysCredential } from './useSelectPasskeysCredential'; +export { default as useSelectPasskeysFetching } from './useSelectPasskeysFetching'; +export { default as useSelectPasskeysSaving } from './useSelectPasskeysSaving'; diff --git a/src/extension/selectors/passkeys/useSelectPasskeysCredential.ts b/src/extension/selectors/passkeys/useSelectPasskeysCredential.ts new file mode 100644 index 00000000..a558e5fb --- /dev/null +++ b/src/extension/selectors/passkeys/useSelectPasskeysCredential.ts @@ -0,0 +1,15 @@ +import { useSelector } from 'react-redux'; + +// types +import type { + IBackgroundRootState, + IMainRootState, + IPasskeyCredential, +} from '@extension/types'; + +export default function useSelectPasskeysCredential(): IPasskeyCredential | null { + return useSelector< + IBackgroundRootState | IMainRootState, + IPasskeyCredential | null + >((state) => state.passkeys.credential); +} diff --git a/src/extension/selectors/passkeys/useSelectPasskeysFetching.ts b/src/extension/selectors/passkeys/useSelectPasskeysFetching.ts new file mode 100644 index 00000000..66935a07 --- /dev/null +++ b/src/extension/selectors/passkeys/useSelectPasskeysFetching.ts @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; + +// types +import type { IBackgroundRootState, IMainRootState } from '@extension/types'; + +export default function useSelectPasskeysFetching(): boolean { + return useSelector( + (state) => state.passkeys.fetching + ); +} diff --git a/src/extension/selectors/passkeys/useSelectPasskeysSaving.ts b/src/extension/selectors/passkeys/useSelectPasskeysSaving.ts new file mode 100644 index 00000000..6755a756 --- /dev/null +++ b/src/extension/selectors/passkeys/useSelectPasskeysSaving.ts @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; + +// types +import type { IBackgroundRootState, IMainRootState } from '@extension/types'; + +export default function useSelectPasskeysSaving(): boolean { + return useSelector( + (state) => state.passkeys.saving + ); +} From a1582d2dc7a798079972a471ecf41375bf4ae952 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sun, 7 Jul 2024 10:31:31 +0100 Subject: [PATCH 09/31] feat: update settings link item to allow for badges --- .../SettingsLinkItem/SettingsLinkItem.tsx | 73 +++++++++++++++---- .../SettingsLinkItem/types/IBadgeProps.ts | 6 ++ .../SettingsLinkItem/types/IProps.ts | 13 ++++ .../SettingsLinkItem/types/index.ts | 2 + 4 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 src/extension/components/SettingsLinkItem/types/IBadgeProps.ts create mode 100644 src/extension/components/SettingsLinkItem/types/IProps.ts create mode 100644 src/extension/components/SettingsLinkItem/types/index.ts diff --git a/src/extension/components/SettingsLinkItem/SettingsLinkItem.tsx b/src/extension/components/SettingsLinkItem/SettingsLinkItem.tsx index 1f8f2040..93953afe 100644 --- a/src/extension/components/SettingsLinkItem/SettingsLinkItem.tsx +++ b/src/extension/components/SettingsLinkItem/SettingsLinkItem.tsx @@ -1,8 +1,17 @@ -import { Button, HStack, Icon, Text } from '@chakra-ui/react'; +import { + Button, + HStack, + Icon, + Tag, + TagLabel, + Text, + VStack, +} from '@chakra-ui/react'; +import { encode as encodeHex } from '@stablelib/hex'; import React, { FC } from 'react'; -import { IconType } from 'react-icons'; import { IoChevronForward } from 'react-icons/io5'; import { Link } from 'react-router-dom'; +import { hash } from 'tweetnacl'; // constants import { DEFAULT_GAP, SETTINGS_ITEM_HEIGHT } from '@extension/constants'; @@ -11,15 +20,21 @@ import { DEFAULT_GAP, SETTINGS_ITEM_HEIGHT } from '@extension/constants'; import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; -interface IProps { - icon: IconType; - label: string; - to: string; -} +// selectors +import { useSelectSettingsColorMode } from '@extension/selectors'; -const SettingsLinkItem: FC = ({ icon, label, to }: IProps) => { - const buttonHoverBackgroundColor: string = useButtonHoverBackgroundColor(); - const defaultTextColor: string = useDefaultTextColor(); +// types +import type { IProps } from './types'; + +const SettingsLinkItem: FC = ({ badges, icon, label, to }) => { + // selectors + const colorMode = useSelectSettingsColorMode(); + // hooks + const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); + const defaultTextColor = useDefaultTextColor(); + // misc + const iconSize = 6; + const labelHash = encodeHex(hash(new TextEncoder().encode(label)), true); return ( ); diff --git a/src/extension/components/SettingsLinkItem/types/IBadgeProps.ts b/src/extension/components/SettingsLinkItem/types/IBadgeProps.ts new file mode 100644 index 00000000..395b861a --- /dev/null +++ b/src/extension/components/SettingsLinkItem/types/IBadgeProps.ts @@ -0,0 +1,6 @@ +interface IBadgeProps { + colorScheme: string; + label: string; +} + +export default IBadgeProps; diff --git a/src/extension/components/SettingsLinkItem/types/IProps.ts b/src/extension/components/SettingsLinkItem/types/IProps.ts new file mode 100644 index 00000000..fd94d74b --- /dev/null +++ b/src/extension/components/SettingsLinkItem/types/IProps.ts @@ -0,0 +1,13 @@ +import type { IconType } from 'react-icons'; + +// types +import type IBadgeProps from './IBadgeProps'; + +interface IProps { + badges?: IBadgeProps[]; + icon: IconType; + label: string; + to: string; +} + +export default IProps; diff --git a/src/extension/components/SettingsLinkItem/types/index.ts b/src/extension/components/SettingsLinkItem/types/index.ts new file mode 100644 index 00000000..cd056309 --- /dev/null +++ b/src/extension/components/SettingsLinkItem/types/index.ts @@ -0,0 +1,2 @@ +export type { default as IProps } from './IProps'; +export type { default as IBadgeProps } from './IBadgeProps'; From f5608f9c8a2b2ed5ea41df4b64f7b98cd188e6e0 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sun, 7 Jul 2024 16:59:34 +0100 Subject: [PATCH 10/31] feat: add passkey ui to handle creating, removing and viewing passkey --- src/extension/apps/main/Root.tsx | 8 +- .../COSEAlgorithmBadge/COSEAlgorithmBadge.tsx | 47 ++ .../components/COSEAlgorithmBadge/index.ts | 1 + .../COSEAlgorithmBadge/types/IProps.ts | 6 + .../COSEAlgorithmBadge/types/index.ts | 1 + .../components/ModalItem/ModalItem.tsx | 8 +- .../components/PageItem/PageItem.tsx | 2 +- .../PageSubHeading/PageSubHeading.tsx | 32 ++ .../components/PageSubHeading/index.ts | 2 + .../components/PageSubHeading/types/IProps.ts | 10 + .../components/PageSubHeading/types/index.ts | 1 + .../PasskeyCapabilities.tsx | 63 +++ .../components/PasskeyCapabilities/index.ts | 1 + .../PasskeyCapabilities/types/IProps.ts | 6 + .../PasskeyCapabilities/types/index.ts | 1 + src/extension/constants/Routes.ts | 1 + src/extension/features/passkeys/slice.ts | 16 +- .../features/passkeys/types/IState.ts | 6 +- .../passkeys/utils/getInitialState.ts | 3 +- .../AddPasskeyModal/AddPasskeyModal.tsx | 457 +++++++++++++++++ .../hooks/useEnableModal/index.ts | 2 + .../types/IUseEnableModalState.ts | 18 + .../hooks/useEnableModal/types/index.ts | 1 + .../hooks/useEnableModal/useEnableModal.ts | 84 +++ src/extension/modals/AddPasskeyModal/index.ts | 1 + .../pages/PasskeyPage/PasskeyPage.tsx | 478 ++++++++++++++++++ .../SecuritySettingsIndexPage.tsx | 57 ++- .../SecuritySettingsRouter.tsx | 3 + src/extension/selectors/index.ts | 1 + src/extension/selectors/passkeys/index.ts | 4 +- ...tial.ts => useSelectPasskeysAddPasskey.ts} | 4 +- .../passkeys/useSelectPasskeysEnabled.ts | 14 + .../passkeys/useSelectPasskeysPasskey.ts | 15 + .../services/PasskeyService/PasskeyService.ts | 17 +- .../types/ICreatePasskeyOptions.ts | 1 + src/extension/translations/en.ts | 49 ++ .../types/passkeys/IPasskeyCredential.ts | 8 + .../calculateIconSize/calculateIconSize.ts | 15 + .../utils/calculateIconSize/index.ts | 1 + 39 files changed, 1419 insertions(+), 26 deletions(-) create mode 100644 src/extension/components/COSEAlgorithmBadge/COSEAlgorithmBadge.tsx create mode 100644 src/extension/components/COSEAlgorithmBadge/index.ts create mode 100644 src/extension/components/COSEAlgorithmBadge/types/IProps.ts create mode 100644 src/extension/components/COSEAlgorithmBadge/types/index.ts create mode 100644 src/extension/components/PageSubHeading/PageSubHeading.tsx create mode 100644 src/extension/components/PageSubHeading/index.ts create mode 100644 src/extension/components/PageSubHeading/types/IProps.ts create mode 100644 src/extension/components/PageSubHeading/types/index.ts create mode 100644 src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx create mode 100644 src/extension/components/PasskeyCapabilities/index.ts create mode 100644 src/extension/components/PasskeyCapabilities/types/IProps.ts create mode 100644 src/extension/components/PasskeyCapabilities/types/index.ts create mode 100644 src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx create mode 100644 src/extension/modals/AddPasskeyModal/hooks/useEnableModal/index.ts create mode 100644 src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/IUseEnableModalState.ts create mode 100644 src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/index.ts create mode 100644 src/extension/modals/AddPasskeyModal/hooks/useEnableModal/useEnableModal.ts create mode 100644 src/extension/modals/AddPasskeyModal/index.ts create mode 100644 src/extension/pages/PasskeyPage/PasskeyPage.tsx rename src/extension/selectors/passkeys/{useSelectPasskeysCredential.ts => useSelectPasskeysAddPasskey.ts} (71%) create mode 100644 src/extension/selectors/passkeys/useSelectPasskeysEnabled.ts create mode 100644 src/extension/selectors/passkeys/useSelectPasskeysPasskey.ts create mode 100644 src/extension/utils/calculateIconSize/calculateIconSize.ts create mode 100644 src/extension/utils/calculateIconSize/index.ts diff --git a/src/extension/apps/main/Root.tsx b/src/extension/apps/main/Root.tsx index b1d12133..2d44f21b 100644 --- a/src/extension/apps/main/Root.tsx +++ b/src/extension/apps/main/Root.tsx @@ -27,7 +27,10 @@ import { } from '@extension/features/networks'; import { fetchFromStorageThunk as fetchNewsFromStorageThunk } from '@extension/features/news'; import { setShowingConfetti } from '@extension/features/notifications'; -import { fetchFromStorageThunk as fetchPasskeyCredentialFromStorageThunk } from '@extension/features/passkeys'; +import { + fetchFromStorageThunk as fetchPasskeyCredentialFromStorageThunk, + setAddPasskey, +} from '@extension/features/passkeys'; import { reset as resetReKeyAccount } from '@extension/features/re-key-account'; import { reset as resetRemoveAssets } from '@extension/features/remove-assets'; import { reset as resetSendAsset } from '@extension/features/send-assets'; @@ -51,6 +54,7 @@ import useNotifications from '@extension/hooks/useNotifications'; import AddAssetsModal, { AddAssetsForWatchAccountModal, } from '@extension/modals/AddAssetsModal'; +import AddPasskeyModal from '@extension/modals/AddPasskeyModal'; import ARC0300KeyRegistrationTransactionSendEventModal from '@extension/modals/ARC0300KeyRegistrationTransactionSendEventModal'; import ConfirmModal from '@extension/modals/ConfirmModal'; import EnableModal from '@extension/modals/EnableModal'; @@ -86,6 +90,7 @@ const Root: FC = () => { const showingConfetti = useSelectNotificationsShowingConfetti(); // handlers const handleAddAssetsModalClose = () => dispatch(resetAddAsset()); + const handleAddPasskeyModalClose = () => dispatch(setAddPasskey(null)); const handleConfirmClose = () => dispatch(setConfirmModal(null)); const handleConfettiComplete = () => dispatch(setShowingConfetti(false)); const handleReKeyAccountModalClose = () => dispatch(resetReKeyAccount()); @@ -156,6 +161,7 @@ const Root: FC = () => { {/*action modals*/} + diff --git a/src/extension/components/COSEAlgorithmBadge/COSEAlgorithmBadge.tsx b/src/extension/components/COSEAlgorithmBadge/COSEAlgorithmBadge.tsx new file mode 100644 index 00000000..03add28d --- /dev/null +++ b/src/extension/components/COSEAlgorithmBadge/COSEAlgorithmBadge.tsx @@ -0,0 +1,47 @@ +import { ColorMode, Tag, TagLabel } from '@chakra-ui/react'; +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +// selectors +import { useSelectSettingsColorMode } from '@extension/selectors'; + +// types +import type { IProps } from './types'; + +const COSEAlgorithmBadge: FC = ({ algorithm, size = 'sm' }: IProps) => { + const { t } = useTranslation(); + // hooks + const colorMode: ColorMode = useSelectSettingsColorMode(); + // misc + let colorScheme = 'orange'; + let label = t('labels.unknown'); + + switch (algorithm) { + case -7: + colorScheme = 'blue'; + label = 'ES256'; + break; + case -8: + colorScheme = 'blue'; + label = 'Ed25519'; + break; + case -257: + colorScheme = 'blue'; + label = 'RS256'; + break; + default: + break; + } + + return ( + + {label} + + ); +}; + +export default COSEAlgorithmBadge; diff --git a/src/extension/components/COSEAlgorithmBadge/index.ts b/src/extension/components/COSEAlgorithmBadge/index.ts new file mode 100644 index 00000000..d133d34e --- /dev/null +++ b/src/extension/components/COSEAlgorithmBadge/index.ts @@ -0,0 +1 @@ +export { default } from './COSEAlgorithmBadge'; diff --git a/src/extension/components/COSEAlgorithmBadge/types/IProps.ts b/src/extension/components/COSEAlgorithmBadge/types/IProps.ts new file mode 100644 index 00000000..d22a88e6 --- /dev/null +++ b/src/extension/components/COSEAlgorithmBadge/types/IProps.ts @@ -0,0 +1,6 @@ +interface IProps { + algorithm: number; + size?: string; +} + +export default IProps; diff --git a/src/extension/components/COSEAlgorithmBadge/types/index.ts b/src/extension/components/COSEAlgorithmBadge/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/COSEAlgorithmBadge/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/ModalItem/ModalItem.tsx b/src/extension/components/ModalItem/ModalItem.tsx index c3567d73..1dbfc614 100644 --- a/src/extension/components/ModalItem/ModalItem.tsx +++ b/src/extension/components/ModalItem/ModalItem.tsx @@ -5,7 +5,7 @@ import React, { FC } from 'react'; import WarningIcon from '@extension/components/WarningIcon'; // constants -import { MODAL_ITEM_HEIGHT } from '@extension/constants'; +import { DEFAULT_GAP, MODAL_ITEM_HEIGHT } from '@extension/constants'; // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; @@ -28,16 +28,16 @@ const ModalItem: FC = ({ alignItems="center" justifyContent="space-between" minH={MODAL_ITEM_HEIGHT} - spacing={2} + spacing={DEFAULT_GAP / 3} w="full" {...stackProps} > {/*label*/} - + {label} - + {/*value*/} {tooltipLabel ? ( = ({ ...stackProps }) => { // hooks - const defaultTextColor: string = useDefaultTextColor(); + const defaultTextColor = useDefaultTextColor(); return ( = ({ color, fontSize = 'md', text }) => { + // hooks + const subTextColor = useSubTextColor(); + + return ( + + {text} + + ); +}; + +export default PageSubHeading; diff --git a/src/extension/components/PageSubHeading/index.ts b/src/extension/components/PageSubHeading/index.ts new file mode 100644 index 00000000..722dfbb5 --- /dev/null +++ b/src/extension/components/PageSubHeading/index.ts @@ -0,0 +1,2 @@ +export { default } from './PageSubHeading'; +export * from './types'; diff --git a/src/extension/components/PageSubHeading/types/IProps.ts b/src/extension/components/PageSubHeading/types/IProps.ts new file mode 100644 index 00000000..85d12a6d --- /dev/null +++ b/src/extension/components/PageSubHeading/types/IProps.ts @@ -0,0 +1,10 @@ +import { ResponsiveValue } from '@chakra-ui/react'; +import * as CSS from 'csstype'; + +interface IProps { + color?: string; + fontSize?: ResponsiveValue; + text: string; +} + +export default IProps; diff --git a/src/extension/components/PageSubHeading/types/index.ts b/src/extension/components/PageSubHeading/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/PageSubHeading/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx b/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx new file mode 100644 index 00000000..6e70d6dd --- /dev/null +++ b/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx @@ -0,0 +1,63 @@ +import { HStack, Icon, Tooltip } from '@chakra-ui/react'; +import React, { FC } from 'react'; +import type { IconType } from 'react-icons'; +import { BsUsbSymbol } from 'react-icons/bs'; +import { IoBluetoothOutline, IoFingerPrintOutline } from 'react-icons/io5'; +import { LuNfc } from 'react-icons/lu'; + +// constants +import { DEFAULT_GAP } from '@extension/constants'; + +// hooks +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// types +import type { IProps } from './types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const PasskeyCapabilities: FC = ({ capabilities, size = 'sm' }) => { + // hooks + const subTextColor = useSubTextColor(); + // misc + const iconSize = calculateIconSize(size); + + return ( + + {capabilities.map((value, index) => { + let icon: IconType; + let label: string; + + switch (value) { + case 'ble': + icon = IoBluetoothOutline; + label = 'Bluetooth'; + break; + case 'internal': + icon = IoFingerPrintOutline; + label = 'Internal'; + break; + case 'nfc': + icon = LuNfc; + label = 'NFC'; + break; + case 'usb': + icon = BsUsbSymbol; + label = 'USB'; + break; + default: + return null; + } + + return ( + + + + ); + })} + + ); +}; + +export default PasskeyCapabilities; diff --git a/src/extension/components/PasskeyCapabilities/index.ts b/src/extension/components/PasskeyCapabilities/index.ts new file mode 100644 index 00000000..380ba92b --- /dev/null +++ b/src/extension/components/PasskeyCapabilities/index.ts @@ -0,0 +1 @@ +export { default } from './PasskeyCapabilities'; diff --git a/src/extension/components/PasskeyCapabilities/types/IProps.ts b/src/extension/components/PasskeyCapabilities/types/IProps.ts new file mode 100644 index 00000000..70e1c19d --- /dev/null +++ b/src/extension/components/PasskeyCapabilities/types/IProps.ts @@ -0,0 +1,6 @@ +interface IProps { + capabilities: AuthenticatorTransport[]; + size?: string; +} + +export default IProps; diff --git a/src/extension/components/PasskeyCapabilities/types/index.ts b/src/extension/components/PasskeyCapabilities/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/PasskeyCapabilities/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/constants/Routes.ts b/src/extension/constants/Routes.ts index 283a6427..bef18c59 100644 --- a/src/extension/constants/Routes.ts +++ b/src/extension/constants/Routes.ts @@ -14,6 +14,7 @@ export const GET_STARTED_ROUTE: string = '/get-started'; export const IMPORT_ACCOUNT_VIA_SEED_PHRASE_ROUTE: string = '/import-account-via-seed-phrase'; export const NFTS_ROUTE: string = '/nfts'; +export const PASSKEY_ROUTE: string = '/passkey'; export const PASSWORD_LOCK_ROUTE: string = '/password-lock'; export const PRIVACY_ROUTE: string = '/privacy'; export const SECURITY_ROUTE: string = '/security'; diff --git a/src/extension/features/passkeys/slice.ts b/src/extension/features/passkeys/slice.ts index 0c07e8ab..7a8c3467 100644 --- a/src/extension/features/passkeys/slice.ts +++ b/src/extension/features/passkeys/slice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction, Reducer } from '@reduxjs/toolkit'; +import { createSlice, Draft, PayloadAction, Reducer } from '@reduxjs/toolkit'; // enums import { StoreNameEnum } from '@extension/enums'; @@ -23,7 +23,7 @@ const slice = createSlice({ builder.addCase( fetchFromStorageThunk.fulfilled, (state: IState, action: PayloadAction) => { - state.credential = action.payload; + state.passkey = action.payload; state.fetching = false; } ); @@ -35,7 +35,7 @@ const slice = createSlice({ }); /** remove from storage **/ builder.addCase(removeFromStorageThunk.fulfilled, (state: IState) => { - state.credential = null; + state.passkey = null; state.saving = false; }); builder.addCase(removeFromStorageThunk.pending, (state: IState) => { @@ -48,7 +48,7 @@ const slice = createSlice({ builder.addCase( saveToStorageThunk.fulfilled, (state: IState, action: PayloadAction) => { - state.credential = action.payload; + state.passkey = action.payload; state.saving = false; } ); @@ -62,10 +62,14 @@ const slice = createSlice({ initialState: getInitialState(), name: StoreNameEnum.Passkeys, reducers: { - noop: () => { - return; + setAddPasskey: ( + state: Draft, + action: PayloadAction + ) => { + state.addPasskey = action.payload; }, }, }); export const reducer: Reducer = slice.reducer; +export const { setAddPasskey } = slice.actions; diff --git a/src/extension/features/passkeys/types/IState.ts b/src/extension/features/passkeys/types/IState.ts index a224e31c..e17ebfd9 100644 --- a/src/extension/features/passkeys/types/IState.ts +++ b/src/extension/features/passkeys/types/IState.ts @@ -2,13 +2,15 @@ import type { IPasskeyCredential } from '@extension/types'; /** + @property {IPasskeyCredential | null} addPasskey - a new passkey to add. * @property {boolean} fetching - whether the credential is being fetched. - * @property {IPasskeyCredential | null} credential - the passkey credential. + * @property {IPasskeyCredential | null} passkey - the stored passkey credential. * @property {boolean} saving - whether the credential is being saved. */ interface IState { + addPasskey: IPasskeyCredential | null; fetching: boolean; - credential: IPasskeyCredential | null; + passkey: IPasskeyCredential | null; saving: boolean; } diff --git a/src/extension/features/passkeys/utils/getInitialState.ts b/src/extension/features/passkeys/utils/getInitialState.ts index 554f44a4..6bf666f5 100644 --- a/src/extension/features/passkeys/utils/getInitialState.ts +++ b/src/extension/features/passkeys/utils/getInitialState.ts @@ -3,8 +3,9 @@ import type { IState } from '../types'; export default function getInitialState(): IState { return { + addPasskey: null, fetching: false, - credential: null, + passkey: null, saving: false, }; } diff --git a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx new file mode 100644 index 00000000..630d5773 --- /dev/null +++ b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx @@ -0,0 +1,457 @@ +import { + Code, + Heading, + HStack, + Icon, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + useDisclosure, + VStack, +} from '@chakra-ui/react'; +import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GoShieldLock } from 'react-icons/go'; +import { IoKeyOutline } from 'react-icons/io5'; +import { useDispatch } from 'react-redux'; + +// components +import Button from '@extension/components/Button'; +import CopyIconButton from '@extension/components/CopyIconButton'; +import COSEAlgorithmBadge from '@extension/components/COSEAlgorithmBadge'; +import ModalItem from '@extension/components/ModalItem'; +import MoreInformationAccordion from '@extension/components/MoreInformationAccordion'; +import PasskeyCapabilities from '@extension/components/PasskeyCapabilities'; +import PasswordInput, { + usePassword, +} from '@extension/components/PasswordInput'; + +// constants +import { + BODY_BACKGROUND_COLOR, + DEFAULT_GAP, + MODAL_ITEM_HEIGHT, +} from '@extension/constants'; + +// enums +import { ErrorCodeEnum } from '@extension/enums'; + +// features +import { create as createNotification } from '@extension/features/notifications'; +import { + removeFromStorageThunk as removePasskeyCredentialFromStorageThunk, + saveToStorageThunk as savePasskeyCredentialToStorageThunk, +} from '@extension/features/passkeys'; + +// hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// selectors +import { + useSelectLogger, + useSelectPasskeysAddPasskey, + useSelectPasskeysSaving, + useSelectPasswordLockPassword, + useSelectSettings, + useSelectSystemInfo, +} from '@extension/selectors'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { + IAppThunkDispatch, + IModalProps, + IPasskeyCredential, +} from '@extension/types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; +import ModalTextItem from '@extension/components/ModalTextItem'; +import ellipseAddress from '@extension/utils/ellipseAddress'; + +const AddPasskeyModal: FC = ({ onClose }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const { + isOpen: isMoreInformationOpen, + onOpen: onMoreInformationOpen, + onClose: onMoreInformationClose, + } = useDisclosure(); + const passwordInputRef = useRef(null); + // selectors + const addPasskey = useSelectPasskeysAddPasskey(); + const logger = useSelectLogger(); + const passwordLockPassword = useSelectPasswordLockPassword(); + const saving = useSelectPasskeysSaving(); + const settings = useSelectSettings(); + const systemInfo = useSelectSystemInfo(); + // hooks + const defaultTextColor = useDefaultTextColor(); + const { + error: passwordError, + onChange: onPasswordChange, + reset: resetPassword, + setError: setPasswordError, + validate: validatePassword, + value: password, + } = usePassword(); + const subTextColor = useSubTextColor(); + // state + const [encrypting, setEncrypting] = useState(false); + // handlers + const handleCancelClick = async () => handleClose(); + const handleClose = () => { + resetPassword(); + setEncrypting(false); + + if (onClose) { + onClose(); + } + }; + const handleEncryptClick = async () => { + const _functionName = 'handleEncryptClick'; + let _password: string | null; + let passkey: IPasskeyCredential; + let inputKeyMaterial: Uint8Array; + + if (!addPasskey) { + return; + } + + // if there is no password lock + if (!settings.security.enablePasswordLock && !passwordLockPassword) { + // validate the password input + if (validatePassword()) { + logger.debug( + `${AddPasskeyModal.name}#${_functionName}: password not valid` + ); + + return; + } + } + + _password = settings.security.enablePasswordLock + ? passwordLockPassword + : password; + + if (!_password) { + logger.debug( + `${AddPasskeyModal.name}#${_functionName}: unable to use password from password lock, value is "null"` + ); + + return; + } + + setEncrypting(true); + + // first save the passkey to storage + passkey = await dispatch( + savePasskeyCredentialToStorageThunk(addPasskey) + ).unwrap(); + + try { + // fetch the encryption key material + inputKeyMaterial = await PasskeyService.fetchInputKeyMaterialFromPasskey({ + credential: passkey, + logger, + }); + // let encryptedBytes = await PasskeyService.encryptBytes({ + // bytes: new TextEncoder().encode(message), + // deviceID: systemInfo.deviceID, + // logger, + // initializationVector: decodeHex(credential.initializationVector), + // inputKeyMaterial, + // }); + // let decryptedBytes = await PasskeyService.decryptBytes({ + // deviceID: systemInfo.deviceID, + // encryptedBytes, + // initializationVector: decodeHex(credential.initializationVector), + // inputKeyMaterial, + // logger, + // }); + + dispatch( + createNotification({ + description: t('captions.passkeyAdded', { + name: passkey.name, + }), + ephemeral: true, + title: t('headings.passkeyAdded'), + type: 'success', + }) + ); + + handleClose(); + } catch (error) { + switch (error.code) { + case ErrorCodeEnum.InvalidPasswordError: + setPasswordError(t('errors.inputs.invalidPassword')); + break; + case ErrorCodeEnum.OfflineError: + dispatch( + createNotification({ + ephemeral: true, + title: t('headings.offline'), + type: 'error', + }) + ); + break; + default: + // remove the previously saved credential + dispatch(removePasskeyCredentialFromStorageThunk()); + + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + break; + } + } + + setEncrypting(false); + }; + const handleKeyUpPasswordInput = async ( + event: KeyboardEvent + ) => { + if (event.key === 'Enter') { + await handleEncryptClick(); + } + }; + const handleMoreInformationToggle = (value: boolean) => + value ? onMoreInformationOpen() : onMoreInformationClose(); + // renders + const renderContent = () => { + const iconSize = calculateIconSize('xl'); + + if (!addPasskey) { + return; + } + + return ( + + {/*icon*/} + + + {/*details*/} + + {/*name*/} + ('labels.name')}:`} + tooltipLabel={addPasskey.name} + value={addPasskey.name} + /> + + {/*credential id*/} + ('labels.credentialID')}:`} + value={ + + + {addPasskey.id} + + + {/*copy credential id button*/} + ('labels.copyCredentialID')} + tooltipLabel={t('labels.copyCredentialID')} + value={addPasskey.id} + /> + + } + /> + + {/*user id*/} + {systemInfo?.deviceID && ( + ('labels.userID')}:`} + value={ + + + {systemInfo.deviceID} + + + {/*copy user id button*/} + ('labels.copyUserID')} + tooltipLabel={t('labels.copyUserID')} + value={systemInfo.deviceID} + /> + + } + /> + )} + + {/*capabilities*/} + ('labels.capabilities')}:`} + value={} + /> + + + + {/*public key*/} + ('labels.publicKey')}:`} + value={ + + + {addPasskey.publicKey || '-'} + + + {/*copy public key button*/} + {addPasskey.publicKey && ( + ('labels.copyPublicKey')} + tooltipLabel={t('labels.copyPublicKey')} + value={addPasskey.publicKey} + /> + )} + + } + /> + + {/*algorithm*/} + ('labels.algorithm')}:`} + value={} + /> + + + + + {/*captions*/} + + + {t('captions.encryptWithPasskey')} + + + + ); + }; + + useEffect(() => { + if (passwordInputRef.current) { + passwordInputRef.current.focus(); + } + }, []); + + return ( + + + + + {t('headings.addPasskey')} + + + + + {renderContent()} + + + + + {/*password input*/} + {!settings.security.enablePasswordLock && !passwordLockPassword && ( + ( + 'captions.mustEnterPasswordToDecryptPrivateKeys' + )} + onChange={onPasswordChange} + onKeyUp={handleKeyUpPasswordInput} + inputRef={passwordInputRef} + value={password} + /> + )} + + {/*buttons*/} + + {/*cancel*/} + + + {/*encrypt*/} + + + + + + + ); +}; + +export default AddPasskeyModal; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/index.ts b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/index.ts new file mode 100644 index 00000000..ab5fd999 --- /dev/null +++ b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './useEnableModal'; +export * from './types'; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/IUseEnableModalState.ts b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/IUseEnableModalState.ts new file mode 100644 index 00000000..13096f04 --- /dev/null +++ b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/IUseEnableModalState.ts @@ -0,0 +1,18 @@ +import type { IEnableParams } from '@agoralabs-sh/avm-web-provider'; + +// types +import type { + IAccountWithExtendedProps, + IClientRequestEvent, + INetworkWithTransactionParams, +} from '@extension/types'; + +interface IUseEnableModalState { + availableAccounts: IAccountWithExtendedProps[] | null; + event: IClientRequestEvent | null; + network: INetworkWithTransactionParams | null; + setAvailableAccounts: (accounts: IAccountWithExtendedProps[] | null) => void; + setNetwork: (network: INetworkWithTransactionParams | null) => void; +} + +export default IUseEnableModalState; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/index.ts b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/index.ts new file mode 100644 index 00000000..12d7d159 --- /dev/null +++ b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/types/index.ts @@ -0,0 +1 @@ +export type { default as IUseEnableModalState } from './IUseEnableModalState'; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/useEnableModal.ts b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/useEnableModal.ts new file mode 100644 index 00000000..a93e3243 --- /dev/null +++ b/src/extension/modals/AddPasskeyModal/hooks/useEnableModal/useEnableModal.ts @@ -0,0 +1,84 @@ +import { + ARC0027MethodEnum, + IEnableParams, +} from '@agoralabs-sh/avm-web-provider'; +import { useEffect, useState } from 'react'; + +// enums +import { EventTypeEnum } from '@extension/enums'; + +// selectors +import { + useSelectAccounts, + useSelectEvents, + useSelectNetworks, +} from '@extension/selectors'; + +// types +import type { + IAccountWithExtendedProps, + IClientRequestEvent, + INetworkWithTransactionParams, +} from '@extension/types'; +import type { IUseEnableModalState } from './types'; + +// utils +import availableAccountsForNetwork from '@extension/utils/availableAccountsForNetwork'; +import selectDefaultNetwork from '@extension/utils/selectDefaultNetwork'; + +export default function useEnableModal(): IUseEnableModalState { + // selectors + const accounts = useSelectAccounts(); + const events = useSelectEvents(); + const networks = useSelectNetworks(); + // state + const [availableAccounts, setAvailableAccounts] = useState< + IAccountWithExtendedProps[] | null + >(null); + const [event, setEvent] = useState | null>( + null + ); + const [network, setNetwork] = useState( + null + ); + + useEffect(() => { + setEvent( + (events.find( + (value) => + value.type === EventTypeEnum.ClientRequest && + value.payload.message.method === ARC0027MethodEnum.Enable + ) as IClientRequestEvent) || null + ); + }, [events]); + // get the available accounts + useEffect(() => { + if (event && network) { + setAvailableAccounts( + availableAccountsForNetwork({ + accounts, + network, + }) + ); + } + }, [accounts, network, event]); + useEffect(() => { + if (event && !network) { + // find the selected network, or use the default one + setNetwork( + networks.find( + (value) => + value.genesisHash === event.payload.message.params?.genesisHash + ) || selectDefaultNetwork(networks) + ); + } + }, [event, networks]); + + return { + availableAccounts, + event, + network, + setAvailableAccounts, + setNetwork, + }; +} diff --git a/src/extension/modals/AddPasskeyModal/index.ts b/src/extension/modals/AddPasskeyModal/index.ts new file mode 100644 index 00000000..4a4737bd --- /dev/null +++ b/src/extension/modals/AddPasskeyModal/index.ts @@ -0,0 +1 @@ +export { default } from './AddPasskeyModal'; diff --git a/src/extension/pages/PasskeyPage/PasskeyPage.tsx b/src/extension/pages/PasskeyPage/PasskeyPage.tsx new file mode 100644 index 00000000..a06fb2af --- /dev/null +++ b/src/extension/pages/PasskeyPage/PasskeyPage.tsx @@ -0,0 +1,478 @@ +import { + Text, + VStack, + useDisclosure, + Icon, + Skeleton, + Code, + HStack, + InputGroup, + Input, +} from '@chakra-ui/react'; +import React, { ChangeEvent, FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IoTrashOutline } from 'react-icons/io5'; +import { GoShield, GoShieldLock } from 'react-icons/go'; +import { useDispatch } from 'react-redux'; + +// components +import Button from '@extension/components/Button'; +import CopyIconButton from '@extension/components/CopyIconButton'; +import COSEAlgorithmBadge from '@extension/components/COSEAlgorithmBadge'; +import MoreInformationAccordion from '@extension/components/MoreInformationAccordion'; +import PageHeader from '@extension/components/PageHeader'; +import PageSubHeading from '@extension/components/PageSubHeading'; +import PageItem from '@extension/components/PageItem'; +import PasskeyCapabilities from '@extension/components/PasskeyCapabilities'; + +// constants +import { DEFAULT_GAP, PAGE_ITEM_HEIGHT } from '@extension/constants'; + +// features +import { create as createNotification } from '@extension/features/notifications'; +import { + removeFromStorageThunk as removePasskeyCredentialFromStorageThunk, + setAddPasskey, +} from '@extension/features/passkeys'; + +// hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import usePrimaryColor from '@extension/hooks/usePrimaryColor'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// selectors +import { + useSelectLogger, + useSelectPasskeysPasskey, + useSelectPasskeysFetching, + useSelectSystemInfo, + useSelectPasskeysSaving, +} from '@extension/selectors'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; + +// types +import { IAppThunkDispatch, IPasskeyCredential } from '@extension/types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; +import { setConfirmModal } from '@extension/features/layout'; +import { saveSettingsToStorageThunk } from '@extension/features/settings'; + +const PasskeyPage: FC = () => { + const { t } = useTranslation(); + const dispatch: IAppThunkDispatch = useDispatch(); + const { + isOpen: isMoreInformationOpen, + onOpen: onMoreInformationOpen, + onClose: onMoreInformationClose, + } = useDisclosure(); + // selectors + const logger = useSelectLogger(); + const passkey = useSelectPasskeysPasskey(); + const fetching = useSelectPasskeysFetching(); + const saving = useSelectPasskeysSaving(); + const systemInfo = useSelectSystemInfo(); + // hooks + const defaultTextColor = useDefaultTextColor(); + const primaryColor = usePrimaryColor(); + const subTextColor = useSubTextColor(); + // states + const [creating, setCreating] = useState(false); + const [passkeyName, setPasskeyName] = useState(''); + // handlers + const handleAddPasskeyClick = async () => { + const _functionName = 'handleAddPasskeyClick'; + let _passkey: IPasskeyCredential; + + if (!systemInfo || !systemInfo.deviceID) { + return; + } + + setCreating(true); + + try { + logger.debug( + `${PasskeyPage.name}#${_functionName}: creating a new passkey` + ); + + _passkey = await PasskeyService.createPasskeyCredential({ + deviceID: systemInfo.deviceID, + logger, + }); + + logger.debug( + `${PasskeyPage.name}#${_functionName}: new passkey "${_passkey.id}" created` + ); + + // set the add passkey to open the add passkey modal + dispatch(setAddPasskey(_passkey)); + } catch (error) { + // show a notification + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + } + + setCreating(false); + }; + const handleMoreInformationToggle = (value: boolean) => + value ? onMoreInformationOpen() : onMoreInformationClose(); + const handleOnNameChange = (event: ChangeEvent) => + setPasskeyName(event.target.value); + const handleRemovePasskeyClick = async () => { + const _functionName = 'handleRemovePasskeyClick'; + + if (!passkey) { + return; + } + + dispatch( + setConfirmModal({ + description: t('captions.removePasskeyConfirm', { + name: passkey.name, + }), + onConfirm: async () => { + // remove the passkey from storage + await dispatch(removePasskeyCredentialFromStorageThunk()).unwrap(); + + logger.debug( + `${PasskeyPage.name}#${_functionName}: removed passkey "${passkey.id}"` + ); + + // display a notification + dispatch( + createNotification({ + description: t('captions.passkeyRemoved', { + name: passkey.name, + }), + ephemeral: true, + title: t('headings.passkeyRemoved'), + type: 'info', + }) + ); + }, + title: t('headings.removePasskeyConfirm'), + warningText: t('captions.removePasskeyWarning'), + }) + ); + }; + // renders + const renderContent = () => { + const iconSize = calculateIconSize('xl'); + + // if passkeys are not supported for teh browser + if (!PasskeyService.isSupported()) { + return ( + + {/*icon*/} + + + {/*captions*/} + + + {t('captions.passkeyNotSupported1')} + + + + {t('captions.passkeyNotSupported2')} + + + + ); + } + + if (fetching) { + return ( + + {/*icon*/} + + + + + {/*captions*/} + + + + {t('captions.passkeyNotSupported1')} + + + + + + {t('captions.passkeyNotSupported2')} + + + + + ); + } + + // if we have a passkey display the details + if (passkey) { + return ( + <> + + {/*icon*/} + + + {/*details*/} + + ('headings.details')} /> + + {/*name*/} + ('labels.id')}> + + + {passkey.id} + + + {/*copy id button*/} + ('labels.copyId')} + tooltipLabel={t('labels.copyId')} + value={passkey.id} + /> + + + + {/*credential id*/} + ('labels.credentialID')}> + + + {passkey.id} + + + {/*copy credential id button*/} + ('labels.copyCredentialID')} + tooltipLabel={t('labels.copyCredentialID')} + value={passkey.id} + /> + + + + {/*user id*/} + ('labels.name')}> + + {passkey.name} + + + + {/*capabilities*/} + ('labels.capabilities')}> + + + + + + {/*public key*/} + ('labels.publicKey')}> + + + {passkey.publicKey || '-'} + + + {/*copy public key button*/} + {passkey.publicKey && ( + ('labels.copyPublicKey')} + tooltipLabel={t('labels.copyPublicKey')} + value={passkey.id} + /> + )} + + + + {/*algorithm*/} + ('labels.algorithm')}> + + + + + + + + + + ); + } + + return ( + <> + + {/*icon*/} + + + {/*instruction*/} + + + {t('captions.addPasskey1')} + + + + {t('captions.addPasskey2')} + + + + {/*instructions*/} + + {t('captions.addPasskeyInstruction')} + + + {/*passkey name*/} + + + {t('labels.passkeyName')} + + + + ('placeholders.passkeyName')} + type="text" + value={passkeyName || ''} + /> + + + + + + + ); + }; + + return ( + <> + ('titles.page', { context: 'passkey' })} /> + + + {renderContent()} + + + ); +}; + +export default PasskeyPage; diff --git a/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx b/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx index e2a76d49..ec18aa34 100644 --- a/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx +++ b/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx @@ -1,6 +1,7 @@ import { useDisclosure, VStack } from '@chakra-ui/react'; import React, { ChangeEvent, FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { GoShieldLock } from 'react-icons/go'; import { IoKeyOutline, IoLockClosedOutline, @@ -21,6 +22,7 @@ import SettingsSwitchItem from '@extension/components/SettingsSwitchItem'; import { CHANGE_PASSWORD_ROUTE, EXPORT_ACCOUNT_ROUTE, + PASSKEY_ROUTE, PASSWORD_LOCK_DURATION_HIGH, PASSWORD_LOCK_DURATION_HIGHER, PASSWORD_LOCK_DURATION_HIGHEST, @@ -40,23 +42,30 @@ import { saveSettingsToStorageThunk } from '@extension/features/settings'; import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; // selectors -import { useSelectLogger, useSelectSettings } from '@extension/selectors'; +import { + useSelectLogger, + useSelectPasskeysEnabled, + useSelectSettings, +} from '@extension/selectors'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; // types -import type { ILogger } from '@common/types'; -import type { IAppThunkDispatch, ISettings } from '@extension/types'; +import type { IAppThunkDispatch } from '@extension/types'; const SecuritySettingsIndexPage: FC = () => { const { t } = useTranslation(); - const dispatch: IAppThunkDispatch = useDispatch(); + const dispatch = useDispatch(); const { isOpen: isPasswordConfirmModalOpen, onClose: onPasswordConfirmModalClose, onOpen: onPasswordConfirmModalOpen, } = useDisclosure(); // selectors - const logger: ILogger = useSelectLogger(); - const settings: ISettings = useSelectSettings(); + const logger = useSelectLogger(); + const passkeyEnabled = useSelectPasskeysEnabled(); + const settings = useSelectSettings(); // misc const durationOptions: IOption[] = [ { @@ -219,6 +228,42 @@ const SecuritySettingsIndexPage: FC = () => { to={`${SETTINGS_ROUTE}${SECURITY_ROUTE}${CHANGE_PASSWORD_ROUTE}`} /> + {/*passkey*/} + ('labels.enabled'), + } + : { + colorScheme: 'red', + label: t('labels.disabled'), + }), + }, + ] + : [ + { + colorScheme: 'yellow', + label: t('labels.notSupported'), + }, + ]), + { + colorScheme: 'blue', + label: t('labels.experimental'), + }, + ]} + icon={GoShieldLock} + label={t('titles.page', { context: 'passkey' })} + to={`${SETTINGS_ROUTE}${SECURITY_ROUTE}${PASSKEY_ROUTE}`} + /> + + {/*accounts*/} + ('headings.accounts')} /> + {/*view seed phrase*/} ( } path="/" /> } path={CHANGE_PASSWORD_ROUTE} /> + } path={PASSKEY_ROUTE} /> } path={EXPORT_ACCOUNT_ROUTE} /> } path={VIEW_SEED_PHRASE_ROUTE} /> diff --git a/src/extension/selectors/index.ts b/src/extension/selectors/index.ts index 96553264..2a55f54a 100644 --- a/src/extension/selectors/index.ts +++ b/src/extension/selectors/index.ts @@ -7,6 +7,7 @@ export * from './layout'; export * from './networks'; export * from './news'; export * from './notifications'; +export * from './passkeys'; export * from './password-lock'; export * from './re-key-account'; export * from './registration'; diff --git a/src/extension/selectors/passkeys/index.ts b/src/extension/selectors/passkeys/index.ts index 05241ca7..e6f953e1 100644 --- a/src/extension/selectors/passkeys/index.ts +++ b/src/extension/selectors/passkeys/index.ts @@ -1,3 +1,5 @@ -export { default as useSelectPasskeysCredential } from './useSelectPasskeysCredential'; +export { default as useSelectPasskeysAddPasskey } from './useSelectPasskeysAddPasskey'; +export { default as useSelectPasskeysEnabled } from './useSelectPasskeysEnabled'; export { default as useSelectPasskeysFetching } from './useSelectPasskeysFetching'; +export { default as useSelectPasskeysPasskey } from './useSelectPasskeysPasskey'; export { default as useSelectPasskeysSaving } from './useSelectPasskeysSaving'; diff --git a/src/extension/selectors/passkeys/useSelectPasskeysCredential.ts b/src/extension/selectors/passkeys/useSelectPasskeysAddPasskey.ts similarity index 71% rename from src/extension/selectors/passkeys/useSelectPasskeysCredential.ts rename to src/extension/selectors/passkeys/useSelectPasskeysAddPasskey.ts index a558e5fb..ca39de41 100644 --- a/src/extension/selectors/passkeys/useSelectPasskeysCredential.ts +++ b/src/extension/selectors/passkeys/useSelectPasskeysAddPasskey.ts @@ -7,9 +7,9 @@ import type { IPasskeyCredential, } from '@extension/types'; -export default function useSelectPasskeysCredential(): IPasskeyCredential | null { +export default function useSelectPasskeysAddPasskey(): IPasskeyCredential | null { return useSelector< IBackgroundRootState | IMainRootState, IPasskeyCredential | null - >((state) => state.passkeys.credential); + >((state) => state.passkeys.addPasskey); } diff --git a/src/extension/selectors/passkeys/useSelectPasskeysEnabled.ts b/src/extension/selectors/passkeys/useSelectPasskeysEnabled.ts new file mode 100644 index 00000000..d1553e01 --- /dev/null +++ b/src/extension/selectors/passkeys/useSelectPasskeysEnabled.ts @@ -0,0 +1,14 @@ +import { useSelector } from 'react-redux'; + +// types +import type { + IBackgroundRootState, + IMainRootState, + IPasskeyCredential, +} from '@extension/types'; + +export default function useSelectPasskeysEnabled(): boolean { + return useSelector( + (state) => !!state.passkeys.passkey + ); +} diff --git a/src/extension/selectors/passkeys/useSelectPasskeysPasskey.ts b/src/extension/selectors/passkeys/useSelectPasskeysPasskey.ts new file mode 100644 index 00000000..52479f1f --- /dev/null +++ b/src/extension/selectors/passkeys/useSelectPasskeysPasskey.ts @@ -0,0 +1,15 @@ +import { useSelector } from 'react-redux'; + +// types +import type { + IBackgroundRootState, + IMainRootState, + IPasskeyCredential, +} from '@extension/types'; + +export default function useSelectPasskeysPasskey(): IPasskeyCredential | null { + return useSelector< + IBackgroundRootState | IMainRootState, + IPasskeyCredential | null + >((state) => state.passkeys.passkey); +} diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index 912397cc..b2c75212 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -108,18 +108,22 @@ export default class PasskeyService { */ public static async createPasskeyCredential({ deviceID, + name, logger, }: ICreatePasskeyCredentialOptions): Promise { const _functionName = 'createPasskey'; + const _name = name && name.length > 0 ? name : 'Kibisis Web Extension'; const salt = randomBytes(SALT_BYTE_SIZE); let _error: string; let credential: PublicKeyCredential | null; let extensionResults: IAuthenticationExtensionsClientOutputs; + let publicKey: ArrayBuffer | null; try { credential = (await navigator.credentials.create({ publicKey: { authenticatorSelection: { + residentKey: 'required', // make passkey discoverable on the device userVerification: 'discouraged', }, challenge: randomBytes(32), @@ -137,12 +141,12 @@ export default class PasskeyService { { alg: -257, type: 'public-key' }, // RS256 ], rp: { - name: 'Kibisis Web Extension', + name: _name, }, user: { id: new TextEncoder().encode(deviceID), name: deviceID, - displayName: 'Kibisis Passkey', + displayName: deviceID, }, }, })) as PublicKeyCredential | null; @@ -171,11 +175,20 @@ export default class PasskeyService { throw new PasskeyNotSupportedError(_error); } + publicKey = ( + credential.response as AuthenticatorAttestationResponse + ).getPublicKey(); + return { + algorithm: ( + credential.response as AuthenticatorAttestationResponse + ).getPublicKeyAlgorithm(), id: encodeHex(new Uint8Array(credential.rawId)), initializationVector: encodeHex( randomBytes(INITIALIZATION_VECTOR_BYTE_SIZE) ), + name: _name, + publicKey: publicKey ? encodeHex(new Uint8Array(publicKey)) : null, salt: encodeHex(salt), transports: ( credential.response as AuthenticatorAttestationResponse diff --git a/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts b/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts index 68a6e5dd..9dcf6273 100644 --- a/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts +++ b/src/extension/services/PasskeyService/types/ICreatePasskeyOptions.ts @@ -3,6 +3,7 @@ import type { IBaseOptions } from '@common/types'; interface ICreatePasskeyOptions extends IBaseOptions { deviceID: string; + name?: string; } export default ICreatePasskeyOptions; diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index a1865ad0..b07af8d1 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -9,6 +9,7 @@ const translation: IResourceLanguage = { add: 'Add', addAccount: 'Add Account', addAsset: 'Add Asset', + addPasskey: 'Add Passkey', allow: 'Allow', approve: 'Approve', cancel: 'Cancel', @@ -22,6 +23,7 @@ const translation: IResourceLanguage = { create: 'Create', dismiss: 'Dismiss', download: 'Download', + encrypt: 'Encrypt', getStarted: 'Get Started', hide: 'Hide', import: 'Import', @@ -34,6 +36,7 @@ const translation: IResourceLanguage = { reject: 'Reject', remove: 'Remove', removeAllSessions: 'Remove All Sessions', + removePasskey: 'Remove Passkey', reset: 'Reset', save: 'Save', scanAWindow: 'Scan A Window', @@ -59,6 +62,10 @@ const translation: IResourceLanguage = { addAssetURI: 'You are about to add the following asset. Select which account your would like to add the asset to.', addedAccount: 'Account {{address}} has been added.', + addPasskey1: + 'Adding a passkey allows you to sign transactions without your password.', + addPasskey2: `The passkey will be used to to encrypt/decrypt your account's private keys.`, + addPasskeyInstruction: `Begin by adding a new passkey on your device.`, addressDoesNotMatch: 'This address does not match the signer', addWatchAccount: 'Add a watch account by providing a valid address.', addWatchAccountComplete: `Press save to confirm adding the watch account.`, @@ -108,6 +115,7 @@ const translation: IResourceLanguage = { 'Passwords will only need to be entered due to inactivity.', enableRequest: 'An application is requesting to connect. Select which accounts you would like to enable:', + encryptWithPasskey: `To complete the process, the passkey will be requested to re-encrypt the account's private keys.`, enterSeedPhrase: `Add your seed phrase to import your account.`, enterWatchAccountAddress: 'Enter the address of the account you would like to watch.', @@ -149,6 +157,7 @@ const translation: IResourceLanguage = { mustEnterPasswordToAuthorizeUndoReKey: 'You must enter your password to authorize the undo re-key.', mustEnterPasswordToConfirm: 'You must enter your password to confirm.', + mustEnterPasswordToDecryptPrivateKeys: `Enter your password to decrypt the account's private keys.`, mustEnterPasswordToImportAccount: 'You must enter your password to import this account.', mustEnterPasswordToSign: 'Enter your password to sign.', @@ -179,6 +188,12 @@ const translation: IResourceLanguage = { 'Standard assets require an "opt-in" fee. This is a transaction of the asset with a "0" amount sent to yourself.', optOutFee: 'Standard assets require an "opt-out" fee. This is a transaction of the asset with a "0" amount sent to yourself.', + passkeyAdded: 'Passkey {{name}} added!', + passkeyRemoved: 'Passkey {{name}} removed.', + passkeyNotSupported1: + 'Unfortunately your browser does not support passkeys.', + passkeyNotSupported2: + 'Try updating your browser to the latest or a newer version.', passwordLockDescription: 'Please re-enter your password to unlock.', passwordScoreInfo: 'To conform with our <2>Strong Password Policy, you are required to use a sufficiently strong password. Password must be at least 8 characters.', @@ -204,6 +219,10 @@ const translation: IResourceLanguage = { 'Please wait while we confirm the opt-out of the asset {{symbol}} with the network.', [`removeAssetConfirming_${AssetTypeEnum.ARC0200}`]: 'Hiding asset {{symbol}}.', + removePasskeyConfirm: + 'Are you sure you want remove the passkey "{{name}}" from your wallet?', + removePasskeyWarning: + 'This action will only remove the passkey from your wallet, the passkey will remain on your device.', saveMnemonicPhrase1: 'Here is your 25 word mnemonic seed phrase; it is the key to your account.', saveMnemonicPhrase2: `Make sure you save this in a secure place.`, @@ -244,6 +263,10 @@ const translation: IResourceLanguage = { code_4001: 'Your balance will fall below the minimum balance required.', code_4002: 'Standard assets must have a zero balance.', code_6000: 'There was an error starting the camera.', + code_8000: 'The device does not support passkey for encryption.', + code_8001: 'Failed to create a passkey on the device.', + code_8002: + 'Failed to communicate with the passkey device. Please try again', }, inputs: { copySeedPhraseRequired: @@ -264,12 +287,17 @@ const translation: IResourceLanguage = { code_4001: '4001 Minimum Balance Required', code_4002: '4002 Assets Need A Zero Balance', code_6000: '6000 Camera Error', + code_8000: '8000 Passkey Not Supported', + code_8001: '8001 Passkey Creation Failure', + code_8002: '8002 Passkey Communication Failed', }, }, headings: { + accounts: 'Accounts', addAsset: 'Add Asset', addedAccount: 'Added Account!', addedAsset: 'Added Asset {{symbol}}!', + addPasskey: 'Add Passkey', addWatchAccount: 'Add A Watch Account', allowMainNetConfirm: 'Allow MainNet Networks', analyticsAndTracking: 'Analytics & Tracking', @@ -284,6 +312,7 @@ const translation: IResourceLanguage = { congratulations: 'Congratulations!', createNewAccount: 'Create A New Account', dangerZone: 'Danger Zone', + details: 'Details', developer: 'Developer', enterAnAddress: 'Enter an address', enterYourSeedPhrase: 'Enter your seed phrase', @@ -304,6 +333,8 @@ const translation: IResourceLanguage = { numberOfTransactions: '{{number}} transaction', numberOfTransactions_multiple: '{{number}} atomic transactions', offline: 'Offline', + passkeyAdded: 'Passkey Added!', + passkeyRemoved: 'Passkey Removed', passwordLock: 'Welcome back', reKeyAccount: 'Re-key Account 🔒', reKeyAccountSuccessful: 'Successfully Re-Keyed Account!', @@ -313,6 +344,7 @@ const translation: IResourceLanguage = { [`removeAsset_${AssetTypeEnum.ARC0200}`]: 'Hide {{symbol}}', removedAsset: 'Asset {{symbol}} Removed!', [`removedAsset_${AssetTypeEnum.ARC0200}`]: 'Asset {{symbol}} Hidden!', + removePasskeyConfirm: 'Remove Passkey', scanningForQRCode: 'Scanning For QR Code', scanQrCode: 'Scan QR Code', selectAccount: 'Select Account', @@ -369,6 +401,7 @@ const translation: IResourceLanguage = { accountToFreeze: 'Account To Freeze', accountToUnfreeze: 'Account To Unfreeze', addAccount: 'Add Account', + algorithm: 'Algorithm', allowActionTracking: 'Allow certain actions to be tracked?', allowBetaNet: 'Allow BetaNet networks?', allowDidTokenFormat: 'Allow DID token format in address sharing?', @@ -383,18 +416,24 @@ const translation: IResourceLanguage = { authorizedAccounts: 'Authorized Accounts', authorizedAddresses: 'Authorized Addresses', balance: 'Balance', + capabilities: 'Capabilities', chain: 'Chain', clawbackAccount: 'Clawback Account', connectWallet: 'Connect Wallet', copyAddress: 'Copy Address', copyApplicationId: 'Copy Application ID', copyAssetId: 'Copy Asset ID', + copyCredentialID: 'Copy Credential ID', copyGroupId: 'Copy Group ID', + copyId: 'Copy ID', + copyPublicKey: 'Copy Public Key', copySeedPhraseConfirm: 'I confirm I have copied my seed phrase to a secure place.', copyTransactionId: 'Copy Transaction ID', + copyUserID: 'Copy User ID', copyValue: 'Copy {{value}}', creatorAccount: 'Creator Account', + credentialID: 'Credential ID', currentAuthorizedAccount: 'Current Authorized Account', dark: 'Dark', date: 'Date', @@ -402,8 +441,11 @@ const translation: IResourceLanguage = { default: 'Default', defaultFrozen: 'Default Frozen', did: 'DID', + disabled: 'Disabled', editAccountName: 'Rename account', + enabled: 'Enabled', enablePasswordLock: 'Enable password lock?', + experimental: 'Experimental', expirationDate: 'Expiration Date', extensionId: 'Extension ID', extraPayment: 'Extra Payment', @@ -440,6 +482,8 @@ const translation: IResourceLanguage = { 'This account has been re-keyed to the account {{address}}, but the address is not available or is a watch account', note: 'Note', noteOptional: 'Note (optional)', + notSupported: 'Not Supported', + passkeyName: 'Passkey name', password: 'Password', passwordLockDuration: 'Never', passwordLockDuration_60000: '1 minute', @@ -451,6 +495,7 @@ const translation: IResourceLanguage = { passwordLockTimeout: 'Password lock timeout', preferredBlockExplorer: 'Preferred Block Explorer', preferredNFTExplorer: 'Preferred NFT Explorer', + publicKey: 'Public Key', reKey: 'Re-key', reKeyed: 'Re-keyed', reKeyedAccount: 'Re-keyed Account', @@ -481,9 +526,11 @@ const translation: IResourceLanguage = { type: 'Type', undoReKey: 'Undo Re-key', unitName: 'Unit Name', + unknown: 'Unknown', unknownApp: 'Unknown App', unknownHost: 'unknown host', url: 'URL', + userID: 'User ID', value: 'Value', version: 'Version', voteFirst: 'Voting First Round', @@ -499,6 +546,7 @@ const translation: IResourceLanguage = { enterNote: 'Enter an optional note', enterPassword: 'Enter password', nameAccount: 'Enter a name for this account (optional)', + passkeyName: 'e.g. Kibisis', pleaseSelect: 'Please select...', }, titles: { @@ -517,6 +565,7 @@ const translation: IResourceLanguage = { page_importAccountViaQRCode: 'Import An Account Via QR Code', page_importAccountViaSeedPhrase: 'Import An Account Via Seed Phrase', page_passwordLock: 'Enter Your Password', + page_passkey: 'Passkey', page_privacy: 'Privacy', page_security: 'Security', page_sessions: 'Sessions', diff --git a/src/extension/types/passkeys/IPasskeyCredential.ts b/src/extension/types/passkeys/IPasskeyCredential.ts index 32c2d458..56657ee2 100644 --- a/src/extension/types/passkeys/IPasskeyCredential.ts +++ b/src/extension/types/passkeys/IPasskeyCredential.ts @@ -1,13 +1,21 @@ /** + * @property {string} algorithm - a number that is equal to a + * {@link https://www.iana.org/assignments/cose/cose.xhtml#algorithms COSE Algorithm Identifier}, representing the + * cryptographic algorithm used for the new credential. * @property {string} id - the hexadecimal encoded ID of the passkey. * @property {string} initializationVector - a hexadecimal encoded initialization vector used in the derivation of the * encryption key. + * @property {string} name - the name given to this passkey. + * @property {string | null} publicKey - the hexadecimal encoded public key of the passkey. * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. * @property {AuthenticatorTransport[]} transports - the transports of the passkey that were determined at creation. */ interface IPasskeyCredential { + algorithm: number; id: string; initializationVector: string; + name: string; + publicKey: string | null; salt: string; transports: AuthenticatorTransport[]; } diff --git a/src/extension/utils/calculateIconSize/calculateIconSize.ts b/src/extension/utils/calculateIconSize/calculateIconSize.ts new file mode 100644 index 00000000..270e8fbd --- /dev/null +++ b/src/extension/utils/calculateIconSize/calculateIconSize.ts @@ -0,0 +1,15 @@ +export default function calculateIconSize(size: string): number { + switch (size) { + case 'lg': + return 10; + case 'md': + return 6; + case 'xl': + return 16; + case 'xs': + return 3; + case 'sm': + default: + return 4; + } +} diff --git a/src/extension/utils/calculateIconSize/index.ts b/src/extension/utils/calculateIconSize/index.ts new file mode 100644 index 00000000..3f9a9ae6 --- /dev/null +++ b/src/extension/utils/calculateIconSize/index.ts @@ -0,0 +1 @@ +export { default } from './calculateIconSize'; From 3254f622eae01635c9a6dde23061d5805bc603d7 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sun, 7 Jul 2024 18:24:25 +0100 Subject: [PATCH 11/31] chore: squash --- .../AnimatedKibisisIcon.tsx | 24 +++ .../components/AnimatedKibisisIcon/index.ts | 1 + src/extension/constants/Dimensions.ts | 4 +- .../AddPasskeyModal/AddPasskeyModal.tsx | 143 +++++++++++++++--- src/extension/translations/en.ts | 3 + 5 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 src/extension/components/AnimatedKibisisIcon/AnimatedKibisisIcon.tsx create mode 100644 src/extension/components/AnimatedKibisisIcon/index.ts diff --git a/src/extension/components/AnimatedKibisisIcon/AnimatedKibisisIcon.tsx b/src/extension/components/AnimatedKibisisIcon/AnimatedKibisisIcon.tsx new file mode 100644 index 00000000..8c405072 --- /dev/null +++ b/src/extension/components/AnimatedKibisisIcon/AnimatedKibisisIcon.tsx @@ -0,0 +1,24 @@ +import { Icon, IconProps } from '@chakra-ui/react'; +import React, { FC } from 'react'; + +const AnimatedKibisisIcon: FC = (props: IconProps) => ( + + + + + +); + +export default AnimatedKibisisIcon; diff --git a/src/extension/components/AnimatedKibisisIcon/index.ts b/src/extension/components/AnimatedKibisisIcon/index.ts new file mode 100644 index 00000000..7069e771 --- /dev/null +++ b/src/extension/components/AnimatedKibisisIcon/index.ts @@ -0,0 +1 @@ +export { default } from './AnimatedKibisisIcon'; diff --git a/src/extension/constants/Dimensions.ts b/src/extension/constants/Dimensions.ts index e4ef6599..95f458ef 100644 --- a/src/extension/constants/Dimensions.ts +++ b/src/extension/constants/Dimensions.ts @@ -1,8 +1,8 @@ export const ACCOUNT_PAGE_HEADER_ITEM_HEIGHT = 10; // 2.5rem - 40px export const ACCOUNT_SELECT_ITEM_MINIMUM_HEIGHT = 60; // px export const DEFAULT_GAP = 6; -export const DEFAULT_POPUP_HEIGHT = 740; -export const DEFAULT_POPUP_WIDTH = 400; +export const DEFAULT_POPUP_HEIGHT = 750; +export const DEFAULT_POPUP_WIDTH = 465; export const MODAL_ITEM_HEIGHT = 10; // 10 = 2.5rem = 40px export const OPTION_HEIGHT = '57px'; export const PAGE_ITEM_HEIGHT = 10; // 10 = 2.5rem = 40px diff --git a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx index 630d5773..0899710b 100644 --- a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx +++ b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx @@ -8,6 +8,7 @@ import { ModalContent, ModalFooter, ModalHeader, + Spinner, Text, useDisclosure, VStack, @@ -16,6 +17,7 @@ import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GoShieldLock } from 'react-icons/go'; import { IoKeyOutline } from 'react-icons/io5'; +import { Radio } from 'react-loader-spinner'; import { useDispatch } from 'react-redux'; // components @@ -23,6 +25,7 @@ import Button from '@extension/components/Button'; import CopyIconButton from '@extension/components/CopyIconButton'; import COSEAlgorithmBadge from '@extension/components/COSEAlgorithmBadge'; import ModalItem from '@extension/components/ModalItem'; +import ModalTextItem from '@extension/components/ModalTextItem'; import MoreInformationAccordion from '@extension/components/MoreInformationAccordion'; import PasskeyCapabilities from '@extension/components/PasskeyCapabilities'; import PasswordInput, { @@ -47,7 +50,9 @@ import { } from '@extension/features/passkeys'; // hooks +import useColorModeValue from '@extension/hooks/useColorModeValue'; import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import usePrimaryColor from '@extension/hooks/usePrimaryColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; // selectors @@ -75,8 +80,6 @@ import type { // utils import calculateIconSize from '@extension/utils/calculateIconSize'; -import ModalTextItem from '@extension/components/ModalTextItem'; -import ellipseAddress from '@extension/utils/ellipseAddress'; const AddPasskeyModal: FC = ({ onClose }) => { const { t } = useTranslation(); @@ -104,14 +107,24 @@ const AddPasskeyModal: FC = ({ onClose }) => { validate: validatePassword, value: password, } = usePassword(); + const primaryColor = usePrimaryColor(); + const primaryColorCode = useColorModeValue( + theme.colors.primaryLight['500'], + theme.colors.primaryDark['500'] + ); const subTextColor = useSubTextColor(); // state const [encrypting, setEncrypting] = useState(false); + const [requestingInputKeyMaterial, setRequestingInputKeyMaterial] = + useState(false); + // misc + const isLoading = encrypting || saving || requestingInputKeyMaterial; // handlers const handleCancelClick = async () => handleClose(); const handleClose = () => { resetPassword(); setEncrypting(false); + setRequestingInputKeyMaterial(false); if (onClose) { onClose(); @@ -151,19 +164,53 @@ const AddPasskeyModal: FC = ({ onClose }) => { return; } - setEncrypting(true); - // first save the passkey to storage passkey = await dispatch( savePasskeyCredentialToStorageThunk(addPasskey) ).unwrap(); + setRequestingInputKeyMaterial(true); + try { + logger.debug( + `${AddPasskeyModal.name}#${_functionName}: requesting input key material from passkey "${passkey.id}"` + ); + // fetch the encryption key material inputKeyMaterial = await PasskeyService.fetchInputKeyMaterialFromPasskey({ credential: passkey, logger, }); + + setRequestingInputKeyMaterial(false); + } catch (error) { + setRequestingInputKeyMaterial(false); + + // remove the previously saved credential + dispatch(removePasskeyCredentialFromStorageThunk()); + + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + + return; + } + + logger.debug( + `${AddPasskeyModal.name}#${_functionName}: received input key material from passkey "${passkey.id}"` + ); + + setEncrypting(true); + + try { // let encryptedBytes = await PasskeyService.encryptBytes({ // bytes: new TextEncoder().encode(message), // deviceID: systemInfo.deviceID, @@ -243,11 +290,65 @@ const AddPasskeyModal: FC = ({ onClose }) => { return; } + if (encrypting) { + return ( + + {/*loader*/} + + + {/*caption*/} + + {t('captions.encryptAccountsWithPasskey', { + name: addPasskey.name, + })} + + + ); + } + + if (requestingInputKeyMaterial) { + return ( + + {/*loader*/} + + + {/*caption*/} + + {t('captions.requestingPasskeyEncryptionKey', { + name: addPasskey.name, + })} + + + ); + } + return ( @@ -402,31 +503,33 @@ const AddPasskeyModal: FC = ({ onClose }) => { - + {renderContent()} {/*password input*/} - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ( - 'captions.mustEnterPasswordToDecryptPrivateKeys' - )} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - inputRef={passwordInputRef} - value={password} - /> - )} + {!settings.security.enablePasswordLock && + !passwordLockPassword && + !isLoading && ( + ( + 'captions.mustEnterPasswordToDecryptPrivateKeys' + )} + onChange={onPasswordChange} + onKeyUp={handleKeyUpPasswordInput} + inputRef={passwordInputRef} + value={password} + /> + )} {/*buttons*/} {/*cancel*/} + + {/*remove*/} + + + + + + + ); +}; + +export default RemovePasskeyModal; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/index.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/index.ts new file mode 100644 index 00000000..32844f13 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/index.ts @@ -0,0 +1 @@ +export { default } from './useRemovePasskey'; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IEncryptPrivateKeyItemWithDelayOptions.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IEncryptPrivateKeyItemWithDelayOptions.ts new file mode 100644 index 00000000..e9b44a5e --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IEncryptPrivateKeyItemWithDelayOptions.ts @@ -0,0 +1,14 @@ +// types +import type { IBaseOptions } from '@common/types'; +import type { IPasskeyCredential, IPrivateKey } from '@extension/types'; + +interface IEncryptPrivateKeyItemWithDelayOptions extends IBaseOptions { + delay?: number; + deviceID: string; + inputKeyMaterial: Uint8Array; + passkey: IPasskeyCredential; + password: string; + privateKeyItem: IPrivateKey; +} + +export default IEncryptPrivateKeyItemWithDelayOptions; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IRemovePasskeyActionOptions.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IRemovePasskeyActionOptions.ts new file mode 100644 index 00000000..43cde053 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IRemovePasskeyActionOptions.ts @@ -0,0 +1,15 @@ +// types +import type { IPasskeyCredential } from '@extension/types'; + +/** + * @property {string} deviceID - the device ID. + * @property {IPasskeyCredential} passkey - the passkey credential to remove. + * @property {string} password - the password used to encrypt the private keys. + */ +interface IRemovePasskeyActionOptions { + deviceID: string; + passkey: IPasskeyCredential; + password: string; +} + +export default IRemovePasskeyActionOptions; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IState.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IState.ts new file mode 100644 index 00000000..a40b6552 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/IState.ts @@ -0,0 +1,17 @@ +// errors +import { BaseExtensionError } from '@extension/errors'; + +// types +import type { IEncryptionState } from '@extension/components/ReEncryptKeysLoadingContent'; +import type IRemovePasskeyActionOptions from './IRemovePasskeyActionOptions'; + +interface IState { + removePasskeyAction: (options: IRemovePasskeyActionOptions) => Promise; + encryptionProgressState: IEncryptionState[]; + encrypting: boolean; + error: BaseExtensionError | null; + requesting: boolean; + resetAction: () => void; +} + +export default IState; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/index.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/index.ts new file mode 100644 index 00000000..508c1dec --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/types/index.ts @@ -0,0 +1,3 @@ +export type { default as IEncryptPrivateKeyItemWithDelayOptions } from './IEncryptPrivateKeyItemWithDelayOptions'; +export type { default as IRemovePasskeyActionOptions } from './IRemovePasskeyActionOptions'; +export type { default as IState } from './IState'; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts new file mode 100644 index 00000000..7c8c2058 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import browser from 'webextension-polyfill'; + +// errors +import { BaseExtensionError, InvalidPasswordError } from '@extension/errors'; + +// features +import { removeFromStorageThunk as removePasskeyToStorageThunk } from '@extension/features/passkeys'; + +// selectors +import { useSelectLogger } from '@extension/selectors'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PasswordService from '@extension/services/PasswordService'; +import PrivateKeyService from '@extension/services/PrivateKeyService'; + +// types +import type { IEncryptionState } from '@extension/components/ReEncryptKeysLoadingContent'; +import type { IAppThunkDispatch, IPrivateKey } from '@extension/types'; +import type { IRemovePasskeyActionOptions, IState } from './types'; + +// utils +import { encryptPrivateKeyItemAndDelay } from './utils'; + +export default function useRemovePasskey(): IState { + const _hookName = 'useAddPasskey'; + const dispatch = useDispatch(); + // selectors + const logger = useSelectLogger(); + // states + const [encryptionProgressState, setEncryptionProgressState] = useState< + IEncryptionState[] + >([]); + const [encrypting, setEncrypting] = useState(false); + const [error, setError] = useState(null); + const [requesting, setRequesting] = useState(false); + // actions + const removePasskeyAction = async ({ + deviceID, + passkey, + password, + }: IRemovePasskeyActionOptions) => { + const _functionName = 'removePasskeyAction'; + const passwordService = new PasswordService({ + logger, + passwordTag: browser.runtime.id, + }); + let inputKeyMaterial: Uint8Array; + let isPasswordValid: boolean; + let privateKeyItems: IPrivateKey[]; + let privateKeyService: PrivateKeyService; + + // reset the previous values + resetAction(); + + isPasswordValid = await passwordService.verifyPassword(password); + + if (!isPasswordValid) { + logger?.debug(`${_hookName}#${_functionName}: invalid password`); + + return setError(new InvalidPasswordError()); + } + + setRequesting(true); + + logger.debug( + `${_hookName}#${_functionName}: requesting input key material from passkey "${passkey.id}"` + ); + + try { + // fetch the encryption key material + inputKeyMaterial = await PasskeyService.fetchInputKeyMaterialFromPasskey({ + credential: passkey, + logger, + }); + } catch (error) { + logger?.debug(`${_hookName}#${_functionName}:`, error); + + setRequesting(false); + + return setError(error); + } + + setRequesting(false); + setEncrypting(true); + + privateKeyService = new PrivateKeyService({ + logger, + }); + privateKeyItems = await privateKeyService.fetchAllFromStorage(); + + // set the encryption state for each item to false + setEncryptionProgressState( + privateKeyItems.map(({ id }) => ({ + id, + encrypted: false, + })) + ); + + // re-encrypt each private key items with the password + try { + privateKeyItems = await Promise.all( + privateKeyItems.map(async (privateKeyItem, index) => { + const item = await encryptPrivateKeyItemAndDelay({ + delay: (index + 1) * 300, // add a staggered delay for the ui to catch up + deviceID, + inputKeyMaterial, + logger, + passkey, + password, + privateKeyItem, + }); + + // update the encryption state + setEncryptionProgressState((_encryptionProgressState) => + _encryptionProgressState.map((value) => + value.id === privateKeyItem.id + ? { + ...value, + encrypted: true, + } + : value + ) + ); + + return item; + }) + ); + } catch (error) { + logger?.debug(`${_hookName}#${_functionName}:`, error); + + setEncrypting(false); + + return setError(error); + } + + // save the new encrypted items to storage + await privateKeyService.saveManyToStorage(privateKeyItems); + + // remove the passkey to storage + await dispatch(removePasskeyToStorageThunk()).unwrap(); + + logger?.debug( + `${_hookName}#${_functionName}: successfully removed passkey` + ); + + setEncrypting(false); + }; + const resetAction = () => { + setEncryptionProgressState([]); + setEncrypting(false); + setError(null); + setRequesting(false); + }; + + return { + encryptionProgressState, + encrypting, + error, + removePasskeyAction, + requesting, + resetAction, + }; +} diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts new file mode 100644 index 00000000..4d589539 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts @@ -0,0 +1,76 @@ +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PasswordService from '@extension/services/PasswordService'; +import PrivateKeyService from '@extension/services/PrivateKeyService'; + +// types +import type { IPrivateKey } from '@extension/types'; +import type { IEncryptPrivateKeyItemWithDelayOptions } from '../types'; + +/** + * Convenience function that decrypts a private key item with the passkey and re-encrypts with the password. + * @param {IEncryptPrivateKeyItemWithDelayOptions} options - the password, the passkey credential, the input key + * material to derive a passkey encryption key, the private key item to decrypt/encrypt and an optional delay. + * @returns {IPrivateKey} a re-encrypted private key item. + */ +export default async function encryptPrivateKeyItemAndDelay({ + delay = 0, + deviceID, + inputKeyMaterial, + logger, + passkey, + password, + privateKeyItem, +}: IEncryptPrivateKeyItemWithDelayOptions): Promise { + const _functionName = 'encryptPrivateKeyItemAndDelay'; + + return new Promise((resolve, reject) => { + setTimeout(async () => { + let decryptedPrivateKey: Uint8Array; + let reEncryptedPrivateKey: Uint8Array; + let version: number = privateKeyItem.version; + + try { + decryptedPrivateKey = await PasskeyService.decryptBytes({ + deviceID, + encryptedBytes: PrivateKeyService.decode( + privateKeyItem.encryptedPrivateKey + ), + inputKeyMaterial, + initializationVector: PasskeyService.decode( + passkey.initializationVector + ), + logger, + }); // decrypt the private key with the passkey + + // if the saved private key is a legacy item, it is using the "secret key" form - the private key concatenated to the public key + if (privateKeyItem.version <= 0) { + logger?.debug( + `${_functionName}: key "${privateKeyItem}" on legacy version "${privateKeyItem.version}", updating` + ); + + decryptedPrivateKey = + PrivateKeyService.extractPrivateKeyFromSecretKey( + decryptedPrivateKey + ); + version = PrivateKeyService.latestVersion; // update to the latest version + } + + reEncryptedPrivateKey = await PasswordService.encryptBytes({ + data: decryptedPrivateKey, + logger, + password, + }); // re-encrypt the private key with the password + } catch (error) { + return reject(error); + } + + return resolve({ + ...privateKeyItem, + encryptedPrivateKey: PrivateKeyService.encode(reEncryptedPrivateKey), + updatedAt: new Date().getTime(), + version, + }); + }, delay); + }); +} diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/index.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/index.ts new file mode 100644 index 00000000..4b24fc41 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/index.ts @@ -0,0 +1 @@ +export { default as encryptPrivateKeyItemAndDelay } from './encryptPrivateKeyItemAndDelay'; diff --git a/src/extension/modals/RemovePasskeyModal/index.ts b/src/extension/modals/RemovePasskeyModal/index.ts new file mode 100644 index 00000000..a976166d --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/index.ts @@ -0,0 +1 @@ +export { default } from './RemovePasskeyModal'; diff --git a/src/extension/modals/RemovePasskeyModal/types/IProps.ts b/src/extension/modals/RemovePasskeyModal/types/IProps.ts new file mode 100644 index 00000000..948e7fb7 --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/types/IProps.ts @@ -0,0 +1,8 @@ +// types +import type { IModalProps, IPasskeyCredential } from '@extension/types'; + +interface IProps extends IModalProps { + removePasskey: IPasskeyCredential | null; +} + +export default IProps; diff --git a/src/extension/modals/RemovePasskeyModal/types/index.ts b/src/extension/modals/RemovePasskeyModal/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/modals/RemovePasskeyModal/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/pages/PasskeyPage/PasskeyPage.tsx b/src/extension/pages/PasskeyPage/PasskeyPage.tsx index a06fb2af..0979ebeb 100644 --- a/src/extension/pages/PasskeyPage/PasskeyPage.tsx +++ b/src/extension/pages/PasskeyPage/PasskeyPage.tsx @@ -12,7 +12,7 @@ import { import React, { ChangeEvent, FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { IoTrashOutline } from 'react-icons/io5'; -import { GoShield, GoShieldLock } from 'react-icons/go'; +import { GoShield, GoShieldCheck, GoShieldLock } from 'react-icons/go'; import { useDispatch } from 'react-redux'; // components @@ -30,16 +30,16 @@ import { DEFAULT_GAP, PAGE_ITEM_HEIGHT } from '@extension/constants'; // features import { create as createNotification } from '@extension/features/notifications'; -import { - removeFromStorageThunk as removePasskeyCredentialFromStorageThunk, - setAddPasskey, -} from '@extension/features/passkeys'; // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import usePrimaryColor from '@extension/hooks/usePrimaryColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; +// modals +import AddPasskeyModal from '@extension/modals/AddPasskeyModal'; +import RemovePasskeyModal from '@extension/modals/RemovePasskeyModal'; + // selectors import { useSelectLogger, @@ -57,8 +57,6 @@ import { IAppThunkDispatch, IPasskeyCredential } from '@extension/types'; // utils import calculateIconSize from '@extension/utils/calculateIconSize'; -import { setConfirmModal } from '@extension/features/layout'; -import { saveSettingsToStorageThunk } from '@extension/features/settings'; const PasskeyPage: FC = () => { const { t } = useTranslation(); @@ -79,8 +77,12 @@ const PasskeyPage: FC = () => { const primaryColor = usePrimaryColor(); const subTextColor = useSubTextColor(); // states + const [addPasskey, setAddPasskey] = useState(null); const [creating, setCreating] = useState(false); const [passkeyName, setPasskeyName] = useState(''); + const [removePasskey, setRemovePasskey] = useState( + null + ); // handlers const handleAddPasskeyClick = async () => { const _functionName = 'handleAddPasskeyClick'; @@ -107,7 +109,7 @@ const PasskeyPage: FC = () => { ); // set the add passkey to open the add passkey modal - dispatch(setAddPasskey(_passkey)); + setAddPasskey(_passkey); } catch (error) { // show a notification dispatch( @@ -125,52 +127,18 @@ const PasskeyPage: FC = () => { setCreating(false); }; + const handleAddPasskeyModalClose = () => setAddPasskey(null); const handleMoreInformationToggle = (value: boolean) => value ? onMoreInformationOpen() : onMoreInformationClose(); const handleOnNameChange = (event: ChangeEvent) => setPasskeyName(event.target.value); - const handleRemovePasskeyClick = async () => { - const _functionName = 'handleRemovePasskeyClick'; - - if (!passkey) { - return; - } - - dispatch( - setConfirmModal({ - description: t('captions.removePasskeyConfirm', { - name: passkey.name, - }), - onConfirm: async () => { - // remove the passkey from storage - await dispatch(removePasskeyCredentialFromStorageThunk()).unwrap(); - - logger.debug( - `${PasskeyPage.name}#${_functionName}: removed passkey "${passkey.id}"` - ); - - // display a notification - dispatch( - createNotification({ - description: t('captions.passkeyRemoved', { - name: passkey.name, - }), - ephemeral: true, - title: t('headings.passkeyRemoved'), - type: 'info', - }) - ); - }, - title: t('headings.removePasskeyConfirm'), - warningText: t('captions.removePasskeyWarning'), - }) - ); - }; + const handleRemovePasskeyClick = () => setRemovePasskey(passkey); + const handleRemovePasskeyModalClose = () => setRemovePasskey(null); // renders const renderContent = () => { const iconSize = calculateIconSize('xl'); - // if passkeys are not supported for teh browser + // if passkeys are not supported for the browser if (!PasskeyService.isSupported()) { return ( { > {/*icon*/} { textAlign="left" w="full" > - {t('labels.passkeyName')} + {`${t('labels.passkeyName')} ${t( + 'labels.optional' + )}`} @@ -460,6 +430,16 @@ const PasskeyPage: FC = () => { return ( <> + {/*modals*/} + + + ('titles.page', { context: 'passkey' })} /> ((state) => state.passkeys.addPasskey); -} diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 639e0135..521d507d 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -64,7 +64,7 @@ const translation: IResourceLanguage = { addedAccount: 'Account {{address}} has been added.', addPasskey1: 'Adding a passkey allows you to sign transactions without your password.', - addPasskey2: `The passkey will be used to to encrypt/decrypt your account's private keys.`, + addPasskey2: `The passkey will be used to to encrypt/decrypt the private keys.`, addPasskeyInstruction: `Begin by adding a new passkey on your device.`, addressDoesNotMatch: 'This address does not match the signer', addWatchAccount: 'Add a watch account by providing a valid address.', @@ -115,7 +115,7 @@ const translation: IResourceLanguage = { 'Passwords will only need to be entered due to inactivity.', enableRequest: 'An application is requesting to connect. Select which accounts you would like to enable:', - encryptWithPasskey: `To complete the process, the passkey will be requested to re-encrypt the account's private keys.`, + encryptWithPasskey: `To complete the process, the passkey will be requested to re-encrypt the private keys.`, enterSeedPhrase: `Add your seed phrase to import your account.`, enterWatchAccountAddress: 'Enter the address of the account you would like to watch.', @@ -157,9 +157,10 @@ const translation: IResourceLanguage = { mustEnterPasswordToAuthorizeUndoReKey: 'You must enter your password to authorize the undo re-key.', mustEnterPasswordToConfirm: 'You must enter your password to confirm.', - mustEnterPasswordToDecryptPrivateKeys: `Enter your password to decrypt the account's private keys.`, + mustEnterPasswordToDecryptPrivateKeys: `Enter your password to decrypt the private keys.`, mustEnterPasswordToImportAccount: 'You must enter your password to import this account.', + mustEnterPasswordToReEncryptPrivateKeys: `Enter your password to re-encrypt the private keys.`, mustEnterPasswordToSign: 'Enter your password to sign.', mustEnterPasswordToSignSecurityToken: 'Enter your password to sign this security token.', @@ -220,12 +221,14 @@ const translation: IResourceLanguage = { 'Please wait while we confirm the opt-out of the asset {{symbol}} with the network.', [`removeAssetConfirming_${AssetTypeEnum.ARC0200}`]: 'Hiding asset {{symbol}}.', - removePasskeyConfirm: - 'Are you sure you want remove the passkey "{{name}}" from your wallet?', - removePasskeyWarning: - 'This action will only remove the passkey from your wallet, the passkey will remain on your device.', - requestingPasskeyEncryptionKey: - 'Requesting encryption key from passkey "{{name}}".', + removePasskey: + 'You are about to remove the passkey "{{name}}". This action will re-enable password authentication.', + removePasskeyInstruction1: + '1. Before you can remove the passkey, you will need to enter your password in order to re-encrypt your keys.', + removePasskeyInstruction2: + '2. You will also be asked to decrypt your keys with your passkey.', + requestingPasskeyPermission: + 'Requesting permission from the passkey "{{name}}".', saveMnemonicPhrase1: 'Here is your 25 word mnemonic seed phrase; it is the key to your account.', saveMnemonicPhrase2: `Make sure you save this in a secure place.`, @@ -347,7 +350,7 @@ const translation: IResourceLanguage = { [`removeAsset_${AssetTypeEnum.ARC0200}`]: 'Hide {{symbol}}', removedAsset: 'Asset {{symbol}} Removed!', [`removedAsset_${AssetTypeEnum.ARC0200}`]: 'Asset {{symbol}} Hidden!', - removePasskeyConfirm: 'Remove Passkey', + removePasskey: 'Remove Passkey', scanningForQRCode: 'Scanning For QR Code', scanQrCode: 'Scan QR Code', selectAccount: 'Select Account', @@ -486,6 +489,7 @@ const translation: IResourceLanguage = { note: 'Note', noteOptional: 'Note (optional)', notSupported: 'Not Supported', + optional: '(optional)', passkeyName: 'Passkey name', password: 'Password', passwordLockDuration: 'Never', From 7ee5511cdac55d01600ab07d44fa37e083ba0cb7 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 10 Jul 2024 00:35:26 +0100 Subject: [PATCH 18/31] feat: add user id to passkey credential and close modals after successful add/remove of passkey --- .../AddPasskeyModal/AddPasskeyModal.tsx | 73 +++++++++++-------- .../types/IAddPasskeyActionOptions.ts | 2 - .../IEncryptPrivateKeyItemWithDelayOptions.ts | 1 - .../hooks/useAddPasskey/types/IState.ts | 2 +- .../hooks/useAddPasskey/useAddPasskey.ts | 16 ++-- .../utils/encryptPrivateKeyItemWithDelay.ts | 6 +- .../RemovePasskeyModal/RemovePasskeyModal.tsx | 36 +++++---- .../IEncryptPrivateKeyItemWithDelayOptions.ts | 1 - .../types/IRemovePasskeyActionOptions.ts | 2 - .../hooks/useRemovePasskey/types/IState.ts | 4 +- .../useRemovePasskey/useRemovePasskey.ts | 16 ++-- .../utils/encryptPrivateKeyItemAndDelay.ts | 6 +- .../services/PasskeyService/PasskeyService.ts | 32 ++++---- .../types/IDecryptBytesOptions.ts | 4 +- .../types/IEncryptBytesOptions.ts | 4 +- .../types/IGenerateEncryptionKeyOptions.ts | 2 +- .../authentication/IPasskeyCredential.ts | 4 +- 17 files changed, 111 insertions(+), 100 deletions(-) diff --git a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx index f21d04cf..f68ee303 100644 --- a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx +++ b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx @@ -57,7 +57,6 @@ import { useSelectPasskeysSaving, useSelectPasswordLockPassword, useSelectSettings, - useSelectSystemInfo, } from '@extension/selectors'; // theme @@ -84,7 +83,6 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { const passwordLockPassword = useSelectPasswordLockPassword(); const saving = useSelectPasskeysSaving(); const settings = useSelectSettings(); - const systemInfo = useSelectSystemInfo(); // hooks const { addPasskeyAction, @@ -124,8 +122,9 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { const handleEncryptClick = async () => { const _functionName = 'handleEncryptClick'; let _password: string | null; + let success: boolean; - if (!addPasskey || !systemInfo?.deviceID) { + if (!addPasskey) { return; } @@ -153,11 +152,27 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { return; } - await addPasskeyAction({ - deviceID: systemInfo.deviceID, + success = await addPasskeyAction({ password: _password, passkey: addPasskey, }); + + if (success) { + // display a success notification + dispatch( + createNotification({ + description: t('captions.passkeyAdded', { + name: addPasskey.name, + }), + ephemeral: true, + title: t('headings.passkeyAdded'), + type: 'success', + }) + ); + + // close the modal + handleClose(); + } }; const handleKeyUpPasswordInput = async ( event: KeyboardEvent @@ -265,31 +280,29 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { /> {/*user id*/} - {systemInfo?.deviceID && ( - ('labels.userID')}:`} - value={ - - - {systemInfo.deviceID} - - - {/*copy user id button*/} - ('labels.copyUserID')} - tooltipLabel={t('labels.copyUserID')} - value={systemInfo.deviceID} - /> - - } - /> - )} + ('labels.userID')}:`} + value={ + + + {addPasskey.userID} + + + {/*copy user id button*/} + ('labels.copyUserID')} + tooltipLabel={t('labels.copyUserID')} + value={addPasskey.userID} + /> + + } + /> {/*capabilities*/} Promise; + addPasskeyAction: (options: IAddPasskeyActionOptions) => Promise; encryptionProgressState: IEncryptionState[]; encrypting: boolean; error: BaseExtensionError | null; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/useAddPasskey.ts b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/useAddPasskey.ts index fef6ac14..c434fccf 100644 --- a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/useAddPasskey.ts +++ b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/useAddPasskey.ts @@ -43,10 +43,9 @@ export default function useAddPasskey(): IState { const [requesting, setRequesting] = useState(false); // actions const addPasskeyAction = async ({ - deviceID, passkey, password, - }: IAddPasskeyActionOptions) => { + }: IAddPasskeyActionOptions): Promise => { const _functionName = 'addPasskeyAction'; const passwordService = new PasswordService({ logger, @@ -66,7 +65,9 @@ export default function useAddPasskey(): IState { if (!isPasswordValid) { logger?.debug(`${_hookName}#${_functionName}: invalid password`); - return setError(new InvalidPasswordError()); + setError(new InvalidPasswordError()); + + return false; } setRequesting(true); @@ -85,8 +86,9 @@ export default function useAddPasskey(): IState { logger?.debug(`${_hookName}#${_functionName}:`, error); setRequesting(false); + setError(error); - return setError(error); + return false; } setRequesting(false); @@ -111,7 +113,6 @@ export default function useAddPasskey(): IState { privateKeyItems.map(async (privateKeyItem, index) => { const item = await encryptPrivateKeyItemWithDelay({ delay: (index + 1) * 300, // add a staggered delay for the ui to catch up - deviceID, inputKeyMaterial, logger, passkey, @@ -138,8 +139,9 @@ export default function useAddPasskey(): IState { logger?.debug(`${_hookName}#${_functionName}:`, error); setEncrypting(false); + setError(error); - return setError(error); + return false; } // save the new encrypted items to storage @@ -154,6 +156,8 @@ export default function useAddPasskey(): IState { setPasskey(_passkey); setEncrypting(false); + + return true; }; const resetAction = () => { setEncryptionProgressState([]); diff --git a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts index 7a7e80de..f71be219 100644 --- a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts +++ b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts @@ -15,7 +15,6 @@ import type { IEncryptPrivateKeyItemWithDelayOptions } from '../types'; */ export default async function encryptPrivateKeyItemWithDelay({ delay = 0, - deviceID, inputKeyMaterial, logger, passkey, @@ -52,11 +51,8 @@ export default async function encryptPrivateKeyItemWithDelay({ reEncryptedPrivateKey = await PasskeyService.encryptBytes({ bytes: decryptedPrivateKey, - deviceID, inputKeyMaterial, - initializationVector: PasskeyService.decode( - passkey.initializationVector - ), + passkey, logger, }); // re-encrypt the private key with the new password } catch (error) { diff --git a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx index f650c134..ce02e640 100644 --- a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx +++ b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx @@ -39,11 +39,7 @@ import useSubTextColor from '@extension/hooks/useSubTextColor'; import useRemovePasskey from './hooks/useRemovePasskey'; // selectors -import { - useSelectLogger, - useSelectPasskeysSaving, - useSelectSystemInfo, -} from '@extension/selectors'; +import { useSelectLogger, useSelectPasskeysSaving } from '@extension/selectors'; // theme import { theme } from '@extension/theme'; @@ -62,7 +58,6 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { // selectors const logger = useSelectLogger(); const saving = useSelectPasskeysSaving(); - const systemInfo = useSelectSystemInfo(); // hooks const { encrypting, @@ -107,8 +102,9 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { }; const handleRemoveClick = async () => { const _functionName = 'handleRemoveClick'; + let success: boolean; - if (!removePasskey || !systemInfo?.deviceID) { + if (!removePasskey) { return; } @@ -121,11 +117,27 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { return; } - await removePasskeyAction({ - deviceID: systemInfo.deviceID, + success = await removePasskeyAction({ password, passkey: removePasskey, }); + + if (success) { + // display a success notification + dispatch( + createNotification({ + description: t('captions.passkeyRemoved', { + name: removePasskey.name, + }), + ephemeral: true, + title: t('headings.passkeyRemoved'), + type: 'info', + }) + ); + + // close the modal + handleClose(); + } }; // renders const renderContent = () => { @@ -242,12 +254,6 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { } } }, [error]); - // if we have the updated the passkey close the modal - // useEffect(() => { - // if (passkey) { - // handleClose(); - // } - // }, [passkey]); return ( Promise; + removePasskeyAction: ( + options: IRemovePasskeyActionOptions + ) => Promise; encryptionProgressState: IEncryptionState[]; encrypting: boolean; error: BaseExtensionError | null; diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts index 7c8c2058..2809ddaa 100644 --- a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/useRemovePasskey.ts @@ -38,10 +38,9 @@ export default function useRemovePasskey(): IState { const [requesting, setRequesting] = useState(false); // actions const removePasskeyAction = async ({ - deviceID, passkey, password, - }: IRemovePasskeyActionOptions) => { + }: IRemovePasskeyActionOptions): Promise => { const _functionName = 'removePasskeyAction'; const passwordService = new PasswordService({ logger, @@ -60,7 +59,9 @@ export default function useRemovePasskey(): IState { if (!isPasswordValid) { logger?.debug(`${_hookName}#${_functionName}: invalid password`); - return setError(new InvalidPasswordError()); + setError(new InvalidPasswordError()); + + return false; } setRequesting(true); @@ -79,8 +80,9 @@ export default function useRemovePasskey(): IState { logger?.debug(`${_hookName}#${_functionName}:`, error); setRequesting(false); + setError(error); - return setError(error); + return false; } setRequesting(false); @@ -105,7 +107,6 @@ export default function useRemovePasskey(): IState { privateKeyItems.map(async (privateKeyItem, index) => { const item = await encryptPrivateKeyItemAndDelay({ delay: (index + 1) * 300, // add a staggered delay for the ui to catch up - deviceID, inputKeyMaterial, logger, passkey, @@ -132,8 +133,9 @@ export default function useRemovePasskey(): IState { logger?.debug(`${_hookName}#${_functionName}:`, error); setEncrypting(false); + setError(error); - return setError(error); + return error; } // save the new encrypted items to storage @@ -147,6 +149,8 @@ export default function useRemovePasskey(): IState { ); setEncrypting(false); + + return true; }; const resetAction = () => { setEncryptionProgressState([]); diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts index 4d589539..5a04ab13 100644 --- a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts @@ -15,7 +15,6 @@ import type { IEncryptPrivateKeyItemWithDelayOptions } from '../types'; */ export default async function encryptPrivateKeyItemAndDelay({ delay = 0, - deviceID, inputKeyMaterial, logger, passkey, @@ -32,14 +31,11 @@ export default async function encryptPrivateKeyItemAndDelay({ try { decryptedPrivateKey = await PasskeyService.decryptBytes({ - deviceID, encryptedBytes: PrivateKeyService.decode( privateKeyItem.encryptedPrivateKey ), inputKeyMaterial, - initializationVector: PasskeyService.decode( - passkey.initializationVector - ), + passkey, logger, }); // decrypt the private key with the passkey diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index 3b7ab57f..6e357d59 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -24,10 +24,7 @@ import { import StorageManager from '@extension/services/StorageManager'; // types -import type { - IAuthenticationExtensionsClientOutputs, - ILogger, -} from '@common/types'; +import type { IAuthenticationExtensionsClientOutputs } from '@common/types'; import type { IPasskeyCredential } from '@extension/types'; import type { ICreatePasskeyCredentialOptions, @@ -40,11 +37,9 @@ import type { export default class PasskeyService { // private variables - private readonly logger: ILogger | null; private readonly storageManager: StorageManager; constructor(options?: INewOptions) { - this.logger = options?.logger || null; this.storageManager = options?.storageManager || new StorageManager(); } @@ -55,15 +50,15 @@ export default class PasskeyService { /** * Generates an encryption key that can be used to decrypt/encrypt bytes. This function imports the key using the * input key material rom the passkey. - * @param {IGenerateEncryptionKeyOptions} options - the device ID and input key material from the passkey. + * @param {IGenerateEncryptionKeyOptions} options - the user ID and the input key material from the passkey. * @returns {Promise} a promise that resolves to an encryption key that can be used to decrypt/encrypt * some bytes. * @private * @static */ private static async _generateEncryptionKeyFromInputKeyMaterial({ - deviceID, inputKeyMaterial, + userID, }: IGenerateEncryptionKeyOptions): Promise { const derivationKey = await crypto.subtle.importKey( 'raw', @@ -76,7 +71,7 @@ export default class PasskeyService { return await crypto.subtle.deriveKey( { name: DERIVATION_KEY_ALGORITHM, - info: new TextEncoder().encode(deviceID), // + info: new TextEncoder().encode(userID), salt: new Uint8Array(), // use an empty salt hash: DERIVATION_KEY_HASH_ALGORITHM, }, @@ -193,6 +188,7 @@ export default class PasskeyService { transports: ( credential.response as AuthenticatorAttestationResponse ).getTransports() as AuthenticatorTransport[], + userID: deviceID, }; } @@ -209,27 +205,26 @@ export default class PasskeyService { /** * Decrypts some previously encrypted bytes using the input key material fetched from a passkey. - * @param {IDecryptBytesOptions} options - the encrypted bytes, the initialization vector created at the passkey - * creation, the device ID and the input key material fetched from the passkey. + * @param {IDecryptBytesOptions} options - the encrypted bytes, the passkey credentials and the input key material + * fetched from the passkey. * @returns {Promise} a promise that resolves to the decrypted bytes. * @public * @static */ public static async decryptBytes({ - deviceID, encryptedBytes, - initializationVector, inputKeyMaterial, + passkey, }: IDecryptBytesOptions): Promise { const encryptionKey = await PasskeyService._generateEncryptionKeyFromInputKeyMaterial({ - deviceID, inputKeyMaterial, + userID: passkey.userID, }); const decryptedBytes = await crypto.subtle.decrypt( { name: ENCRYPTION_KEY_ALGORITHM, - iv: initializationVector, + iv: PasskeyService.decode(passkey.initializationVector), }, encryptionKey, encryptedBytes @@ -260,19 +255,18 @@ export default class PasskeyService { */ public static async encryptBytes({ bytes, - deviceID, - initializationVector, inputKeyMaterial, + passkey, }: IEncryptBytesOptions): Promise { const encryptionKey = await PasskeyService._generateEncryptionKeyFromInputKeyMaterial({ - deviceID, inputKeyMaterial, + userID: passkey.userID, }); const encryptedBytes = await crypto.subtle.encrypt( { name: ENCRYPTION_KEY_ALGORITHM, - iv: initializationVector, + iv: PasskeyService.decode(passkey.initializationVector), }, encryptionKey, bytes diff --git a/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts b/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts index 6bbbd616..0045399d 100644 --- a/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts +++ b/src/extension/services/PasskeyService/types/IDecryptBytesOptions.ts @@ -1,11 +1,11 @@ // types import type { IBaseOptions } from '@common/types'; +import type { IPasskeyCredential } from '@extension/types'; interface IDecryptBytesOptions extends IBaseOptions { - deviceID: string; encryptedBytes: Uint8Array; - initializationVector: Uint8Array; inputKeyMaterial: Uint8Array; + passkey: IPasskeyCredential; } export default IDecryptBytesOptions; diff --git a/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts b/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts index c06e97ae..32b25b21 100644 --- a/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts +++ b/src/extension/services/PasskeyService/types/IEncryptBytesOptions.ts @@ -1,11 +1,11 @@ // types import type { IBaseOptions } from '@common/types'; +import type { IPasskeyCredential } from '@extension/types'; interface IEncryptBytesOptions extends IBaseOptions { bytes: Uint8Array; - deviceID: string; - initializationVector: Uint8Array; inputKeyMaterial: Uint8Array; + passkey: IPasskeyCredential; } export default IEncryptBytesOptions; diff --git a/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts b/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts index 2355fc1e..1ada8382 100644 --- a/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts +++ b/src/extension/services/PasskeyService/types/IGenerateEncryptionKeyOptions.ts @@ -1,6 +1,6 @@ interface IGenerateEncryptionKeyOptions { - deviceID: string; inputKeyMaterial: Uint8Array; + userID: string; } export default IGenerateEncryptionKeyOptions; diff --git a/src/extension/types/authentication/IPasskeyCredential.ts b/src/extension/types/authentication/IPasskeyCredential.ts index 56657ee2..0041a2b1 100644 --- a/src/extension/types/authentication/IPasskeyCredential.ts +++ b/src/extension/types/authentication/IPasskeyCredential.ts @@ -2,13 +2,14 @@ * @property {string} algorithm - a number that is equal to a * {@link https://www.iana.org/assignments/cose/cose.xhtml#algorithms COSE Algorithm Identifier}, representing the * cryptographic algorithm used for the new credential. - * @property {string} id - the hexadecimal encoded ID of the passkey. + * @property {string} id - the hexadecimal encoded ID of the passkey credential. * @property {string} initializationVector - a hexadecimal encoded initialization vector used in the derivation of the * encryption key. * @property {string} name - the name given to this passkey. * @property {string | null} publicKey - the hexadecimal encoded public key of the passkey. * @property {string} salt - the hexadecimal encoded salt used in creation of the passkey. * @property {AuthenticatorTransport[]} transports - the transports of the passkey that were determined at creation. + * @property {string} userID - the ID of the user. This should be the unique device (extension) ID. */ interface IPasskeyCredential { algorithm: number; @@ -18,6 +19,7 @@ interface IPasskeyCredential { publicKey: string | null; salt: string; transports: AuthenticatorTransport[]; + userID: string; } export default IPasskeyCredential; From 53b043180c61dd3c6dce6461304ddbf473addc54 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 10 Jul 2024 20:45:58 +0100 Subject: [PATCH 19/31] feat: add encryption method to provate key items --- src/extension/enums/EncryptionMethodEnum.ts | 6 ++++ src/extension/enums/index.ts | 1 + .../thunks/saveCredentialsThunk.ts | 4 ++- .../useChangePassword/useChangePassword.ts | 4 +-- ...y.ts => encryptPrivateKeyItemWithDelay.ts} | 2 +- .../hooks/useChangePassword/utils/index.ts | 2 +- .../utils/encryptPrivateKeyItemWithDelay.ts | 5 +++ .../utils/encryptPrivateKeyItemAndDelay.ts | 27 +++++++++++++++- .../PrivateKeyService/PrivateKeyService.ts | 32 ++++++++++++++----- .../types/ICreatePrivateKeyOptions.ts | 6 +++- .../types/authentication/IPrivateKey.ts | 19 ++++++++++- .../savePrivateKeyItemWithPassword.ts | 6 +++- 12 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 src/extension/enums/EncryptionMethodEnum.ts rename src/extension/hooks/useChangePassword/utils/{reEncryptPrivateKeyItemWithDelay.ts => encryptPrivateKeyItemWithDelay.ts} (96%) diff --git a/src/extension/enums/EncryptionMethodEnum.ts b/src/extension/enums/EncryptionMethodEnum.ts new file mode 100644 index 00000000..0ba22ac7 --- /dev/null +++ b/src/extension/enums/EncryptionMethodEnum.ts @@ -0,0 +1,6 @@ +enum EncryptionMethodEnum { + Passkey = 'passkey', + Password = 'password', +} + +export default EncryptionMethodEnum; diff --git a/src/extension/enums/index.ts b/src/extension/enums/index.ts index e2698e6d..fdc34b43 100644 --- a/src/extension/enums/index.ts +++ b/src/extension/enums/index.ts @@ -9,6 +9,7 @@ export { default as ARC0300EncodingEnum } from './ARC0300EncodingEnum'; export { default as ARC0300PathEnum } from './ARC0300PathEnum'; export { default as ARC0300QueryEnum } from './ARC0300QueryEnum'; export { default as AssetTypeEnum } from './AssetTypeEnum'; +export { default as EncryptionMethodEnum } from './EncryptionMethodEnum'; export { default as EventTypeEnum } from './EventTypeEnum'; export { default as ErrorCodeEnum } from './ErrorCodeEnum'; export { default as EventsThunkEnum } from './EventsThunkEnum'; diff --git a/src/extension/features/registration/thunks/saveCredentialsThunk.ts b/src/extension/features/registration/thunks/saveCredentialsThunk.ts index 747a6dbc..28ec5f06 100644 --- a/src/extension/features/registration/thunks/saveCredentialsThunk.ts +++ b/src/extension/features/registration/thunks/saveCredentialsThunk.ts @@ -3,6 +3,7 @@ import { encode as encodeUtf8 } from '@stablelib/utf8'; import browser from 'webextension-polyfill'; // enums +import { EncryptionMethodEnum } from '@extension/enums'; import { ThunkEnum } from '../enums'; // errors @@ -97,7 +98,8 @@ const saveCredentialsThunk: AsyncThunk< logger, password, }), - passwordTagId: passwordTagItem.id, + encryptionID: passwordTagItem.id, + encryptionMethod: EncryptionMethodEnum.Password, publicKey: keyPair.publicKey, }) ); diff --git a/src/extension/hooks/useChangePassword/useChangePassword.ts b/src/extension/hooks/useChangePassword/useChangePassword.ts index f210a63c..68329ab8 100644 --- a/src/extension/hooks/useChangePassword/useChangePassword.ts +++ b/src/extension/hooks/useChangePassword/useChangePassword.ts @@ -22,7 +22,7 @@ import type { IPasswordTag, IPrivateKey } from '@extension/types'; import { IChangePasswordActionOptions, IUseChangePasswordState } from './types'; // utils -import { reEncryptPrivateKeyItemWithDelay } from './utils'; +import { encryptPrivateKeyItemWithDelay } from './utils'; export default function useChangePassword(): IUseChangePasswordState { const _hookName = 'useChangePassword'; @@ -128,7 +128,7 @@ export default function useChangePassword(): IUseChangePasswordState { try { privateKeyItems = await Promise.all( privateKeyItems.map(async (privateKeyItem, index) => { - const item = await reEncryptPrivateKeyItemWithDelay({ + const item = await encryptPrivateKeyItemWithDelay({ currentPassword, delay: (index + 1) * 300, // add a staggered delay for the ui to catch up logger, diff --git a/src/extension/hooks/useChangePassword/utils/reEncryptPrivateKeyItemWithDelay.ts b/src/extension/hooks/useChangePassword/utils/encryptPrivateKeyItemWithDelay.ts similarity index 96% rename from src/extension/hooks/useChangePassword/utils/reEncryptPrivateKeyItemWithDelay.ts rename to src/extension/hooks/useChangePassword/utils/encryptPrivateKeyItemWithDelay.ts index d1d8148b..66d353be 100644 --- a/src/extension/hooks/useChangePassword/utils/reEncryptPrivateKeyItemWithDelay.ts +++ b/src/extension/hooks/useChangePassword/utils/encryptPrivateKeyItemWithDelay.ts @@ -6,7 +6,7 @@ import PrivateKeyService from '@extension/services/PrivateKeyService'; import type { IPrivateKey } from '@extension/types'; import type { IReEncryptPrivateKeyItemWithDelayOptions } from '../types'; -export default async function reEncryptPrivateKeyItemWithDelay({ +export default async function encryptPrivateKeyItemWithDelay({ currentPassword, delay = 0, logger, diff --git a/src/extension/hooks/useChangePassword/utils/index.ts b/src/extension/hooks/useChangePassword/utils/index.ts index 0356b5c8..758b9fa5 100644 --- a/src/extension/hooks/useChangePassword/utils/index.ts +++ b/src/extension/hooks/useChangePassword/utils/index.ts @@ -1 +1 @@ -export { default as reEncryptPrivateKeyItemWithDelay } from './reEncryptPrivateKeyItemWithDelay'; +export { default as encryptPrivateKeyItemWithDelay } from './encryptPrivateKeyItemWithDelay'; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts index f71be219..8575eb04 100644 --- a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts +++ b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/utils/encryptPrivateKeyItemWithDelay.ts @@ -1,3 +1,6 @@ +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // services import PasskeyService from '@extension/services/PasskeyService'; import PasswordService from '@extension/services/PasswordService'; @@ -62,6 +65,8 @@ export default async function encryptPrivateKeyItemWithDelay({ return resolve({ ...privateKeyItem, encryptedPrivateKey: PrivateKeyService.encode(reEncryptedPrivateKey), + encryptionID: passkey.id, + encryptionMethod: EncryptionMethodEnum.Passkey, updatedAt: new Date().getTime(), version, }); diff --git a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts index 5a04ab13..91f5a290 100644 --- a/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts +++ b/src/extension/modals/RemovePasskeyModal/hooks/useRemovePasskey/utils/encryptPrivateKeyItemAndDelay.ts @@ -1,10 +1,18 @@ +import browser from 'webextension-polyfill'; + +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +// errors +import { MalformedDataError } from '@extension/errors'; + // services import PasskeyService from '@extension/services/PasskeyService'; import PasswordService from '@extension/services/PasswordService'; import PrivateKeyService from '@extension/services/PrivateKeyService'; // types -import type { IPrivateKey } from '@extension/types'; +import type { IPasswordTag, IPrivateKey } from '@extension/types'; import type { IEncryptPrivateKeyItemWithDelayOptions } from '../types'; /** @@ -25,7 +33,13 @@ export default async function encryptPrivateKeyItemAndDelay({ return new Promise((resolve, reject) => { setTimeout(async () => { + const passwordService = new PasswordService({ + logger, + passwordTag: browser.runtime.id, + }); + let _error: string; let decryptedPrivateKey: Uint8Array; + let passwordTagItem: IPasswordTag | null; let reEncryptedPrivateKey: Uint8Array; let version: number = privateKeyItem.version; @@ -57,6 +71,15 @@ export default async function encryptPrivateKeyItemAndDelay({ logger, password, }); // re-encrypt the private key with the password + passwordTagItem = await passwordService.fetchFromStorage(); + + if (!passwordTagItem) { + _error = `failed to get password tag from storage, doesn't exist`; + + logger?.error(`${_functionName}: ${_error}`); + + throw new MalformedDataError(_error); + } } catch (error) { return reject(error); } @@ -64,6 +87,8 @@ export default async function encryptPrivateKeyItemAndDelay({ return resolve({ ...privateKeyItem, encryptedPrivateKey: PrivateKeyService.encode(reEncryptedPrivateKey), + encryptionID: passwordTagItem.id, + encryptionMethod: EncryptionMethodEnum.Password, updatedAt: new Date().getTime(), version, }); diff --git a/src/extension/services/PrivateKeyService/PrivateKeyService.ts b/src/extension/services/PrivateKeyService/PrivateKeyService.ts index 53bf2766..3f901331 100644 --- a/src/extension/services/PrivateKeyService/PrivateKeyService.ts +++ b/src/extension/services/PrivateKeyService/PrivateKeyService.ts @@ -1,5 +1,5 @@ import { decode as decodeHex, encode as encodeHex } from '@stablelib/hex'; -import { sign, SignKeyPair } from 'tweetnacl'; +import { sign } from 'tweetnacl'; import { v4 as uuid } from 'uuid'; // constants @@ -11,6 +11,7 @@ import StorageManager from '../StorageManager'; // types import type { IPrivateKey } from '@extension/types'; import type { ICreatePrivateKeyOptions, INewOptions } from './types'; +import { EncryptionMethodEnum } from '@extension/enums'; // @@ -23,6 +24,7 @@ import type { ICreatePrivateKeyOptions, INewOptions } from './types'; * @version 1: * * The `encryptedPrivateKey` property is replaced with the actual private key (seed) rather than the "secret key" * (private key concentrated to the public key). + * * `passwordTagId` has been replaced with `encryptionID` and `encryptionMethod` */ export default class PrivateKeyService { // public static variables @@ -41,15 +43,16 @@ export default class PrivateKeyService { /** * Convenience function that creates a new private key item. - * @param {ICreatePrivateKeyOptions} options - the raw encrypted private key, the raw public key and the password tag - * used to encrypt the private key. + * @param {ICreatePrivateKeyOptions} options - the raw encrypted private key, the raw public key and the encryption + * method & ID. * @returns {IPrivateKey} an initialized private key item. * @public * @static */ public static createPrivateKey({ encryptedPrivateKey, - passwordTagId, + encryptionID, + encryptionMethod, publicKey, }: ICreatePrivateKeyOptions): IPrivateKey { const now = new Date(); @@ -57,8 +60,9 @@ export default class PrivateKeyService { return { createdAt: now.getTime(), encryptedPrivateKey: PrivateKeyService.encode(encryptedPrivateKey), + encryptionID, + encryptionMethod, id: uuid(), - passwordTagId, publicKey: PrivateKeyService.encode(publicKey), updatedAt: now.getTime(), version: PrivateKeyService.latestVersion, @@ -129,19 +133,33 @@ export default class PrivateKeyService { private _sanitize({ createdAt, encryptedPrivateKey, + encryptionID, id, passwordTagId, publicKey, + encryptionMethod, updatedAt, version, }: IPrivateKey): IPrivateKey { const _version = !version ? 0 : version; // if there is no version, start at zero (legacy) + let _encryptionID = encryptionID; + let _encryptionMethod = encryptionMethod; + + // if there is a password tag id, this means it is using the old style, replace it with the new properties (v1+) + if ( + passwordTagId && + (!encryptionID || encryptionMethod !== EncryptionMethodEnum.Passkey) + ) { + _encryptionID = passwordTagId; + _encryptionMethod = EncryptionMethodEnum.Password; + } return { createdAt, id, encryptedPrivateKey, - passwordTagId, + encryptionID: _encryptionID, + encryptionMethod: _encryptionMethod, publicKey, updatedAt, version: _version, @@ -186,8 +204,6 @@ export default class PrivateKeyService { this._createPrivateKeyItemKey(_publicKey.toUpperCase()) ); - console.log('private key:', item ? this._sanitize(item) : null); - return item ? this._sanitize(item) : null; } diff --git a/src/extension/services/PrivateKeyService/types/ICreatePrivateKeyOptions.ts b/src/extension/services/PrivateKeyService/types/ICreatePrivateKeyOptions.ts index be1463b9..8fc7f2d5 100644 --- a/src/extension/services/PrivateKeyService/types/ICreatePrivateKeyOptions.ts +++ b/src/extension/services/PrivateKeyService/types/ICreatePrivateKeyOptions.ts @@ -1,6 +1,10 @@ +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + interface ICreatePrivateKeyOptions { encryptedPrivateKey: Uint8Array; - passwordTagId: string; + encryptionID: string; + encryptionMethod: EncryptionMethodEnum; publicKey: Uint8Array; } diff --git a/src/extension/types/authentication/IPrivateKey.ts b/src/extension/types/authentication/IPrivateKey.ts index 7d9016bc..12d6ea93 100644 --- a/src/extension/types/authentication/IPrivateKey.ts +++ b/src/extension/types/authentication/IPrivateKey.ts @@ -1,11 +1,28 @@ +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +/** + * @property {number} createdAt - the time in milliseconds since the UNIX epoch for when the resource was created. + * @property {string} encryptedPrivateKey - the hexadecimal encoded encrypted private key. + * @property {string} encryptionID - the ID of the encryption method. For 'password' encryption, this will be the ID of + * the saved password tag, for 'passkey' this will be the passkey credential ID. + * @property {EncryptionMethodEnum} encryptionMethod - the encryption method used to encrypt the private key. + * @property {string} id - a unique v4 UUID compliant string. + * @property {string} publicKey - the hexadecimal encoded public key. + * @property {number} updatedAt - the time in milliseconds since the UNIX epoch for when the resource was updated. + * @property {number} version - the version of this resource. + */ interface IPrivateKey { createdAt: number; encryptedPrivateKey: string; + encryptionID: string; + encryptionMethod: EncryptionMethodEnum; id: string; - passwordTagId: string; publicKey: string; updatedAt: number; version: number; + /** @deprecated `passwordTagId` no longer in use in favour of `encryptionID` & `method` since v1+ **/ + passwordTagId?: string; } export default IPrivateKey; diff --git a/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts b/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts index 18252a58..63ad0a22 100644 --- a/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts +++ b/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts @@ -1,5 +1,8 @@ import browser from 'webextension-polyfill'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // errors import { InvalidPasswordError, MalformedDataError } from '@extension/errors'; @@ -81,7 +84,8 @@ export default async function savePrivateKeyItemWithPassword({ privateKeyItem = await _privateKeyService.saveToStorage( PrivateKeyService.createPrivateKey({ encryptedPrivateKey, - passwordTagId: passwordTagItem.id, + encryptionID: passwordTagItem.id, + encryptionMethod: EncryptionMethodEnum.Password, publicKey: keyPair.publicKey, }) ); From ad6ed25a8a482ab927cf1541a631cb98523b0cfc Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Thu, 11 Jul 2024 14:45:56 +0100 Subject: [PATCH 20/31] feat: create new authentication modal that handles password and passkey authentication --- .../AuthenticationModal.tsx | 277 ++++++++++++++++++ .../modals/AuthenticationModal/index.ts | 2 + .../AuthenticationModal/types/IProps.ts | 15 + .../types/TOnConfirmResult.ts | 14 + .../modals/AuthenticationModal/types/index.ts | 2 + .../ConfirmPasswordModal.tsx | 3 +- .../SignTransactionsModal.tsx | 201 +++++++------ .../signTransactions/signTransactions.ts | 15 +- .../types/{IOptions.ts => TOptions.ts} | 19 +- .../utils/signTransactions/types/index.ts | 2 +- ...hDecryptedKeyPairFromStorageWithPasskey.ts | 103 +++++++ .../index.ts | 2 + .../types/IOptions.ts | 22 ++ .../types/index.ts | 1 + ...DecryptedKeyPairFromStorageWithPassword.ts | 2 +- .../utils/signTransaction/signTransaction.ts | 40 ++- .../types/{IOptions.ts => TOptions.ts} | 19 +- .../utils/signTransaction/types/index.ts | 2 +- 18 files changed, 616 insertions(+), 125 deletions(-) create mode 100644 src/extension/modals/AuthenticationModal/AuthenticationModal.tsx create mode 100644 src/extension/modals/AuthenticationModal/index.ts create mode 100644 src/extension/modals/AuthenticationModal/types/IProps.ts create mode 100644 src/extension/modals/AuthenticationModal/types/TOnConfirmResult.ts create mode 100644 src/extension/modals/AuthenticationModal/types/index.ts rename src/extension/modals/SignTransactionsModal/utils/signTransactions/types/{IOptions.ts => TOptions.ts} (68%) create mode 100644 src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/fetchDecryptedKeyPairFromStorageWithPasskey.ts create mode 100644 src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/index.ts create mode 100644 src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/IOptions.ts create mode 100644 src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/index.ts rename src/extension/utils/signTransaction/types/{IOptions.ts => TOptions.ts} (68%) diff --git a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx new file mode 100644 index 00000000..e501aa4d --- /dev/null +++ b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx @@ -0,0 +1,277 @@ +import { + HStack, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalOverlay, + Text, + VStack, +} from '@chakra-ui/react'; +import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Radio } from 'react-loader-spinner'; +import browser from 'webextension-polyfill'; + +// components +import Button from '@extension/components/Button'; +import PasswordInput, { + usePassword, +} from '@extension/components/PasswordInput'; + +// constants +import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; + +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +// errors +import { MalformedDataError } from '@extension/errors'; + +// hooks +import useColorModeValue from '@extension/hooks/useColorModeValue'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// selectors +import { + useSelectLogger, + useSelectPasskeysPasskey, +} from '@extension/selectors'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PasswordService from '@extension/services/PasswordService'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { IProps } from './types'; + +const AuthenticationModal: FC = ({ + isOpen, + onCancel, + onConfirm, + onError, + passwordHint, +}) => { + const { t } = useTranslation(); + const passwordInputRef = useRef(null); + // selectors + const logger = useSelectLogger(); + const passkey = useSelectPasskeysPasskey(); + // hooks + const primaryColorCode = useColorModeValue( + theme.colors.primaryLight['500'], + theme.colors.primaryDark['500'] + ); + const { + error: passwordError, + onChange: onPasswordChange, + reset: resetPassword, + setError: setPasswordError, + validate: validatePassword, + value: password, + } = usePassword(); + const subTextColor = useSubTextColor(); + // states + const [verifying, setVerifying] = useState(false); + // misc + const reset = () => { + resetPassword(); + setVerifying(false); + }; + // handlers + const handleCancelClick = () => handleClose(); + const handleConfirmClick = async () => { + let isValid: boolean; + let passwordService: PasswordService; + + // check if the input is valid + if (validatePassword()) { + return; + } + + passwordService = new PasswordService({ + logger, + passwordTag: browser.runtime.id, + }); + + setVerifying(true); + + isValid = await passwordService.verifyPassword(password); + + setVerifying(false); + + if (!isValid) { + setPasswordError(t('errors.inputs.invalidPassword')); + + return; + } + + onConfirm({ + password, + type: EncryptionMethodEnum.Password, + }); + + // clean up + reset(); + }; + const handleClose = () => { + onCancel(); + reset(); // clean up + }; + const handleKeyUpPasswordInput = async ( + event: KeyboardEvent + ) => { + if (event.key === 'Enter') { + await handleConfirmClick(); + } + }; + + // set focus when opening + useEffect(() => { + if (passwordInputRef.current) { + passwordInputRef.current.focus(); + } + }, []); + useEffect(() => { + if (!passkey) { + return; + } + + (async () => { + let _error: string; + let inputKeyMaterial: Uint8Array; + + if (!passkey) { + _error = `no passkey found`; + + logger.error(`${AuthenticationModal.name}#useEffect: ${_error}`); + + return onError && onError(new MalformedDataError(_error)); + } + + try { + // fetch the encryption key material + inputKeyMaterial = + await PasskeyService.fetchInputKeyMaterialFromPasskey({ + credential: passkey, + logger, + }); + + onConfirm({ + inputKeyMaterial, + type: EncryptionMethodEnum.Passkey, + }); + + // clean up + return reset(); + } catch (error) { + logger.error(`${AuthenticationModal.name}#useEffect:`, error); + + return onError && onError(error); + } + })(); + }, [passkey]); + + return ( + + + + + {/*content*/} + + {passkey ? ( + // passkey + + {/*passkey loader*/} + + + {/*caption*/} + + {t('captions.requestingPasskeyPermission', { + name: passkey?.name || 'unknown', + })} + + + ) : ( + // password + + ('captions.mustEnterPasswordToConfirm') + } + inputRef={passwordInputRef} + onChange={onPasswordChange} + onKeyUp={handleKeyUpPasswordInput} + value={password || ''} + /> + + )} + + + {/*footer*/} + {!passkey && ( + + + + + + + + )} + + + ); +}; + +export default AuthenticationModal; diff --git a/src/extension/modals/AuthenticationModal/index.ts b/src/extension/modals/AuthenticationModal/index.ts new file mode 100644 index 00000000..6aa58ab4 --- /dev/null +++ b/src/extension/modals/AuthenticationModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './AuthenticationModal'; +export * from './types'; diff --git a/src/extension/modals/AuthenticationModal/types/IProps.ts b/src/extension/modals/AuthenticationModal/types/IProps.ts new file mode 100644 index 00000000..74bb0e3c --- /dev/null +++ b/src/extension/modals/AuthenticationModal/types/IProps.ts @@ -0,0 +1,15 @@ +// errors +import { BaseExtensionError } from '@extension/errors'; + +// types +import TOnConfirmResult from './TOnConfirmResult'; + +interface IProps { + isOpen: boolean; + passwordHint?: string; + onCancel: () => void; + onConfirm: (result: TOnConfirmResult) => void; + onError?: (error: BaseExtensionError) => void; +} + +export default IProps; diff --git a/src/extension/modals/AuthenticationModal/types/TOnConfirmResult.ts b/src/extension/modals/AuthenticationModal/types/TOnConfirmResult.ts new file mode 100644 index 00000000..0e08d9a7 --- /dev/null +++ b/src/extension/modals/AuthenticationModal/types/TOnConfirmResult.ts @@ -0,0 +1,14 @@ +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +type TOnConfirmResult = + | { + password: string; + type: EncryptionMethodEnum.Password; + } + | { + inputKeyMaterial: Uint8Array; + type: EncryptionMethodEnum.Passkey; + }; + +export default TOnConfirmResult; diff --git a/src/extension/modals/AuthenticationModal/types/index.ts b/src/extension/modals/AuthenticationModal/types/index.ts new file mode 100644 index 00000000..84e7f139 --- /dev/null +++ b/src/extension/modals/AuthenticationModal/types/index.ts @@ -0,0 +1,2 @@ +export type { default as IProps } from './IProps'; +export type { default as TOnConfirmResult } from './TOnConfirmResult'; diff --git a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx index 2bde6f56..0af58dc5 100644 --- a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx +++ b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx @@ -114,6 +114,7 @@ const ConfirmPasswordModal: FC = ({ return ( = ({ {/*footer*/} - + - - - - + + + + ); }; diff --git a/src/extension/modals/SignTransactionsModal/utils/signTransactions/signTransactions.ts b/src/extension/modals/SignTransactionsModal/utils/signTransactions/signTransactions.ts index ad218d67..9aed3f78 100644 --- a/src/extension/modals/SignTransactionsModal/utils/signTransactions/signTransactions.ts +++ b/src/extension/modals/SignTransactionsModal/utils/signTransactions/signTransactions.ts @@ -3,7 +3,7 @@ import { decode as decodeBase64, encode as encodeBase64, } from '@stablelib/base64'; -import { encodeAddress, Transaction } from 'algosdk'; +import type { Transaction } from 'algosdk'; // errors import { MalformedDataError } from '@extension/errors'; @@ -12,9 +12,10 @@ import { MalformedDataError } from '@extension/errors'; import PrivateKeyService from '@extension/services/PrivateKeyService'; // types -import type { IOptions } from './types'; +import type { TOptions } from './types'; // utils +import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; import decodeUnsignedTransaction from '@extension/utils/decodeUnsignedTransaction'; import signTransaction from '@extension/utils/signTransaction'; @@ -32,8 +33,8 @@ export default async function signTransactions({ authAccounts, logger, networks, - password, -}: IOptions): Promise<(string | null)[]> { + ...encryptionOptions +}: TOptions): Promise<(string | null)[]> { const _functionName: string = 'signTransactions'; return await Promise.all( @@ -55,7 +56,9 @@ export default async function signTransactions({ } try { - signerAddress = encodeAddress(unsignedTransaction.from.publicKey); + signerAddress = convertPublicKeyToAVMAddress( + unsignedTransaction.from.publicKey + ); } catch (error) { logger?.error(`${_functionName}: ${error.message}`); @@ -89,11 +92,11 @@ export default async function signTransactions({ try { signedTransaction = await signTransaction({ + ...encryptionOptions, accounts, authAccounts, logger, networks, - password, unsignedTransaction, }); diff --git a/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/IOptions.ts b/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts similarity index 68% rename from src/extension/modals/SignTransactionsModal/utils/signTransactions/types/IOptions.ts rename to src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts index 195e8129..25c63ed0 100644 --- a/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/IOptions.ts +++ b/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts @@ -1,5 +1,8 @@ import type { IARC0001Transaction } from '@agoralabs-sh/avm-web-provider'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // types import type { IBaseOptions } from '@common/types'; import type { IAccountWithExtendedProps, INetwork } from '@extension/types'; @@ -9,14 +12,24 @@ import type { IAccountWithExtendedProps, INetwork } from '@extension/types'; * @property {IAccountWithExtendedProps[]} authAccounts - [optional] a list of auth accounts that can sign the transaction for * re-keyed accounts. * @property {IARC0001Transaction[]} arc0001Transactions - the transactions to be signed. - * @property {string} password - the password that was used to encrypt the private key. */ interface IOptions extends IBaseOptions { accounts: IAccountWithExtendedProps[]; arc0001Transactions: IARC0001Transaction[]; authAccounts: IAccountWithExtendedProps[]; networks: INetwork[]; - password: string; } -export default IOptions; +type TEncryptionOptions = + | { + password: string; + type: EncryptionMethodEnum.Password; + } + | { + inputKeyMaterial: Uint8Array; + type: EncryptionMethodEnum.Passkey; + }; + +type TOptions = IOptions & TEncryptionOptions; + +export default TOptions; diff --git a/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/index.ts b/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/index.ts index 68e70016..b9eb0637 100644 --- a/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/index.ts +++ b/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/index.ts @@ -1 +1 @@ -export type { default as IOptions } from './IOptions'; +export type { default as TOptions } from './TOptions'; diff --git a/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/fetchDecryptedKeyPairFromStorageWithPasskey.ts b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/fetchDecryptedKeyPairFromStorageWithPasskey.ts new file mode 100644 index 00000000..0f5ca037 --- /dev/null +++ b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/fetchDecryptedKeyPairFromStorageWithPasskey.ts @@ -0,0 +1,103 @@ +// errors +import { MalformedDataError } from '@extension/errors'; + +// models +import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PrivateKeyService from '@extension/services/PrivateKeyService'; + +// types +import type { IPasskeyCredential, IPrivateKey } from '@extension/types'; +import type { IOptions } from './types'; + +/** + * Convenience function that fetches the private key from storage and converts it to a key pair using the passkey. + * @param {IOptions} options - the passkey input key material and the public key. + * @returns {Promise} a promise that resolves to the key pair or null if there was no private key + * associated with the public key in storage. + * @throws {MalformedDataError} if no passkey exists. + * @throws {DecryptionError} if the private key failed to be decrypted with the supplied passkey. + */ +export default async function fetchDecryptedKeyPairFromStorageWithPasskey({ + inputKeyMaterial, + logger, + passkeyService, + privateKeyService, + publicKey, +}: IOptions): Promise { + const _functionName = 'fetchDecryptedKeyPairFromStorageWithPasskey'; + const _passkeyService = + passkeyService || + new PasskeyService({ + logger, + }); + const _privateKeyService = + privateKeyService || + new PrivateKeyService({ + logger, + }); + let _error: string; + let _publicKey: string; + let decryptedPrivateKey: Uint8Array; + let passkey: IPasskeyCredential | null; + let privateKeyItem: IPrivateKey | null; + + _publicKey = + typeof publicKey !== 'string' + ? PrivateKeyService.encode(publicKey) + : publicKey; // encode the public key if it isn't already + privateKeyItem = await _privateKeyService.fetchFromStorageByPublicKey( + _publicKey + ); + + if (!privateKeyItem) { + logger?.debug( + `${_functionName}: no private key stored for public key "${_publicKey}"` + ); + + return null; + } + + logger?.debug( + `${_functionName}: decrypting private key for public key "${_publicKey}"` + ); + + passkey = await _passkeyService.fetchFromStorage(); + + if (!passkey) { + _error = `no passkey found in storage`; + + logger?.error(`${_functionName}: ${_error}`); + + throw new MalformedDataError(_error); + } + + // this is the legacy version, we need to convert the "secret key" to a private key + if (privateKeyItem.version <= 0) { + decryptedPrivateKey = await PasskeyService.decryptBytes({ + encryptedBytes: PrivateKeyService.decode( + privateKeyItem.encryptedPrivateKey + ), + inputKeyMaterial, + logger, + passkey, + }); + + return Ed21559KeyPair.generateFromPrivateKey( + PrivateKeyService.extractPrivateKeyFromSecretKey(decryptedPrivateKey) + ); + } + + decryptedPrivateKey = await PasskeyService.decryptBytes({ + encryptedBytes: PrivateKeyService.decode( + privateKeyItem.encryptedPrivateKey + ), + inputKeyMaterial, + logger, + passkey, + }); + + return Ed21559KeyPair.generateFromPrivateKey(decryptedPrivateKey); +} diff --git a/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/index.ts b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/index.ts new file mode 100644 index 00000000..6e2f37ac --- /dev/null +++ b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/index.ts @@ -0,0 +1,2 @@ +export { default } from './fetchDecryptedKeyPairFromStorageWithPasskey'; +export * from './types'; diff --git a/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/IOptions.ts b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/IOptions.ts new file mode 100644 index 00000000..4d286cbf --- /dev/null +++ b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/IOptions.ts @@ -0,0 +1,22 @@ +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PrivateKeyService from '@extension/services/PrivateKeyService'; + +// types +import type { IBaseOptions } from '@common/types'; + +/** + * @property {Uint8Array} inputKeyMaterial - the input key material to derive the encryption key. + * @property {PasskeyService} passkeyService - [optional] a passkey service to use, if omitted a new one is created. + * @property {PrivateKeyService} privateKeyService - [optional] a private key service to use, if omitted a new one is + * created. + * @property {Uint8Array | string} publicKey - the raw or hexadecimal encoded public key. + */ +interface IOptions extends IBaseOptions { + inputKeyMaterial: Uint8Array; + passkeyService?: PasskeyService; + privateKeyService?: PrivateKeyService; + publicKey: Uint8Array | string; +} + +export default IOptions; diff --git a/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/index.ts b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/index.ts new file mode 100644 index 00000000..68e70016 --- /dev/null +++ b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey/types/index.ts @@ -0,0 +1 @@ +export type { default as IOptions } from './IOptions'; diff --git a/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPassword/fetchDecryptedKeyPairFromStorageWithPassword.ts b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPassword/fetchDecryptedKeyPairFromStorageWithPassword.ts index d2ff9098..8d9a29fe 100644 --- a/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPassword/fetchDecryptedKeyPairFromStorageWithPassword.ts +++ b/src/extension/utils/fetchDecryptedKeyPairFromStorageWithPassword/fetchDecryptedKeyPairFromStorageWithPassword.ts @@ -15,7 +15,7 @@ import type { IPrivateKey } from '@extension/types'; import type { IOptions } from './types'; /** - * Convenience function that fetches the private key from storage and converts it to a key pair. + * Convenience function that fetches the private key from storage and converts it to a key pair using the password. * @param {IOptions} options - the password and the public key. * @returns {Promise} a promise that resolves to the key pair or null if there was no private key * associated with the public key in storage. diff --git a/src/extension/utils/signTransaction/signTransaction.ts b/src/extension/utils/signTransaction/signTransaction.ts index 71e8e652..99ea05d0 100644 --- a/src/extension/utils/signTransaction/signTransaction.ts +++ b/src/extension/utils/signTransaction/signTransaction.ts @@ -1,5 +1,7 @@ import { encode as encodeBase64 } from '@stablelib/base64'; -import { encodeAddress } from 'algosdk'; + +// enums +import { EncryptionMethodEnum } from '@extension/enums'; // errors import { MalformedDataError } from '@extension/errors'; @@ -16,10 +18,11 @@ import type { IAccountInformation, IAccountWithExtendedProps, } from '@extension/types'; -import type { IOptions } from './types'; +import type { TOptions } from './types'; // utils import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; +import fetchDecryptedKeyPairFromStorageWithPasskey from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey'; import fetchDecryptedKeyPairFromStorageWithPassword from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPassword'; /** @@ -35,9 +38,9 @@ export default async function signTransaction({ authAccounts = [], logger, networks, - password, unsignedTransaction, -}: IOptions): Promise { + ...encryptionOptions +}: TOptions): Promise { const _functionName = 'signTransaction'; const base64EncodedGenesisHash = encodeBase64( unsignedTransaction.genesisHash @@ -52,7 +55,8 @@ export default async function signTransaction({ let account: IAccountWithExtendedProps | null; let accountInformation: IAccountInformation | null; let authAccount: IAccountWithExtendedProps | null; - let keyPair: Ed21559KeyPair | null; + let keyPair: Ed21559KeyPair | null = null; + let publicKey: string | Uint8Array = unsignedTransaction.from.publicKey; logger?.debug( `${_functionName}: signing transaction "${unsignedTransaction.txID()}"` @@ -96,12 +100,6 @@ export default async function signTransaction({ throw new MalformedDataError(_error); } - keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ - logger, - password, - publicKey: unsignedTransaction.from.publicKey, - }); - // if the account is re-keyed, attempt to get the auth account's private key to sign if (accountInformation.authAddress) { authAccount = @@ -120,10 +118,26 @@ export default async function signTransaction({ throw new MalformedDataError(_error); } + publicKey = authAccount.publicKey; + } + + logger?.debug( + `${_functionName}: decrypting private key using "${encryptionOptions.type}" encryption method` + ); + + if (encryptionOptions.type === EncryptionMethodEnum.Password) { keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ logger, - password, - publicKey: authAccount.publicKey, + password: encryptionOptions.password, + publicKey, + }); + } + + if (encryptionOptions.type === EncryptionMethodEnum.Passkey) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPasskey({ + inputKeyMaterial: encryptionOptions.inputKeyMaterial, + logger, + publicKey, }); } diff --git a/src/extension/utils/signTransaction/types/IOptions.ts b/src/extension/utils/signTransaction/types/TOptions.ts similarity index 68% rename from src/extension/utils/signTransaction/types/IOptions.ts rename to src/extension/utils/signTransaction/types/TOptions.ts index a800dee8..3fd8b212 100644 --- a/src/extension/utils/signTransaction/types/IOptions.ts +++ b/src/extension/utils/signTransaction/types/TOptions.ts @@ -1,5 +1,8 @@ import type { Transaction } from 'algosdk'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // types import type { IBaseOptions } from '@common/types'; import type { IAccountWithExtendedProps, INetwork } from '@extension/types'; @@ -9,15 +12,25 @@ import type { IAccountWithExtendedProps, INetwork } from '@extension/types'; * @property {IAccountWithExtendedProps[]} authAccounts - [optional] a list of auth accounts that can sign the transaction for * re-keyed accounts. * @property {INetwork[]} networks - a list of networks. - * @property {string} password - the password used to get the private keys for the transaction signer. * @property {algosdk.Transaction} unsignedTransaction - the unsigned transaction. */ interface IOptions extends IBaseOptions { accounts: IAccountWithExtendedProps[]; authAccounts: IAccountWithExtendedProps[]; networks: INetwork[]; - password: string; unsignedTransaction: Transaction; } -export default IOptions; +type TEncryptionOptions = + | (IOptions & { + password: string; + type: EncryptionMethodEnum.Password; + }) + | { + inputKeyMaterial: Uint8Array; + type: EncryptionMethodEnum.Passkey; + }; + +type TOptions = IOptions & TEncryptionOptions; + +export default TOptions; diff --git a/src/extension/utils/signTransaction/types/index.ts b/src/extension/utils/signTransaction/types/index.ts index 68e70016..b9eb0637 100644 --- a/src/extension/utils/signTransaction/types/index.ts +++ b/src/extension/utils/signTransaction/types/index.ts @@ -1 +1 @@ -export type { default as IOptions } from './IOptions'; +export type { default as TOptions } from './TOptions'; From 397e7c067015baf2f5008bd780230d99e1ab4954 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Thu, 11 Jul 2024 15:55:43 +0100 Subject: [PATCH 21/31] chore: squash --- ...egistrationTransactionSendModalContent.tsx | 244 +++++++++--------- .../AuthenticationModal.tsx | 183 +++++++------ .../SignTransactionsModal.tsx | 8 +- src/extension/translations/en.ts | 1 + 4 files changed, 233 insertions(+), 203 deletions(-) diff --git a/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx b/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx index 6803d87c..9ff5f346 100644 --- a/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx +++ b/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx @@ -14,7 +14,7 @@ import { encode as encodeBase64, } from '@stablelib/base64'; import { Transaction } from 'algosdk'; -import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; @@ -22,9 +22,6 @@ import { useDispatch } from 'react-redux'; import Button from '@extension/components/Button'; import KeyRegistrationTransactionModalBody from '@extension/components/KeyRegistrationTransactionModalBody'; import ModalSkeletonItem from '@extension/components/ModalSkeletonItem'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; // constants import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; @@ -32,12 +29,16 @@ import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; // enums import { ARC0300QueryEnum, + EncryptionMethodEnum, ErrorCodeEnum, TransactionTypeEnum, } from '@extension/enums'; // errors -import { NotEnoughMinimumBalanceError } from '@extension/errors'; +import { + BaseExtensionError, + NotEnoughMinimumBalanceError, +} from '@extension/errors'; // features import { updateAccountsThunk } from '@extension/features/accounts'; @@ -46,13 +47,17 @@ import { create as createNotification } from '@extension/features/notifications' // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors import { useSelectAccountByAddress, useSelectAccounts, useSelectLogger, useSelectNetworks, - useSelectPasswordLockPassword, useSelectSettings, } from '@extension/selectors'; @@ -82,28 +87,28 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< > > = ({ cancelButtonIcon, cancelButtonLabel, onComplete, onCancel, schema }) => { const { t } = useTranslation(); - const passwordInputRef = useRef(null); const dispatch: IAppThunkDispatch = useDispatch(); - const { isOpen, onOpen, onClose } = useDisclosure(); + + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); + const { + isOpen: isMoreInformationToggleOpen, + onOpen: onMoreInformationOpen, + onClose: onMoreInformationClose, + } = useDisclosure(); // selectors const account = useSelectAccountByAddress( schema.query[ARC0300QueryEnum.Sender] ); const accounts = useSelectAccounts(); const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); const networks = useSelectNetworks(); const settings = useSelectSettings(); // hooks const defaultTextColor: string = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); // states const [sending, setSending] = useState(false); const [unsignedTransaction, setUnsignedTransaction] = @@ -129,20 +134,12 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< : selectNetworkFromSettings(networks, settings) || selectDefaultNetwork(networks); // if we have the genesis hash get the network, otherwise get the selected network const reset = () => { - resetPassword(); setSending(false); setUnsignedTransaction(null); }; // handlers - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - await handleSendClick(); - } - }; const handleMoreInformationToggle = (value: boolean) => - value ? onOpen() : onClose(); + value ? onMoreInformationOpen() : onMoreInformationClose(); const handleOnComplete = () => { reset(); onComplete(); @@ -151,9 +148,22 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< reset(); onCancel(); }; - const handleSendClick = async () => { - const _functionName: string = 'handleSendClick'; - let _password: string | null; + const handleError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleSendClick'; let signedTransaction: Uint8Array; if (!unsignedTransaction) { @@ -184,30 +194,6 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< return; } - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${ARC0300KeyRegistrationTransactionSendModalContent.name}#${_functionName}: password not valid` - ); - - return; - } - } - - _password = settings.security.enablePasswordLock - ? passwordLockPassword - : password; - - if (!_password) { - logger.debug( - `${ARC0300KeyRegistrationTransactionSendModalContent.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - setSending(true); try { @@ -229,8 +215,16 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< authAccounts: accounts, logger, networks, - password, unsignedTransaction, + ...(result.type === EncryptionMethodEnum.Password + ? { + password: result.password, + type: EncryptionMethodEnum.Password, + } + : { + inputKeyMaterial: result.inputKeyMaterial, + type: EncryptionMethodEnum.Passkey, + }), }); await sendTransactionsForNetwork({ @@ -269,9 +263,6 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< handleOnComplete(); } catch (error) { switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - break; case ErrorCodeEnum.OfflineError: dispatch( createNotification({ @@ -299,12 +290,8 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< setSending(false); }; + const handleSendClick = () => onAuthenticationModalOpen(); - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); useEffect(() => { if (account && network) { (async () => @@ -319,68 +306,71 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< }, [account, network, schema]); return ( - - {/*header*/} - - - {t( - isOnlineKeyRegistrationTransaction(schema) - ? `headings.transaction_${TransactionTypeEnum.KeyRegistrationOnline}` - : `headings.transaction_${TransactionTypeEnum.KeyRegistrationOffline}` - )} - - - - {/*body*/} - - - - {t('captions.keyRegistrationURI', { - status: isOnlineKeyRegistrationTransaction(schema) - ? 'online' - : 'offline', - })} - - - {!account || !network || !unsignedTransaction ? ( - - - - - - ) : ( - - )} - - - - {/*footer*/} - - - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ('captions.mustEnterPasswordToSendTransaction')} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - inputRef={passwordInputRef} - value={password} - /> - )} - + <> + {/*authentication*/} + ('captions.mustEnterPasswordToSendTransaction')} + /> + + + {/*header*/} + + + {t( + isOnlineKeyRegistrationTransaction(schema) + ? `headings.transaction_${TransactionTypeEnum.KeyRegistrationOnline}` + : `headings.transaction_${TransactionTypeEnum.KeyRegistrationOffline}` + )} + + + + {/*body*/} + + + + {t('captions.keyRegistrationURI', { + status: isOnlineKeyRegistrationTransaction(schema) + ? 'online' + : 'offline', + })} + + + {!account || !network || !unsignedTransaction ? ( + + + + + + ) : ( + + )} + + + + {/*footer*/} + {/*cancel button*/} - - - + + + ); }; diff --git a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx index e501aa4d..0e072a2f 100644 --- a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx +++ b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx @@ -5,6 +5,7 @@ import { ModalContent, ModalFooter, ModalOverlay, + Spinner, Text, VStack, } from '@chakra-ui/react'; @@ -26,7 +27,6 @@ import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; import { EncryptionMethodEnum } from '@extension/enums'; // errors -import { MalformedDataError } from '@extension/errors'; // hooks import useColorModeValue from '@extension/hooks/useColorModeValue'; @@ -36,6 +36,8 @@ import useSubTextColor from '@extension/hooks/useSubTextColor'; import { useSelectLogger, useSelectPasskeysPasskey, + useSelectPasswordLockPassword, + useSelectSettings, } from '@extension/selectors'; // services @@ -47,6 +49,7 @@ import { theme } from '@extension/theme'; // types import type { IProps } from './types'; +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; const AuthenticationModal: FC = ({ isOpen, @@ -60,7 +63,10 @@ const AuthenticationModal: FC = ({ // selectors const logger = useSelectLogger(); const passkey = useSelectPasskeysPasskey(); + const passwordLockPassword = useSelectPasswordLockPassword(); + const settings = useSelectSettings(); // hooks + const defaultTextColor = useDefaultTextColor(); const primaryColorCode = useColorModeValue( theme.colors.primaryLight['500'], theme.colors.primaryDark['500'] @@ -128,6 +134,78 @@ const AuthenticationModal: FC = ({ await handleConfirmClick(); } }; + // renders + const renderContent = () => { + // show a loader for passkeys + if (passkey) { + return ( + + {/*passkey loader*/} + + + {/*caption*/} + + {t('captions.requestingPasskeyPermission', { + name: passkey.name, + })} + + + ); + } + + // show a loader if there is a password lock and password + if (settings.security.enablePasswordLock && passwordLockPassword) { + return ( + + {/*loader*/} + + + {/*caption*/} + + {t('captions.checkingAuthenticationCredentials')} + + + ); + } + + return ( + + ('captions.mustEnterPasswordToConfirm') + } + inputRef={passwordInputRef} + onChange={onPasswordChange} + onKeyUp={handleKeyUpPasswordInput} + value={password || ''} + /> + + ); + }; // set focus when opening useEffect(() => { @@ -136,44 +214,47 @@ const AuthenticationModal: FC = ({ } }, []); useEffect(() => { - if (!passkey) { - return; - } - (async () => { let _error: string; let inputKeyMaterial: Uint8Array; - if (!passkey) { - _error = `no passkey found`; - - logger.error(`${AuthenticationModal.name}#useEffect: ${_error}`); - - return onError && onError(new MalformedDataError(_error)); + if (!isOpen) { + return; } - try { - // fetch the encryption key material - inputKeyMaterial = - await PasskeyService.fetchInputKeyMaterialFromPasskey({ - credential: passkey, - logger, + // if there is a passkey, attempt to fetch the passkey input key material + if (passkey) { + try { + // fetch the encryption key material + inputKeyMaterial = + await PasskeyService.fetchInputKeyMaterialFromPasskey({ + credential: passkey, + logger, + }); + + onConfirm({ + inputKeyMaterial, + type: EncryptionMethodEnum.Passkey, }); - onConfirm({ - inputKeyMaterial, - type: EncryptionMethodEnum.Passkey, - }); + // clean up + return reset(); + } catch (error) { + logger.error(`${AuthenticationModal.name}#useEffect:`, error); - // clean up - return reset(); - } catch (error) { - logger.error(`${AuthenticationModal.name}#useEffect:`, error); + return onError && onError(error); + } + } - return onError && onError(error); + // otherwise, check if there is a password lock and password lock password present + if (settings.security.enablePasswordLock && passwordLockPassword) { + return onConfirm({ + password: passwordLockPassword, + type: EncryptionMethodEnum.Password, + }); } })(); - }, [passkey]); + }, [isOpen]); return ( = ({ minH={0} > {/*content*/} - - {passkey ? ( - // passkey - - {/*passkey loader*/} - - - {/*caption*/} - - {t('captions.requestingPasskeyPermission', { - name: passkey?.name || 'unknown', - })} - - - ) : ( - // password - - ('captions.mustEnterPasswordToConfirm') - } - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password || ''} - /> - - )} - + {renderContent()} {/*footer*/} {!passkey && ( diff --git a/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx b/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx index dde4fcb1..f33c48f1 100644 --- a/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx +++ b/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx @@ -36,6 +36,12 @@ import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; // contexts import { MultipleTransactionsContext } from './contexts'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +// errors +import { BaseExtensionError } from '@extension/errors'; + // features import { removeEventByIdThunk } from '@extension/features/events'; import { sendSignTransactionsResponseThunk } from '@extension/features/messages'; @@ -73,8 +79,6 @@ import decodeUnsignedTransaction from '@extension/utils/decodeUnsignedTransactio import groupTransactions from '@extension/utils/groupTransactions'; import authorizedAccountsForEvent from './utils/authorizedAccountsForEvent'; import signTransactions from './utils/signTransactions'; -import { EncryptionMethodEnum } from '@extension/enums'; -import { BaseExtensionError } from '@extension/errors'; const SignTransactionsModal: FC = ({ onClose }) => { const { t } = useTranslation(); diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 521d507d..5df10025 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -98,6 +98,7 @@ const translation: IResourceLanguage = { changePassword2: 'You will be prompted to enter your current password when you press "Change Password".', changeTheme: 'Choose between dark and light mode.', + checkingAuthenticationCredentials: 'Checking authentication credentials.', confirmingTransaction: 'Please wait, the transaction is being processed.', connectingToWalletConnect: 'Attempting to connect to WalletConnect.', copied: 'Copied!', From f1df7f8b18d02f2e7eb84f2c12cb324446840a8b Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Thu, 11 Jul 2024 16:27:56 +0100 Subject: [PATCH 22/31] chore: squash --- .../accounts/thunks/saveNewAccountThunk.ts | 31 ++- .../accounts/types/ISaveNewAccountPayload.ts | 11 +- .../ConfirmPasswordModal.tsx | 176 ------------------ .../modals/ConfirmPasswordModal/index.ts | 2 - .../ConfirmPasswordModal/types/IProps.ts | 8 - .../ConfirmPasswordModal/types/index.ts | 1 - .../utils/signTransactions/types/TOptions.ts | 21 +-- .../AddAccountMainRouter.tsx | 110 +++++------ .../authentication/TEncryptionCredentials.ts | 14 ++ src/extension/types/authentication/index.ts | 1 + .../savePrivateKeyItemWithPasskey/index.ts | 2 + .../savePrivateKeyItemWithPasskey.ts | 83 +++++++++ .../types/IOptions.ts | 25 +++ .../types/index.ts | 1 + .../savePrivateKeyItemWithPassword.ts | 2 +- .../utils/signTransaction/types/TOptions.ts | 21 +-- 16 files changed, 228 insertions(+), 281 deletions(-) delete mode 100644 src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx delete mode 100644 src/extension/modals/ConfirmPasswordModal/index.ts delete mode 100644 src/extension/modals/ConfirmPasswordModal/types/IProps.ts delete mode 100644 src/extension/modals/ConfirmPasswordModal/types/index.ts create mode 100644 src/extension/types/authentication/TEncryptionCredentials.ts create mode 100644 src/extension/utils/savePrivateKeyItemWithPasskey/index.ts create mode 100644 src/extension/utils/savePrivateKeyItemWithPasskey/savePrivateKeyItemWithPasskey.ts create mode 100644 src/extension/utils/savePrivateKeyItemWithPasskey/types/IOptions.ts create mode 100644 src/extension/utils/savePrivateKeyItemWithPasskey/types/index.ts diff --git a/src/extension/features/accounts/thunks/saveNewAccountThunk.ts b/src/extension/features/accounts/thunks/saveNewAccountThunk.ts index ab0d1b0d..aac05b2c 100644 --- a/src/extension/features/accounts/thunks/saveNewAccountThunk.ts +++ b/src/extension/features/accounts/thunks/saveNewAccountThunk.ts @@ -1,5 +1,8 @@ import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // errors import { MalformedDataError } from '@extension/errors'; @@ -19,6 +22,7 @@ import type { import type { ISaveNewAccountPayload } from '../types'; // utils +import savePrivateKeyItemWithPasskey from '@extension/utils/savePrivateKeyItemWithPasskey'; import savePrivateKeyItemWithPassword from '@extension/utils/savePrivateKeyItemWithPassword'; const saveNewAccountThunk: AsyncThunk< @@ -31,20 +35,33 @@ const saveNewAccountThunk: AsyncThunk< IAsyncThunkConfigWithRejectValue >( ThunkEnum.SaveNewAccount, - async ({ name, keyPair, password }, { getState, rejectWithValue }) => { + async ( + { name, keyPair, ...encryptionOptions }, + { getState, rejectWithValue } + ) => { const encodedPublicKey = PrivateKeyService.encode(keyPair.publicKey); const logger = getState().system.logger; let _error: string; let account: IAccountWithExtendedProps; let accountService: AccountService; - let privateKeyItem: IPrivateKey | null; + let privateKeyItem: IPrivateKey | null = null; try { - privateKeyItem = await savePrivateKeyItemWithPassword({ - keyPair, - logger, - password, - }); + if (encryptionOptions.type === EncryptionMethodEnum.Passkey) { + privateKeyItem = await savePrivateKeyItemWithPasskey({ + inputKeyMaterial: encryptionOptions.inputKeyMaterial, + keyPair, + logger, + }); + } + + if (encryptionOptions.type === EncryptionMethodEnum.Password) { + privateKeyItem = await savePrivateKeyItemWithPassword({ + keyPair, + logger, + password: encryptionOptions.password, + }); + } } catch (error) { return rejectWithValue(error); } diff --git a/src/extension/features/accounts/types/ISaveNewAccountPayload.ts b/src/extension/features/accounts/types/ISaveNewAccountPayload.ts index a97ea6ae..c608a69c 100644 --- a/src/extension/features/accounts/types/ISaveNewAccountPayload.ts +++ b/src/extension/features/accounts/types/ISaveNewAccountPayload.ts @@ -1,10 +1,15 @@ // models import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; -interface ISaveNewAccountPayload { +// types +import type { TEncryptionCredentials } from '@extension/types'; + +interface ISaveNewAccountPayloadFragment { keyPair: Ed21559KeyPair; name: string | null; - password: string; } -export default ISaveNewAccountPayload; +type TSaveNewAccountPayload = ISaveNewAccountPayloadFragment & + TEncryptionCredentials; + +export default TSaveNewAccountPayload; diff --git a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx deleted file mode 100644 index 0af58dc5..00000000 --- a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { - HStack, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalOverlay, - VStack, -} from '@chakra-ui/react'; -import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import browser from 'webextension-polyfill'; - -// components -import Button from '@extension/components/Button'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; - -// constants -import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; - -// selectors -import { useSelectLogger } from '@extension/selectors'; - -// services -import PasswordService from '@extension/services/PasswordService'; - -// theme -import { theme } from '@extension/theme'; - -// types -import type { IProps } from './types'; - -const ConfirmPasswordModal: FC = ({ - hint, - isOpen, - onCancel, - onConfirm, -}) => { - const { t } = useTranslation(); - const passwordInputRef = useRef(null); - // hooks - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); - // selectors - const logger = useSelectLogger(); - // state - const [verifying, setVerifying] = useState(false); - // misc - const reset = () => { - resetPassword(); - setVerifying(false); - }; - // handlers - const handleCancelClick = () => handleClose(); - const handleConfirmClick = async () => { - let isValid: boolean; - let passwordService: PasswordService; - - // check if the input is valid - if (validatePassword()) { - return; - } - - passwordService = new PasswordService({ - logger, - passwordTag: browser.runtime.id, - }); - - setVerifying(true); - - isValid = await passwordService.verifyPassword(password); - - setVerifying(false); - - if (!isValid) { - setPasswordError(t('errors.inputs.invalidPassword')); - - return; - } - - onConfirm(password); - - // clean up - reset(); - }; - const handleClose = () => { - onCancel(); - - // clean up - reset(); - }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - await handleConfirmClick(); - } - }; - - // set focus when opening - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); - - return ( - - - - - {/*content*/} - - - ('captions.mustEnterPasswordToConfirm')} - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password || ''} - /> - - - - {/*footer*/} - - - - - - - - - - ); -}; - -export default ConfirmPasswordModal; diff --git a/src/extension/modals/ConfirmPasswordModal/index.ts b/src/extension/modals/ConfirmPasswordModal/index.ts deleted file mode 100644 index 7ee7a1a6..00000000 --- a/src/extension/modals/ConfirmPasswordModal/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './ConfirmPasswordModal'; -export * from './types'; diff --git a/src/extension/modals/ConfirmPasswordModal/types/IProps.ts b/src/extension/modals/ConfirmPasswordModal/types/IProps.ts deleted file mode 100644 index 94db3c14..00000000 --- a/src/extension/modals/ConfirmPasswordModal/types/IProps.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface IProps { - hint?: string; - isOpen: boolean; - onCancel: () => void; - onConfirm: (password: string) => void; -} - -export default IProps; diff --git a/src/extension/modals/ConfirmPasswordModal/types/index.ts b/src/extension/modals/ConfirmPasswordModal/types/index.ts deleted file mode 100644 index f404deed..00000000 --- a/src/extension/modals/ConfirmPasswordModal/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { default as IProps } from './IProps'; diff --git a/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts b/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts index 25c63ed0..a69fa995 100644 --- a/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts +++ b/src/extension/modals/SignTransactionsModal/utils/signTransactions/types/TOptions.ts @@ -1,11 +1,12 @@ import type { IARC0001Transaction } from '@agoralabs-sh/avm-web-provider'; -// enums -import { EncryptionMethodEnum } from '@extension/enums'; - // types import type { IBaseOptions } from '@common/types'; -import type { IAccountWithExtendedProps, INetwork } from '@extension/types'; +import type { + IAccountWithExtendedProps, + INetwork, + TEncryptionCredentials, +} from '@extension/types'; /** * @property {IAccountWithExtendedProps[]} accounts - the authorized accounts. @@ -20,16 +21,6 @@ interface IOptions extends IBaseOptions { networks: INetwork[]; } -type TEncryptionOptions = - | { - password: string; - type: EncryptionMethodEnum.Password; - } - | { - inputKeyMaterial: Uint8Array; - type: EncryptionMethodEnum.Passkey; - }; - -type TOptions = IOptions & TEncryptionOptions; +type TOptions = IOptions & TEncryptionCredentials; export default TOptions; diff --git a/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx b/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx index 4d11c602..d6191663 100644 --- a/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx +++ b/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx @@ -1,5 +1,5 @@ import { useDisclosure } from '@chakra-ui/react'; -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { Route, Routes, useNavigate } from 'react-router-dom'; @@ -17,8 +17,12 @@ import { AccountTabEnum, ARC0300AuthorityEnum, ARC0300PathEnum, + EncryptionMethodEnum, } from '@extension/enums'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features import { saveActiveAccountDetails, @@ -30,7 +34,9 @@ import { setScanQRCodeModal } from '@extension/features/layout'; import { create as createNotification } from '@extension/features/notifications'; // modals -import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; // models import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; @@ -47,9 +53,7 @@ import ImportAccountViaSeedPhrasePage from '@extension/pages/ImportAccountViaSee import { useSelectActiveAccountDetails, useSelectLogger, - useSelectPasswordLockPassword, useSelectAccountsSaving, - useSelectSettings, } from '@extension/selectors'; // services @@ -71,21 +75,26 @@ const AddAccountMainRouter: FC = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const { - isOpen: isConfirmPasswordModalOpen, - onClose: onConfirmPasswordModalClose, - onOpen: onConfirmPasswordModalOpen, + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, } = useDisclosure(); // selectors const activeAccountDetails = useSelectActiveAccountDetails(); const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); const saving = useSelectAccountsSaving(); - const settings = useSelectSettings(); // states const [keyPair, setKeyPair] = useState(null); const [name, setName] = useState(null); - const [password, setPassword] = useState(null); // handlers + const handleImportAccountViaQRCodeClick = () => + dispatch( + setScanQRCodeModal({ + // only allow account import + allowedAuthorities: [ARC0300AuthorityEnum.Account], + allowedParams: [ARC0300PathEnum.Import], + }) + ); const handleOnAddAccountComplete = async ({ name, keyPair, @@ -93,16 +102,9 @@ const AddAccountMainRouter: FC = () => { setKeyPair(keyPair); setName(name); - // if the password lock is enabled and the password is active, use the password - if (settings.security.enablePasswordLock && passwordLockPassword) { - setPassword(passwordLockPassword); - - return; - } - - // get the password from the modal - onConfirmPasswordModalOpen(); + onAuthenticationModalOpen(); }; + const handleOnAuthenticationModalClose = () => onAuthenticationModalClose(); const handleOnAddWatchAccountComplete = async ({ address, name, @@ -153,30 +155,13 @@ const AddAccountMainRouter: FC = () => { updateAccounts(account.id); reset(); }; - const handleOnConfirmPasswordModalClose = () => onConfirmPasswordModalClose(); - const handleOnConfirmPasswordModalConfirm = async (password: string) => { - setPassword(password); - onConfirmPasswordModalClose(); - }; - const handleImportAccountViaQRCodeClick = () => - dispatch( - setScanQRCodeModal({ - // only allow account import - allowedAuthorities: [ARC0300AuthorityEnum.Account], - allowedParams: [ARC0300PathEnum.Import], - }) - ); - // misc - const reset = () => { - setKeyPair(null); - setName(null); - setPassword(null); - }; - const saveNewAccount = async () => { - const _functionName = 'saveNewAccount'; + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleOnAuthenticationModalConfirm'; let account: IAccountWithExtendedProps; - if (!password || !keyPair) { + if (!keyPair) { return; } @@ -185,7 +170,15 @@ const AddAccountMainRouter: FC = () => { saveNewAccountThunk({ keyPair, name, - password, + ...(result.type === EncryptionMethodEnum.Password + ? { + password: result.password, + type: EncryptionMethodEnum.Password, + } + : { + inputKeyMaterial: result.inputKeyMaterial, + type: EncryptionMethodEnum.Passkey, + }), }) ).unwrap(); } catch (error) { @@ -222,6 +215,23 @@ const AddAccountMainRouter: FC = () => { updateAccounts(account.id); reset(); }; + const handleOnError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + // misc + const reset = () => { + setKeyPair(null); + setName(null); + }; const updateAccounts = (accountId: string) => { dispatch( updateAccountsThunk({ @@ -239,19 +249,13 @@ const AddAccountMainRouter: FC = () => { }); }; - // if we have the password and the private key, we can save a new account - useEffect(() => { - if (keyPair && password) { - (async () => await saveNewAccount())(); - } - }, [keyPair, password]); - return ( <> - diff --git a/src/extension/types/authentication/TEncryptionCredentials.ts b/src/extension/types/authentication/TEncryptionCredentials.ts new file mode 100644 index 00000000..6f10ae26 --- /dev/null +++ b/src/extension/types/authentication/TEncryptionCredentials.ts @@ -0,0 +1,14 @@ +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +type TEncryptionCredentials = + | { + password: string; + type: EncryptionMethodEnum.Password; + } + | { + inputKeyMaterial: Uint8Array; + type: EncryptionMethodEnum.Passkey; + }; + +export default TEncryptionCredentials; diff --git a/src/extension/types/authentication/index.ts b/src/extension/types/authentication/index.ts index 796a4684..9f3ba96b 100644 --- a/src/extension/types/authentication/index.ts +++ b/src/extension/types/authentication/index.ts @@ -1,3 +1,4 @@ export type { default as IPasskeyCredential } from './IPasskeyCredential'; export type { default as IPasswordTag } from './IPasswordTag'; export type { default as IPrivateKey } from './IPrivateKey'; +export type { default as TEncryptionCredentials } from './TEncryptionCredentials'; diff --git a/src/extension/utils/savePrivateKeyItemWithPasskey/index.ts b/src/extension/utils/savePrivateKeyItemWithPasskey/index.ts new file mode 100644 index 00000000..252afc02 --- /dev/null +++ b/src/extension/utils/savePrivateKeyItemWithPasskey/index.ts @@ -0,0 +1,2 @@ +export { default } from './savePrivateKeyItemWithPasskey'; +export * from './types'; diff --git a/src/extension/utils/savePrivateKeyItemWithPasskey/savePrivateKeyItemWithPasskey.ts b/src/extension/utils/savePrivateKeyItemWithPasskey/savePrivateKeyItemWithPasskey.ts new file mode 100644 index 00000000..86dc7b14 --- /dev/null +++ b/src/extension/utils/savePrivateKeyItemWithPasskey/savePrivateKeyItemWithPasskey.ts @@ -0,0 +1,83 @@ +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + +// errors +import { MalformedDataError } from '@extension/errors'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PrivateKeyService from '@extension/services/PrivateKeyService'; + +// types +import type { IPrivateKey } from '@extension/types'; +import type { IOptions } from './types'; + +/** + * Convenience function that encrypts a private key with the passkey and saves it to storage. + * @param {IOptions} options - the input key material used to derive the encryption key and the key pair. + * @returns {Promise} a promise that resolves to the saved private key item or null if the private + * key failed to save. + * @throws {MalformedDataError} if no passkey exists. + * @throws {EncryptionError} if the private key failed to be encrypted with the supplied input key material. + */ +export default async function savePrivateKeyItemWithPasskey({ + inputKeyMaterial, + keyPair, + logger, + passkeyService, + privateKeyService, +}: IOptions): Promise { + const _functionName = 'savePrivateKeyItemWithPasskey'; + const _passkeyService = + passkeyService || + new PasskeyService({ + logger, + }); + const _privateKeyService = + privateKeyService || + new PrivateKeyService({ + logger, + }); + const passkey = await _passkeyService.fetchFromStorage(); + let _error: string; + let encryptedPrivateKey: Uint8Array; + let privateKeyItem: IPrivateKey | null; + + if (!passkey) { + _error = `no passkey found in storage`; + + logger?.error(`${_functionName}: ${_error}`); + + throw new MalformedDataError(_error); + } + + privateKeyItem = await _privateKeyService.fetchFromStorageByPublicKey( + keyPair.publicKey + ); + + if (!privateKeyItem) { + logger?.debug( + `${_functionName}: key for "${PrivateKeyService.encode( + keyPair.publicKey + )}" (public key) doesn't exist, creating a new one` + ); + + // encrypt the private key and add it to storage + encryptedPrivateKey = await PasskeyService.encryptBytes({ + bytes: keyPair.privateKey, + inputKeyMaterial, + logger, + passkey, + }); + privateKeyItem = await _privateKeyService.saveToStorage( + PrivateKeyService.createPrivateKey({ + encryptedPrivateKey, + encryptionID: passkey.id, + encryptionMethod: EncryptionMethodEnum.Passkey, + publicKey: keyPair.publicKey, + }) + ); + } + + return privateKeyItem; +} diff --git a/src/extension/utils/savePrivateKeyItemWithPasskey/types/IOptions.ts b/src/extension/utils/savePrivateKeyItemWithPasskey/types/IOptions.ts new file mode 100644 index 00000000..1112552f --- /dev/null +++ b/src/extension/utils/savePrivateKeyItemWithPasskey/types/IOptions.ts @@ -0,0 +1,25 @@ +// models +import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; + +// services +import PasskeyService from '@extension/services/PasskeyService'; +import PrivateKeyService from '@extension/services/PrivateKeyService'; + +// types +import type { IBaseOptions } from '@common/types'; + +/** + * @property {Uint8Array} inputKeyMaterial - the input key material used to derive the private key. + * @property {Ed21559KeyPair} keyPair - an Ed21559 key pair. + * @property {PasskeyService} passkeyService - [optional] a passkey service to use, if omitted a new one is created. + * @property {PrivateKeyService} privateKeyService - [optional] a private key service to use, if omitted a new one is + * created. + */ +interface IOptions extends IBaseOptions { + inputKeyMaterial: Uint8Array; + keyPair: Ed21559KeyPair; + passkeyService?: PasskeyService; + privateKeyService?: PrivateKeyService; +} + +export default IOptions; diff --git a/src/extension/utils/savePrivateKeyItemWithPasskey/types/index.ts b/src/extension/utils/savePrivateKeyItemWithPasskey/types/index.ts new file mode 100644 index 00000000..68e70016 --- /dev/null +++ b/src/extension/utils/savePrivateKeyItemWithPasskey/types/index.ts @@ -0,0 +1 @@ +export type { default as IOptions } from './IOptions'; diff --git a/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts b/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts index 63ad0a22..0ea2b290 100644 --- a/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts +++ b/src/extension/utils/savePrivateKeyItemWithPassword/savePrivateKeyItemWithPassword.ts @@ -15,7 +15,7 @@ import type { IPasswordTag, IPrivateKey } from '@extension/types'; import type { IOptions } from './types'; /** - * Convenience function that saves a encrypts a private key and saves it to storage. + * Convenience function that encrypts a private key with the password and saves it to storage. * @param {IOptions} options - the password and the key pair. * @returns {Promise} a promise that resolves to the saved private key item or null if the private * key failed to save. diff --git a/src/extension/utils/signTransaction/types/TOptions.ts b/src/extension/utils/signTransaction/types/TOptions.ts index 3fd8b212..f2625d57 100644 --- a/src/extension/utils/signTransaction/types/TOptions.ts +++ b/src/extension/utils/signTransaction/types/TOptions.ts @@ -1,11 +1,12 @@ import type { Transaction } from 'algosdk'; -// enums -import { EncryptionMethodEnum } from '@extension/enums'; - // types import type { IBaseOptions } from '@common/types'; -import type { IAccountWithExtendedProps, INetwork } from '@extension/types'; +import type { + IAccountWithExtendedProps, + INetwork, + TEncryptionCredentials, +} from '@extension/types'; /** * @property {IAccountWithExtendedProps[]} accounts - a list of accounts that can sign the transaction. @@ -21,16 +22,6 @@ interface IOptions extends IBaseOptions { unsignedTransaction: Transaction; } -type TEncryptionOptions = - | (IOptions & { - password: string; - type: EncryptionMethodEnum.Password; - }) - | { - inputKeyMaterial: Uint8Array; - type: EncryptionMethodEnum.Passkey; - }; - -type TOptions = IOptions & TEncryptionOptions; +type TOptions = IOptions & TEncryptionCredentials; export default TOptions; From 1fc20d18dd6a081d802bff73584532b2218efd6f Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sun, 14 Jul 2024 14:20:40 +0100 Subject: [PATCH 23/31] chore: squash --- ...ccountImportWithPrivateKeyModalContent.tsx | 330 ++++++++---------- ...egistrationTransactionSendModalContent.tsx | 3 +- .../thunks/addStandardAssetHoldingsThunk.ts | 10 +- .../removeStandardAssetHoldingsThunk.ts | 6 +- .../IUpdateStandardAssetHoldingsPayload.ts | 10 - .../TUpdateStandardAssetHoldingsPayload.ts | 8 + .../features/accounts/types/index.ts | 2 +- .../modals/AddAssetsModal/AddAssetsModal.tsx | 196 +++++------ .../ViewSeedPhrasePage/ViewSeedPhrasePage.tsx | 131 ++++--- .../pages/ViewSeedPhrasePage/index.ts | 1 + .../types/ISeedPhraseInput.ts | 6 + .../pages/ViewSeedPhrasePage/types/index.ts | 1 + 12 files changed, 330 insertions(+), 374 deletions(-) delete mode 100644 src/extension/features/accounts/types/IUpdateStandardAssetHoldingsPayload.ts create mode 100644 src/extension/features/accounts/types/TUpdateStandardAssetHoldingsPayload.ts create mode 100644 src/extension/pages/ViewSeedPhrasePage/types/ISeedPhraseInput.ts create mode 100644 src/extension/pages/ViewSeedPhrasePage/types/index.ts diff --git a/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx b/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx index 0ee7a971..94b3f418 100644 --- a/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx +++ b/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx @@ -6,9 +6,10 @@ import { ModalFooter, ModalHeader, Text, + useDisclosure, VStack, } from '@chakra-ui/react'; -import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; @@ -22,9 +23,6 @@ import ModalSkeletonItem from '@extension/components/ModalSkeletonItem'; import ModalItem from '@extension/components/ModalItem'; import ModalTextItem from '@extension/components/ModalTextItem'; import ModalSubHeading from '@extension/components/ModalSubHeading'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; // constants import { @@ -40,6 +38,9 @@ import { ErrorCodeEnum, } from '@extension/enums'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features import { addARC0200AssetHoldingsThunk, @@ -55,6 +56,11 @@ import usePrimaryButtonTextColor from '@extension/hooks/usePrimaryButtonTextColo import useSubTextColor from '@extension/hooks/useSubTextColor'; import useUpdateARC0200Assets from '@extension/hooks/useUpdateARC0200Assets'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // models import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; @@ -62,9 +68,7 @@ import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; import { useSelectActiveAccountDetails, useSelectLogger, - useSelectPasswordLockPassword, useSelectSelectedNetwork, - useSelectSettings, } from '@extension/selectors'; // services @@ -97,13 +101,15 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< const { t } = useTranslation(); const dispatch = useDispatch(); const navigate = useNavigate(); - const passwordInputRef = useRef(null); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const activeAccountDetails = useSelectActiveAccountDetails(); const logger = useSelectLogger(); const network = useSelectSelectedNetwork(); - const passwordLockPassword = useSelectPasswordLockPassword(); - const settings = useSelectSettings(); // hooks const defaultTextColor = useDefaultTextColor(); const { @@ -111,71 +117,31 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< loading, reset: resetUpdateAssets, } = useUpdateARC0200Assets(schema.query[ARC0300QueryEnum.Asset]); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const primaryButtonTextColor = usePrimaryButtonTextColor(); const subTextColor = useSubTextColor(); // states const [address, setAddress] = useState(null); const [saving, setSaving] = useState(false); + // misc + const reset = () => { + resetUpdateAssets(); + setSaving(false); + }; // handlers const handleCancelClick = () => { reset(); onCancel(); }; - const handleImportClick = async () => { - const _functionName: string = 'handleImportClick'; - let _password: string | null; + const handleImportClick = () => onAuthenticationModalOpen(); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleOnAuthenticationModalConfirm'; + const privateKey: Uint8Array | null = + decodePrivateKeyFromAccountImportSchema(schema); let account: IAccount | null; let questsService: QuestsService; - let privateKey: Uint8Array | null; - let result: IUpdateAssetHoldingsResult; - - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${ARC0300AccountImportWithPrivateKeyModalContent.name}#${_functionName}: password not valid` - ); - - return; - } - } - - _password = settings.security.enablePasswordLock - ? passwordLockPassword - : password; - - if (!_password) { - logger.debug( - `${ARC0300AccountImportWithPrivateKeyModalContent.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - dispatch( - createNotification({ - description: t('errors.descriptions.code', { - context: ErrorCodeEnum.ParsingError, - type: 'password', - }), - ephemeral: true, - title: t('errors.titles.code', { - context: ErrorCodeEnum.ParsingError, - }), - type: 'error', - }) - ); - - return; - } - - privateKey = decodePrivateKeyFromAccountImportSchema(schema); + let updateAssetHoldingsResult: IUpdateAssetHoldingsResult; if (!privateKey) { logger.debug( @@ -206,13 +172,13 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< saveNewAccountThunk({ keyPair: Ed21559KeyPair.generateFromPrivateKey(privateKey), name: null, - password: _password, + ...result, }) ).unwrap(); // if there are assets, add them to the new account if (assets.length > 0 && network) { - result = await dispatch( + updateAssetHoldingsResult = await dispatch( addARC0200AssetHoldingsThunk({ accountId: account.id, assets, @@ -220,14 +186,10 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< }) ).unwrap(); - account = result.account; + account = updateAssetHoldingsResult.account; } } catch (error) { switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - - break; case ErrorCodeEnum.PrivateKeyAlreadyExistsError: logger.debug( `${ARC0300AccountImportWithPrivateKeyModalContent.name}#${_functionName}: account already exists, carry on` @@ -297,28 +259,23 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< // clean up and close handleOnComplete(); }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - await handleImportClick(); - } - }; + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); const handleOnComplete = () => { reset(); onComplete(); }; - const reset = () => { - resetPassword(); - resetUpdateAssets(); - setSaving(false); - }; - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); useEffect(() => { const privateKey: Uint8Array | null = decodePrivateKeyFromAccountImportSchema(schema); @@ -329,107 +286,110 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< }, []); return ( - - {/*header*/} - - - {t('headings.importAccount')} - - - - {/*body*/} - - - - {t('captions.importAccount')} - - - - ('labels.account')} /> - - {/*address*/} - {!address ? ( - - ) : ( - ('labels.address')}:`} - tooltipLabel={address} - value={ellipseAddress(address, { - end: 10, - start: 10, - })} - /> - )} - + <> + {/*authentication modal*/} + ('captions.mustEnterPasswordToImportAccount')} + /> + + + {/*header*/} + + + {t('headings.importAccount')} + + + + {/*body*/} + + + + {t('captions.importAccount')} + - {/*assets*/} - {loading && ( - - - - - )} - {assets.length > 0 && !loading && ( - - ('labels.assets')} /> - - {assets.map((value, index) => ( - - {/*icon*/} - - } - size="xs" - /> - - {/*symbol*/} - - {value.symbol} - - - {/*type*/} - - - } + ('labels.account')} /> + + {/*address*/} + {!address ? ( + + ) : ( + ('labels.address')}:`} + tooltipLabel={address} + value={ellipseAddress(address, { + end: 10, + start: 10, + })} /> - ))} + )} - )} - - - - {/*footer*/} - - - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ('captions.mustEnterPasswordToImportAccount')} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - inputRef={passwordInputRef} - value={password} - /> - )} + {/*assets*/} + {loading && ( + + + + + + )} + {assets.length > 0 && !loading && ( + + ('labels.assets')} /> + + {assets.map((value, index) => ( + + {/*icon*/} + + } + size="xs" + /> + + {/*symbol*/} + + {value.symbol} + + + {/*type*/} + + + } + /> + ))} + + )} + + + + {/*footer*/} + {/*cancel button*/} - - - + + + ); }; diff --git a/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx b/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx index 9ff5f346..ccece933 100644 --- a/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx +++ b/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx @@ -88,7 +88,6 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< > = ({ cancelButtonIcon, cancelButtonLabel, onComplete, onCancel, schema }) => { const { t } = useTranslation(); const dispatch: IAppThunkDispatch = useDispatch(); - const { isOpen: isAuthenticationModalOpen, onClose: onAuthenticationModalClose, @@ -163,7 +162,7 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< const handleOnAuthenticationModalConfirm = async ( result: TOnConfirmResult ) => { - const _functionName = 'handleSendClick'; + const _functionName = 'handleOnAuthenticationModalConfirm'; let signedTransaction: Uint8Array; if (!unsignedTransaction) { diff --git a/src/extension/features/accounts/thunks/addStandardAssetHoldingsThunk.ts b/src/extension/features/accounts/thunks/addStandardAssetHoldingsThunk.ts index f6c837ea..bd56095d 100644 --- a/src/extension/features/accounts/thunks/addStandardAssetHoldingsThunk.ts +++ b/src/extension/features/accounts/thunks/addStandardAssetHoldingsThunk.ts @@ -36,8 +36,8 @@ import type { IStandardAsset, } from '@extension/types'; import type { - IUpdateStandardAssetHoldingsPayload, IUpdateStandardAssetHoldingsResult, + TUpdateStandardAssetHoldingsPayload, } from '../types'; // utils @@ -54,16 +54,16 @@ import { findAccountWithoutExtendedProps } from '../utils'; const addStandardAssetHoldingsThunk: AsyncThunk< IUpdateStandardAssetHoldingsResult, // return - IUpdateStandardAssetHoldingsPayload, // args + TUpdateStandardAssetHoldingsPayload, // args IBaseAsyncThunkConfig > = createAsyncThunk< IUpdateStandardAssetHoldingsResult, - IUpdateStandardAssetHoldingsPayload, + TUpdateStandardAssetHoldingsPayload, IBaseAsyncThunkConfig >( ThunkEnum.AddStandardAssetHoldings, async ( - { accountId, assets, genesisHash, password }, + { accountId, assets, genesisHash, ...encryptionOptions }, { getState, rejectWithValue } ) => { const accounts = getState().accounts.items; @@ -194,11 +194,11 @@ const addStandardAssetHoldingsThunk: AsyncThunk< signedTransactions = await Promise.all( unsignedTransactions.map((value) => signTransaction({ + ...encryptionOptions, accounts, authAccounts: accounts, logger, networks, - password, unsignedTransaction: value, }) ) diff --git a/src/extension/features/accounts/thunks/removeStandardAssetHoldingsThunk.ts b/src/extension/features/accounts/thunks/removeStandardAssetHoldingsThunk.ts index 8b8351ee..ce2546cf 100644 --- a/src/extension/features/accounts/thunks/removeStandardAssetHoldingsThunk.ts +++ b/src/extension/features/accounts/thunks/removeStandardAssetHoldingsThunk.ts @@ -37,7 +37,7 @@ import type { IStandardAssetHolding, } from '@extension/types'; import type { - IUpdateStandardAssetHoldingsPayload, + TUpdateStandardAssetHoldingsPayload, IUpdateStandardAssetHoldingsResult, } from '../types'; @@ -55,11 +55,11 @@ import { findAccountWithoutExtendedProps } from '../utils'; const removeStandardAssetHoldingsThunk: AsyncThunk< IUpdateStandardAssetHoldingsResult, // return - IUpdateStandardAssetHoldingsPayload, // args + TUpdateStandardAssetHoldingsPayload, // args IBaseAsyncThunkConfig > = createAsyncThunk< IUpdateStandardAssetHoldingsResult, - IUpdateStandardAssetHoldingsPayload, + TUpdateStandardAssetHoldingsPayload, IBaseAsyncThunkConfig >( ThunkEnum.RemoveStandardAssetHoldings, diff --git a/src/extension/features/accounts/types/IUpdateStandardAssetHoldingsPayload.ts b/src/extension/features/accounts/types/IUpdateStandardAssetHoldingsPayload.ts deleted file mode 100644 index 5b7db7a8..00000000 --- a/src/extension/features/accounts/types/IUpdateStandardAssetHoldingsPayload.ts +++ /dev/null @@ -1,10 +0,0 @@ -// types -import type { IStandardAsset } from '@extension/types'; -import type IUpdateAssetHoldingsPayload from './IUpdateAssetHoldingsPayload'; - -interface IUpdateStandardAssetHoldingsPayload - extends IUpdateAssetHoldingsPayload { - password: string; -} - -export default IUpdateStandardAssetHoldingsPayload; diff --git a/src/extension/features/accounts/types/TUpdateStandardAssetHoldingsPayload.ts b/src/extension/features/accounts/types/TUpdateStandardAssetHoldingsPayload.ts new file mode 100644 index 00000000..d94b03f5 --- /dev/null +++ b/src/extension/features/accounts/types/TUpdateStandardAssetHoldingsPayload.ts @@ -0,0 +1,8 @@ +// types +import type { IStandardAsset, TEncryptionCredentials } from '@extension/types'; +import type IUpdateAssetHoldingsPayload from './IUpdateAssetHoldingsPayload'; + +type TUpdateStandardAssetHoldingsPayload = + IUpdateAssetHoldingsPayload & TEncryptionCredentials; + +export default TUpdateStandardAssetHoldingsPayload; diff --git a/src/extension/features/accounts/types/index.ts b/src/extension/features/accounts/types/index.ts index 1f81705a..a6d50e73 100644 --- a/src/extension/features/accounts/types/index.ts +++ b/src/extension/features/accounts/types/index.ts @@ -8,5 +8,5 @@ export type { default as IState } from './IState'; export type { default as IUpdateAccountsPayload } from './IUpdateAccountsPayload'; export type { default as IUpdateAssetHoldingsPayload } from './IUpdateAssetHoldingsPayload'; export type { default as IUpdateAssetHoldingsResult } from './IUpdateAssetHoldingsResult'; -export type { default as IUpdateStandardAssetHoldingsPayload } from './IUpdateStandardAssetHoldingsPayload'; export type { default as IUpdateStandardAssetHoldingsResult } from './IUpdateStandardAssetHoldingsResult'; +export type { default as TUpdateStandardAssetHoldingsPayload } from './TUpdateStandardAssetHoldingsPayload'; diff --git a/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx b/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx index 63abf190..2b899c0d 100644 --- a/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx +++ b/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx @@ -11,14 +11,13 @@ import { ModalHeader, Spinner, Text, + useDisclosure, VStack, } from '@chakra-ui/react'; import React, { ChangeEvent, FC, - KeyboardEvent, ReactNode, - useEffect, useMemo, useRef, useState, @@ -30,9 +29,6 @@ import { useDispatch } from 'react-redux'; // components import Button from '@extension/components/Button'; import IconButton from '@extension/components/IconButton'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import AddAssetsARC0200AssetItem from './AddAssetsARC0200AssetItem'; import AddAssetsARC0200AssetSummaryModalContent from './AddAssetsARC0200AssetSummaryModalContent'; import AddAssetsConfirmingModalContent from './AddAssetsConfirmingModalContent'; @@ -45,6 +41,9 @@ import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; // enums import { AssetTypeEnum, ErrorCodeEnum } from '@extension/enums'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features import { addARC0200AssetHoldingsThunk, @@ -69,6 +68,11 @@ import usePrimaryColor from '@extension/hooks/usePrimaryColor'; import usePrimaryColorScheme from '@extension/hooks/usePrimaryColorScheme'; import useIsNewSelectedAsset from './hooks/useIsNewSelectedAsset'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors import { useSelectAccounts, @@ -79,10 +83,8 @@ import { useSelectAddAssetsSelectedAsset, useSelectAddAssetsStandardAssets, useSelectLogger, - useSelectPasswordLockPassword, useSelectSettingsPreferredBlockExplorer, useSelectSelectedNetwork, - useSelectSettings, } from '@extension/selectors'; // services @@ -114,9 +116,13 @@ import isReKeyedAuthAccountAvailable from '@extension/utils/isReKeyedAuthAccount const AddAssetsModal: FC = ({ onClose }) => { const { t } = useTranslation(); - const passwordInputRef = useRef(null); const dispatch = useDispatch(); const assetContainerRef = useRef(null); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const account = useSelectAddAssetsAccount(); const accounts = useSelectAccounts(); @@ -125,10 +131,8 @@ const AddAssetsModal: FC = ({ onClose }) => { const explorer = useSelectSettingsPreferredBlockExplorer(); const fetching = useSelectAddAssetsFetching(); const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); const selectedNetwork = useSelectSelectedNetwork(); const selectedAsset = useSelectAddAssetsSelectedAsset(); - const settings = useSelectSettings(); const standardAssets = useSelectAddAssetsStandardAssets(); // hooks const defaultTextColor = useDefaultTextColor(); @@ -156,14 +160,6 @@ const AddAssetsModal: FC = ({ onClose }) => { // if it has not been re-keyed, check if it is a watch account return !account.watchAccount; }, [account, accounts, selectedNetwork]); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const primaryColor = usePrimaryColor(); const primaryColorScheme = usePrimaryColorScheme(); // state @@ -279,9 +275,11 @@ const AddAssetsModal: FC = ({ onClose }) => { dispatch(setConfirming(false)); }; - const handleAddStandardAssetClick = async () => { - const _functionName: string = 'handleAddStandardAssetClick'; - let _password: string | null; + const handleAddStandardAssetClick = () => onAuthenticationModalOpen(); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleOnAuthenticationModalConfirm'; let hasQuestBeenCompletedToday: boolean = false; let questsSent: boolean = false; let questsService: QuestsService; @@ -295,30 +293,6 @@ const AddAssetsModal: FC = ({ onClose }) => { return; } - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${AddAssetsModal.name}#${_functionName}: password not valid` - ); - - return; - } - } - - _password = settings.security.enablePasswordLock - ? passwordLockPassword - : password; - - if (!_password) { - logger.debug( - `${AddAssetsModal.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - dispatch(setConfirming(true)); try { @@ -327,7 +301,7 @@ const AddAssetsModal: FC = ({ onClose }) => { accountId: account.id, assets: [selectedAsset], genesisHash: selectedNetwork.genesisHash, - password: _password, + ...result, }) ).unwrap(); @@ -376,10 +350,6 @@ const AddAssetsModal: FC = ({ onClose }) => { handleClose(); } catch (error) { switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - - break; case ErrorCodeEnum.OfflineError: dispatch( createNotification({ @@ -413,7 +383,6 @@ const AddAssetsModal: FC = ({ onClose }) => { dispatch(clearAssets()); }; const handleClose = () => { - resetPassword(); setQuery(''); setQueryARC0200AssetDispatch(null); setQueryStandardAssetDispatch(null); @@ -485,13 +454,18 @@ const AddAssetsModal: FC = ({ onClose }) => { ) ); }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - await handleAddStandardAssetClick(); - } - }; + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); const handleOnQueryChange = (event: ChangeEvent) => { setQuery(event.target.value); }; @@ -525,7 +499,6 @@ const AddAssetsModal: FC = ({ onClose }) => { }; const handlePreviousClick = () => { dispatch(setSelectedAsset(null)); - resetPassword(); }; const handleSelectAssetClick = (asset: IAssetTypes) => dispatch(setSelectedAsset(asset)); @@ -677,31 +650,18 @@ const AddAssetsModal: FC = ({ onClose }) => { // for standard assets, we need a password to authorize the opt-in transaction if (selectedAsset.type === AssetTypeEnum.Standard) { return ( - - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ('captions.mustEnterPasswordToAuthorizeOptIn')} - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password} - /> - )} + + {previousButtonNode} - - {previousButtonNode} - - - - + + ); } @@ -728,43 +688,43 @@ const AddAssetsModal: FC = ({ onClose }) => { ); }; - // only standard assets will have the password submit - useEffect(() => { - if ( - selectedAsset && - selectedAsset.type === AssetTypeEnum.Standard && - passwordInputRef.current - ) { - passwordInputRef.current.focus(); - } - }, [selectedAsset]); - return ( - - + {/*authentication modal*/} + ('captions.mustEnterPasswordToAuthorizeOptIn')} + /> + + - - - {t('headings.addAsset')} - - - - - {renderContent()} - - - {renderFooter()} - - + + + + {t('headings.addAsset')} + + + + + {renderContent()} + + + {renderFooter()} + + + ); }; diff --git a/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx b/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx index 92499078..87378c4a 100644 --- a/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx +++ b/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx @@ -19,8 +19,11 @@ import { DEFAULT_GAP, } from '@extension/constants'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // errors -import { DecryptionError } from '@extension/errors'; +import { BaseExtensionError, DecryptionError } from '@extension/errors'; // features import { create as createNotification } from '@extension/features/notifications'; @@ -30,7 +33,9 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import usePrimaryColorScheme from '@extension/hooks/usePrimaryColorScheme'; // modals -import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; // models import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; @@ -38,8 +43,8 @@ import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; // selectors import { useSelectActiveAccount, - useSelectNonWatchAccounts, useSelectLogger, + useSelectNonWatchAccounts, } from '@extension/selectors'; // types @@ -47,16 +52,22 @@ import type { IAccountWithExtendedProps, IAppThunkDispatch, } from '@extension/types'; +import type { ISeedPhraseInput } from './types'; // utils import convertPrivateKeyToSeedPhrase from '@extension/utils/convertPrivateKeyToSeedPhrase'; import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; +import fetchDecryptedKeyPairFromStorageWithPasskey from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey'; import fetchDecryptedKeyPairFromStorageWithPassword from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPassword'; const ViewSeedPhrasePage: FC = () => { const { t } = useTranslation(); const dispatch = useDispatch(); - const { isOpen, onClose, onOpen } = useDisclosure(); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const accounts = useSelectNonWatchAccounts(); const activeAccount = useSelectActiveAccount(); @@ -65,32 +76,49 @@ const ViewSeedPhrasePage: FC = () => { const defaultTextColor = useDefaultTextColor(); const primaryColorScheme = usePrimaryColorScheme(); // state - const [password, setPassword] = useState(null); - const [seedPhrase, setSeedPhrase] = useState( - createMaskedSeedPhrase() - ); + const [decrypting, setDecrypting] = useState(false); + const [seedPhrase, setSeedPhrase] = useState({ + masked: true, + value: createMaskedSeedPhrase(), + }); const [selectedAccount, setSelectedAccount] = useState(null); - // misc - const decryptSeedPhrase = async () => { - const _functionName = 'decryptSeedPhrase'; - let keyPair: Ed21559KeyPair | null; + // handlers + const handleAccountSelect = (account: IAccountWithExtendedProps) => + setSelectedAccount(account); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleOnAuthenticationModalConfirm'; + let keyPair: Ed21559KeyPair | null = null; - if (!password || !selectedAccount) { + if (!selectedAccount) { logger?.debug( - `${ViewSeedPhrasePage.name}#${_functionName}: no password or account found` + `${ViewSeedPhrasePage.name}#${_functionName}: no account selected` ); return; } + setDecrypting(true); + // get the private key try { - keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ - logger, - password, - publicKey: selectedAccount.publicKey, - }); + if (result.type === EncryptionMethodEnum.Passkey) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPasskey({ + inputKeyMaterial: result.inputKeyMaterial, + logger, + publicKey: selectedAccount.publicKey, + }); + } + + if (result.type === EncryptionMethodEnum.Password) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ + logger, + password: result.password, + publicKey: selectedAccount.publicKey, + }); + } if (!keyPair) { throw new DecryptionError( @@ -101,19 +129,21 @@ const ViewSeedPhrasePage: FC = () => { } // convert the private key to the seed phrase - setSeedPhrase( - convertPrivateKeyToSeedPhrase({ + setSeedPhrase({ + masked: false, + value: convertPrivateKeyToSeedPhrase({ logger, privateKey: keyPair.privateKey, - }) - ); + }), + }); } catch (error) { - logger?.error( - `${ViewSeedPhrasePage.name}#${_functionName}: ${error.message}` - ); + logger?.error(`${ViewSeedPhrasePage.name}#${_functionName}:`, error); // reset the seed phrase - setSeedPhrase(createMaskedSeedPhrase()); + setSeedPhrase({ + masked: true, + value: createMaskedSeedPhrase(), + }); dispatch( createNotification({ @@ -126,26 +156,24 @@ const ViewSeedPhrasePage: FC = () => { type: 'error', }) ); - - return; } - }; - // handlers - const handleAccountSelect = (account: IAccountWithExtendedProps) => - setSelectedAccount(account); - const handleOnConfirmPasswordModalConfirm = async (password: string) => { - // close the password modal - onClose(); - setPassword(password); + setDecrypting(false); }; - const handleViewClick = () => onOpen(); + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + const handleViewClick = () => onAuthenticationModalOpen(); - useEffect(() => { - if (password) { - (async () => await decryptSeedPhrase())(); - } - }, [password, selectedAccount]); useEffect(() => { let _selectedAccount: IAccountWithExtendedProps; @@ -163,10 +191,12 @@ const ViewSeedPhrasePage: FC = () => { return ( <> - {/*page title*/} @@ -217,15 +247,15 @@ const ViewSeedPhrasePage: FC = () => { {/*seed phrase*/} - + - {password ? ( + {!seedPhrase.masked ? ( // copy seed phrase button @@ -234,6 +264,7 @@ const ViewSeedPhrasePage: FC = () => { ) : ( // view button + + + + + + + ); +}; + +export default ConfirmPasswordModal; diff --git a/src/extension/modals/ConfirmPasswordModal/index.ts b/src/extension/modals/ConfirmPasswordModal/index.ts new file mode 100644 index 00000000..7ee7a1a6 --- /dev/null +++ b/src/extension/modals/ConfirmPasswordModal/index.ts @@ -0,0 +1,2 @@ +export { default } from './ConfirmPasswordModal'; +export * from './types'; diff --git a/src/extension/modals/ConfirmPasswordModal/types/IProps.ts b/src/extension/modals/ConfirmPasswordModal/types/IProps.ts new file mode 100644 index 00000000..36b3ef1e --- /dev/null +++ b/src/extension/modals/ConfirmPasswordModal/types/IProps.ts @@ -0,0 +1,11 @@ +// errors +import { BaseExtensionError } from '@extension/errors'; + +interface IProps { + isOpen: boolean; + hint?: string; + onCancel: () => void; + onConfirm: (password: string) => void; +} + +export default IProps; diff --git a/src/extension/modals/ConfirmPasswordModal/types/index.ts b/src/extension/modals/ConfirmPasswordModal/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/modals/ConfirmPasswordModal/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx b/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx index 55f1d7a7..f7fa2cf0 100644 --- a/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx +++ b/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx @@ -6,18 +6,16 @@ import { ModalContent, ModalFooter, ModalHeader, + useDisclosure, VStack, } from '@chakra-ui/react'; -import React, { FC, KeyboardEvent, ReactNode, useRef } from 'react'; +import React, { FC, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; // components import Button from '@extension/components/Button'; import ModalSkeletonItem from '@extension/components/ModalSkeletonItem'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import ReKeyAccountConfirmingModalContent from './ReKeyAccountConfirmingModalContent'; import ReKeyAccountModalContent from './ReKeyAccountModalContent'; import UndoReKeyAccountModalContent from './UndoReKeyAccountModalContent'; @@ -44,13 +42,13 @@ import { useAddressInput } from '@extension/components/AddressInput'; import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useReKeyAccountModal from './hooks/useReKeyAccountModal'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors -import { - useSelectAccounts, - useSelectLogger, - useSelectPasswordLockPassword, - useSelectSettings, -} from '@extension/selectors'; +import { useSelectAccounts, useSelectLogger } from '@extension/selectors'; // theme import { theme } from '@extension/theme'; @@ -63,13 +61,15 @@ import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVM const ReKeyAccountModal: FC = ({ onClose }) => { const { t } = useTranslation(); - const passwordInputRef = useRef(null); const dispatch = useDispatch(); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const accounts = useSelectAccounts(); const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); - const settings = useSelectSettings(); // hooks const { error: authAddressError, @@ -80,14 +80,6 @@ const ReKeyAccountModal: FC = ({ onClose }) => { value: authAddress, } = useAddressInput(); const defaultTextColor = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const { account, accountInformation, @@ -96,74 +88,8 @@ const ReKeyAccountModal: FC = ({ onClose }) => { type: reKeyType, } = useReKeyAccountModal(); // misc - const checkPassword = (): string | null => { - const _functionName = 'checkPassword'; - - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${ReKeyAccountModal.name}#${_functionName}: password not valid` - ); - - return null; - } - } - - return settings.security.enablePasswordLock - ? passwordLockPassword - : password; - }; const isOpen = !!account && !!accountInformation; - const handleError = (error: BaseExtensionError) => { - switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - - break; - case ErrorCodeEnum.OfflineError: - dispatch( - createNotification({ - ephemeral: true, - title: t('headings.offline'), - type: 'error', - }) - ); - break; - default: - dispatch( - createNotification({ - description: t('errors.descriptions.code', { - code: error.code, - context: error.code, - }), - ephemeral: true, - title: t('errors.titles.code', { context: error.code }), - type: 'error', - }) - ); - break; - } - }; - const handleCancelClick = () => handleClose(); - const handleClose = () => { - resetPassword(); - resetAuthAddress(); - onClose && onClose(); - }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - reKeyType === 'undo' - ? await handleUndoReKeyClick() - : await handleReKeyClick(); - } - }; - const handleReKeyClick = async () => { - const _functionName = 'handleReKeyClick'; - let _password: string | null; + const reKeyAccount = async (result: TOnConfirmResult) => { let transactionId: string | null; if ( @@ -176,23 +102,13 @@ const ReKeyAccountModal: FC = ({ onClose }) => { return; } - _password = checkPassword(); - - if (!_password) { - logger.debug( - `${ReKeyAccountModal.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - try { transactionId = await dispatch( reKeyAccountThunk({ authorizedAddress: authAddress, reKeyAccount: account, network: network, - password: _password, + ...result, }) ).unwrap(); @@ -219,31 +135,19 @@ const ReKeyAccountModal: FC = ({ onClose }) => { handleError(error); } }; - const handleUndoReKeyClick = async () => { - const _functionName = 'handleUndoReKeyClick'; - let _password: string | null; + const undoReKeyAccount = async (result: TOnConfirmResult) => { let transactionId: string | null; if (!account || !accountInformation || !network) { return; } - _password = checkPassword(); - - if (!_password) { - logger.debug( - `${ReKeyAccountModal.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - try { transactionId = await dispatch( undoReKeyAccountThunk({ reKeyAccount: account, network: network, - password: _password, + ...result, }) ).unwrap(); @@ -270,6 +174,50 @@ const ReKeyAccountModal: FC = ({ onClose }) => { handleError(error); } }; + // handlers + const handleError = (error: BaseExtensionError) => { + switch (error.code) { + case ErrorCodeEnum.OfflineError: + dispatch( + createNotification({ + ephemeral: true, + title: t('headings.offline'), + type: 'error', + }) + ); + break; + default: + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + break; + } + }; + const handleCancelClick = () => handleClose(); + const handleClose = () => { + resetAuthAddress(); + onClose && onClose(); + }; + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + if (reKeyType === 'rekey') { + return await reKeyAccount(result); + } + + if (reKeyType === 'undo') { + return await undoReKeyAccount(result); + } + }; + const handleReKeyOrUndoClick = () => onAuthenticationModalOpen(); // renders const renderContent = () => { if (account && accountInformation && network) { @@ -350,46 +298,18 @@ const ReKeyAccountModal: FC = ({ onClose }) => { if (accountInformation) { return ( - - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ( - reKeyType === 'undo' - ? 'captions.mustEnterPasswordToAuthorizeUndoReKey' - : 'captions.mustEnterPasswordToAuthorizeReKey' - )} - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password} - /> - )} - - - {cancelButtonNode} - - {reKeyType === 'undo' ? ( - - ) : ( - - )} - - + + {cancelButtonNode} + + + ); } @@ -397,35 +317,50 @@ const ReKeyAccountModal: FC = ({ onClose }) => { }; return ( - - + {/*authentication modal*/} + ( + reKeyType === 'undo' + ? 'captions.mustEnterPasswordToAuthorizeUndoReKey' + : 'captions.mustEnterPasswordToAuthorizeReKey' + )} + /> + + - - - {t( - accountInformation && reKeyType === 'undo' - ? 'headings.undoReKey' - : 'headings.reKeyAccount' - )} - - - - - {renderContent()} - - - {renderFooter()} - - + + + + {t( + accountInformation && reKeyType === 'undo' + ? 'headings.undoReKey' + : 'headings.reKeyAccount' + )} + + + + + {renderContent()} + + + {renderFooter()} + + + ); }; diff --git a/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx b/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx index 6990299f..f112abbe 100644 --- a/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx +++ b/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx @@ -7,10 +7,11 @@ import { ModalFooter, ModalHeader, Text, + useDisclosure, VStack, } from '@chakra-ui/react'; import BigNumber from 'bignumber.js'; -import React, { FC, KeyboardEvent, ReactNode, useEffect, useRef } from 'react'; +import React, { FC, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; @@ -25,9 +26,6 @@ import ModalAssetItem from '@extension/components/ModalAssetItem'; import ModalItem from '@extension/components/ModalItem'; import ModalTextItem from '@extension/components/ModalTextItem'; import OpenTabIconButton from '@extension/components/OpenTabIconButton'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import RemoveAssetsConfirmingModalContent from './RemoveAssetsConfirmingModalContent'; // constants @@ -40,6 +38,9 @@ import { // enums import { AssetTypeEnum, ErrorCodeEnum } from '@extension/enums'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features import { removeARC0200AssetHoldingsThunk, @@ -52,16 +53,18 @@ import { setConfirming } from '@extension/features/remove-assets'; import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors import { useSelectAccounts, - useSelectLogger, - useSelectPasswordLockPassword, useSelectRemoveAssetsAccount, useSelectRemoveAssetsConfirming, useSelectRemoveAssetsSelectedAsset, useSelectSelectedNetwork, - useSelectSettings, useSelectSettingsPreferredBlockExplorer, } from '@extension/selectors'; @@ -78,29 +81,22 @@ import createIconFromDataUri from '@extension/utils/createIconFromDataUri'; const RemoveAssetsModal: FC = ({ onClose }) => { const { t } = useTranslation(); - const passwordInputRef = useRef(null); const dispatch = useDispatch(); const navigate = useNavigate(); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const account = useSelectRemoveAssetsAccount(); const accounts = useSelectAccounts(); const confirming = useSelectRemoveAssetsConfirming(); const explorer = useSelectSettingsPreferredBlockExplorer(); - const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); const selectedNetwork = useSelectSelectedNetwork(); const selectedAsset = useSelectRemoveAssetsSelectedAsset(); - const settings = useSelectSettings(); // hooks const defaultTextColor = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const subTextColor = useSubTextColor(); // misc const isOpen = !!account && !!selectedAsset; @@ -157,10 +153,11 @@ const RemoveAssetsModal: FC = ({ onClose }) => { dispatch(setConfirming(false)); }; - const handleRemoveStandardAssetClick = async () => { - const _functionName = 'handleAddStandardAssetClick'; - let _password: string | null; - + const handleRemoveStandardAssetClick = async () => + onAuthenticationModalOpen(); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { if ( !selectedNetwork || !account || @@ -170,30 +167,6 @@ const RemoveAssetsModal: FC = ({ onClose }) => { return; } - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${RemoveAssetsModal.name}#${_functionName}: password not valid` - ); - - return; - } - } - - _password = settings.security.enablePasswordLock - ? passwordLockPassword - : password; - - if (!_password) { - logger.debug( - `${RemoveAssetsModal.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - dispatch(setConfirming(true)); try { @@ -202,7 +175,7 @@ const RemoveAssetsModal: FC = ({ onClose }) => { accountId: account.id, assets: [selectedAsset], genesisHash: selectedNetwork.genesisHash, - password: _password, + ...result, }) ).unwrap(); @@ -224,10 +197,6 @@ const RemoveAssetsModal: FC = ({ onClose }) => { handleClose(); } catch (error) { switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - - break; case ErrorCodeEnum.OfflineError: dispatch( createNotification({ @@ -256,23 +225,19 @@ const RemoveAssetsModal: FC = ({ onClose }) => { dispatch(setConfirming(false)); }; const handleCancelClick = () => handleClose(); - const handleClose = () => { - resetPassword(); - onClose(); - }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - if (selectedAsset?.type === AssetTypeEnum.ARC0200) { - return await handleRemoveARC0200AssetClick(); - } - - if (selectedAsset?.type === AssetTypeEnum.Standard) { - return await handleRemoveStandardAssetClick(); - } - } - }; + const handleClose = () => onClose(); + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); // renders const renderContent = () => { let address: string; @@ -479,31 +444,18 @@ const RemoveAssetsModal: FC = ({ onClose }) => { if (selectedAsset.type === AssetTypeEnum.Standard) { return ( - - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ('captions.mustEnterPasswordToAuthorizeOptOut')} - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password} - /> - )} + + {cancelButtonNode} - - {cancelButtonNode} - - - - + + ); } } @@ -541,41 +493,41 @@ const RemoveAssetsModal: FC = ({ onClose }) => { ); }; - // only standard assets will have the password submit - useEffect(() => { - if ( - selectedAsset && - selectedAsset.type === AssetTypeEnum.Standard && - passwordInputRef.current - ) { - passwordInputRef.current.focus(); - } - }, [selectedAsset]); - return ( - - + {/*authentication modal*/} + ('captions.mustEnterPasswordToAuthorizeOptOut')} + /> + + - - {renderHeader()} - - - - {renderContent()} - - - {renderFooter()} - - + + + {renderHeader()} + + + + {renderContent()} + + + {renderFooter()} + + + ); }; diff --git a/src/extension/modals/SendAssetModal/SendAssetModal.tsx b/src/extension/modals/SendAssetModal/SendAssetModal.tsx index 2c6d6655..d5f4847b 100644 --- a/src/extension/modals/SendAssetModal/SendAssetModal.tsx +++ b/src/extension/modals/SendAssetModal/SendAssetModal.tsx @@ -8,18 +8,12 @@ import { ModalHeader, Text, Textarea, + useDisclosure, VStack, } from '@chakra-ui/react'; import { Transaction } from 'algosdk'; import BigNumber from 'bignumber.js'; -import React, { - ChangeEvent, - FC, - KeyboardEvent, - useEffect, - useRef, - useState, -} from 'react'; +import React, { ChangeEvent, FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { IoArrowBackOutline, IoArrowForwardOutline } from 'react-icons/io5'; import { useDispatch } from 'react-redux'; @@ -31,20 +25,20 @@ import AddressInput, { } from '@extension/components/AddressInput'; import AssetSelect from '@extension/components/AssetSelect'; import Button from '@extension/components/Button'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import SendAmountInput from './SendAmountInput'; import SendAssetModalConfirmingContent from './SendAssetModalConfirmingContent'; import SendAssetModalContentSkeleton from './SendAssetModalContentSkeleton'; import SendAssetModalSummaryContent from './SendAssetModalSummaryContent'; // constants -import { DEFAULT_GAP } from '@extension/constants'; +import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; // enums import { AssetTypeEnum, ErrorCodeEnum } from '@extension/enums'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features import { updateAccountsThunk } from '@extension/features/accounts'; import { create as createNotification } from '@extension/features/notifications'; @@ -63,13 +57,17 @@ import { import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import usePrimaryColor from '@extension/hooks/usePrimaryColor'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors import { useSelectAccounts, useSelectARC0200AssetsBySelectedNetwork, useSelectAvailableAccountsForSelectedNetwork, useSelectLogger, - useSelectPasswordLockPassword, useSelectSelectedNetwork, useSelectSendAssetAmountInStandardUnits, useSelectSendAssetConfirming, @@ -77,7 +75,6 @@ import { useSelectSendAssetFromAccount, useSelectSendAssetNote, useSelectSendAssetSelectedAsset, - useSelectSettings, useSelectStandardAssetsBySelectedNetwork, } from '@extension/selectors'; @@ -105,8 +102,12 @@ import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVM const SendAssetModal: FC = ({ onClose }) => { const { t } = useTranslation(); - const passwordInputRef = useRef(null); const dispatch = useDispatch(); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const accounts = useSelectAccounts(); const amountInStandardUnits = useSelectSendAssetAmountInStandardUnits(); @@ -119,9 +120,7 @@ const SendAssetModal: FC = ({ onClose }) => { const logger = useSelectLogger(); const network = useSelectSelectedNetwork(); const note = useSelectSendAssetNote(); - const passwordLockPassword = useSelectPasswordLockPassword(); const selectedAsset = useSelectSendAssetSelectedAsset(); - const settings = useSelectSettings(); // hooks const { error: toAddressError, @@ -131,16 +130,8 @@ const SendAssetModal: FC = ({ onClose }) => { validate: validateToAddress, value: toAddress, } = useAddressInput(); - const defaultTextColor: string = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); - const primaryColor: string = usePrimaryColor(); + const defaultTextColor = useDefaultTextColor(); + const primaryColor = usePrimaryColor(); // state const [maximumTransactionAmount, setMaximumTransactionAmount] = useState('0'); @@ -170,13 +161,12 @@ const SendAssetModal: FC = ({ onClose }) => { // reset modal input and transactions setTransactions(null); resetToAddress(); - resetPassword(); onClose && onClose(); }; const handleFromAccountChange = (account: IAccountWithExtendedProps) => dispatch(setFromAddress(convertPublicKeyToAVMAddress(account.publicKey))); const handleNextClick = async () => { - const _functionName: string = 'handleNextClick'; + const _functionName = 'handleNextClick'; let _transactions: Transaction[]; if (validateToAddress()) { @@ -221,20 +211,23 @@ const SendAssetModal: FC = ({ onClose }) => { dispatch( setNote(event.target.value.length > 0 ? event.target.value : null) ); - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + const handlePreviousClick = () => setTransactions(null); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult ) => { - if (event.key === 'Enter') { - await handleSendClick(); - } - }; - const handlePreviousClick = () => { - resetPassword(); - setTransactions(null); - }; - const handleSendClick = async () => { - const _functionName: string = 'handleSendClick'; - let _password: string | null; + const _functionName = 'handleOnAuthenticationModalConfirm'; let fromAddress: string; let hasQuestBeenCompletedToday: boolean = false; let questsService: QuestsService; @@ -246,35 +239,11 @@ const SendAssetModal: FC = ({ onClose }) => { return; } - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${SendAssetModal.name}#${_functionName}: password not valid` - ); - - return; - } - } - - _password = settings.security.enablePasswordLock - ? passwordLockPassword - : password; - - if (!_password) { - logger.debug( - `${SendAssetModal.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - try { transactionIds = await dispatch( submitTransactionThunk({ - password: _password, transactions, + ...result, }) ).unwrap(); toAccount = @@ -382,10 +351,6 @@ const SendAssetModal: FC = ({ onClose }) => { handleClose(); } catch (error) { switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - - break; case ErrorCodeEnum.OfflineError: dispatch( createNotification({ @@ -411,6 +376,7 @@ const SendAssetModal: FC = ({ onClose }) => { } } }; + const handleSendClick = () => onAuthenticationModalOpen(); const handleToAddressChange = (value: string) => { dispatch(setToAddress(value.length > 0 ? value : null)); onToAddressChange(value); @@ -541,39 +507,21 @@ const SendAssetModal: FC = ({ onClose }) => { if (transactions && transactions.length > 0) { return ( - - {!settings.security.enablePasswordLock && !passwordLockPassword && ( - ('captions.mustEnterPasswordToSendTransaction')} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - inputRef={passwordInputRef} - value={password} - /> - )} - - - - - - - + + + + + ); } @@ -631,11 +579,6 @@ const SendAssetModal: FC = ({ onClose }) => { } }; - useEffect(() => { - if (transactions && transactions.length > 0 && passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, [transactions]); useEffect(() => { let newMaximumTransactionAmount: BigNumber; @@ -663,29 +606,40 @@ const SendAssetModal: FC = ({ onClose }) => { }, [fromAccount, network, selectedAsset]); return ( - - + {/*authentication modal*/} + ('captions.mustEnterPasswordToSendTransaction')} + /> + + - - {renderHeader()} - - - - {renderContent()} - - - {renderFooter()} - - + + + {renderHeader()} + + + + {renderContent()} + + + {renderFooter()} + + + ); }; diff --git a/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx b/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx index e3a5baa7..f9f18cd4 100644 --- a/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx +++ b/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx @@ -36,7 +36,11 @@ const ChangePasswordPage: FC = () => { const { t } = useTranslation(); const dispatch = useDispatch(); const navigate = useNavigate(); - const { isOpen, onClose, onOpen } = useDisclosure(); + const { + isOpen: isConfirmPasswordModalOpen, + onClose: onConfirmPasswordModalClose, + onOpen: onConfirmPasswordModalOpen, + } = useDisclosure(); // hooks const { changePasswordAction, @@ -60,13 +64,13 @@ const ChangePasswordPage: FC = () => { }; const handleChangeClick = () => { if (!validate(newPassword || '', score, t)) { - onOpen(); + onConfirmPasswordModalOpen(); } }; const handleOnConfirmPasswordModalConfirm = async ( currentPassword: string ) => { - onClose(); + onConfirmPasswordModalClose(); // save the new password if (newPassword) { @@ -116,8 +120,8 @@ const ChangePasswordPage: FC = () => { isOpen={isLoading} /> diff --git a/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx b/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx index 54f1cfbf..586fe3dc 100644 --- a/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx +++ b/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx @@ -17,7 +17,6 @@ import { useDispatch } from 'react-redux'; // components import AccountSelect from '@extension/components/AccountSelect'; -import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; import CopyButton from '@extension/components/CopyButton'; import EmptyState from '@extension/components/EmptyState'; import PageHeader from '@extension/components/PageHeader'; @@ -25,8 +24,11 @@ import PageHeader from '@extension/components/PageHeader'; // constants import { DEFAULT_GAP } from '@extension/constants'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // errors -import { DecryptionError } from '@extension/errors'; +import { BaseExtensionError, DecryptionError } from '@extension/errors'; // features import { create as createNotification } from '@extension/features/notifications'; @@ -38,6 +40,11 @@ import usePrimaryColorScheme from '@extension/hooks/usePrimaryColorScheme'; // images import qrCodePlaceholderImage from '@extension/images/placeholder_qr_code.png'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // models import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; @@ -46,8 +53,6 @@ import { useSelectAccounts, useSelectActiveAccount, useSelectLogger, - useSelectPasswordLockPassword, - useSelectSettings, } from '@extension/selectors'; // services @@ -64,37 +69,34 @@ import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVM import createAccountImportURI from '@extension/utils/createAccountImportURI'; import createWatchAccountImportURI from '@extension/utils/createWatchAccountImportURI'; import fetchDecryptedKeyPairFromStorageWithPassword from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPassword'; +import fetchDecryptedKeyPairFromStorageWithPasskey from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey'; const ExportAccountPage: FC = () => { const { t } = useTranslation(); const dispatch = useDispatch(); const { - isOpen: isPasswordConfirmModalOpen, - onClose: onPasswordConfirmModalClose, - onOpen: onPasswordConfirmModalOpen, + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, } = useDisclosure(); // selectors const accounts = useSelectAccounts(); const activeAccount = useSelectActiveAccount(); const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); - const settings = useSelectSettings(); // hooks const defaultTextColor = useDefaultTextColor(); const primaryColorScheme = usePrimaryColorScheme(); // states - const [password, setPassword] = useState(null); const [selectedAccount, setSelectedAccount] = useState(null); const [svgString, setSvgString] = useState(null); const [uri, setURI] = useState(null); // misc const qrCodeSize = 300; - const createQRCodeForPrivateKey = async () => { - const _functionName = 'createQRCode'; + const createQRCodeForWatchAccount = async () => { + const _functionName = 'createQRCodeForWatchAccount'; let _svgString: string; let _uri: string; - let keyPair: Ed21559KeyPair | null; if (!selectedAccount) { logger.debug( @@ -104,32 +106,12 @@ const ExportAccountPage: FC = () => { return; } - if (!password) { - logger.debug( - `${ExportAccountPage.name}#${_functionName}: no password found` - ); - - return; - } - try { - keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ - logger, - password, - publicKey: selectedAccount.publicKey, - }); - - if (!keyPair) { - throw new DecryptionError( - `failed to get private key for account "${convertPublicKeyToAVMAddress( - PrivateKeyService.decode(selectedAccount.publicKey) - )}"` - ); - } - - _uri = createAccountImportURI({ + _uri = createWatchAccountImportURI({ + address: convertPublicKeyToAVMAddress( + PrivateKeyService.decode(selectedAccount.publicKey) + ), assets: [], - privateKey: keyPair.privateKey, }); _svgString = await toString(_uri, { type: 'svg', @@ -159,10 +141,31 @@ const ExportAccountPage: FC = () => { return; } }; - const createQRCodeForWatchAccount = async () => { - const _functionName = 'createQRCodeForWatchAccount'; + // handlers + const handleOnAccountSelect = async (account: IAccountWithExtendedProps) => { + setSvgString(null); + setURI(null); + setSelectedAccount(account); + }; + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleOnAuthenticationModalConfirm'; let _svgString: string; let _uri: string; + let keyPair: Ed21559KeyPair | null = null; if (!selectedAccount) { logger.debug( @@ -173,11 +176,33 @@ const ExportAccountPage: FC = () => { } try { - _uri = createWatchAccountImportURI({ - address: convertPublicKeyToAVMAddress( - PrivateKeyService.decode(selectedAccount.publicKey) - ), + if (result.type === EncryptionMethodEnum.Passkey) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPasskey({ + inputKeyMaterial: result.inputKeyMaterial, + logger, + publicKey: selectedAccount.publicKey, + }); + } + + if (result.type === EncryptionMethodEnum.Password) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ + logger, + password: result.password, + publicKey: selectedAccount.publicKey, + }); + } + + if (!keyPair) { + throw new DecryptionError( + `failed to get private key for account "${convertPublicKeyToAVMAddress( + PrivateKeyService.decode(selectedAccount.publicKey) + )}"` + ); + } + + _uri = createAccountImportURI({ assets: [], + privateKey: keyPair.privateKey, }); _svgString = await toString(_uri, { type: 'svg', @@ -203,41 +228,15 @@ const ExportAccountPage: FC = () => { setSvgString(null); setURI(null); - - return; } }; - // handlers - const handleOnAccountSelect = async (account: IAccountWithExtendedProps) => { - setSvgString(null); - setURI(null); - setSelectedAccount(account); - }; - const handleOnConfirmModalConfirm = (_password: string) => { - onPasswordConfirmModalClose(); - setPassword(_password); - }; - const handleViewClick = () => { - if (settings.security.enablePasswordLock && passwordLockPassword) { - setPassword(passwordLockPassword); - - return; - } - - // get password - onPasswordConfirmModalOpen(); - }; + const handleViewClick = () => onAuthenticationModalOpen(); useEffect(() => { if (selectedAccount && selectedAccount.watchAccount) { (async () => await createQRCodeForWatchAccount())(); } }, [selectedAccount]); - useEffect(() => { - if (password && selectedAccount && !selectedAccount.watchAccount) { - (async () => await createQRCodeForPrivateKey())(); - } - }, [selectedAccount, password]); useEffect(() => { if (activeAccount && !selectedAccount) { setSelectedAccount(activeAccount); @@ -246,10 +245,12 @@ const ExportAccountPage: FC = () => { return ( <> - { const { t } = useTranslation(); const dispatch = useDispatch(); const { - isOpen: isPasswordConfirmModalOpen, - onClose: onPasswordConfirmModalClose, - onOpen: onPasswordConfirmModalOpen, + isOpen: isConfirmPasswordModalOpen, + onClose: onConfirmPasswordModalClose, + onOpen: onConfirmPasswordModalOpen, } = useDisclosure(); // selectors const logger = useSelectLogger(); @@ -113,11 +113,11 @@ const SecuritySettingsIndexPage: FC = () => { const handleEnablePasswordLockSwitchChange = async ( event: ChangeEvent ) => { - const _functionName: string = 'handleEnablePasswordLockSwitchChange'; + const _functionName = 'handleEnablePasswordLockSwitchChange'; // if we are enabling, we need to set the password if (event.target.checked) { - onPasswordConfirmModalOpen(); + onConfirmPasswordModalOpen(); return; } @@ -143,9 +143,9 @@ const SecuritySettingsIndexPage: FC = () => { } }; const handleOnConfirmPasswordModalConfirm = async (password: string) => { - const _functionName: string = 'handleOnConfirmPasswordModalConfirm'; + const _functionName = 'handleOnConfirmPasswordModalConfirm'; - onPasswordConfirmModalClose(); + onConfirmPasswordModalClose(); try { // enable the lock and wait for the settings to be updated @@ -181,9 +181,10 @@ const SecuritySettingsIndexPage: FC = () => { return ( <> + {/*confirm password modal*/} From ae626fcb603774bf13cb2fe54a6fdafae0e55db7 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Sun, 14 Jul 2024 15:44:17 +0100 Subject: [PATCH 25/31] chore: squash --- .../SignMessageModal/SignMessageModal.tsx | 181 +++++++++--------- .../SignTransactionsModal.tsx | 4 +- .../PasswordLockPage/PasswordLockPage.tsx | 154 ++++++++------- src/extension/utils/signBytes/signBytes.ts | 32 +++- .../types/{IOptions.ts => TOptions.ts} | 6 +- src/extension/utils/signBytes/types/index.ts | 2 +- 6 files changed, 198 insertions(+), 181 deletions(-) rename src/extension/utils/signBytes/types/{IOptions.ts => TOptions.ts} (72%) diff --git a/src/extension/modals/SignMessageModal/SignMessageModal.tsx b/src/extension/modals/SignMessageModal/SignMessageModal.tsx index b0dee4ad..749fd719 100644 --- a/src/extension/modals/SignMessageModal/SignMessageModal.tsx +++ b/src/extension/modals/SignMessageModal/SignMessageModal.tsx @@ -11,10 +11,11 @@ import { ModalFooter, ModalHeader, Text, + useDisclosure, VStack, } from '@chakra-ui/react'; import { encode as encodeBase64 } from '@stablelib/base64'; -import React, { FC, KeyboardEvent, useEffect, useRef } from 'react'; +import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; @@ -25,16 +26,13 @@ import Button from '@extension/components/Button'; import ClientHeader, { ClientHeaderSkeleton, } from '@extension/components/ClientHeader'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import SignMessageContentSkeleton from './SignMessageContentSkeleton'; // constants import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; -// enums -import { ErrorCodeEnum } from '@extension/enums'; +// errors +import { BaseExtensionError } from '@extension/errors'; // features import { removeEventByIdThunk } from '@extension/features/events'; @@ -45,6 +43,11 @@ import { create as createNotification } from '@extension/features/notifications' import useSubTextColor from '@extension/hooks/useSubTextColor'; import useSignMessageModal from './hooks/useSignMessageModal'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors import { useSelectAccountsFetching, @@ -71,20 +74,16 @@ import signBytes from '@extension/utils/signBytes'; const SignMessageModal: FC = ({ onClose }) => { const { t } = useTranslation(); - const passwordInputRef = useRef(null); const dispatch = useDispatch(); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const fetching = useSelectAccountsFetching(); const logger = useSelectLogger(); // hooks - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const { authorizedAccounts, event, @@ -96,6 +95,18 @@ const SignMessageModal: FC = ({ onClose }) => { // handlers const handleAccountSelect = (account: IAccountWithExtendedProps) => setSigner(account); + const handleAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); const handleCancelClick = async () => { if (event) { await dispatch( @@ -117,33 +128,20 @@ const SignMessageModal: FC = ({ onClose }) => { handleClose(); }; const handleClose = () => { - resetPassword(); setAuthorizedAccounts(null); setSigner(null); - if (onClose) { - onClose(); - } + onClose && onClose(); }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult ) => { - if (event.key === 'Enter') { - await handleSignClick(); - } - }; - const handleSignClick = async () => { - const _functionName = 'handleSignClick'; + const _functionName = 'handleOnAuthenticationModalConfirm'; let questsService: QuestsService; let signature: Uint8Array; let signerAddress: string; - if ( - validatePassword() || - !event || - !event.payload.message.params || - !signer - ) { + if (!event || !event.payload.message.params || !signer) { return; } @@ -157,8 +155,8 @@ const SignMessageModal: FC = ({ onClose }) => { signature = await signBytes({ bytes: new TextEncoder().encode(event.payload.message.params.message), logger, - password, publicKey: PrivateKeyService.decode(signer.publicKey), + ...result, }); logger.debug( @@ -187,10 +185,6 @@ const SignMessageModal: FC = ({ onClose }) => { handleClose(); } catch (error) { switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - - break; default: dispatch( createNotification({ @@ -208,6 +202,8 @@ const SignMessageModal: FC = ({ onClose }) => { } } }; + const handleSignClick = () => onAuthenticationModalOpen(); + // renders const renderContent = () => { if ( fetching || @@ -258,61 +254,56 @@ const SignMessageModal: FC = ({ onClose }) => { ); }; - // focus when the modal is opened - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); - return ( - - + {/*authentication modal*/} + ('captions.mustEnterPasswordToSign')} + /> + + - - {event ? ( - - - - {/*caption*/} - - {t('captions.signMessageRequest')} - - - ) : ( - - )} - - - {renderContent()} - - - - ('captions.mustEnterPasswordToSign')} - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password} - /> - + + + {event ? ( + + + + {/*caption*/} + + {t('captions.signMessageRequest')} + + + ) : ( + + )} + + + {renderContent()} + + - - - - + + + + ); }; diff --git a/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx b/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx index f33c48f1..9590c355 100644 --- a/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx +++ b/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx @@ -208,7 +208,7 @@ const SignTransactionsModal: FC = ({ onClose }) => { setSigning(false); }; - const handleError = (error: BaseExtensionError) => + const handleAuthenticationError = (error: BaseExtensionError) => dispatch( createNotification({ description: t('errors.descriptions.code', { @@ -271,7 +271,7 @@ const SignTransactionsModal: FC = ({ onClose }) => { isOpen={isAuthenticationModalOpen} onCancel={onAuthenticationModalClose} onConfirm={handleOnAuthenticationModalConfirm} - onError={handleError} + onError={handleAuthenticationError} {...(event && event.payload.message.params && { passwordHint: t( diff --git a/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx b/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx index a8a71fcb..077414fd 100644 --- a/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx +++ b/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx @@ -1,27 +1,33 @@ -import { Center, Flex, Heading, VStack } from '@chakra-ui/react'; -import React, { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import { Center, Flex, Heading, useDisclosure, VStack } from '@chakra-ui/react'; +import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import browser from 'webextension-polyfill'; // components -import KibisisIcon from '@extension/components/KibisisIcon'; import Button from '@extension/components/Button'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; +import KibisisIcon from '@extension/components/KibisisIcon'; // constants import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features +import { create as createNotification } from '@extension/features/notifications'; import { savePasswordLockThunk } from '@extension/features/password-lock'; // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import usePrimaryColor from '@extension/hooks/usePrimaryColor'; +// modals +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; + // selectors import { useSelectLogger, @@ -39,34 +45,39 @@ const PasswordLockPage: FC = () => { const { t } = useTranslation(); const dispatch = useDispatch(); const navigate = useNavigate(); - const passwordInputRef = useRef(null); + const { + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, + } = useDisclosure(); // selectors const logger = useSelectLogger(); const passwordLockPassword = useSelectPasswordLockPassword(); const saving = useSelectPasswordLockSaving(); // hooks const defaultTextColor = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const primaryColor = usePrimaryColor(); // states const [verifying, setVerifying] = useState(false); // misc const isLoading = saving || verifying; // handlers - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); + const handleUnlockClick = () => onAuthenticationModalOpen(); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult ) => { - if (event.key === 'Enter') { - await handleConfirmClick(); - } - }; - const handleConfirmClick = async () => { let isValid: boolean; let passwordService: PasswordService; @@ -96,12 +107,6 @@ const PasswordLockPage: FC = () => { dispatch(savePasswordLockThunk(password)); }; - // focus on password input - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); useEffect(() => { if (passwordLockPassword) { navigate(-1); @@ -109,56 +114,61 @@ const PasswordLockPage: FC = () => { }, [passwordLockPassword]); return ( -
- - + {/*authentication modal*/} + ('captions.mustEnterPasswordToUnlock')} + /> + +
+ - - - {/*icon*/} - - - {/*heading*/} - - {t('headings.passwordLock')} - + + + + {/*icon*/} + + + {/*heading*/} + + {t('headings.passwordLock')} + + - {/*password input*/} - ('captions.mustEnterPasswordToUnlock')} - inputRef={passwordInputRef} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - value={password || ''} - /> + {/*unlock button*/} + - - {/*confirm button*/} - - - -
+
+
+ ); }; diff --git a/src/extension/utils/signBytes/signBytes.ts b/src/extension/utils/signBytes/signBytes.ts index d654113a..e4dda898 100644 --- a/src/extension/utils/signBytes/signBytes.ts +++ b/src/extension/utils/signBytes/signBytes.ts @@ -1,6 +1,9 @@ import { sign } from 'tweetnacl'; import browser from 'webextension-polyfill'; +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // errors import { MalformedDataError } from '@extension/errors'; @@ -8,10 +11,11 @@ import { MalformedDataError } from '@extension/errors'; import Ed21559KeyPair from '@extension/models/Ed21559KeyPair'; // types -import type { IOptions } from './types'; +import type { TOptions } from './types'; // utils import fetchDecryptedKeyPairFromStorageWithPassword from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPassword'; +import fetchDecryptedKeyPairFromStorageWithPasskey from '@extension/utils/fetchDecryptedKeyPairFromStorageWithPasskey'; /** * Convenience function that signs an arbitrary bit of data using the supplied signer. @@ -24,18 +28,28 @@ import fetchDecryptedKeyPairFromStorageWithPassword from '@extension/utils/fetch export default async function signBytes({ bytes, logger, - password, publicKey, -}: IOptions): Promise { + ...encryptionOptions +}: TOptions): Promise { const _functionName = 'signBytes'; - let keyPair: Ed21559KeyPair | null; + let keyPair: Ed21559KeyPair | null = null; let signature: Uint8Array; - keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ - logger, - password, - publicKey, - }); + if (encryptionOptions.type === EncryptionMethodEnum.Password) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPassword({ + logger, + password: encryptionOptions.password, + publicKey, + }); + } + + if (encryptionOptions.type === EncryptionMethodEnum.Passkey) { + keyPair = await fetchDecryptedKeyPairFromStorageWithPasskey({ + inputKeyMaterial: encryptionOptions.inputKeyMaterial, + logger, + publicKey, + }); + } if (!keyPair) { throw new MalformedDataError(`failed to get private key from storage`); diff --git a/src/extension/utils/signBytes/types/IOptions.ts b/src/extension/utils/signBytes/types/TOptions.ts similarity index 72% rename from src/extension/utils/signBytes/types/IOptions.ts rename to src/extension/utils/signBytes/types/TOptions.ts index ad945519..4a22738b 100644 --- a/src/extension/utils/signBytes/types/IOptions.ts +++ b/src/extension/utils/signBytes/types/TOptions.ts @@ -1,5 +1,6 @@ // types import type { IBaseOptions } from '@common/types'; +import type { TEncryptionCredentials } from '@extension/types'; /** * @property {Uint8Array} bytes - the bytes to be signed. @@ -8,8 +9,9 @@ import type { IBaseOptions } from '@common/types'; */ interface IOptions extends IBaseOptions { bytes: Uint8Array; - password: string; publicKey: Uint8Array; } -export default IOptions; +type TOptions = IOptions & TEncryptionCredentials; + +export default TOptions; diff --git a/src/extension/utils/signBytes/types/index.ts b/src/extension/utils/signBytes/types/index.ts index 68e70016..b9eb0637 100644 --- a/src/extension/utils/signBytes/types/index.ts +++ b/src/extension/utils/signBytes/types/index.ts @@ -1 +1 @@ -export type { default as IOptions } from './IOptions'; +export type { default as TOptions } from './TOptions'; From 319c60c3ea072c3fc078fb02daf99da4a23f5c0a Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 16 Jul 2024 09:34:50 +0100 Subject: [PATCH 26/31] chore: squash --- src/extension/apps/main/App.tsx | 9 +- src/extension/apps/main/Root.tsx | 4 +- src/extension/features/password-lock/slice.ts | 15 +- .../thunks/savePasswordLockThunk.ts | 26 ++- .../features/password-lock/types/IState.ts | 7 +- .../password-lock/utils/getInitialState.ts | 2 +- .../useOnMainAppMessage.ts | 6 +- .../AddPasskeyModal/AddPasskeyModal.tsx | 192 ++++++------------ .../types/IAddPasskeyActionOptions.ts | 2 +- .../AuthenticationModal.tsx | 15 +- .../ConfirmPasswordModal.tsx | 15 +- .../RemovePasskeyModal/RemovePasskeyModal.tsx | 163 ++++++--------- .../PasswordLockPage/PasswordLockPage.tsx | 37 +--- .../SecuritySettingsIndexPage.tsx | 49 +++-- .../selectors/password-lock/index.ts | 2 +- .../useSelectPasswordLockCredentials.ts | 15 ++ .../useSelectPasswordLockPassword.ts | 10 - 17 files changed, 228 insertions(+), 341 deletions(-) create mode 100644 src/extension/selectors/password-lock/useSelectPasswordLockCredentials.ts delete mode 100644 src/extension/selectors/password-lock/useSelectPasswordLockPassword.ts diff --git a/src/extension/apps/main/App.tsx b/src/extension/apps/main/App.tsx index b8ec3cb0..01467fc4 100644 --- a/src/extension/apps/main/App.tsx +++ b/src/extension/apps/main/App.tsx @@ -69,6 +69,7 @@ import type { IAppThunkDispatch, IMainRootState, ISettings, + TEncryptionCredentials, } from '@extension/types'; // utils @@ -144,17 +145,17 @@ const createRouter = ({ dispatch, getState }: Store) => { ], element: , loader: async () => { - let password: string | null; + let credentials: TEncryptionCredentials | null; let settings: ISettings; try { settings = await (dispatch as IAppThunkDispatch)( fetchSettingsFromStorageThunk() ).unwrap(); // fetch the settings from storage - password = getState().passwordLock.password; + credentials = getState().passwordLock.credentials; - // if the password lock is on, we need the password - if (settings.security.enablePasswordLock && !password) { + // if the password lock is on, we need the passkey/password + if (settings.security.enablePasswordLock && !credentials) { return redirect(PASSWORD_LOCK_ROUTE); } } catch (error) { diff --git a/src/extension/apps/main/Root.tsx b/src/extension/apps/main/Root.tsx index ce71e196..f445e6e9 100644 --- a/src/extension/apps/main/Root.tsx +++ b/src/extension/apps/main/Root.tsx @@ -67,7 +67,7 @@ import WalletConnectModal from '@extension/modals/WalletConnectModal'; // selectors import { useSelectAccounts, - useSelectPasswordLockPassword, + useSelectPasswordLockCredentials, useSelectNotificationsShowingConfetti, useSelectSelectedNetwork, useSelectSettings, @@ -81,7 +81,7 @@ const Root: FC = () => { const navigate = useNavigate(); // selectors const accounts = useSelectAccounts(); - const passwordLockPassword = useSelectPasswordLockPassword(); + const passwordLockPassword = useSelectPasswordLockCredentials(); const selectedNetwork = useSelectSelectedNetwork(); const settings = useSelectSettings(); const showingConfetti = useSelectNotificationsShowingConfetti(); diff --git a/src/extension/features/password-lock/slice.ts b/src/extension/features/password-lock/slice.ts index b8aeb5d8..63dc51a4 100644 --- a/src/extension/features/password-lock/slice.ts +++ b/src/extension/features/password-lock/slice.ts @@ -7,6 +7,7 @@ import { StoreNameEnum } from '@extension/enums'; import { savePasswordLockThunk } from './thunks'; // types +import type { TEncryptionCredentials } from '@extension/types'; import type { IState } from './types'; // utils @@ -14,11 +15,11 @@ import { getInitialState } from './utils'; const slice = createSlice({ extraReducers: (builder) => { - /** Save credentials **/ + /**save password lock**/ builder.addCase( savePasswordLockThunk.fulfilled, - (state: IState, action: PayloadAction) => { - state.password = action.payload; + (state: IState, action: PayloadAction) => { + state.credentials = action.payload; state.saving = false; } ); @@ -32,14 +33,14 @@ const slice = createSlice({ initialState: getInitialState(), name: StoreNameEnum.PasswordLock, reducers: { - setPassword: ( + setCredentials: ( state: Draft, - action: PayloadAction + action: PayloadAction ) => { - state.password = action.payload; + state.credentials = action.payload; }, }, }); export const reducer: Reducer = slice.reducer; -export const { setPassword } = slice.actions; +export const { setCredentials } = slice.actions; diff --git a/src/extension/features/password-lock/thunks/savePasswordLockThunk.ts b/src/extension/features/password-lock/thunks/savePasswordLockThunk.ts index 32068cc9..e4d8b931 100644 --- a/src/extension/features/password-lock/thunks/savePasswordLockThunk.ts +++ b/src/extension/features/password-lock/thunks/savePasswordLockThunk.ts @@ -8,23 +8,27 @@ import { PasswordLockThunkEnum } from '@extension/enums'; import { ProviderPasswordLockClearMessage } from '@common/messages'; // types -import type { IBaseAsyncThunkConfig } from '@extension/types'; +import type { + IBaseAsyncThunkConfig, + TEncryptionCredentials, +} from '@extension/types'; /** * Sends a message to the background service worker to clear the password lock alarm. This is either called when setting - * the password (when the password lock screen is successful), or when the password lock is being disabled. + * the passkey/password (when the password lock screen is successful), or when the password lock is being disabled. */ const savePasswordLockThunk: AsyncThunk< - string | null, // return - string | null, // args + TEncryptionCredentials | null, // return + TEncryptionCredentials | null, // args IBaseAsyncThunkConfig -> = createAsyncThunk( - PasswordLockThunkEnum.SavePasswordLock, - async (password) => { - await browser.runtime.sendMessage(new ProviderPasswordLockClearMessage()); +> = createAsyncThunk< + TEncryptionCredentials | null, + TEncryptionCredentials | null, + IBaseAsyncThunkConfig +>(PasswordLockThunkEnum.SavePasswordLock, async (credentials) => { + await browser.runtime.sendMessage(new ProviderPasswordLockClearMessage()); - return password; - } -); + return credentials; +}); export default savePasswordLockThunk; diff --git a/src/extension/features/password-lock/types/IState.ts b/src/extension/features/password-lock/types/IState.ts index 9fc05610..cb1fd661 100644 --- a/src/extension/features/password-lock/types/IState.ts +++ b/src/extension/features/password-lock/types/IState.ts @@ -1,9 +1,12 @@ +// types +import type { TEncryptionCredentials } from '@extension/types'; + /** - * @property {string | null} password - the password to use to secure accounts. + * @property {TEncryptionCredentials | null} credentials - the password or the passkey used to secure accounts. * @property {saving} saving - whether the password lock is being saved or not. */ interface IState { - password: string | null; + credentials: TEncryptionCredentials | null; saving: boolean; } diff --git a/src/extension/features/password-lock/utils/getInitialState.ts b/src/extension/features/password-lock/utils/getInitialState.ts index ee751ba0..996cd951 100644 --- a/src/extension/features/password-lock/utils/getInitialState.ts +++ b/src/extension/features/password-lock/utils/getInitialState.ts @@ -3,7 +3,7 @@ import type { IState } from '../types'; export default function getInitialState(): IState { return { - password: null, + credentials: null, saving: false, }; } diff --git a/src/extension/hooks/useOnMainAppMessage/useOnMainAppMessage.ts b/src/extension/hooks/useOnMainAppMessage/useOnMainAppMessage.ts index 8cf57ff8..8226c7b9 100644 --- a/src/extension/hooks/useOnMainAppMessage/useOnMainAppMessage.ts +++ b/src/extension/hooks/useOnMainAppMessage/useOnMainAppMessage.ts @@ -7,7 +7,7 @@ import { ProviderMessageReferenceEnum } from '@common/enums'; // features import { handleNewEventByIdThunk } from '@extension/features/events'; -import { setPassword } from '@extension/features/password-lock'; +import { setCredentials as setPasswordLockCredentials } from '@extension/features/password-lock'; // messages import { ProviderEventAddedMessage } from '@common/messages'; @@ -37,8 +37,8 @@ export default function useOnMainAppMessage(): void { break; case ProviderMessageReferenceEnum.PasswordLockTimeout: - // remove the password - dispatch(setPassword(null)); + // remove the password lock credentials + dispatch(setPasswordLockCredentials(null)); break; default: diff --git a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx index f68ee303..e009572d 100644 --- a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx +++ b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx @@ -12,7 +12,7 @@ import { useDisclosure, VStack, } from '@chakra-ui/react'; -import React, { FC, KeyboardEvent, useEffect, useRef } from 'react'; +import React, { FC, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { GoShieldLock } from 'react-icons/go'; import { IoKeyOutline } from 'react-icons/io5'; @@ -27,9 +27,6 @@ import ModalItem from '@extension/components/ModalItem'; import ModalTextItem from '@extension/components/ModalTextItem'; import MoreInformationAccordion from '@extension/components/MoreInformationAccordion'; import PasskeyCapabilities from '@extension/components/PasskeyCapabilities'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import ReEncryptKeysLoadingContent from '@extension/components/ReEncryptKeysLoadingContent'; // constants @@ -39,9 +36,6 @@ import { MODAL_ITEM_HEIGHT, } from '@extension/constants'; -// enums -import { ErrorCodeEnum } from '@extension/enums'; - // features import { create as createNotification } from '@extension/features/notifications'; @@ -51,13 +45,11 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; import useAddPasskey from './hooks/useAddPasskey'; +// modals +import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; + // selectors -import { - useSelectLogger, - useSelectPasskeysSaving, - useSelectPasswordLockPassword, - useSelectSettings, -} from '@extension/selectors'; +import { useSelectPasskeysSaving } from '@extension/selectors'; // theme import { theme } from '@extension/theme'; @@ -72,17 +64,18 @@ import calculateIconSize from '@extension/utils/calculateIconSize'; const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { const { t } = useTranslation(); const dispatch = useDispatch(); + const { + isOpen: isConfirmPasswordModalOpen, + onClose: onConfirmPasswordModalClose, + onOpen: onConfirmPasswordModalOpen, + } = useDisclosure(); const { isOpen: isMoreInformationOpen, onOpen: onMoreInformationOpen, onClose: onMoreInformationClose, } = useDisclosure(); - const passwordInputRef = useRef(null); // selectors - const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); const saving = useSelectPasskeysSaving(); - const settings = useSelectSettings(); // hooks const { addPasskeyAction, @@ -94,14 +87,6 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { resetAction: resetAddPasskeyAction, } = useAddPasskey(); const defaultTextColor = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const primaryColorCode = useColorModeValue( theme.colors.primaryLight['500'], theme.colors.primaryDark['500'] @@ -112,49 +97,21 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { // handlers const handleCancelClick = async () => handleClose(); const handleClose = () => { - resetPassword(); resetAddPasskeyAction(); - if (onClose) { - onClose(); - } + onClose && onClose(); }; - const handleEncryptClick = async () => { - const _functionName = 'handleEncryptClick'; - let _password: string | null; + const handleEncryptClick = () => onConfirmPasswordModalOpen(); + const handleOnConfirmPasswordModalConfirm = async (password: string) => { let success: boolean; if (!addPasskey) { return; } - // if there is no password lock - if (!settings.security.enablePasswordLock && !passwordLockPassword) { - // validate the password input - if (validatePassword()) { - logger.debug( - `${AddPasskeyModal.name}#${_functionName}: password not valid` - ); - - return; - } - } - - _password = settings.security.enablePasswordLock - ? passwordLockPassword - : password; - - if (!_password) { - logger.debug( - `${AddPasskeyModal.name}#${_functionName}: unable to use password from password lock, value is "null"` - ); - - return; - } - success = await addPasskeyAction({ - password: _password, passkey: addPasskey, + password, }); if (success) { @@ -174,13 +131,6 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { handleClose(); } }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - await handleEncryptClick(); - } - }; const handleMoreInformationToggle = (value: boolean) => value ? onMoreInformationOpen() : onMoreInformationClose(); // renders @@ -366,33 +316,20 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { ); }; - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); // if there is an error from the hook, show a toast useEffect(() => { - if (error) { - switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - break; - default: - dispatch( - createNotification({ - description: t('errors.descriptions.code', { - code: error.code, - context: error.code, - }), - ephemeral: true, - title: t('errors.titles.code', { context: error.code }), - type: 'error', - }) - ); - break; - } - } + error && + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); }, [error]); // if we have the updated the passkey close the modal useEffect(() => { @@ -414,47 +351,38 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { }, [passkey]); return ( - - + {/*confirm password modal*/} + ('captions.mustEnterPasswordToDecryptPrivateKeys')} + isOpen={isConfirmPasswordModalOpen} + onCancel={onConfirmPasswordModalClose} + onConfirm={handleOnConfirmPasswordModalConfirm} + /> + + - - - {t('headings.addPasskey')} - - - - - {renderContent()} - - - - - {/*password input*/} - {!settings.security.enablePasswordLock && - !passwordLockPassword && - !isLoading && ( - ( - 'captions.mustEnterPasswordToDecryptPrivateKeys' - )} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - inputRef={passwordInputRef} - value={password} - /> - )} + + + + {t('headings.addPasskey')} + + + + + {renderContent()} + - {/*buttons*/} + {/*cancel*/} - - - - + + + + ); }; diff --git a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/types/IAddPasskeyActionOptions.ts b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/types/IAddPasskeyActionOptions.ts index 9b63e378..42bb81b8 100644 --- a/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/types/IAddPasskeyActionOptions.ts +++ b/src/extension/modals/AddPasskeyModal/hooks/useAddPasskey/types/IAddPasskeyActionOptions.ts @@ -2,7 +2,7 @@ import type { IPasskeyCredential } from '@extension/types'; /** - * @property {IPasskeyCredential} passkey - the passkey credential. + * @property {IPasskeyCredential} passkey - the passkey credential to add. * @property {string} password - the password used to encrypt the private keys. */ interface IAddPasskeyActionOptions { diff --git a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx index 0e072a2f..b8a08078 100644 --- a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx +++ b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx @@ -36,7 +36,7 @@ import useSubTextColor from '@extension/hooks/useSubTextColor'; import { useSelectLogger, useSelectPasskeysPasskey, - useSelectPasswordLockPassword, + useSelectPasswordLockCredentials, useSelectSettings, } from '@extension/selectors'; @@ -63,7 +63,7 @@ const AuthenticationModal: FC = ({ // selectors const logger = useSelectLogger(); const passkey = useSelectPasskeysPasskey(); - const passwordLockPassword = useSelectPasswordLockPassword(); + const passwordLockCredentials = useSelectPasswordLockCredentials(); const settings = useSelectSettings(); // hooks const defaultTextColor = useDefaultTextColor(); @@ -164,7 +164,7 @@ const AuthenticationModal: FC = ({ } // show a loader if there is a password lock and password - if (settings.security.enablePasswordLock && passwordLockPassword) { + if (settings.security.enablePasswordLock && passwordLockCredentials) { return ( = ({ } } - // otherwise, check if there is a password lock and password lock password present - if (settings.security.enablePasswordLock && passwordLockPassword) { - return onConfirm({ - password: passwordLockPassword, - type: EncryptionMethodEnum.Password, - }); + // otherwise, check if there is a password lock and passkey/password present + if (settings.security.enablePasswordLock && passwordLockCredentials) { + return onConfirm(passwordLockCredentials); } })(); }, [isOpen]); diff --git a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx index 7b74e0de..211c2a12 100644 --- a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx +++ b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx @@ -23,7 +23,6 @@ import PasswordInput, { import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; // errors - // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; @@ -31,7 +30,7 @@ import useSubTextColor from '@extension/hooks/useSubTextColor'; // selectors import { useSelectLogger, - useSelectPasswordLockPassword, + useSelectPasswordLockCredentials, useSelectSettings, } from '@extension/selectors'; @@ -43,6 +42,7 @@ import { theme } from '@extension/theme'; // types import type { IProps } from './types'; +import { EncryptionMethodEnum } from '@extension/enums'; const ConfirmPasswordModal: FC = ({ isOpen, @@ -54,7 +54,7 @@ const ConfirmPasswordModal: FC = ({ const passwordInputRef = useRef(null); // selectors const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); + const passwordLockCredentials = useSelectPasswordLockCredentials(); const settings = useSelectSettings(); // hooks const defaultTextColor = useDefaultTextColor(); @@ -121,7 +121,7 @@ const ConfirmPasswordModal: FC = ({ // renders const renderContent = () => { // show a loader if there is a password lock and password - if (settings.security.enablePasswordLock && passwordLockPassword) { + if (settings.security.enablePasswordLock && passwordLockCredentials) { return ( = ({ }, []); // check if there is a password lock and password lock password present useEffect(() => { - if (settings.security.enablePasswordLock && passwordLockPassword) { - return onConfirm(passwordLockPassword); + if ( + settings.security.enablePasswordLock && + passwordLockCredentials?.type === EncryptionMethodEnum.Password + ) { + return onConfirm(passwordLockCredentials.password); } }, [isOpen]); diff --git a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx index ce02e640..8bd3575a 100644 --- a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx +++ b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx @@ -8,9 +8,10 @@ import { ModalFooter, ModalHeader, Text, + useDisclosure, VStack, } from '@chakra-ui/react'; -import React, { FC, KeyboardEvent, useEffect, useRef } from 'react'; +import React, { FC, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { GoShieldSlash } from 'react-icons/go'; import { Radio } from 'react-loader-spinner'; @@ -18,17 +19,11 @@ import { useDispatch } from 'react-redux'; // components import Button from '@extension/components/Button'; -import PasswordInput, { - usePassword, -} from '@extension/components/PasswordInput'; import ReEncryptKeysLoadingContent from '@extension/components/ReEncryptKeysLoadingContent'; // constants import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; -// enums -import { ErrorCodeEnum } from '@extension/enums'; - // features import { create as createNotification } from '@extension/features/notifications'; @@ -38,6 +33,9 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; import useRemovePasskey from './hooks/useRemovePasskey'; +// modals +import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; + // selectors import { useSelectLogger, useSelectPasskeysSaving } from '@extension/selectors'; @@ -54,7 +52,11 @@ import calculateIconSize from '@extension/utils/calculateIconSize'; const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { const { t } = useTranslation(); const dispatch = useDispatch(); - const passwordInputRef = useRef(null); + const { + isOpen: isConfirmPasswordModalOpen, + onClose: onConfirmPasswordModalClose, + onOpen: onConfirmPasswordModalOpen, + } = useDisclosure(); // selectors const logger = useSelectLogger(); const saving = useSelectPasskeysSaving(); @@ -68,14 +70,6 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { resetAction: resetRemovePasskeyAction, } = useRemovePasskey(); const defaultTextColor = useDefaultTextColor(); - const { - error: passwordError, - onChange: onPasswordChange, - reset: resetPassword, - setError: setPasswordError, - validate: validatePassword, - value: password, - } = usePassword(); const primaryColorCode = useColorModeValue( theme.colors.primaryLight['500'], theme.colors.primaryDark['500'] @@ -86,40 +80,20 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { // handlers const handleCancelClick = async () => handleClose(); const handleClose = () => { - resetPassword(); resetRemovePasskeyAction(); - if (onClose) { - onClose(); - } - }; - const handleKeyUpPasswordInput = async ( - event: KeyboardEvent - ) => { - if (event.key === 'Enter') { - await handleRemoveClick(); - } + onClose && onClose(); }; - const handleRemoveClick = async () => { - const _functionName = 'handleRemoveClick'; + const handleOnConfirmPasswordModalConfirm = async (password: string) => { let success: boolean; if (!removePasskey) { return; } - // validate the password input - if (validatePassword()) { - logger.debug( - `${RemovePasskeyModal.name}#${_functionName}: password not valid` - ); - - return; - } - success = await removePasskeyAction({ - password, passkey: removePasskey, + password, }); if (success) { @@ -139,6 +113,7 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { handleClose(); } }; + const handleRemoveClick = () => onConfirmPasswordModalOpen(); // renders const renderContent = () => { const iconSize = calculateIconSize('xl'); @@ -226,75 +201,55 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { ); }; - useEffect(() => { - if (passwordInputRef.current) { - passwordInputRef.current.focus(); - } - }, []); // if there is an error from the hook, show a toast useEffect(() => { - if (error) { - switch (error.code) { - case ErrorCodeEnum.InvalidPasswordError: - setPasswordError(t('errors.inputs.invalidPassword')); - break; - default: - dispatch( - createNotification({ - description: t('errors.descriptions.code', { - code: error.code, - context: error.code, - }), - ephemeral: true, - title: t('errors.titles.code', { context: error.code }), - type: 'error', - }) - ); - break; - } - } + error && + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); }, [error]); return ( - - + {/*confirm password modal*/} + ('captions.mustEnterPasswordToReEncryptPrivateKeys')} + isOpen={isConfirmPasswordModalOpen} + onCancel={onConfirmPasswordModalClose} + onConfirm={handleOnConfirmPasswordModalConfirm} + /> + + - - - {t('headings.removePasskey')} - - - - - {renderContent()} - + + + + {t('headings.removePasskey')} + + - - - {/*password input*/} - {!isLoading && ( - ( - 'captions.mustEnterPasswordToReEncryptPrivateKeys' - )} - onChange={onPasswordChange} - onKeyUp={handleKeyUpPasswordInput} - inputRef={passwordInputRef} - value={password} - /> - )} + + {renderContent()} + - {/*buttons*/} + {/*cancel*/} - - - - + + + + ); }; diff --git a/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx b/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx index 077414fd..a5cab1e4 100644 --- a/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx +++ b/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx @@ -3,7 +3,6 @@ import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; -import browser from 'webextension-polyfill'; // components import Button from '@extension/components/Button'; @@ -31,13 +30,10 @@ import AuthenticationModal, { // selectors import { useSelectLogger, - useSelectPasswordLockPassword, + useSelectPasswordLockCredentials, useSelectPasswordLockSaving, } from '@extension/selectors'; -// services -import PasswordService from '@extension/services/PasswordService'; - // types import type { IAppThunkDispatch } from '@extension/types'; @@ -52,7 +48,7 @@ const PasswordLockPage: FC = () => { } = useDisclosure(); // selectors const logger = useSelectLogger(); - const passwordLockPassword = useSelectPasswordLockPassword(); + const passwordLockPassword = useSelectPasswordLockCredentials(); const saving = useSelectPasswordLockSaving(); // hooks const defaultTextColor = useDefaultTextColor(); @@ -78,33 +74,8 @@ const PasswordLockPage: FC = () => { const handleOnAuthenticationModalConfirm = async ( result: TOnConfirmResult ) => { - let isValid: boolean; - let passwordService: PasswordService; - - // check if the input is valid - if (validatePassword()) { - return; - } - - passwordService = new PasswordService({ - logger, - passwordTag: browser.runtime.id, - }); - - setVerifying(true); - - isValid = await passwordService.verifyPassword(password); - - setVerifying(false); - - if (!isValid) { - setPasswordError(t('errors.inputs.invalidPassword')); - - return; - } - - // save the password lock password and clear any alarms - dispatch(savePasswordLockThunk(password)); + // save the password lock passkey/password and clear any alarms + dispatch(savePasswordLockThunk(result)); }; useEffect(() => { diff --git a/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx b/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx index a26cf85a..c9339c8d 100644 --- a/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx +++ b/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx @@ -34,12 +34,18 @@ import { VIEW_SEED_PHRASE_ROUTE, } from '@extension/constants'; +// errors +import { BaseExtensionError } from '@extension/errors'; + // features +import { create as createNotification } from '@extension/features/notifications'; import { savePasswordLockThunk } from '@extension/features/password-lock'; import { saveSettingsToStorageThunk } from '@extension/features/settings'; // modals -import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; +import AuthenticationModal, { + TOnConfirmResult, +} from '@extension/modals/AuthenticationModal'; // selectors import { @@ -58,9 +64,9 @@ const SecuritySettingsIndexPage: FC = () => { const { t } = useTranslation(); const dispatch = useDispatch(); const { - isOpen: isConfirmPasswordModalOpen, - onClose: onConfirmPasswordModalClose, - onOpen: onConfirmPasswordModalOpen, + isOpen: isAuthenticationModalOpen, + onClose: onAuthenticationModalClose, + onOpen: onAuthenticationModalOpen, } = useDisclosure(); // selectors const logger = useSelectLogger(); @@ -117,7 +123,7 @@ const SecuritySettingsIndexPage: FC = () => { // if we are enabling, we need to set the password if (event.target.checked) { - onConfirmPasswordModalOpen(); + onAuthenticationModalOpen(); return; } @@ -142,10 +148,10 @@ const SecuritySettingsIndexPage: FC = () => { ); } }; - const handleOnConfirmPasswordModalConfirm = async (password: string) => { - const _functionName = 'handleOnConfirmPasswordModalConfirm'; - - onConfirmPasswordModalClose(); + const handleOnAuthenticationModalConfirm = async ( + result: TOnConfirmResult + ) => { + const _functionName = 'handleOnAuthenticationModalConfirm'; try { // enable the lock and wait for the settings to be updated @@ -160,13 +166,25 @@ const SecuritySettingsIndexPage: FC = () => { ).unwrap(); // then... save the new password to the password lock - dispatch(savePasswordLockThunk(password)); + dispatch(savePasswordLockThunk(result)); } catch (error) { logger.debug( `${SecuritySettingsIndexPage.name}#${_functionName}: failed save settings` ); } }; + const handleOnAuthenticationError = (error: BaseExtensionError) => + dispatch( + createNotification({ + description: t('errors.descriptions.code', { + code: error.code, + context: error.code, + }), + ephemeral: true, + title: t('errors.titles.code', { context: error.code }), + type: 'error', + }) + ); const handlePasswordTimeoutDurationChange = (option: IOption) => { dispatch( saveSettingsToStorageThunk({ @@ -181,11 +199,12 @@ const SecuritySettingsIndexPage: FC = () => { return ( <> - {/*confirm password modal*/} - ('titles.page', { context: 'security' })} /> diff --git a/src/extension/selectors/password-lock/index.ts b/src/extension/selectors/password-lock/index.ts index 5f396821..c5659ae3 100644 --- a/src/extension/selectors/password-lock/index.ts +++ b/src/extension/selectors/password-lock/index.ts @@ -1,2 +1,2 @@ -export { default as useSelectPasswordLockPassword } from './useSelectPasswordLockPassword'; +export { default as useSelectPasswordLockCredentials } from './useSelectPasswordLockCredentials'; export { default as useSelectPasswordLockSaving } from './useSelectPasswordLockSaving'; diff --git a/src/extension/selectors/password-lock/useSelectPasswordLockCredentials.ts b/src/extension/selectors/password-lock/useSelectPasswordLockCredentials.ts new file mode 100644 index 00000000..a8db18c6 --- /dev/null +++ b/src/extension/selectors/password-lock/useSelectPasswordLockCredentials.ts @@ -0,0 +1,15 @@ +import { useSelector } from 'react-redux'; + +// types +import type { + IBackgroundRootState, + IMainRootState, + TEncryptionCredentials, +} from '@extension/types'; + +export default function useSelectPasswordLockCredentials(): TEncryptionCredentials | null { + return useSelector< + IBackgroundRootState | IMainRootState, + TEncryptionCredentials | null + >((state) => state.passwordLock.credentials); +} diff --git a/src/extension/selectors/password-lock/useSelectPasswordLockPassword.ts b/src/extension/selectors/password-lock/useSelectPasswordLockPassword.ts deleted file mode 100644 index 5b1beda5..00000000 --- a/src/extension/selectors/password-lock/useSelectPasswordLockPassword.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useSelector } from 'react-redux'; - -// types -import type { IBackgroundRootState, IMainRootState } from '@extension/types'; - -export default function useSelectPasswordLockPassword(): string | null { - return useSelector( - (state) => state.passwordLock.password - ); -} From 2816e7a06389e690b05f22f8efae545c9b995f63 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 16 Jul 2024 09:56:06 +0100 Subject: [PATCH 27/31] fix: close authentication and password modals --- ...0AccountImportWithPrivateKeyModalContent.tsx | 2 +- ...yRegistrationTransactionSendModalContent.tsx | 2 +- .../modals/AddAssetsModal/AddAssetsModal.tsx | 2 +- .../modals/AddPasskeyModal/AddPasskeyModal.tsx | 6 +++--- .../AuthenticationModal/AuthenticationModal.tsx | 12 +++++------- .../modals/AuthenticationModal/types/IProps.ts | 6 +++--- .../ConfirmPasswordModal.tsx | 17 +++++++++-------- .../modals/ConfirmPasswordModal/types/IProps.ts | 9 ++++----- .../ReKeyAccountModal/ReKeyAccountModal.tsx | 2 +- .../RemoveAssetsModal/RemoveAssetsModal.tsx | 2 +- .../RemovePasskeyModal/RemovePasskeyModal.tsx | 2 +- .../modals/SendAssetModal/SendAssetModal.tsx | 2 +- .../SignMessageModal/SignMessageModal.tsx | 2 +- .../SignTransactionsModal.tsx | 2 +- .../ChangePasswordPage/ChangePasswordPage.tsx | 2 +- .../ExportAccountPage/ExportAcountPage.tsx | 2 +- .../pages/PasswordLockPage/PasswordLockPage.tsx | 2 +- .../SecuritySettingsIndexPage.tsx | 2 +- .../ViewSeedPhrasePage/ViewSeedPhrasePage.tsx | 2 +- .../AddAccountMainRouter.tsx | 2 +- 20 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx b/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx index 94b3f418..52f77a27 100644 --- a/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx +++ b/src/extension/components/ARC0300AccountImportWithPrivateKeyModalContent/ARC0300AccountImportWithPrivateKeyModalContent.tsx @@ -290,7 +290,7 @@ const ARC0300AccountImportWithPrivateKeyModalContent: FC< {/*authentication modal*/} ('captions.mustEnterPasswordToImportAccount')} diff --git a/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx b/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx index ccece933..1e5dd13c 100644 --- a/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx +++ b/src/extension/components/ARC0300KeyRegistrationTransactionSendModalContent/ARC0300KeyRegistrationTransactionSendModalContent.tsx @@ -309,7 +309,7 @@ const ARC0300KeyRegistrationTransactionSendModalContent: FC< {/*authentication*/} ('captions.mustEnterPasswordToSendTransaction')} diff --git a/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx b/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx index 5590b7fb..b7a35e98 100644 --- a/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx +++ b/src/extension/modals/AddAssetsModal/AddAssetsModal.tsx @@ -692,7 +692,7 @@ const AddAssetsModal: FC = ({ onClose }) => { {/*authentication modal*/} ('captions.mustEnterPasswordToAuthorizeOptIn')} diff --git a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx index e009572d..2b40e290 100644 --- a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx +++ b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx @@ -97,9 +97,9 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { // handlers const handleCancelClick = async () => handleClose(); const handleClose = () => { - resetAddPasskeyAction(); - onClose && onClose(); + + resetAddPasskeyAction(); }; const handleEncryptClick = () => onConfirmPasswordModalOpen(); const handleOnConfirmPasswordModalConfirm = async (password: string) => { @@ -356,7 +356,7 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { ('captions.mustEnterPasswordToDecryptPrivateKeys')} isOpen={isConfirmPasswordModalOpen} - onCancel={onConfirmPasswordModalClose} + onClose={onConfirmPasswordModalClose} onConfirm={handleOnConfirmPasswordModalConfirm} /> diff --git a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx index b8a08078..8d513bb5 100644 --- a/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx +++ b/src/extension/modals/AuthenticationModal/AuthenticationModal.tsx @@ -53,7 +53,7 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; const AuthenticationModal: FC = ({ isOpen, - onCancel, + onClose, onConfirm, onError, passwordHint, @@ -120,11 +120,11 @@ const AuthenticationModal: FC = ({ type: EncryptionMethodEnum.Password, }); - // clean up - reset(); + handleClose(); }; const handleClose = () => { - onCancel(); + onClose && onClose(); + reset(); // clean up }; const handleKeyUpPasswordInput = async ( @@ -215,7 +215,6 @@ const AuthenticationModal: FC = ({ }, []); useEffect(() => { (async () => { - let _error: string; let inputKeyMaterial: Uint8Array; if (!isOpen) { @@ -237,8 +236,7 @@ const AuthenticationModal: FC = ({ type: EncryptionMethodEnum.Passkey, }); - // clean up - return reset(); + return handleClose(); } catch (error) { logger.error(`${AuthenticationModal.name}#useEffect:`, error); diff --git a/src/extension/modals/AuthenticationModal/types/IProps.ts b/src/extension/modals/AuthenticationModal/types/IProps.ts index 74bb0e3c..a03dc8a7 100644 --- a/src/extension/modals/AuthenticationModal/types/IProps.ts +++ b/src/extension/modals/AuthenticationModal/types/IProps.ts @@ -2,12 +2,12 @@ import { BaseExtensionError } from '@extension/errors'; // types -import TOnConfirmResult from './TOnConfirmResult'; +import type { IModalProps } from '@extension/types'; +import type TOnConfirmResult from './TOnConfirmResult'; -interface IProps { +interface IProps extends IModalProps { isOpen: boolean; passwordHint?: string; - onCancel: () => void; onConfirm: (result: TOnConfirmResult) => void; onError?: (error: BaseExtensionError) => void; } diff --git a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx index 211c2a12..226be946 100644 --- a/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx +++ b/src/extension/modals/ConfirmPasswordModal/ConfirmPasswordModal.tsx @@ -22,7 +22,9 @@ import PasswordInput, { // constants import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; -// errors +// enums +import { EncryptionMethodEnum } from '@extension/enums'; + // hooks import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; @@ -42,12 +44,11 @@ import { theme } from '@extension/theme'; // types import type { IProps } from './types'; -import { EncryptionMethodEnum } from '@extension/enums'; const ConfirmPasswordModal: FC = ({ isOpen, hint, - onCancel, + onClose, onConfirm, }) => { const { t } = useTranslation(); @@ -103,12 +104,11 @@ const ConfirmPasswordModal: FC = ({ } onConfirm(password); - - // clean up - reset(); + handleClose(); }; const handleClose = () => { - onCancel(); + onClose && onClose(); + reset(); // clean up }; const handleKeyUpPasswordInput = async ( @@ -174,7 +174,8 @@ const ConfirmPasswordModal: FC = ({ settings.security.enablePasswordLock && passwordLockCredentials?.type === EncryptionMethodEnum.Password ) { - return onConfirm(passwordLockCredentials.password); + onConfirm(passwordLockCredentials.password); + handleClose(); } }, [isOpen]); diff --git a/src/extension/modals/ConfirmPasswordModal/types/IProps.ts b/src/extension/modals/ConfirmPasswordModal/types/IProps.ts index 36b3ef1e..95a69f3e 100644 --- a/src/extension/modals/ConfirmPasswordModal/types/IProps.ts +++ b/src/extension/modals/ConfirmPasswordModal/types/IProps.ts @@ -1,10 +1,9 @@ -// errors -import { BaseExtensionError } from '@extension/errors'; +// types +import type { IModalProps } from '@extension/types'; -interface IProps { - isOpen: boolean; +interface IProps extends IModalProps { hint?: string; - onCancel: () => void; + isOpen: boolean; onConfirm: (password: string) => void; } diff --git a/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx b/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx index f7fa2cf0..8cffbda9 100644 --- a/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx +++ b/src/extension/modals/ReKeyAccountModal/ReKeyAccountModal.tsx @@ -321,7 +321,7 @@ const ReKeyAccountModal: FC = ({ onClose }) => { {/*authentication modal*/} ( diff --git a/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx b/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx index f112abbe..25bb28e4 100644 --- a/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx +++ b/src/extension/modals/RemoveAssetsModal/RemoveAssetsModal.tsx @@ -498,7 +498,7 @@ const RemoveAssetsModal: FC = ({ onClose }) => { {/*authentication modal*/} ('captions.mustEnterPasswordToAuthorizeOptOut')} diff --git a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx index 8bd3575a..005f4b48 100644 --- a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx +++ b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx @@ -223,7 +223,7 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { ('captions.mustEnterPasswordToReEncryptPrivateKeys')} isOpen={isConfirmPasswordModalOpen} - onCancel={onConfirmPasswordModalClose} + onClose={onConfirmPasswordModalClose} onConfirm={handleOnConfirmPasswordModalConfirm} /> diff --git a/src/extension/modals/SendAssetModal/SendAssetModal.tsx b/src/extension/modals/SendAssetModal/SendAssetModal.tsx index d5f4847b..143a8fd6 100644 --- a/src/extension/modals/SendAssetModal/SendAssetModal.tsx +++ b/src/extension/modals/SendAssetModal/SendAssetModal.tsx @@ -610,7 +610,7 @@ const SendAssetModal: FC = ({ onClose }) => { {/*authentication modal*/} ('captions.mustEnterPasswordToSendTransaction')} diff --git a/src/extension/modals/SignMessageModal/SignMessageModal.tsx b/src/extension/modals/SignMessageModal/SignMessageModal.tsx index 749fd719..c5419ba9 100644 --- a/src/extension/modals/SignMessageModal/SignMessageModal.tsx +++ b/src/extension/modals/SignMessageModal/SignMessageModal.tsx @@ -259,7 +259,7 @@ const SignMessageModal: FC = ({ onClose }) => { {/*authentication modal*/} ('captions.mustEnterPasswordToSign')} diff --git a/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx b/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx index 9590c355..760bb8a2 100644 --- a/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx +++ b/src/extension/modals/SignTransactionsModal/SignTransactionsModal.tsx @@ -269,7 +269,7 @@ const SignTransactionsModal: FC = ({ onClose }) => { {/*authentication*/} { /> diff --git a/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx b/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx index 586fe3dc..9ea0d87d 100644 --- a/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx +++ b/src/extension/pages/ExportAccountPage/ExportAcountPage.tsx @@ -248,7 +248,7 @@ const ExportAccountPage: FC = () => { {/*authentication modal*/} diff --git a/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx b/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx index a5cab1e4..f610d25d 100644 --- a/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx +++ b/src/extension/pages/PasswordLockPage/PasswordLockPage.tsx @@ -89,7 +89,7 @@ const PasswordLockPage: FC = () => { {/*authentication modal*/} ('captions.mustEnterPasswordToUnlock')} diff --git a/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx b/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx index c9339c8d..494199b8 100644 --- a/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx +++ b/src/extension/pages/SecuritySettingsIndexPage/SecuritySettingsIndexPage.tsx @@ -202,7 +202,7 @@ const SecuritySettingsIndexPage: FC = () => { {/*authentication modal*/} diff --git a/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx b/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx index 87378c4a..40c53fed 100644 --- a/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx +++ b/src/extension/pages/ViewSeedPhrasePage/ViewSeedPhrasePage.tsx @@ -194,7 +194,7 @@ const ViewSeedPhrasePage: FC = () => { {/*authentication modal*/} diff --git a/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx b/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx index d6191663..83919258 100644 --- a/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx +++ b/src/extension/routers/AddAccountMainRouter/AddAccountMainRouter.tsx @@ -253,7 +253,7 @@ const AddAccountMainRouter: FC = () => { <> From 373fe5f279b755148263a8b64c6c8208b6dd8a31 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 16 Jul 2024 10:35:06 +0100 Subject: [PATCH 28/31] feat: update captions for add passkey and remove passkey --- .../PasskeyCapabilities.tsx | 4 ++- .../AddPasskeyModal/AddPasskeyModal.tsx | 26 ++++++++++++++----- .../RemovePasskeyModal/RemovePasskeyModal.tsx | 3 +-- .../pages/PasskeyPage/PasskeyPage.tsx | 17 +++++++----- src/extension/translations/en.ts | 11 ++++---- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx b/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx index 6e70d6dd..fc8e9282 100644 --- a/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx +++ b/src/extension/components/PasskeyCapabilities/PasskeyCapabilities.tsx @@ -52,7 +52,9 @@ const PasskeyCapabilities: FC = ({ capabilities, size = 'sm' }) => { return ( - + + + ); })} diff --git a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx index 2b40e290..79933fb0 100644 --- a/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx +++ b/src/extension/modals/AddPasskeyModal/AddPasskeyModal.tsx @@ -304,13 +304,27 @@ const AddPasskeyModal: FC = ({ addPasskey, onClose }) => { /> - - {/*captions*/} - - - {t('captions.encryptWithPasskey')} - + {/*instructions*/} + + + {t('captions.encryptWithPasskeyInstruction1')} + + + + {t('captions.encryptWithPasskeyInstruction2')} + + ); diff --git a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx index 005f4b48..8b5f058a 100644 --- a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx +++ b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx @@ -37,7 +37,7 @@ import useRemovePasskey from './hooks/useRemovePasskey'; import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; // selectors -import { useSelectLogger, useSelectPasskeysSaving } from '@extension/selectors'; +import { useSelectPasskeysSaving } from '@extension/selectors'; // theme import { theme } from '@extension/theme'; @@ -58,7 +58,6 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { onOpen: onConfirmPasswordModalOpen, } = useDisclosure(); // selectors - const logger = useSelectLogger(); const saving = useSelectPasskeysSaving(); // hooks const { diff --git a/src/extension/pages/PasskeyPage/PasskeyPage.tsx b/src/extension/pages/PasskeyPage/PasskeyPage.tsx index 0979ebeb..883df8e7 100644 --- a/src/extension/pages/PasskeyPage/PasskeyPage.tsx +++ b/src/extension/pages/PasskeyPage/PasskeyPage.tsx @@ -102,6 +102,7 @@ const PasskeyPage: FC = () => { _passkey = await PasskeyService.createPasskeyCredential({ deviceID: systemInfo.deviceID, logger, + name: passkeyName, }); logger.debug( @@ -363,7 +364,7 @@ const PasskeyPage: FC = () => { {/*icon*/} - {/*instruction*/} + {/*captions*/} { > {t('captions.addPasskey2')} - - {/*instructions*/} - - {t('captions.addPasskeyInstruction')} - + + {t('captions.addPasskeyInstruction')} + + {/*passkey name*/} diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 5df10025..0271a11b 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -64,8 +64,8 @@ const translation: IResourceLanguage = { addedAccount: 'Account {{address}} has been added.', addPasskey1: 'Adding a passkey allows you to sign transactions without your password.', - addPasskey2: `The passkey will be used to to encrypt/decrypt the private keys.`, - addPasskeyInstruction: `Begin by adding a new passkey on your device.`, + addPasskey2: `The passkey will be used to to encrypt/decrypt the private keys of your accounts.`, + addPasskeyInstruction: `To begin, you will be asked to add a supported passkey.`, addressDoesNotMatch: 'This address does not match the signer', addWatchAccount: 'Add a watch account by providing a valid address.', addWatchAccountComplete: `Press save to confirm adding the watch account.`, @@ -116,7 +116,8 @@ const translation: IResourceLanguage = { 'Passwords will only need to be entered due to inactivity.', enableRequest: 'An application is requesting to connect. Select which accounts you would like to enable:', - encryptWithPasskey: `To complete the process, the passkey will be requested to re-encrypt the private keys.`, + encryptWithPasskeyInstruction1: `1. You will be asked to enter your password to decrypt your private keys.`, + encryptWithPasskeyInstruction2: `2. After your password has been confirmed, you will then be asked to use your passkey to re-encrypt the private keys.`, enterSeedPhrase: `Add your seed phrase to import your account.`, enterWatchAccountAddress: 'Enter the address of the account you would like to watch.', @@ -225,9 +226,9 @@ const translation: IResourceLanguage = { removePasskey: 'You are about to remove the passkey "{{name}}". This action will re-enable password authentication.', removePasskeyInstruction1: - '1. Before you can remove the passkey, you will need to enter your password in order to re-encrypt your keys.', + '1. Before you can remove the passkey, you will need to enter your password which will be used to re-encrypt your keys.', removePasskeyInstruction2: - '2. You will also be asked to decrypt your keys with your passkey.', + '2. After your password has been confirmed, you will then be asked to use your passkey to decrypt the private keys.', requestingPasskeyPermission: 'Requesting permission from the passkey "{{name}}".', saveMnemonicPhrase1: From 5fce1083f1c2cd160f914ef1914e3f93f27fd68f Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Tue, 16 Jul 2024 10:54:27 +0100 Subject: [PATCH 29/31] fix: when changing password avoid re-encrypting keys when passkey is enabled --- .../{IUseChangePasswordState.ts => IState.ts} | 6 +- .../hooks/useChangePassword/types/index.ts | 2 +- .../useChangePassword/useChangePassword.ts | 123 ++++++++++-------- .../RemovePasskeyModal/RemovePasskeyModal.tsx | 2 +- .../ChangePasswordPage/ChangePasswordPage.tsx | 33 +++-- src/extension/translations/en.ts | 1 + 6 files changed, 103 insertions(+), 64 deletions(-) rename src/extension/hooks/useChangePassword/types/{IUseChangePasswordState.ts => IState.ts} (85%) diff --git a/src/extension/hooks/useChangePassword/types/IUseChangePasswordState.ts b/src/extension/hooks/useChangePassword/types/IState.ts similarity index 85% rename from src/extension/hooks/useChangePassword/types/IUseChangePasswordState.ts rename to src/extension/hooks/useChangePassword/types/IState.ts index 629395e9..2314f603 100644 --- a/src/extension/hooks/useChangePassword/types/IUseChangePasswordState.ts +++ b/src/extension/hooks/useChangePassword/types/IState.ts @@ -6,10 +6,10 @@ import type { IEncryptionState } from '@extension/components/ReEncryptKeysLoadin import type { IPasswordTag } from '@extension/types'; import type IChangePasswordActionOptions from './IChangePasswordActionOptions'; -interface IUseChangePasswordState { +interface IState { changePasswordAction: ( options: IChangePasswordActionOptions - ) => Promise; + ) => Promise; encryptionProgressState: IEncryptionState[]; encrypting: boolean; error: BaseExtensionError | null; @@ -18,4 +18,4 @@ interface IUseChangePasswordState { validating: boolean; } -export default IUseChangePasswordState; +export default IState; diff --git a/src/extension/hooks/useChangePassword/types/index.ts b/src/extension/hooks/useChangePassword/types/index.ts index 1473aaea..e83b4ab6 100644 --- a/src/extension/hooks/useChangePassword/types/index.ts +++ b/src/extension/hooks/useChangePassword/types/index.ts @@ -1,3 +1,3 @@ export type { default as IChangePasswordActionOptions } from './IChangePasswordActionOptions'; export type { default as IReEncryptPrivateKeyItemWithDelayOptions } from './IReEncryptPrivateKeyItemWithDelayOptions'; -export type { default as IUseChangePasswordState } from './IUseChangePasswordState'; +export type { default as IState } from './IState'; diff --git a/src/extension/hooks/useChangePassword/useChangePassword.ts b/src/extension/hooks/useChangePassword/useChangePassword.ts index 68329ab8..9efa47fa 100644 --- a/src/extension/hooks/useChangePassword/useChangePassword.ts +++ b/src/extension/hooks/useChangePassword/useChangePassword.ts @@ -10,7 +10,10 @@ import { } from '@extension/errors'; // selectors -import { useSelectLogger } from '@extension/selectors'; +import { + useSelectLogger, + useSelectPasskeysEnabled, +} from '@extension/selectors'; // services import PasswordService from '@extension/services/PasswordService'; @@ -19,15 +22,16 @@ import PrivateKeyService from '@extension/services/PrivateKeyService'; // types import type { IEncryptionState } from '@extension/components/ReEncryptKeysLoadingContent'; import type { IPasswordTag, IPrivateKey } from '@extension/types'; -import { IChangePasswordActionOptions, IUseChangePasswordState } from './types'; +import type { IChangePasswordActionOptions, IState } from './types'; // utils import { encryptPrivateKeyItemWithDelay } from './utils'; -export default function useChangePassword(): IUseChangePasswordState { +export default function useChangePassword(): IState { const _hookName = 'useChangePassword'; // selectors const logger = useSelectLogger(); + const passkeyEnabled = useSelectPasskeysEnabled(); // states const [encryptionProgressState, setEncryptionProgressState] = useState< IEncryptionState[] @@ -40,7 +44,7 @@ export default function useChangePassword(): IUseChangePasswordState { const changePasswordAction = async ({ currentPassword, newPassword, - }: IChangePasswordActionOptions) => { + }: IChangePasswordActionOptions): Promise => { const _functionName = 'changePasswordAction'; const passwordService = new PasswordService({ logger, @@ -64,8 +68,9 @@ export default function useChangePassword(): IUseChangePasswordState { logger.debug(`${_hookName}#${_functionName}: ${_error}`); setValidating(false); + setError(new MalformedDataError(_error)); - return setError(new MalformedDataError(_error)); + return false; } if (currentPassword === newPassword) { @@ -73,7 +78,9 @@ export default function useChangePassword(): IUseChangePasswordState { `${_hookName}#${_functionName}: passwords match, ignoring update` ); - return setPasswordTag(passwordTag); + setPasswordTag(passwordTag); + + return true; } isPasswordValid = await passwordService.verifyPassword(currentPassword); @@ -82,8 +89,9 @@ export default function useChangePassword(): IUseChangePasswordState { logger?.debug(`${_hookName}#${_functionName}: invalid password`); setValidating(false); + setError(new InvalidPasswordError()); - return setError(new InvalidPasswordError()); + return false; } setValidating(false); @@ -103,61 +111,72 @@ export default function useChangePassword(): IUseChangePasswordState { }; } catch (error) { setEncrypting(false); + setError(error); - return setError(error); + return false; } logger?.debug( `${_hookName}#${_functionName}: re-encrypted password tag "${passwordTag.id}"` ); - privateKeyService = new PrivateKeyService({ - logger, - }); - privateKeyItems = await privateKeyService.fetchAllFromStorage(); - - // set the encryption state for each item to false - setEncryptionProgressState( - privateKeyItems.map(({ id }) => ({ - id, - encrypted: false, - })) - ); + // only re-encrypt the keys if the passkey is not enabled + if (!passkeyEnabled) { + logger?.debug( + `${_hookName}#${_functionName}: re-encrypting private keys` + ); - // re-encrypt each private key items - try { - privateKeyItems = await Promise.all( - privateKeyItems.map(async (privateKeyItem, index) => { - const item = await encryptPrivateKeyItemWithDelay({ - currentPassword, - delay: (index + 1) * 300, // add a staggered delay for the ui to catch up - logger, - newPassword, - privateKeyItem, - }); - - setEncryptionProgressState((_encryptionProgressState) => - _encryptionProgressState.map((value) => - value.id === privateKeyItem.id - ? { - ...value, - encrypted: true, - } - : value - ) - ); - - return item; - }) + privateKeyService = new PrivateKeyService({ + logger, + }); + privateKeyItems = await privateKeyService.fetchAllFromStorage(); + + // set the encryption state for each item to false + setEncryptionProgressState( + privateKeyItems.map(({ id }) => ({ + id, + encrypted: false, + })) ); - } catch (error) { - setEncrypting(false); - return setError(error); - } + // re-encrypt each private key items + try { + privateKeyItems = await Promise.all( + privateKeyItems.map(async (privateKeyItem, index) => { + const item = await encryptPrivateKeyItemWithDelay({ + currentPassword, + delay: (index + 1) * 300, // add a staggered delay for the ui to catch up + logger, + newPassword, + privateKeyItem, + }); + + setEncryptionProgressState((_encryptionProgressState) => + _encryptionProgressState.map((value) => + value.id === privateKeyItem.id + ? { + ...value, + encrypted: true, + } + : value + ) + ); + + return item; + }) + ); + } catch (error) { + setEncrypting(false); + setError(error); - // save the new encrypted items to storage - await privateKeyService.saveManyToStorage(privateKeyItems); + return false; + } + + // save the new encrypted items to storage + await privateKeyService.saveManyToStorage(privateKeyItems); + + logger?.debug(`${_hookName}#${_functionName}: re-encrypted private keys`); + } // save the new password tag to storage passwordTag = await passwordService.saveToStorage(passwordTag); @@ -168,6 +187,8 @@ export default function useChangePassword(): IUseChangePasswordState { setPasswordTag(passwordTag); setEncrypting(false); + + return true; }; const resetAction = () => { setEncryptionProgressState([]); diff --git a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx index 8b5f058a..41c8635d 100644 --- a/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx +++ b/src/extension/modals/RemovePasskeyModal/RemovePasskeyModal.tsx @@ -269,7 +269,7 @@ const RemovePasskeyModal: FC = ({ onClose, removePasskey }) => { variant="solid" w="full" > - {t('buttons.confirm')} + {t('buttons.remove')} diff --git a/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx b/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx index 7949f3de..5c82babf 100644 --- a/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx +++ b/src/extension/pages/ChangePasswordPage/ChangePasswordPage.tsx @@ -30,7 +30,7 @@ import ChangePasswordLoadingModal from '@extension/modals/ChangePasswordLoadingM import ConfirmPasswordModal from '@extension/modals/ConfirmPasswordModal'; // types -import { IAppThunkDispatch } from '@extension/types'; +import type { IAppThunkDispatch } from '@extension/types'; const ChangePasswordPage: FC = () => { const { t } = useTranslation(); @@ -70,14 +70,32 @@ const ChangePasswordPage: FC = () => { const handleOnConfirmPasswordModalConfirm = async ( currentPassword: string ) => { - onConfirmPasswordModalClose(); + let success: boolean; + + if (!newPassword) { + return; + } // save the new password - if (newPassword) { - await changePasswordAction({ - currentPassword, - newPassword, + success = await changePasswordAction({ + currentPassword, + newPassword, + }); + + if (success) { + dispatch( + createNotification({ + ephemeral: true, + title: t('headings.passwordChanged'), + type: 'info', + }) + ); + navigate(`${SETTINGS_ROUTE}${SECURITY_ROUTE}`, { + replace: true, }); + + // clean up + reset(); } }; const reset = () => { @@ -88,7 +106,7 @@ const ChangePasswordPage: FC = () => { // if there is an error from the hook, show a toast useEffect(() => { - if (error) { + error && dispatch( createNotification({ description: t('errors.descriptions.code', { @@ -100,7 +118,6 @@ const ChangePasswordPage: FC = () => { type: 'error', }) ); - } }, [error]); // if we have the updated password tag navigate back useEffect(() => { diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 0271a11b..21c32968 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -343,6 +343,7 @@ const translation: IResourceLanguage = { offline: 'Offline', passkeyAdded: 'Passkey Added!', passkeyRemoved: 'Passkey Removed', + passwordChanged: 'Password Changed!', passwordLock: 'Welcome back', reKeyAccount: 'Re-key Account 🔒', reKeyAccountSuccessful: 'Successfully Re-Keyed Account!', From 67984436ed79287b8d8cc773534da79533935190 Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 17 Jul 2024 14:28:11 +0100 Subject: [PATCH 30/31] feat: add new circular progress component and add to transaction --- .../CircularProgressWithIcon.tsx | 57 +++++++++++++++++++ .../CircularProgressWithIcon/index.ts | 2 + .../CircularProgressWithIcon/types/IProps.ts | 17 ++++++ .../CircularProgressWithIcon/types/index.ts | 2 + .../ReEncryptKeysLoadingContent.tsx | 41 ++++--------- .../modals/SendAssetModal/SendAssetModal.tsx | 20 ++++--- .../SendAssetModalConfirmingContent.tsx | 35 +++++++----- .../SendAssetModalSummaryContent.tsx | 4 +- .../ISendAssetModalConfirmingContentProps.ts | 5 ++ ... => ISendAssetModalSummaryContentProps.ts} | 4 +- .../modals/SendAssetModal/types/index.ts | 3 +- .../SingleTransactionContent.tsx | 2 - .../pages/PasskeyPage/PasskeyPage.tsx | 7 ++- .../services/PasskeyService/PasskeyService.ts | 2 + src/extension/translations/en.ts | 5 +- src/extension/types/styles/TSizes.ts | 2 +- .../calculateIconSize/calculateIconSize.ts | 5 +- src/extension/utils/signBytes/signBytes.ts | 1 - 18 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 src/extension/components/CircularProgressWithIcon/CircularProgressWithIcon.tsx create mode 100644 src/extension/components/CircularProgressWithIcon/index.ts create mode 100644 src/extension/components/CircularProgressWithIcon/types/IProps.ts create mode 100644 src/extension/components/CircularProgressWithIcon/types/index.ts create mode 100644 src/extension/modals/SendAssetModal/types/ISendAssetModalConfirmingContentProps.ts rename src/extension/modals/SendAssetModal/types/{SendAssetModalSummaryContentProps.ts => ISendAssetModalSummaryContentProps.ts} (82%) diff --git a/src/extension/components/CircularProgressWithIcon/CircularProgressWithIcon.tsx b/src/extension/components/CircularProgressWithIcon/CircularProgressWithIcon.tsx new file mode 100644 index 00000000..52262f86 --- /dev/null +++ b/src/extension/components/CircularProgressWithIcon/CircularProgressWithIcon.tsx @@ -0,0 +1,57 @@ +import { + CircularProgress, + CircularProgressLabel, + Icon, +} from '@chakra-ui/react'; +import React, { FC } from 'react'; + +// hooks +import usePrimaryColor from '@extension/hooks/usePrimaryColor'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// types +import type { IProps } from './types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const CircularProgressWithIcon: FC = ({ + icon, + iconColor, + progress, + progressColor, +}) => { + // hooks + const primaryColor = usePrimaryColor(); + const subTextColor = useSubTextColor(); + // misc + const iconSize = calculateIconSize('lg'); + + return ( + 0 ? (progress[0] / progress[1]) * 100 : 0, + })} + > + + + + + ); +}; + +export default CircularProgressWithIcon; diff --git a/src/extension/components/CircularProgressWithIcon/index.ts b/src/extension/components/CircularProgressWithIcon/index.ts new file mode 100644 index 00000000..5be2216f --- /dev/null +++ b/src/extension/components/CircularProgressWithIcon/index.ts @@ -0,0 +1,2 @@ +export { default } from './CircularProgressWithIcon'; +export * from './types'; diff --git a/src/extension/components/CircularProgressWithIcon/types/IProps.ts b/src/extension/components/CircularProgressWithIcon/types/IProps.ts new file mode 100644 index 00000000..ebe44dde --- /dev/null +++ b/src/extension/components/CircularProgressWithIcon/types/IProps.ts @@ -0,0 +1,17 @@ +import { IconType } from 'react-icons'; + +/** + * @property {IconType} icon - the icon to use in the centre. + * @property {string} iconColor - [optional] the color of the icon. Defaults to the default text color. + * @property {[number, number]} progress - [optional] a tuple where the first value is the count and the second value + * is the total. If this value is omitted, the progress will be indeterminate. + * @property {string} progressColor - [optional] the color of the progress bar. Defaults to the primary color. + */ +interface IProps { + icon: IconType; + iconColor?: string; + progress?: [number, number]; + progressColor?: string; +} + +export default IProps; diff --git a/src/extension/components/CircularProgressWithIcon/types/index.ts b/src/extension/components/CircularProgressWithIcon/types/index.ts new file mode 100644 index 00000000..3abe3389 --- /dev/null +++ b/src/extension/components/CircularProgressWithIcon/types/index.ts @@ -0,0 +1,2 @@ +export type { default as IEncryptionState } from './IEncryptionState'; +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/ReEncryptKeysLoadingContent/ReEncryptKeysLoadingContent.tsx b/src/extension/components/ReEncryptKeysLoadingContent/ReEncryptKeysLoadingContent.tsx index 9a7e59f0..42648c6b 100644 --- a/src/extension/components/ReEncryptKeysLoadingContent/ReEncryptKeysLoadingContent.tsx +++ b/src/extension/components/ReEncryptKeysLoadingContent/ReEncryptKeysLoadingContent.tsx @@ -1,43 +1,36 @@ -import { - CircularProgress, - CircularProgressLabel, - Icon, - Text, - VStack, -} from '@chakra-ui/react'; +import { Text, VStack } from '@chakra-ui/react'; import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { IoLockClosedOutline, IoLockOpenOutline } from 'react-icons/io5'; +// components +import CircularProgressWithIcon from '@extension/components/CircularProgressWithIcon'; + // constants import { DEFAULT_GAP } from '@extension/constants'; // hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import useSubTextColor from '@extension/hooks/useSubTextColor'; // types import type { IProps } from './types'; -// utils -import calculateIconSize from '@extension/utils/calculateIconSize'; - const ReEncryptKeysLoadingContent: FC = ({ encryptionProgressState, fontSize = 'sm', }) => { const { t } = useTranslation(); // hooks + const defaultTextColor = useDefaultTextColor(); const subTextColor = useSubTextColor(); // misc const count = encryptionProgressState.filter( ({ encrypted }) => encrypted ).length; - const iconSize = calculateIconSize('lg'); const total = encryptionProgressState.length; const incomplete = count < total || total <= 0; - console.log('encryptionProgressState:', encryptionProgressState); - return ( = ({ w="full" > {/*progress*/} - 0 ? (count / total) * 100 : 0} - > - - - - + {/*caption*/} = ({ onClose }) => { ...result, }) ).unwrap(); - toAccount = - accounts.find( - (value) => convertPublicKeyToAVMAddress(value.publicKey) === toAddress - ) || null; logger.debug( `${ @@ -259,6 +255,10 @@ const SendAssetModal: FC = ({ onClose }) => { .join(',')}] to the network` ); + toAccount = + accounts.find( + (value) => convertPublicKeyToAVMAddress(value.publicKey) === toAddress + ) || null; fromAddress = convertPublicKeyToAVMAddress(fromAccount.publicKey); questsService = new QuestsService({ logger, @@ -383,14 +383,18 @@ const SendAssetModal: FC = ({ onClose }) => { }; // renders const renderContent = () => { - if (confirming) { - return ; - } - if (!fromAccount || !network || !selectedAsset) { return ; } + if (confirming) { + return ( + + ); + } + if (transactions && transactions.length > 0) { return ( { +// types +import type { ISendAssetModalConfirmingContentProps } from './types'; + +const SendAssetModalConfirmingContent: FC< + ISendAssetModalConfirmingContentProps +> = ({ numberOfTransactions }) => { const { t } = useTranslation(); // hooks - const defaultTextColor: string = useDefaultTextColor(); - const primaryColor: string = usePrimaryColor(); + const defaultTextColor = useDefaultTextColor(); return ( - + {/*progress*/} + + {/*captions*/} - {t('captions.confirmingTransaction')} + {numberOfTransactions + ? t('captions.confirmingTransactionWithAmountWithAmount', { + number: numberOfTransactions, + }) + : t('captions.confirmingTransactionWithAmount')} ); diff --git a/src/extension/modals/SendAssetModal/SendAssetModalSummaryContent.tsx b/src/extension/modals/SendAssetModal/SendAssetModalSummaryContent.tsx index 6c970610..ef2cc318 100644 --- a/src/extension/modals/SendAssetModal/SendAssetModalSummaryContent.tsx +++ b/src/extension/modals/SendAssetModal/SendAssetModalSummaryContent.tsx @@ -25,7 +25,7 @@ import useMinimumBalanceRequirementsForTransactions from '@extension/hooks/useMi import useSubTextColor from '@extension/hooks/useSubTextColor'; // types -import type { SendAssetModalSummaryContentProps } from './types'; +import type { ISendAssetModalSummaryContentProps } from './types'; // utils import convertToAtomicUnit from '@common/utils/convertToAtomicUnit'; @@ -34,7 +34,7 @@ import formatCurrencyUnit from '@common/utils/formatCurrencyUnit'; import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; import createIconFromDataUri from '@extension/utils/createIconFromDataUri'; -const SendAssetModalSummaryContent: FC = ({ +const SendAssetModalSummaryContent: FC = ({ accounts, amountInStandardUnits, asset, diff --git a/src/extension/modals/SendAssetModal/types/ISendAssetModalConfirmingContentProps.ts b/src/extension/modals/SendAssetModal/types/ISendAssetModalConfirmingContentProps.ts new file mode 100644 index 00000000..8aa455d1 --- /dev/null +++ b/src/extension/modals/SendAssetModal/types/ISendAssetModalConfirmingContentProps.ts @@ -0,0 +1,5 @@ +interface ISendAssetModalConfirmingContentProps { + numberOfTransactions?: number; +} + +export default ISendAssetModalConfirmingContentProps; diff --git a/src/extension/modals/SendAssetModal/types/SendAssetModalSummaryContentProps.ts b/src/extension/modals/SendAssetModal/types/ISendAssetModalSummaryContentProps.ts similarity index 82% rename from src/extension/modals/SendAssetModal/types/SendAssetModalSummaryContentProps.ts rename to src/extension/modals/SendAssetModal/types/ISendAssetModalSummaryContentProps.ts index 809bf6e7..369784b2 100644 --- a/src/extension/modals/SendAssetModal/types/SendAssetModalSummaryContentProps.ts +++ b/src/extension/modals/SendAssetModal/types/ISendAssetModalSummaryContentProps.ts @@ -8,7 +8,7 @@ import { INetworkWithTransactionParams, } from '@extension/types'; -interface SendAssetModalSummaryContentProps { +interface ISendAssetModalSummaryContentProps { accounts: IAccountWithExtendedProps[]; amountInStandardUnits: string; asset: IAssetTypes | INativeCurrency; @@ -19,4 +19,4 @@ interface SendAssetModalSummaryContentProps { transactions: Transaction[]; } -export default SendAssetModalSummaryContentProps; +export default ISendAssetModalSummaryContentProps; diff --git a/src/extension/modals/SendAssetModal/types/index.ts b/src/extension/modals/SendAssetModal/types/index.ts index 1d3694a1..9510946a 100644 --- a/src/extension/modals/SendAssetModal/types/index.ts +++ b/src/extension/modals/SendAssetModal/types/index.ts @@ -1 +1,2 @@ -export type { default as SendAssetModalSummaryContentProps } from './SendAssetModalSummaryContentProps'; +export type { default as ISendAssetModalConfirmingContentProps } from './ISendAssetModalConfirmingContentProps'; +export type { default as ISendAssetModalSummaryContentProps } from './ISendAssetModalSummaryContentProps'; diff --git a/src/extension/modals/SignTransactionsModal/SingleTransactionContent.tsx b/src/extension/modals/SignTransactionsModal/SingleTransactionContent.tsx index 1870c144..811bee0b 100644 --- a/src/extension/modals/SignTransactionsModal/SingleTransactionContent.tsx +++ b/src/extension/modals/SignTransactionsModal/SingleTransactionContent.tsx @@ -22,7 +22,6 @@ import { useSelectNetworkByGenesisHash, useSelectSettingsPreferredBlockExplorer, useSelectStandardAssetsByGenesisHash, - useSelectStandardAssetsUpdating, } from '@extension/selectors'; // services @@ -52,7 +51,6 @@ const SingleTransactionContent: FC = ({ const preferredExplorer = useSelectSettingsPreferredBlockExplorer(); const standardAssets = useSelectStandardAssetsByGenesisHash(encodedGenesisHash); - const updatingStandardAssets = useSelectStandardAssetsUpdating(); // states const [fetchingAccountInformation, setFetchingAccountInformation] = useState(false); diff --git a/src/extension/pages/PasskeyPage/PasskeyPage.tsx b/src/extension/pages/PasskeyPage/PasskeyPage.tsx index 883df8e7..728656c1 100644 --- a/src/extension/pages/PasskeyPage/PasskeyPage.tsx +++ b/src/extension/pages/PasskeyPage/PasskeyPage.tsx @@ -362,7 +362,12 @@ const PasskeyPage: FC = () => { <> {/*icon*/} - + {/*captions*/} diff --git a/src/extension/services/PasskeyService/PasskeyService.ts b/src/extension/services/PasskeyService/PasskeyService.ts index 6e357d59..790ded5e 100644 --- a/src/extension/services/PasskeyService/PasskeyService.ts +++ b/src/extension/services/PasskeyService/PasskeyService.ts @@ -123,6 +123,7 @@ export default class PasskeyService { }, challenge: randomBytes(32), extensions: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore prf: { eval: { @@ -306,6 +307,7 @@ export default class PasskeyService { ], challenge: randomBytes(CHALLENGE_BYTE_SIZE), extensions: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore prf: { eval: { diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 21c32968..e52acf64 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -99,7 +99,10 @@ const translation: IResourceLanguage = { 'You will be prompted to enter your current password when you press "Change Password".', changeTheme: 'Choose between dark and light mode.', checkingAuthenticationCredentials: 'Checking authentication credentials.', - confirmingTransaction: 'Please wait, the transaction is being processed.', + confirmingTransaction: + 'Your transaction(s) are being sent to the network to be processed.', + confirmingTransactionWithAmount: + '{{number}} transaction(s) are being sent to the network to be processed.', connectingToWalletConnect: 'Attempting to connect to WalletConnect.', copied: 'Copied!', createNewAccount: diff --git a/src/extension/types/styles/TSizes.ts b/src/extension/types/styles/TSizes.ts index ef1cae79..dc4416c7 100644 --- a/src/extension/types/styles/TSizes.ts +++ b/src/extension/types/styles/TSizes.ts @@ -1,3 +1,3 @@ -type TSizes = 'lg' | 'md' | 'sm' | 'xs'; +type TSizes = 'lg' | 'md' | 'sm' | 'xl' | 'xs'; export default TSizes; diff --git a/src/extension/utils/calculateIconSize/calculateIconSize.ts b/src/extension/utils/calculateIconSize/calculateIconSize.ts index 270e8fbd..f5f348be 100644 --- a/src/extension/utils/calculateIconSize/calculateIconSize.ts +++ b/src/extension/utils/calculateIconSize/calculateIconSize.ts @@ -1,4 +1,7 @@ -export default function calculateIconSize(size: string): number { +// types +import type { TSizes } from '@extension/types'; + +export default function calculateIconSize(size?: TSizes): number { switch (size) { case 'lg': return 10; diff --git a/src/extension/utils/signBytes/signBytes.ts b/src/extension/utils/signBytes/signBytes.ts index e4dda898..725defd4 100644 --- a/src/extension/utils/signBytes/signBytes.ts +++ b/src/extension/utils/signBytes/signBytes.ts @@ -1,5 +1,4 @@ import { sign } from 'tweetnacl'; -import browser from 'webextension-polyfill'; // enums import { EncryptionMethodEnum } from '@extension/enums'; From de5652f95e7510c411a4ef2c73eecd2fe659336c Mon Sep 17 00:00:00 2001 From: Kieran O'Neill Date: Wed, 17 Jul 2024 14:34:39 +0100 Subject: [PATCH 31/31] chore: squash --- .../components/CircularProgressWithIcon/types/index.ts | 1 - src/extension/components/PasskeyCapabilities/types/IProps.ts | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/extension/components/CircularProgressWithIcon/types/index.ts b/src/extension/components/CircularProgressWithIcon/types/index.ts index 3abe3389..f404deed 100644 --- a/src/extension/components/CircularProgressWithIcon/types/index.ts +++ b/src/extension/components/CircularProgressWithIcon/types/index.ts @@ -1,2 +1 @@ -export type { default as IEncryptionState } from './IEncryptionState'; export type { default as IProps } from './IProps'; diff --git a/src/extension/components/PasskeyCapabilities/types/IProps.ts b/src/extension/components/PasskeyCapabilities/types/IProps.ts index 70e1c19d..de4686de 100644 --- a/src/extension/components/PasskeyCapabilities/types/IProps.ts +++ b/src/extension/components/PasskeyCapabilities/types/IProps.ts @@ -1,6 +1,9 @@ +// types +import type { TSizes } from '@extension/types'; + interface IProps { capabilities: AuthenticatorTransport[]; - size?: string; + size?: TSizes; } export default IProps;