diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c2f0f059 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Mojito is a **non-custodial** Bitcoin + Mintlayer wallet shipped as a Manifest V3 browser extension for both Chromium browsers and Firefox. Seeds and private keys never leave the device: everything is encrypted at rest in IndexedDB and decrypted only in memory during an unlock. There is no server to fall back on, so a bug that corrupts or mis-encrypts a stored account destroys funds permanently. Treat anything under `src/services/Crypto` and `src/services/Entity/Account` accordingly. + +Node 18 (`.nvmrc`). + +## Commands + +```bash +npm start # webpack dev server on :3000 +npm run build # production build + packing.sh -> build/, ext.zip, extFF.zip +npm test # jest (NODE_ENV=test) +npm run lint # eslint +npm run pretty-quick # prettier +npm run e2e # playwright, headless +npm run e2e:ui # playwright, visual runner +``` + +A single test file or test: + +```bash +npx jest src/services/Crypto/Cipher/Cipher.test.js +npx jest src/services/Entity/Account -t "unlocks with a passkey" +npx jest --coverage --collectCoverageFrom='src/services/Crypto/Cipher/Cipher.js' +``` + +The pre-commit hook runs prettier, eslint and the whole suite, so all three must be green before a commit will land. + +Jest ignores `/tests/` (Playwright lives there) and `src/pages`. + +## Build layout + +`webpack.config.js` emits only `index.html`. `packing.sh` then copies it to **`popup.html`** and swaps in the per-browser manifest before zipping. Two consequences: + +- `popup.html` does not exist under `npm start`. Any code path that opens it 404s in dev. +- `public/manifestDefault.json` (Chromium) and `public/manifestFirefox.json` are separate files; whichever is being renamed to `manifest.json` at packing time wins. Changes must usually be made in both. + +The same `index.html` is the side panel (`side_panel.default_path` on Chromium, `sidebar_action.default_panel` on Firefox) and `popup.html` is the standalone tab. `src/index.js` and `AccountProvider` branch on `window.location.href.includes('popup.html')` to tell the two apart. Routing is `MemoryRouter`, so **the URL carries no route** — opening a new document always starts the app at its initial screen. + +## Path aliases + +Defined three times and kept in sync by hand: `jsconfig.json`, the `aliases` object in `webpack.config.js`, and `moduleNameMapper` in `jest.config.js`. Adding an alias means editing all three. + +`@BasicComponents` `@ComposedComponents` `@LayoutComponents` `@ContainerComponents` `@Contexts` `@Hooks` `@Pages` `@APIs` `@Cryptos` `@Databases` `@Entities` `@Helpers` `@Constants` `@Storage` `@TestData` `@Assets` `@Version` + +## Architecture + +### Layering (enforced by convention, see CONTRIBUTING.md) + +`basic` → `composed` → `layouts` are generic and must stay free of app logic. Only `containers` may hold page-specific logic. **Components must not import `@APIs`, `@Databases`, `@Cryptos` or `@Entities` directly** — those belong behind `@Entities`, which pages wire into containers via props. + +### The Account entity is the hub + +`src/services/Entity/Account/Account.js` owns every read and write of a stored account: creation, unlock, passkey enrolment, HTLC secrets and the encryption-version migration. Nothing else should write to the accounts store. + +Concurrency is guarded by `withAccountLock(id, task)`, an in-module promise chain. It only serialises within one document — the side panel, a standalone tab and the background worker each run their own module instance, so cross-document writes are still last-writer-wins. When a value is derived from the stored record, read it again immediately before the write. + +### Envelope encryption (version 4) + +A random 32-byte **DEK** encrypts the content (BTC seed, both ML private keys, HTLC secrets). The DEK is then wrapped separately by each credential that may unlock the wallet: + +- password → PBKDF2-SHA512, 600k iterations → `wrappedDek.password` +- each passkey → WebAuthn PRF output → HKDF-SHA256 → `wrappedDek.passkeys[]` + +Every AES-GCM operation is bound with `additionalData`, built by `contentAad` / `wrapperAad` / `htlsAad` in `Cipher.js`. These strings and the KDF parameters are a **storage format**: changing them consistently across encrypt and decrypt keeps round-trip tests green while making every already-stored wallet unopenable. They are pinned by known-answer tests in `Cipher.test.js` — if one of those fails, the change breaks existing users, not the test. + +Versions 1/2/3 are pre-envelope (content encrypted directly with the password key). `getAccountVersion` defaults a missing field to 1, `isEnvelope()` gates behaviour, and `aadFor()` returns `undefined` for pre-v4 records so legacy ciphertext still opens. Migration to v4 happens opportunistically on a successful unlock and is deliberately swallowed on failure: a failed migration must leave the account on its old version rather than deny access to a wallet that just decrypted fine. + +### Web Workers, and why tests do not see them + +`EnvVars.USE_WEB_WORKERS` is `process.env.NODE_ENV !== 'test'`. `loadAccountSubRoutines()` returns worker-backed functions in the browser and direct imports under jest, because jest cannot handle `new Worker(new URL(..., import.meta.url))`. + +**The production path is therefore invisible to ordinary tests.** A bug that only exists in the worker plumbing — a dropped argument, a swallowed error — passes the whole suite. `Account.workerPath.test.js` exists to cover that path by stubbing `global.Worker` and driving the real worker modules; extend it when touching `Account.worker.js`. + +Workers report failure through the envelope in `src/services/Crypto/Worker/WorkerContract.js`. The marker is `__workerError` rather than `error` because Mintlayer API payloads legitimately carry an `error` field. + +### Storage + +`IndexedDB.js` holds `SCHEMAVERSION` and the `accounts` store. Migrations run **inside the `versionchange` transaction** in `createOrUpdateDatabase`, gated on `event.oldVersion`, and `oldVersion === 0` returns early because a brand-new database has nothing to migrate. Migrations in `src/services/Database/migrations/migrations.js` are pure per-account transforms applied in sequence over a single read — issuing separate `getAll()` calls per migration makes a later one overwrite an earlier one's work. + +### Test environment + +Real Mintlayer wasm runs under jest via `src/tests/helpers/initWasm.js`; call `initWasm()` in `beforeAll`. `babel.config.js` stubs `import.meta` in the test env only. `jest.config.js` maps bare `buffer` to the npm polyfill so `Buffer` instances match the realm the wasm bindings validate against. + +Network-backed code is not testable as-is: `ML.getWalletAddresses` loops until the API reports an unused address, so without a stubbed response it spins until the heap dies. Assert on decrypted key material instead of on generated addresses. + +## Conventions + +- Branches `A-[asana id]`, PR titles `A-[asana id]: description`, base branch `dev`, squash merge. +- Plain JS is the default. Some newer files are `.tsx`; do not convert existing JS to TypeScript. +- CSS Modules for new components. No `:global()`. +- Each component lives in its own folder with its own test and CSS file. diff --git a/babel.config.js b/babel.config.js index aa1b3db6..0ca083ce 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,22 @@ +const stubImportMeta = () => ({ + visitor: { + MetaProperty(path) { + if (path.node.meta?.name !== 'import') return + + path.replaceWithSourceString('({ url: "file:///" })') + }, + }, +}) + module.exports = { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], + env: { + test: { + plugins: [stubImportMeta], + }, + }, } diff --git a/eslint.config.mjs b/eslint.config.mjs index 1cdfe6e0..eab3e38a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -70,7 +70,7 @@ export default [ }, }, { - files: ['src/tests/mock/**/*.js'], + files: ['src/tests/**/*.js'], languageOptions: { globals: { ...globals.node, diff --git a/jest.config.js b/jest.config.js index aedc5619..b8ff43fa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -39,7 +39,7 @@ module.exports = { '^react-router-dom$': '/node_modules/react-router-dom/dist/index.js', '^src/(.*)$': '/src/$1', - '.*wasm_wrappers.js': '/src/tests/mock/wasmCrypro/wasmCrypto.js', + '^buffer$': '/node_modules/buffer/index.js', }, collectCoverageFrom: ['!src/pages'], coveragePathIgnorePatterns: [ diff --git a/src/assets/images/icon-passkey.svg b/src/assets/images/icon-passkey.svg new file mode 100644 index 00000000..dc088035 --- /dev/null +++ b/src/assets/images/icon-passkey.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/icon-plus.svg b/src/assets/images/icon-plus.svg new file mode 100644 index 00000000..0ae3cf7e --- /dev/null +++ b/src/assets/images/icon-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/composed/AddWallet/AddWallet.css b/src/components/composed/AddWallet/AddWallet.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/composed/AddWallet/AddWallet.js b/src/components/composed/AddWallet/AddWallet.js deleted file mode 100644 index c89ea65b..00000000 --- a/src/components/composed/AddWallet/AddWallet.js +++ /dev/null @@ -1,203 +0,0 @@ -import { useState, useContext } from 'react' -import { Button, Error } from '@BasicComponents' -import { TextField, InputList } from '@ComposedComponents' -import { VerticalGroup, CenteredLayout } from '@LayoutComponents' -import { BTC, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' -import { AccountContext } from '@Contexts' -import { Account, AccountHelpers } from '@Entities' -import { useNavigate } from 'react-router' -import { wordlists } from 'bip39' - -const AddWallet = ({ - account, - walletType, - setAllowClosing, - setOpenConnectConfirmation, -}) => { - const navigate = useNavigate() - const [pass, setPass] = useState(null) - const { accountID, setWalletInfo } = useContext(AccountContext) - const [wordsFields, setWordsFields] = useState([]) - const [passValidity, setPassValidity] = useState(false) - const [passPristinity, setPassPristinity] = useState(true) - const [passErrorMessage, setPassErrorMessage] = useState('') - const [mnemonicErrorMessage, setMnemonicErrorMessage] = useState('') - const firstStep = account.seed.encryptedMlTestnetPrivateKey ? 3 : 1 - const [step, setStep] = useState(firstStep) - - const getMnemonics = () => - wordsFields.reduce((acc, word) => `${acc} ${word.value}`, '').trim() - - const submitButtonTitle = step === 3 ? 'Add Wallet' : 'Next' - - const changePassHandle = (value) => { - setPass(value) - } - - const connectWalletHandle = async (id, walletType, mnemonic) => { - //TODO: refactor this - const unlockedAccount = await Account.unlockAccount(id, pass) - if (!pass || !unlockedAccount) { - setPassPristinity(false) - setPassValidity(false) - setPassErrorMessage('Password must be set.') - return - } - const currentAccount = await Account.getAccount(id) - const btcWalletType = - currentAccount.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT - const currentWallets = currentAccount.walletsToCreate - const walletToAdd = walletType.value - - if (currentWallets.includes(walletToAdd)) { - console.error('Wallet already added') - setPassErrorMessage('Wallet already added') - return - } - - if (!account.seed.encryptedMlTestnetPrivateKey) { - const { - encryptedMlTestnetPrivateKey, - encryptedMlMainnetPrivateKey, - mlTestnetPrivKeyIv, - mlMainnetPrivKeyIv, - mlTestnetPrivKeyTag, - mlMainnetPrivKeyTag, - } = await AccountHelpers.getEncryptedPrivateKeys( - pass, - account.salt, - mnemonic, - ) - return await Account.updateAccount(id, { - iv: { - ...account.iv, - mlTestnetPrivKeyIv, - mlMainnetPrivKeyIv, - }, - seed: { - ...account.seed, - encryptedMlTestnetPrivateKey, - encryptedMlMainnetPrivateKey, - }, - tag: { - ...account.tag, - mlTestnetPrivKeyTag, - mlMainnetPrivKeyTag, - }, - walletsToCreate: [...currentWallets, walletToAdd], - }) - } - - return await Account.updateAccount(id, { - walletType: btcWalletType, - walletsToCreate: [...currentWallets, walletToAdd], - }) - } - - const onConnectSubmit = async (e) => { - e.preventDefault() - if (step === 1) { - setStep(2) - return - } - if (step === 2) { - const words = wordsFields.map((field) => field.value) - const mnemonic = words.join(' ') - const isValid = BTC.validateMnemonic(mnemonic) - if (!isValid) { - console.error('Invalid mnemonic') - setMnemonicErrorMessage('Invalid Seed Pharse, please try again') - return - } - setMnemonicErrorMessage('') - setStep(3) - return - } - try { - setAllowClosing(false) - const mnemonic = getMnemonics(wordlists) - const response = await connectWalletHandle( - accountID, - walletType, - mnemonic, - ) - if (response) { - const { addresses, name } = await Account.unlockAccount(accountID, pass) - setWalletInfo(addresses, accountID, name) - setOpenConnectConfirmation(false) - setPassValidity(true) - setPassErrorMessage('') - navigate('/') - } - return response - } catch (e) { - console.error(e) - setPassPristinity(false) - setPassValidity(false) - setPass('') - setPassErrorMessage('Incorrect password. Account could not be added.') - setAllowClosing(true) - } finally { - setAllowClosing(true) - } - } - return ( -
- - {step === 1 && ( - - -

- In order to add the wallet, we will ask you to enter your Seed - Phrase and password again. -

-

- We strongly recomend you to write down the same words you used - to create the account. -

-
-
- )} - {step === 2 && ( - -

- Please enter your 12 Seed Phrase -

- -
- )} - {step === 3 && ( - - )} - {mnemonicErrorMessage && } - - - -
-
- ) -} - -export default AddWallet diff --git a/src/components/composed/AddWallet/AddWallet.test.js b/src/components/composed/AddWallet/AddWallet.test.js deleted file mode 100644 index 1cd03c71..00000000 --- a/src/components/composed/AddWallet/AddWallet.test.js +++ /dev/null @@ -1,131 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react' -import { AccountContext } from '@Contexts' -import AddWallet from './AddWallet' -import { BrowserRouter } from 'react-router' - -describe('AddWallet', () => { - const mockContext = { - accountID: 'test-account-id', - setWalletInfo: jest.fn(), - } - - const mockPropsEmpty = { - account: { seed: {} }, - walletType: {}, - setAllowClosing: jest.fn(), - setOpenConnectConfirmation: jest.fn(), - } - - const mockProps = { - account: { - seed: { - encryptedMlTestnetPrivateKey: 'test-encrypted-ml-testnet-private-key', - }, - walletType: {}, - setAllowClosing: jest.fn(), - setOpenConnectConfirmation: jest.fn(), - }, - } - - const memoryRouterFeature = { - v7_startTransition: true, - v7_relativeSplatPath: true, - v7_partialHydration: true, - } - - test('renders without crashing', () => { - render( - - - - - , - ) - }) - - test('renders description paragraphs when step is 1', () => { - render( - - - - - , - ) - - const descriptionParagraphs = screen.getAllByTestId('description-paragraph') - - expect(descriptionParagraphs.length).toBe(2) - descriptionParagraphs.forEach((paragraph) => { - expect(paragraph).not.toBeEmptyDOMElement() - }) - }) - - test('renders InputList when step is 2', () => { - render( - - - - - , - ) - const submitButton = screen.getByText('Next') - fireEvent.click(submitButton) - const inputList = screen.getByTestId('inputs-list') - expect(inputList).toBeInTheDocument() - - const fields = screen.getAllByTestId('inputs-list-item') - expect(fields.length).toBe(12) - - const inputs = screen.getAllByTestId('input') - expect(inputs.length).toBe(12) - - inputs.forEach((input) => { - fireEvent.change(input, { target: { value: 'test' } }) - }) - - inputs.forEach((input) => { - expect(input.value).toBe('test') - expect(input).toHaveClass('valid') - }) - }) - - test('renders password field and submit button when step is 3', async () => { - render( - - - - - , - ) - - const label = screen.getByTestId('label') - const passwordField = screen.getByTestId('input') - - expect(label).toBeInTheDocument() - expect(label).toHaveTextContent('Enter your password') - - expect(passwordField).toBeInTheDocument() - - const submitButton = screen.getByTestId('button') - expect(submitButton).toBeInTheDocument() - expect(submitButton).toHaveTextContent('Add Wallet') - }) - - test('changes step on submit button click', () => { - render( - - - - - , - ) - - const submitButton = screen.getByText('Next') - fireEvent.click(submitButton) - - expect(submitButton).toBeInTheDocument() - - const inputList = screen.getByTestId('inputs-list') - expect(inputList).toBeInTheDocument() - }) -}) diff --git a/src/components/composed/index.js b/src/components/composed/index.js index fb97010f..5162a584 100644 --- a/src/components/composed/index.js +++ b/src/components/composed/index.js @@ -15,7 +15,6 @@ import FeeField from './FeeField/FeeField' import FeeFieldML from './FeeField/FeeFieldML' import ConnectionErrorPopup from './ConnectionErrorPopup/ConnectionErrorPopup' import WalletList from './WalletList/WalletList' -import AddWallet from './AddWallet/AddWallet' import CurrentStaking from './CurrentStaking/CurrentStaking' import HelpTooltip from './HelpTooltip/HelpTooltip' import RestoreSeedField from './RestoreSeedField/RestoreSeedField' @@ -52,7 +51,6 @@ export { FeeFieldML, ConnectionErrorPopup, WalletList, - AddWallet, CurrentStaking, HelpTooltip, RestoreSeedField, diff --git a/src/components/containers/Dashboard/CryptoList.js b/src/components/containers/Dashboard/CryptoList.js index 57e2aa82..5135c57a 100644 --- a/src/components/containers/Dashboard/CryptoList.js +++ b/src/components/containers/Dashboard/CryptoList.js @@ -102,39 +102,7 @@ export const CryptoItem = ({ onClickItem, item }) => { ) } -export const ConnectItem = ({ walletType, onClick }) => { - const { networkType } = useContext(SettingsContext) - const isDisabled = walletType.disabled - const symbol = - networkType === AppInfo.NETWORK_TYPES.MAINNET - ? walletType.symbol - : 'Testnet' - - const onItemClick = () => { - if (!isDisabled) onClick(walletType) - } - const message = isDisabled ? 'Coming soon' : 'Add wallet' - return ( -
  • - {walletType.name === 'Mintlayer' ? : } -
    -
    - {walletType.name} ({symbol}) -
    -
    -
    {message}
    -
  • - ) -} -const CryptoList = ({ cryptoList, onWalletItemClick, onConnectItemClick }) => { - const missingWalletTypes = AppInfo.walletTypes.filter( - (walletType) => - !cryptoList.find((crypto) => crypto.name === walletType.name), - ) +const CryptoList = ({ cryptoList, onWalletItemClick }) => { const coins = cryptoList.filter((crypto) => crypto.type !== 'token') const tokens = cryptoList.filter((crypto) => crypto.type === 'token') const showGroupTitles = tokens.some((token) => !token.isPlaceholder) @@ -153,14 +121,6 @@ const CryptoList = ({ cryptoList, onWalletItemClick, onConnectItemClick }) => { onClickItem={onWalletItemClick} /> ))} - - {missingWalletTypes.map((walletType) => ( - - ))} {tokens.length ? ( diff --git a/src/components/containers/Dashboard/CryptoList.test.js b/src/components/containers/Dashboard/CryptoList.test.js index f90fea49..afafc342 100644 --- a/src/components/containers/Dashboard/CryptoList.test.js +++ b/src/components/containers/Dashboard/CryptoList.test.js @@ -1,7 +1,7 @@ import React from 'react' import { render, fireEvent, screen } from '@testing-library/react' import { SettingsContext, MintlayerContext } from '@Contexts' -import { CryptoItem, ConnectItem } from './CryptoList' +import { CryptoItem } from './CryptoList' import CryptoList from './CryptoList' describe('CryptoItem', () => { @@ -104,93 +104,6 @@ describe('CryptoItem', () => { }) }) -describe('ConnectItem', () => { - const walletType = { - name: 'Bitcoin', - symbol: 'BTC', - disabled: false, - } - - const onClick = jest.fn() - - const renderComponent = (networkType) => - render( - - - - - , - ) - - it('renders the connect item correctly', () => { - renderComponent('mainnet') - - expect(screen.getByText('Bitcoin (BTC)')).toBeInTheDocument() - expect(screen.getByText('Add wallet')).toBeInTheDocument() - }) - - it('renders the Mintlayer logo for Mintlayer items', () => { - const mintlayerWalletType = { - ...walletType, - name: 'Mintlayer', - symbol: 'ML', - } - - render( - - - - - , - ) - - expect(screen.getByTestId('logo-round')).toBeInTheDocument() - }) - - it('calls the onClick callback when the item is clicked', () => { - renderComponent('mainnet') - - fireEvent.click(screen.getByText('Add wallet')) - - expect(onClick).toHaveBeenCalledWith(walletType) - }) - - it('does not disable the item for other wallet types on testnet', () => { - const otherWalletType = { - ...walletType, - name: 'Other', - symbol: 'OTH', - disabled: false, - } - - render( - - - - - , - ) - - expect(screen.getByText('Add wallet')).toBeInTheDocument() - expect(screen.getByTestId('connect-item')).not.toHaveClass('disabled') - }) -}) - describe('CryptoList', () => { const colorList = { btc: '#f7931a', @@ -227,7 +140,6 @@ describe('CryptoList', () => { // ] const onWalletItemClick = jest.fn() - const onConnectItemClick = jest.fn() //TDOO: enable this test when mainnet is ready // const renderComponent = (networkType) => @@ -238,7 +150,6 @@ describe('CryptoList', () => { // cryptoList={cryptoList} // colorList={colorList} // onWalletItemClick={onWalletItemClick} - // onConnectItemClick={onConnectItemClick} // /> // // , @@ -255,7 +166,6 @@ describe('CryptoList', () => { cryptoList={[]} colorList={colorList} onWalletItemClick={onWalletItemClick} - onConnectItemClick={onConnectItemClick} /> , @@ -283,14 +193,6 @@ describe('CryptoList', () => { // expect(onWalletItemClick).toHaveBeenCalledTimes(2) // }) - it('calls the onConnectItemClick callback when the add wallet item is clicked', () => { - renderEmptyComponent('mainnet') - - fireEvent.click(screen.getAllByText('Add wallet')[0]) - - expect(onConnectItemClick).toHaveBeenCalled() - }) - const coin = { id: 'Mintlayer', name: 'Mintlayer', @@ -322,7 +224,6 @@ describe('CryptoList', () => { cryptoList={list} colorList={colorList} onWalletItemClick={onWalletItemClick} - onConnectItemClick={onConnectItemClick} /> , diff --git a/src/components/containers/Login/SetPassword.module.css b/src/components/containers/Login/SetPassword.module.css index e45863a2..e2e66c41 100644 --- a/src/components/containers/Login/SetPassword.module.css +++ b/src/components/containers/Login/SetPassword.module.css @@ -39,7 +39,6 @@ height: 13px; max-width: 13px; max-height: 13px; - margin-left: var(--space-sm); } .loginPasswordSubmit { @@ -47,6 +46,17 @@ margin-top: var(--space-3xs); } +.loginPasskeySubmit { + width: 100%; +} + +.passkeyButtonIcon { + display: block; + flex: 0 0 auto; + width: 16px; + height: 16px; +} + .loginPasswordSubmit:hover .loginButtonIcon { animation: moveArrowRight 0.3s ease-in-out; } diff --git a/src/components/containers/Login/SetPassword.tsx b/src/components/containers/Login/SetPassword.tsx index b433b54f..63d61352 100644 --- a/src/components/containers/Login/SetPassword.tsx +++ b/src/components/containers/Login/SetPassword.tsx @@ -1,4 +1,4 @@ -import { useState, FormEvent, ReactNode } from 'react' +import { useState, useEffect, FormEvent, ReactNode } from 'react' import { useLocation } from 'react-router' import { Button } from '@BasicComponents' @@ -6,6 +6,7 @@ import { LoadingScreen, TextField } from '@ComposedComponents' import { VerticalGroup, CenteredLayout } from '@LayoutComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' +import { ReactComponent as IconPasskey } from '@Assets/images/icon-passkey.svg' import styles from './SetPassword.module.css' @@ -29,6 +30,10 @@ interface SetPasswordProps { selectedAccount?: Account buttonTitle?: string customLabel?: string | ReactNode + passkey?: { + isEnrolled: (id: string | number) => Promise + unlock: (id: string | number) => Promise + } } const SetPassword = ({ @@ -38,6 +43,7 @@ const SetPassword = ({ selectedAccount, buttonTitle = 'Unlock wallet', customLabel, + passkey, }: SetPasswordProps) => { const location = useLocation() const account: Account = selectedAccount @@ -53,6 +59,39 @@ const SetPassword = ({ const [accountPasswordErrorMessage, setAccountPasswordErrorMessage] = useState(null) const [unlockingAccount, setUnlockingAccount] = useState(false) + const [passkeyAvailable, setPasskeyAvailable] = useState(false) + + useEffect(() => { + if (!passkey) return + + let active = true + + passkey + .isEnrolled(account.id) + .then((available) => active && setPasskeyAvailable(available)) + .catch(() => active && setPasskeyAvailable(false)) + + return () => { + active = false + } + }, [account.id, passkey]) + + const passkeyHandler = async () => { + setAccountPasswordPristinity(false) + setUnlockingAccount(true) + + try { + const validated = await passkey!.unlock(account.id) + + if (!validated?.addresses) throw new Error('Unlock failed') + + onSubmit && onSubmit(validated.addresses, account.id, account.name) + } catch { + setUnlockingAccount(false) + setAccountPasswordValid(false) + setAccountPasswordErrorMessage('Could not unlock with the passkey') + } + } const passwordFieldValidity = async () => { try { @@ -136,6 +175,19 @@ const SetPassword = ({ + {passkeyAvailable ? ( + + + + ) : null} ) : ( diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js new file mode 100644 index 00000000..b8a2f38a --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js @@ -0,0 +1,177 @@ +import { useState, useEffect, useContext, useCallback } from 'react' + +import { Error } from '@BasicComponents' +import { TextField } from '@ComposedComponents' +import { AccountContext } from '@Contexts' +import { Account } from '@Entities' +import { Passkey } from '@Cryptos' +import { ReactComponent as PasskeyIcon } from '@Assets/images/icon-passkey.svg' +import { ReactComponent as PlusIcon } from '@Assets/images/icon-plus.svg' + +import styles from './SettingsPasskey.module.css' + +const formatDate = (value) => + value + ? new Date(value).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + : 'This device' + +const SettingsPasskey = () => { + const { accountID } = useContext(AccountContext) + const [passkeys, setPasskeys] = useState([]) + const [password, setPassword] = useState('') + const [adding, setAdding] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + const supported = Passkey.isSupported() + + const refresh = useCallback(async () => { + if (!accountID) return + setPasskeys(await Account.getPasskeys(accountID)) + }, [accountID]) + + useEffect(() => { + refresh() + }, [refresh]) + + const closeForm = () => { + setAdding(false) + setPassword('') + setError('') + } + + const submitAdding = async () => { + setBusy(true) + setError('') + + try { + await Account.enrollPasskey({ + accountId: accountID, + password, + label: `Passkey ${passkeys.length + 1}`, + }) + setAdding(false) + setPassword('') + await refresh() + } catch (e) { + setError(typeof e === 'string' ? e : 'Could not add the passkey') + } finally { + setBusy(false) + } + } + + const remove = async (credentialId) => { + setError('') + + try { + await Account.removePasskey({ accountId: accountID, credentialId }) + await refresh() + } catch { + setError('Could not remove the passkey') + } + } + + return ( +
    +

    Passkeys

    + + {!supported ? ( +

    + This browser cannot use passkeys for a wallet. +

    + ) : ( + <> +

    + Your password keeps working and stays the only way to recover the + wallet. Removing a passkey stops it opening this wallet, but backups + exported earlier still accept it. +

    + + {passkeys.length ? ( +
      + {passkeys.map((passkey) => ( +
    • + + + {passkey.label} + + Added {formatDate(passkey.createdAt)} + + + +
    • + ))} +
    + ) : ( +

    No passkeys yet.

    + )} + + {adding ? ( +
    +

    Confirm with your password

    + +
    + + +
    +
    + ) : ( + + )} + + + + )} +
    + ) +} + +export default SettingsPasskey diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css new file mode 100644 index 00000000..0c146401 --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css @@ -0,0 +1,207 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-md); + width: 100%; +} + +.description { + font-size: var(--font-size-sm); + line-height: 1.5; + color: rgb(var(--color-dark-gray)); + margin: 0; +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; + padding: 0; + list-style: none; +} + +.item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: 1.5px solid rgb(var(--color-medium-gray)); + border-radius: var(--border-radius-input); + background: rgb(var(--color-white)); +} + +.itemIcon { + flex: 0 0 auto; + width: 18px; + height: 18px; + color: rgb(var(--color-dark-gray)); +} + +.itemText { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.itemLabel { + font-size: var(--font-size-md); + font-weight: 600; + color: rgb(var(--color-black)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemMeta { + font-size: var(--font-size-xs); + color: rgb(var(--color-dark-gray)); +} + +.remove { + flex: 0 0 auto; + padding: var(--space-2xs) var(--space-sm); + border: none; + border-radius: var(--round-size-big); + background: transparent; + font-size: var(--font-size-sm); + font-weight: 600; + color: rgb(var(--color-dark-gray)); + cursor: pointer; + transition: all 0.25s ease; +} + +.remove:hover:not(:disabled) { + background: rgb(var(--color-red), 0.1); + color: rgb(var(--color-red)); +} + +.remove:focus-visible { + outline: 2px solid rgb(var(--color-red)); + outline-offset: 2px; +} + +.remove:disabled { + opacity: 0.5; + cursor: default; +} + +.empty { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + border: 1.5px dashed rgb(var(--color-medium-gray)); + border-radius: var(--border-radius-input); + font-size: var(--font-size-sm); + color: rgb(var(--color-dark-gray)); +} + +.add { + align-self: flex-start; + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-lg); + border: 1.5px solid rgb(var(--mojito-green)); + border-radius: var(--round-size-big); + background: rgb(var(--mojito-green-soft)); + font-size: var(--font-size-md); + font-weight: 600; + color: rgb(var(--mojito-green)); + cursor: pointer; + transition: all 0.25s ease; +} + +.add:hover { + background: rgb(var(--mojito-green)); + color: rgb(var(--color-white)); +} + +.add:focus-visible { + outline: 2px solid rgb(var(--mojito-green)); + outline-offset: 2px; +} + +.add svg { + width: 16px; + height: 16px; +} + +.form { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.formTitle { + font-size: var(--font-size-sm); + font-weight: 600; + color: rgb(var(--color-black)); + margin: 0; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2xs); +} + +.confirm, +.cancel { + padding: var(--space-sm) var(--space-xl); + border: none; + border-radius: var(--round-size-big); + font-size: var(--font-size-md); + font-weight: 600; + cursor: pointer; + transition: all 0.25s ease; +} + +.confirm { + background: rgb(var(--mojito-green)); + color: rgb(var(--color-white)); +} + +.confirm:hover:not(:disabled) { + background: rgb(var(--mojito-green-dark)); +} + +.confirm:disabled { + background: rgb(var(--color-medium-gray)); + color: rgb(var(--color-dark-gray)); + cursor: default; +} + +.cancel { + background: transparent; + color: rgb(var(--color-dark-gray)); +} + +.cancel:hover:not(:disabled) { + background: rgb(var(--color-black), 0.05); +} + +.cancel:disabled { + opacity: 0.5; + cursor: default; +} + +.confirm:focus-visible, +.cancel:focus-visible { + outline: 2px solid rgb(var(--mojito-green)); + outline-offset: 2px; +} + +.notice { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + border-radius: var(--border-radius-input); + background: rgb(var(--color-gray)); + font-size: var(--font-size-sm); + color: rgb(var(--color-dark-gray)); +} diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js new file mode 100644 index 00000000..75743c6e --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js @@ -0,0 +1,110 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { AccountContext } from '@Contexts' +import { Account } from '@Entities' +import { Passkey } from '@Cryptos' + +import SettingsPasskey from './SettingsPasskey' + +jest.mock('@Entities', () => ({ + Account: { + getPasskeys: jest.fn(), + enrollPasskey: jest.fn(), + removePasskey: jest.fn(), + }, +})) + +const renderComponent = () => + render( + + + , + ) + +beforeEach(() => { + jest.clearAllMocks() + Account.getPasskeys.mockResolvedValue([]) + jest.spyOn(Passkey, 'isSupported').mockReturnValue(true) +}) + +afterEach(() => jest.restoreAllMocks()) + +test('SettingsPasskey - lists the enrolled passkeys', async () => { + Account.getPasskeys.mockResolvedValue([ + { credentialId: 'a', label: 'Laptop', createdAt: 1 }, + { credentialId: 'b', label: 'Phone', createdAt: 2 }, + ]) + + renderComponent() + + expect(await screen.findByText('Laptop')).toBeInTheDocument() + expect(screen.getByText('Phone')).toBeInTheDocument() +}) + +test('SettingsPasskey - tells the user when the browser cannot do this', async () => { + Passkey.isSupported.mockReturnValue(false) + + renderComponent() + + expect( + await screen.findByText('This browser cannot use passkeys for a wallet.'), + ).toBeInTheDocument() + expect(screen.queryByTestId('add-passkey')).not.toBeInTheDocument() +}) + +test('SettingsPasskey - enrolling asks for the password and refreshes the list', async () => { + Account.enrollPasskey.mockResolvedValue({ credentialId: 'a' }) + + renderComponent() + + await userEvent.click(await screen.findByTestId('add-passkey')) + await userEvent.type(screen.getByPlaceholderText('Password'), 'pass') + + Account.getPasskeys.mockResolvedValue([ + { credentialId: 'a', label: 'Passkey 1', createdAt: 1 }, + ]) + + await userEvent.click(screen.getByTestId('confirm-passkey')) + + await waitFor(() => + expect(Account.enrollPasskey).toHaveBeenCalledWith({ + accountId: 1, + password: 'pass', + label: 'Passkey 1', + }), + ) + expect(await screen.findByText('Passkey 1')).toBeInTheDocument() +}) + +test('SettingsPasskey - a failed enrolment is reported', async () => { + Account.enrollPasskey.mockRejectedValue('This passkey is already enrolled') + + renderComponent() + + await userEvent.click(await screen.findByTestId('add-passkey')) + await userEvent.type(screen.getByPlaceholderText('Password'), 'pass') + await userEvent.click(screen.getByTestId('confirm-passkey')) + + expect( + await screen.findByText('This passkey is already enrolled'), + ).toBeInTheDocument() +}) + +test('SettingsPasskey - removing a passkey refreshes the list', async () => { + Account.getPasskeys.mockResolvedValue([ + { credentialId: 'a', label: 'Laptop', createdAt: 1 }, + ]) + Account.removePasskey.mockResolvedValue(undefined) + + renderComponent() + + await userEvent.click(await screen.findByTestId('remove-passkey-a')) + + await waitFor(() => + expect(Account.removePasskey).toHaveBeenCalledWith({ + accountId: 1, + credentialId: 'a', + }), + ) +}) diff --git a/src/components/containers/index.js b/src/components/containers/index.js index 41c73bb8..47786703 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -32,6 +32,7 @@ import SettingsDelete from './Settings/SettingsDelete/SettingsDelete' import SettingsTestnet from './Settings/SettingsTestnet/SettingsTestnet.tsx' import SettingsAbout from './Settings/SettingsAbout/SettingsAbout.tsx' import SettingsBackup from './Settings/SettingsBackup/SettingsBackup' +import SettingsPasskey from './Settings/SettingsPasskey/SettingsPasskey' import SettingsSection from './Settings/SettingsSection/SettingsSection.tsx' import SignMessage from './Message/SignMessage/SignMessage' @@ -74,6 +75,7 @@ const Settings = { SettingsTestnet, SettingsDelete, SettingsBackup, + SettingsPasskey, SettingsSection, } diff --git a/src/pages/Dashboard/Dashboard.js b/src/pages/Dashboard/Dashboard.js index f75f5d63..63f1dbdc 100644 --- a/src/pages/Dashboard/Dashboard.js +++ b/src/pages/Dashboard/Dashboard.js @@ -1,8 +1,6 @@ /* eslint-disable max-params */ -import { useContext, useState, useEffect } from 'react' -import { PopUp, AddWallet } from '@ComposedComponents' +import { useContext } from 'react' import { AccountContext, SettingsContext } from '@Contexts' -import { Account } from '@Entities' import { useExchangeRates, @@ -21,14 +19,9 @@ import { BTC } from '@Helpers' import { AppInfo } from '@Constants' const DashboardPage = () => { - const { addresses, accountName, accountID } = useContext(AccountContext) + const { addresses, accountName } = useContext(AccountContext) const { networkType } = useContext(SettingsContext) - const [openConnectConfirmation, setOpenConnectConfirmation] = useState(false) - const [allowClosing, setAllowClosing] = useState(true) - const [account, setAccount] = useState(null) - - const [connectedWalletType, setConnectedWalletType] = useState('') const { balance: btcBalance, fetchingBalances: btcFetchingBalances, @@ -192,21 +185,6 @@ const DashboardPage = () => { navigate('/wallet/' + walletType.id) } - const onConnectItemClick = (walletType) => { - setConnectedWalletType(walletType) - setOpenConnectConfirmation(true) - setAllowClosing(true) - } - - const getCurrentAccount = async (accountID) => { - const currentAccount = await Account.getAccount(accountID) - return currentAccount - } - - useEffect(() => { - getCurrentAccount(accountID).then((account) => setAccount(account)) - }, [accountID]) - return (
    @@ -223,21 +201,7 @@ const DashboardPage = () => { - {openConnectConfirmation && ( - - - - )} ) } diff --git a/src/pages/Login/SetAccountPassword.tsx b/src/pages/Login/SetAccountPassword.tsx index b100cfc8..454d9792 100644 --- a/src/pages/Login/SetAccountPassword.tsx +++ b/src/pages/Login/SetAccountPassword.tsx @@ -17,6 +17,12 @@ interface SetAccountPasswordPageProps { nextAfterUnlock?: NextAfterUnlock | null } +const passkey = { + isEnrolled: async (id: string | number) => + (await Account.getPasskeys(id)).length > 0, + unlock: Account.unlockAccountWithPasskey, +} + const SetAccountPasswordPage = ({ nextAfterUnlock, }: SetAccountPasswordPageProps) => { @@ -37,6 +43,7 @@ const SetAccountPasswordPage = ({ ) diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx index af02261c..a8d912a6 100644 --- a/src/pages/Settings/Settings.tsx +++ b/src/pages/Settings/Settings.tsx @@ -19,6 +19,7 @@ const SettingsPage = ({ unlocked }: SettingsPageProps) => { key: 'wallet', visible: unlocked, items: [ + { key: 'passkey', component: }, { key: 'backup', component: }, { key: 'delete', component: }, ], diff --git a/src/services/Crypto/BTC/BTC.worker.js b/src/services/Crypto/BTC/BTC.worker.js index c7e7e662..25433d21 100644 --- a/src/services/Crypto/BTC/BTC.worker.js +++ b/src/services/Crypto/BTC/BTC.worker.js @@ -1,27 +1,14 @@ import { generateMnemonic, getSeedFromMnemonic } from './BTC' +import { registerWorkerJobs } from 'src/services/Crypto/Worker/WorkerContract' const WalletWorkerEnum = { GENERATE_MNEMONIC: 'GENERATE_MNEMONIC', GET_SEED_FROM_MNEMONIC: 'GET_SEED_FROM_MNEMONIC', } -const WalletWorkerJobs = { +registerWorkerJobs({ GENERATE_MNEMONIC: generateMnemonic, GET_SEED_FROM_MNEMONIC: getSeedFromMnemonic, -} - -const isValidJob = (choosenJob) => { - if (!choosenJob) return false - return Object.hasOwn(WalletWorkerJobs, choosenJob) -} - -self.onmessage = ({ data }) => { - if (!isValidJob(data.job)) return false - - const jobResult = WalletWorkerJobs[data.job](data.data) - postMessage(jobResult) - - return true -} +}) export { WalletWorkerEnum } diff --git a/src/services/Crypto/Cipher/Cipher.js b/src/services/Crypto/Cipher/Cipher.js index 4e9b2229..484a814b 100644 --- a/src/services/Crypto/Cipher/Cipher.js +++ b/src/services/Crypto/Cipher/Cipher.js @@ -1,11 +1,14 @@ // keySize is in bytes: 16 => AES-128, 32 => AES-256. // V1/V2 must keep keySize 16 so already-stored data stays decryptable. +// V4 shares V3's KDF parameters; it marks the envelope record format, not a new KDF. const V1 = { iterations: 10000, keySize: 16 } const V2 = { ...V1, iterations: 600000 } const V3 = { ...V2, keySize: 32 } +const V4 = { ...V3 } -const CURRENT_ENCRYPTION_VERSION = 3 -const ENCRYPTION_VERSIONS = { 1: V1, 2: V2, 3: V3 } +const CURRENT_ENCRYPTION_VERSION = 4 +const ENVELOPE_ENCRYPTION_VERSION = 4 +const ENCRYPTION_VERSIONS = { 1: V1, 2: V2, 3: V3, 4: V4 } const getVersionConfig = (version) => { const config = ENCRYPTION_VERSIONS[version] @@ -15,6 +18,14 @@ const getVersionConfig = (version) => { const IVSIZE = 12 const SALTSIZE = 16 +const DEKSIZE = 32 + +const PASSKEY_KDF = 'HKDF-SHA256' + +const contentAad = (field) => `mojito/v4/content/${field}` +const wrapperAad = (wrapperId) => `mojito/v4/dek/${wrapperId}` +const htlsAad = (hash) => `mojito/v4/htls/${hash}` +const PASSKEY_KEK_INFO = 'mojito/v1/passkey-kek' const hexToBytes = (hexString) => { const pairs = hexString?.match(/.{1,2}/g) @@ -70,7 +81,12 @@ const generatePBKDF2Key = async ({ const generateIV = async () => crypto.getRandomValues(new Uint8Array(IVSIZE)) -const encryptAES = async ({ data, key }) => { +const gcmParams = (iv, aad) => + aad + ? { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(aad) } + : { name: 'AES-GCM', iv } + +const encryptAES = async ({ data, key, aad }) => { const iv = await generateIV() const hex = Buffer.from(data).toString('hex') @@ -83,7 +99,7 @@ const encryptAES = async ({ data, key }) => { ) const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, + gcmParams(iv, aad), cryptoKey, new TextEncoder().encode(hex), ) @@ -96,7 +112,7 @@ const encryptAES = async ({ data, key }) => { } } -const decryptAES = async ({ data, key, iv, tag }) => { +const decryptAES = async ({ data, key, iv, tag, aad }) => { const cryptoKey = await crypto.subtle.importKey( 'raw', new Uint8Array(key), @@ -113,7 +129,7 @@ const decryptAES = async ({ data, key, iv, tag }) => { try { const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: binaryStringToBytes(iv) }, + gcmParams(binaryStringToBytes(iv), aad), cryptoKey, combined, ) @@ -123,10 +139,76 @@ const decryptAES = async ({ data, key, iv, tag }) => { } } +const generateDek = async () => [ + ...crypto.getRandomValues(new Uint8Array(DEKSIZE)), +] + +const isKeyBytes = (key) => + (ArrayBuffer.isView(key) || Array.isArray(key)) && + key.length === DEKSIZE && + Array.prototype.every.call( + key, + (byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255, + ) + +const assertKeySize = (key, message) => { + if (!isKeyBytes(key)) throw new Error(message) +} + +const deriveKekFromPrf = async (prfOutput) => { + assertKeySize(prfOutput, 'Invalid PRF output') + + const ikm = await crypto.subtle.importKey( + 'raw', + new Uint8Array(prfOutput), + 'HKDF', + false, + ['deriveBits'], + ) + + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(), + info: new TextEncoder().encode(PASSKEY_KEK_INFO), + }, + ikm, + DEKSIZE * 8, + ) + + return [...new Uint8Array(derivedBits)] +} + +const wrapDek = async ({ dek, wrappingKey, aad }) => { + assertKeySize(dek, 'Invalid data encryption key') + assertKeySize(wrappingKey, 'Invalid wrapping key') + return encryptAES({ data: new Uint8Array(dek), key: wrappingKey, aad }) +} + +const unwrapDek = async ({ data, iv, tag, wrappingKey, aad }) => { + assertKeySize(wrappingKey, 'Invalid wrapping key') + if (!data || !iv || !tag) throw new Error('Missing key wrapper') + + const dek = await decryptAES({ data, iv, tag, key: wrappingKey, aad }) + assertKeySize(dek, 'Invalid data encryption key') + return [...dek] +} + export { IVSIZE, + DEKSIZE, + PASSKEY_KDF, + contentAad, + wrapperAad, + htlsAad, CURRENT_ENCRYPTION_VERSION, + ENVELOPE_ENCRYPTION_VERSION, ENCRYPTION_VERSIONS, + generateDek, + wrapDek, + unwrapDek, + deriveKekFromPrf, getVersionConfig, generateSalt, generatePBKDF2Key, diff --git a/src/services/Crypto/Cipher/Cipher.test.js b/src/services/Crypto/Cipher/Cipher.test.js index 54398014..27b69a46 100644 --- a/src/services/Crypto/Cipher/Cipher.test.js +++ b/src/services/Crypto/Cipher/Cipher.test.js @@ -6,9 +6,18 @@ import { decryptAES, hexToBytes, IVSIZE, + DEKSIZE, CURRENT_ENCRYPTION_VERSION, + ENVELOPE_ENCRYPTION_VERSION, ENCRYPTION_VERSIONS, getVersionConfig, + generateDek, + wrapDek, + unwrapDek, + deriveKekFromPrf, + contentAad, + wrapperAad, + htlsAad, } from './Cipher' test('Cipher - HEX to Bytes', () => { @@ -335,7 +344,7 @@ test('Cipher - decryptAES tampered tag throws', async () => { }) test('Cipher - constants are correct', () => { - expect(CURRENT_ENCRYPTION_VERSION).toBe(3) + expect(CURRENT_ENCRYPTION_VERSION).toBe(4) expect(getVersionConfig(1).iterations).toBe(10000) expect(getVersionConfig(2).iterations).toBe(600000) expect(getVersionConfig(3).iterations).toBe(600000) @@ -718,3 +727,526 @@ test('Cipher - migration v1 -> v3 upgrades to AES-256 and preserves data', async expect(fromKey.length).toBe(16) // AES-128 expect(toKey.length).toBe(32) // AES-256 }) + +test('Cipher - v4 shares v3 KDF parameters', () => { + expect(ENVELOPE_ENCRYPTION_VERSION).toBe(4) + expect(getVersionConfig(4)).toStrictEqual(getVersionConfig(3)) +}) + +test('Cipher - generateDek returns a fresh 32 byte key', async () => { + const dek1 = await generateDek() + const dek2 = await generateDek() + + expect(dek1.length).toBe(DEKSIZE) + expect(Array.isArray(dek1)).toBe(true) + expect(dek1.every((byte) => byte >= 0 && byte <= 255)).toBe(true) + expect(dek1).not.toStrictEqual(dek2) +}) + +test('Cipher - wrapDek / unwrapDek round trip', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + + const wrapped = await wrapDek({ dek, wrappingKey }) + + expect(wrapped.encryptedData).toBeDefined() + expect(wrapped.iv.length).toBe(IVSIZE) + expect(wrapped.tag.length).toBe(16) + + const unwrapped = await unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey, + }) + + expect(unwrapped).toStrictEqual(dek) +}) + +test('Cipher - the same DEK can be wrapped by two independent keys', async () => { + const dek = await generateDek() + const { key: passwordKey } = await generatePBKDF2Key({ + password: 'MyStr0ng!Pass', + }) + const passkeyKey = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const byPassword = await wrapDek({ dek, wrappingKey: passwordKey }) + const byPasskey = await wrapDek({ dek, wrappingKey: passkeyKey }) + + expect(byPassword.encryptedData).not.toBe(byPasskey.encryptedData) + + await expect( + unwrapDek({ + ...byPassword, + data: byPassword.encryptedData, + wrappingKey: passwordKey, + }), + ).resolves.toStrictEqual(dek) + await expect( + unwrapDek({ + ...byPasskey, + data: byPasskey.encryptedData, + wrappingKey: passkeyKey, + }), + ).resolves.toStrictEqual(dek) +}) + +test('Cipher - unwrapDek rejects a wrong wrapping key', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const { key: wrongKey } = await generatePBKDF2Key({ password: 'WrongPass' }) + + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: wrongKey, + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - wrapDek rejects a DEK of the wrong size', async () => { + const wrappingKey = await generateDek() + + const invalidDeks = [ + ['too short', [1, 2, 3]], + ['too long', new Array(DEKSIZE + 1).fill(1)], + ['empty', []], + ['undefined', undefined], + ['null', null], + ] + + for (const [, dek] of invalidDeks) { + await expect(wrapDek({ dek, wrappingKey })).rejects.toThrow( + 'Invalid data encryption key', + ) + } +}) + +test('Cipher - wrapDek rejects a wrapping key that is not 32 bytes', async () => { + const dek = await generateDek() + const { key: legacyKey } = await generatePBKDF2Key({ + password: 'MyStr0ng!Pass', + version: 1, + }) + + expect(legacyKey.length).toBe(16) + + await expect(wrapDek({ dek, wrappingKey: legacyKey })).rejects.toThrow( + 'Invalid wrapping key', + ) + await expect(wrapDek({ dek, wrappingKey: undefined })).rejects.toThrow( + 'Invalid wrapping key', + ) +}) + +test('Cipher - unwrapDek rejects a wrapping key that is not 32 bytes', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: [1, 2, 3], + }), + ).rejects.toThrow('Invalid wrapping key') +}) + +test('Cipher - unwrapDek reports a missing wrapper distinctly from a wrong key', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const wrapped = await wrapDek({ dek, wrappingKey }) + const full = { ...wrapped, data: wrapped.encryptedData } + + const incomplete = [ + { ...full, data: undefined }, + { ...full, iv: undefined }, + { ...full, tag: undefined }, + {}, + ] + + for (const wrapper of incomplete) { + await expect(unwrapDek({ ...wrapper, wrappingKey })).rejects.toThrow( + 'Missing key wrapper', + ) + } +}) + +test('Cipher - unwrapDek rejects a payload that is not a 32 byte key', async () => { + const wrappingKey = await generateDek() + + const payloads = [new Uint8Array([1, 2, 3]), new Uint8Array(DEKSIZE + 1)] + + for (const payload of payloads) { + const notAKey = await encryptAES({ data: payload, key: wrappingKey }) + await expect( + unwrapDek({ ...notAKey, data: notAKey.encryptedData, wrappingKey }), + ).rejects.toThrow('Invalid data encryption key') + } +}) + +test('Cipher - unwrapDek rejects tampered ciphertext, tag and IV', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const wrapped = await wrapDek({ dek, wrappingKey }) + const full = { ...wrapped, data: wrapped.encryptedData } + + const flipFirstByte = (binaryString) => + String.fromCharCode(binaryString.charCodeAt(0) ^ 0xff) + + binaryString.slice(1) + + const tampered = [ + { ...full, data: flipFirstByte(full.data) }, + { ...full, tag: flipFirstByte(full.tag) }, + { ...full, iv: flipFirstByte(full.iv) }, + ] + + for (const wrapper of tampered) { + await expect(unwrapDek({ ...wrapper, wrappingKey })).rejects.toThrow( + 'Incorrect password', + ) + } +}) + +test('Cipher - wrapDek uses a fresh IV for every wrap', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + + const first = await wrapDek({ dek, wrappingKey }) + const second = await wrapDek({ dek, wrappingKey }) + + expect(first.iv).not.toBe(second.iv) + expect(first.encryptedData).not.toBe(second.encryptedData) + + await expect( + unwrapDek({ ...first, data: first.encryptedData, wrappingKey }), + ).resolves.toStrictEqual(dek) + await expect( + unwrapDek({ ...second, data: second.encryptedData, wrappingKey }), + ).resolves.toStrictEqual(dek) +}) + +test('Cipher - generateDek uses crypto.getRandomValues', async () => { + const originalGetRandomValues = crypto.getRandomValues.bind(crypto) + const mockGetRandomValues = jest.fn((arr) => { + for (let i = 0; i < arr.length; i++) arr[i] = 0x5a + return arr + }) + crypto.getRandomValues = mockGetRandomValues + + const dek = await generateDek() + + expect(mockGetRandomValues).toHaveBeenCalled() + expect(dek).toStrictEqual(new Array(DEKSIZE).fill(0x5a)) + + crypto.getRandomValues = originalGetRandomValues +}) + +test('Cipher - wrapDek accepts a Uint8Array DEK and unwraps to a plain array', async () => { + const wrappingKey = await generateDek() + const dek = new Uint8Array(DEKSIZE).fill(7) + + const wrapped = await wrapDek({ dek, wrappingKey }) + const unwrapped = await unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey, + }) + + expect(Array.isArray(unwrapped)).toBe(true) + expect(unwrapped).toStrictEqual(new Array(DEKSIZE).fill(7)) +}) + +test('Cipher - a wrapped DEK survives a JSON round trip at byte value edges', async () => { + const wrappingKey = await generateDek() + + const edgeCases = [ + new Array(DEKSIZE).fill(0x00), + new Array(DEKSIZE).fill(0xff), + Array.from({ length: DEKSIZE }, (_, i) => (i % 2 ? 0x00 : 0xff)), + ] + + for (const dek of edgeCases) { + const wrapped = await wrapDek({ dek, wrappingKey }) + const restored = JSON.parse(JSON.stringify(wrapped)) + + const unwrapped = await unwrapDek({ + ...restored, + data: restored.encryptedData, + wrappingKey, + }) + + expect(unwrapped).toStrictEqual(dek) + } +}) + +test('Cipher - generatePBKDF2Key accepts version 4 and yields a 32 byte key', async () => { + const salt = 'fixedsalt' + const password = 'MyStr0ng!Pass' + + const { key: v4Key } = await generatePBKDF2Key({ + password, + salt, + version: ENVELOPE_ENCRYPTION_VERSION, + }) + const { key: v3Key } = await generatePBKDF2Key({ password, salt, version: 3 }) + + expect(v4Key.length).toBe(DEKSIZE) + expect(v4Key).toStrictEqual(v3Key) +}) + +test('Cipher - ENCRYPTION_VERSIONS exposes every supported version', () => { + expect(Object.keys(ENCRYPTION_VERSIONS)).toStrictEqual(['1', '2', '3', '4']) + expect(() => getVersionConfig(5)).toThrow('Unknown encryption version: 5') +}) + +test('Cipher - hexToBytes returns an empty array for unusable input', () => { + expect(hexToBytes('')).toStrictEqual(new Uint8Array()) + expect(hexToBytes(undefined)).toStrictEqual(new Uint8Array()) + expect(hexToBytes(null)).toStrictEqual(new Uint8Array()) +}) + +test('Cipher - deriveKekFromPrf is deterministic and returns a 32 byte key', async () => { + const prfOutput = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const first = await deriveKekFromPrf(prfOutput) + const second = await deriveKekFromPrf(prfOutput) + + expect(first.length).toBe(DEKSIZE) + expect(Array.isArray(first)).toBe(true) + expect(first).toStrictEqual(second) +}) + +test('Cipher - deriveKekFromPrf never returns the raw PRF output', async () => { + const prfOutput = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + const other = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const kek = await deriveKekFromPrf(prfOutput) + + expect(kek).not.toStrictEqual(prfOutput) + expect(kek).not.toStrictEqual(await deriveKekFromPrf(other)) +}) + +test('Cipher - deriveKekFromPrf rejects anything that is not 32 bytes', async () => { + const invalid = [ + [1, 2, 3], + new Array(DEKSIZE + 1).fill(1), + [], + undefined, + null, + ] + + for (const prfOutput of invalid) { + await expect(deriveKekFromPrf(prfOutput)).rejects.toThrow( + 'Invalid PRF output', + ) + } +}) + +test('Cipher - a DEK wrapped by a PRF-derived key round trips', async () => { + const dek = await generateDek() + const prfOutput = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const wrappingKey = await deriveKekFromPrf(prfOutput) + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: await deriveKekFromPrf(prfOutput), + }), + ).resolves.toStrictEqual(dek) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: prfOutput, + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - keys must be real bytes, not just the right length', async () => { + const dek = await generateDek() + const notBytes = [ + new Array(DEKSIZE).fill(undefined), + new Array(DEKSIZE).fill(NaN), + new Array(DEKSIZE).fill(1.5), + new Array(DEKSIZE).fill(256), + new Array(DEKSIZE).fill(-1), + 'a'.repeat(DEKSIZE), + { length: DEKSIZE }, + ] + + for (const value of notBytes) { + await expect(wrapDek({ dek: value, wrappingKey: dek })).rejects.toThrow( + 'Invalid data encryption key', + ) + await expect(wrapDek({ dek, wrappingKey: value })).rejects.toThrow( + 'Invalid wrapping key', + ) + await expect(deriveKekFromPrf(value)).rejects.toThrow('Invalid PRF output') + } +}) + +test('Cipher - typed arrays are accepted as keys', async () => { + const dek = new Uint8Array(DEKSIZE).fill(3) + const wrappingKey = new Uint8Array(DEKSIZE).fill(9) + + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ ...wrapped, data: wrapped.encryptedData, wrappingKey }), + ).resolves.toStrictEqual([...dek]) +}) + +test('Cipher - additional data must match on decryption', async () => { + const key = await generateDek() + const encrypted = await encryptAES({ data: 'secret', key, aad: 'context/a' }) + + await expect( + decryptAES({ + ...encrypted, + data: encrypted.encryptedData, + key, + aad: 'context/a', + }), + ).resolves.toBeDefined() + + await expect( + decryptAES({ + ...encrypted, + data: encrypted.encryptedData, + key, + aad: 'context/b', + }), + ).rejects.toThrow('Incorrect password') + + await expect( + decryptAES({ ...encrypted, data: encrypted.encryptedData, key }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - data written without additional data still decrypts without it', async () => { + const key = await generateDek() + const encrypted = await encryptAES({ data: 'legacy', key }) + + const decrypted = await decryptAES({ + ...encrypted, + data: encrypted.encryptedData, + key, + }) + + expect(Buffer.from(decrypted).toString()).toBe('legacy') +}) + +test('Cipher - a DEK wrapper is bound to the wrapper it belongs to', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + + const wrapped = await wrapDek({ + dek, + wrappingKey, + aad: wrapperAad('password'), + }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey, + aad: wrapperAad('some-credential-id'), + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - the aad builders are distinct per purpose and per field', () => { + expect(contentAad('btcEncryptedSeed')).not.toBe( + contentAad('encryptedMlMainnetPrivateKey'), + ) + expect(wrapperAad('password')).not.toBe(wrapperAad('cred')) + expect(htlsAad('a')).not.toBe(htlsAad('b')) + expect(contentAad('x')).not.toBe(wrapperAad('x')) + expect(contentAad('x')).not.toBe(htlsAad('x')) +}) + +test('Cipher - the passkey KEK derivation is pinned to a known vector', async () => { + const prfOutput = Array.from({ length: 32 }, (_, i) => i) + + expect(await deriveKekFromPrf(prfOutput)).toStrictEqual([ + 128, 134, 211, 213, 27, 178, 194, 105, 0, 85, 111, 79, 63, 120, 223, 76, 3, + 92, 102, 175, 17, 153, 205, 237, 54, 226, 12, 209, 117, 179, 157, 239, + ]) +}) + +const KNOWN_PASSWORD = 'correct horse battery staple' +const KNOWN_SALT = '000102030405060708090a0b0c0d0e0f' +const fromCodes = (codes) => String.fromCharCode(...codes) + +test.each([ + [1, [54, 3, 60, 234, 152, 152, 20, 171, 192, 66, 40, 128, 46, 37, 81, 44]], + [ + 2, + [99, 123, 106, 23, 174, 237, 18, 166, 11, 134, 179, 112, 110, 201, 235, 79], + ], + [ + 3, + [ + 99, 123, 106, 23, 174, 237, 18, 166, 11, 134, 179, 112, 110, 201, 235, 79, + 147, 133, 30, 40, 70, 44, 26, 215, 254, 11, 159, 186, 168, 109, 179, 6, + ], + ], + [ + 4, + [ + 99, 123, 106, 23, 174, 237, 18, 166, 11, 134, 179, 112, 110, 201, 235, 79, + 147, 133, 30, 40, 70, 44, 26, 215, 254, 11, 159, 186, 168, 109, 179, 6, + ], + ], +])( + 'Cipher - the v%i key derivation is pinned to a known vector', + async (version, expected) => { + const { key } = await generatePBKDF2Key({ + password: KNOWN_PASSWORD, + salt: KNOWN_SALT, + version, + }) + + expect(key).toStrictEqual(expected) + }, +) + +test('Cipher - a blob encrypted before this change still decrypts', async () => { + const { key } = await generatePBKDF2Key({ + password: KNOWN_PASSWORD, + salt: KNOWN_SALT, + version: 4, + }) + + const decrypted = await decryptAES({ + data: fromCodes([ + 107, 143, 59, 183, 211, 112, 106, 253, 33, 97, 249, 54, 47, 237, 187, 183, + 59, 154, 142, 159, 223, 35, 203, 194, 204, 252, 98, 17, + ]), + iv: fromCodes([16, 167, 231, 76, 225, 178, 64, 6, 199, 106, 255, 246]), + tag: fromCodes([ + 27, 133, 117, 246, 162, 232, 227, 29, 64, 161, 172, 141, 11, 201, 145, 46, + ]), + key, + aad: contentAad('btcEncryptedSeed'), + }) + + expect(new TextDecoder().decode(decrypted)).toBe('attack at dawn') +}) + +test('Cipher - the additional data strings are part of the stored format', () => { + expect(contentAad('btcEncryptedSeed')).toBe( + 'mojito/v4/content/btcEncryptedSeed', + ) + expect(wrapperAad('password')).toBe('mojito/v4/dek/password') + expect(htlsAad('abc')).toBe('mojito/v4/htls/abc') +}) diff --git a/src/services/Crypto/Cipher/Cipher.worker.js b/src/services/Crypto/Cipher/Cipher.worker.js index d5f2b6bf..f7a75bcd 100644 --- a/src/services/Crypto/Cipher/Cipher.worker.js +++ b/src/services/Crypto/Cipher/Cipher.worker.js @@ -1,4 +1,5 @@ import { generatePBKDF2Key, encryptAES, decryptAES } from './Cipher' +import { registerWorkerJobs } from 'src/services/Crypto/Worker/WorkerContract' const CipherWorkerEnum = { GENERATE_PBKDF2_KEY: 'GENERATE_PBKDF2_KEY', @@ -6,29 +7,10 @@ const CipherWorkerEnum = { DECRYPT_AES: 'DECRYPT_AES', } -const CipherWorkerJobs = { +registerWorkerJobs({ GENERATE_PBKDF2_KEY: generatePBKDF2Key, ENCRYPT_AES: encryptAES, DECRYPT_AES: decryptAES, -} - -const isValidJob = (choosenJob) => { - if (!choosenJob) return false - return Object.hasOwn(CipherWorkerJobs, choosenJob) -} - -self.onmessage = async ({ data }) => { - if (!isValidJob(data.job)) return false - - try { - const jobResult = await CipherWorkerJobs[data.job](data.data) - - postMessage(jobResult) - - return true - } catch (error) { - postMessage({ error: error.message }) - } -} +}) export { CipherWorkerEnum } diff --git a/src/services/Crypto/Mintlayer/Mintlayer.test.js b/src/services/Crypto/Mintlayer/Mintlayer.test.js new file mode 100644 index 00000000..6692e3e4 --- /dev/null +++ b/src/services/Crypto/Mintlayer/Mintlayer.test.js @@ -0,0 +1,48 @@ +import { AppInfo } from '@Constants' +import initWasm from 'src/tests/helpers/initWasm' +import * as ML from './Mintlayer' + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +beforeAll(() => initWasm()) + +test('Mintlayer - the real wasm module is loaded, not a mock', () => { + const key = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.TESTNET, + ) + + expect(key).toBeInstanceOf(Uint8Array) + expect(key.length).toBeGreaterThan(0) +}) + +test('Mintlayer - key derivation is deterministic and network specific', () => { + const testnet = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.TESTNET, + ) + const mainnet = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.MAINNET, + ) + + expect( + ML.getPrivateKeyFromMnemonic(MNEMONIC, AppInfo.NETWORK_TYPES.TESTNET), + ).toStrictEqual(testnet) + expect(Buffer.from(testnet)).not.toStrictEqual(Buffer.from(mainnet)) +}) + +test('Mintlayer - initWasm is idempotent', async () => { + await expect(ML.initWasm()).resolves.toBeUndefined() + await expect(ML.initWasm()).resolves.toBeUndefined() +}) + +test('Mintlayer - the import.meta stub does not touch new.target', () => { + function Callable() { + return new.target + } + + expect(new Callable()).toBe(Callable) + expect(Callable()).toBeUndefined() +}) diff --git a/src/services/Crypto/Passkey/Passkey.js b/src/services/Crypto/Passkey/Passkey.js new file mode 100644 index 00000000..cd8273f5 --- /dev/null +++ b/src/services/Crypto/Passkey/Passkey.js @@ -0,0 +1,132 @@ +import { + uint8ArrayToString, + stringToUint8Array, +} from 'src/utils/Helpers/Array/Array' + +const RP_NAME = 'Mojito' +const FIREFOX_RP_ID = 'keys.mintlayer.org' +const PRF_SALT_SIZE = 32 +const CHALLENGE_SIZE = 32 +const USER_ID_SIZE = 16 + +const isFirefox = () => navigator.userAgent.includes('Firefox') + +// Chrome derives the RP ID from the extension origin when rp.id is omitted, which +// no website and no other extension can claim. Firefox rejects moz-extension:// +// and needs a domain listed in host_permissions. +const getRpId = () => (isFirefox() ? FIREFOX_RP_ID : undefined) + +const isSupported = () => + typeof PublicKeyCredential !== 'undefined' && + typeof navigator.credentials?.create === 'function' + +const randomBytes = (size) => crypto.getRandomValues(new Uint8Array(size)) + +const toBase64Url = (bytes) => + uint8ArrayToString(new Uint8Array(bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +const fromBase64Url = (value) => + stringToUint8Array(value.replace(/-/g, '+').replace(/_/g, '/')) + +const createCredential = async (accountName) => { + const rpId = getRpId() + + const credential = await navigator.credentials.create({ + publicKey: { + rp: { name: RP_NAME, ...(rpId ? { id: rpId } : {}) }, + user: { + id: randomBytes(USER_ID_SIZE), + name: accountName, + displayName: accountName, + }, + challenge: randomBytes(CHALLENGE_SIZE), + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + attestation: 'none', + extensions: { prf: {}, credProps: true }, + }, + }) + + if (!credential) throw new Error('Passkey creation was cancelled') + + // Firefox reports an unsupported extension as {} rather than {enabled:false}, + // so anything falsy has to be treated as no PRF at all. + if (!credential.getClientExtensionResults()?.prf?.enabled) + throw new Error('This authenticator cannot protect a wallet') + + return toBase64Url(credential.rawId) +} + +// Every enrolled credential is offered, each with its own salt, so the user can +// unlock with whichever authenticator they have at hand. The assertion tells us +// which one answered. +const getPrfOutput = async (passkeys) => { + if (!passkeys?.length) throw new Error('No passkey is enrolled') + + const rpId = getRpId() + + const assertion = await navigator.credentials.get({ + publicKey: { + ...(rpId ? { rpId } : {}), + challenge: randomBytes(CHALLENGE_SIZE), + allowCredentials: passkeys.map(({ credentialId }) => ({ + type: 'public-key', + id: fromBase64Url(credentialId), + })), + userVerification: 'required', + extensions: { + prf: { + evalByCredential: Object.fromEntries( + passkeys.map(({ credentialId, prfSalt }) => [ + credentialId, + { first: fromBase64Url(prfSalt) }, + ]), + ), + }, + }, + }, + }) + + if (!assertion) throw new Error('Passkey verification was cancelled') + + const prfOutput = assertion.getClientExtensionResults()?.prf?.results?.first + + if (!prfOutput) throw new Error('This authenticator did not return a key') + + return { + credentialId: toBase64Url(assertion.rawId), + prfOutput: [...new Uint8Array(prfOutput)], + } +} + +// PRF results are not returned by create() on most platforms, so enrolment always +// follows the ceremony with an assertion to obtain the first output. +const enroll = async (accountName) => { + const credentialId = await createCredential(accountName) + const prfSalt = toBase64Url(randomBytes(PRF_SALT_SIZE)) + const { prfOutput } = await getPrfOutput([{ credentialId, prfSalt }]) + + return { credentialId, prfSalt, prfOutput } +} + +export { + RP_NAME, + FIREFOX_RP_ID, + isSupported, + getRpId, + toBase64Url, + fromBase64Url, + createCredential, + getPrfOutput, + enroll, +} diff --git a/src/services/Crypto/Passkey/Passkey.test.js b/src/services/Crypto/Passkey/Passkey.test.js new file mode 100644 index 00000000..5756bfe3 --- /dev/null +++ b/src/services/Crypto/Passkey/Passkey.test.js @@ -0,0 +1,279 @@ +import { + FIREFOX_RP_ID, + isSupported, + getRpId, + toBase64Url, + fromBase64Url, + createCredential, + getPrfOutput, + enroll, +} from './Passkey' + +const CREDENTIAL_ID = new Uint8Array([1, 2, 3, 4]) +const PRF_OUTPUT = new Uint8Array(32).fill(7) + +const setUserAgent = (value) => + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value, + }) + +const mockCredentials = ({ create, get }) => { + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { create, get }, + }) +} + +const credentialWith = (extensionResults) => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => extensionResults, +}) + +beforeEach(() => { + setUserAgent('Chrome') + global.PublicKeyCredential = function PublicKeyCredential() {} +}) + +afterEach(() => { + delete global.PublicKeyCredential +}) + +test('Passkey - base64url round trips without padding characters', () => { + const bytes = new Uint8Array([251, 255, 190, 0, 1]) + const encoded = toBase64Url(bytes) + + expect(encoded).not.toMatch(/[+/=]/) + expect(fromBase64Url(encoded)).toStrictEqual(bytes) +}) + +test('Passkey - support detection needs both the interface and the API', () => { + mockCredentials({ create: jest.fn(), get: jest.fn() }) + expect(isSupported()).toBe(true) + + mockCredentials({ create: undefined, get: jest.fn() }) + expect(isSupported()).toBe(false) + + mockCredentials({ create: jest.fn(), get: jest.fn() }) + delete global.PublicKeyCredential + expect(isSupported()).toBe(false) +}) + +test('Passkey - the RP id is omitted on Chrome and a domain on Firefox', () => { + expect(getRpId()).toBeUndefined() + + setUserAgent('Mozilla/5.0 Firefox/150.0') + expect(getRpId()).toBe(FIREFOX_RP_ID) +}) + +test('Passkey - creation asks for a discoverable credential and user verification', async () => { + const create = jest.fn(async () => + credentialWith({ prf: { enabled: true }, credProps: { rk: true } }), + ) + mockCredentials({ create, get: jest.fn() }) + + const credentialId = await createCredential('Savings') + + expect(credentialId).toBe(toBase64Url(CREDENTIAL_ID)) + + const { publicKey } = create.mock.calls[0][0] + + expect(publicKey.rp.id).toBeUndefined() + expect(publicKey.authenticatorSelection).toStrictEqual({ + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }) + expect(publicKey.attestation).toBe('none') + expect(publicKey.extensions.prf).toStrictEqual({}) + expect(publicKey.challenge.length).toBe(32) + expect(publicKey.user.name).toBe('Savings') +}) + +test('Passkey - creation sends the RP id on Firefox', async () => { + setUserAgent('Mozilla/5.0 Firefox/150.0') + const create = jest.fn(async () => credentialWith({ prf: { enabled: true } })) + mockCredentials({ create, get: jest.fn() }) + + await createCredential('Savings') + + expect(create.mock.calls[0][0].publicKey.rp.id).toBe(FIREFOX_RP_ID) +}) + +test('Passkey - an authenticator without PRF is refused', async () => { + mockCredentials({ + create: jest.fn(async () => credentialWith({})), + get: jest.fn(), + }) + + await expect(createCredential('Savings')).rejects.toThrow( + 'This authenticator cannot protect a wallet', + ) + + mockCredentials({ + create: jest.fn(async () => credentialWith({ prf: { enabled: false } })), + get: jest.fn(), + }) + + await expect(createCredential('Savings')).rejects.toThrow( + 'This authenticator cannot protect a wallet', + ) +}) + +test('Passkey - a cancelled creation is refused', async () => { + mockCredentials({ create: jest.fn(async () => null), get: jest.fn() }) + + await expect(createCredential('Savings')).rejects.toThrow( + 'Passkey creation was cancelled', + ) +}) + +test('Passkey - the assertion evaluates PRF with the stored salt', async () => { + const get = jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create: jest.fn(), get }) + + const credentialId = toBase64Url(CREDENTIAL_ID) + const prfSalt = toBase64Url(new Uint8Array(32).fill(9)) + + const result = await getPrfOutput([{ credentialId, prfSalt }]) + + expect(result).toStrictEqual({ credentialId, prfOutput: [...PRF_OUTPUT] }) + + const { publicKey } = get.mock.calls[0][0] + + expect(publicKey.userVerification).toBe('required') + expect(publicKey.allowCredentials[0].id).toStrictEqual(CREDENTIAL_ID) + expect(publicKey.extensions.prf.evalByCredential[credentialId]).toStrictEqual( + { first: new Uint8Array(32).fill(9) }, + ) +}) + +test('Passkey - every enrolled credential is offered with its own salt', async () => { + const secondId = new Uint8Array([9, 9, 9]) + const get = jest.fn(async () => ({ + rawId: secondId.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create: jest.fn(), get }) + + const first = { + credentialId: toBase64Url(CREDENTIAL_ID), + prfSalt: toBase64Url(new Uint8Array(32).fill(1)), + } + const second = { + credentialId: toBase64Url(secondId), + prfSalt: toBase64Url(new Uint8Array(32).fill(2)), + } + + const result = await getPrfOutput([first, second]) + + expect(result.credentialId).toBe(second.credentialId) + + const { publicKey } = get.mock.calls[0][0] + + expect(publicKey.allowCredentials).toHaveLength(2) + expect(Object.keys(publicKey.extensions.prf.evalByCredential)).toStrictEqual([ + first.credentialId, + second.credentialId, + ]) + expect( + publicKey.extensions.prf.evalByCredential[second.credentialId].first, + ).toStrictEqual(new Uint8Array(32).fill(2)) +}) + +test('Passkey - an empty credential list is refused before any ceremony', async () => { + const get = jest.fn() + mockCredentials({ create: jest.fn(), get }) + + await expect(getPrfOutput([])).rejects.toThrow('No passkey is enrolled') + expect(get).not.toHaveBeenCalled() +}) + +test('Passkey - a missing PRF result fails closed', async () => { + mockCredentials({ + create: jest.fn(), + get: jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({}), + })), + }) + + await expect( + getPrfOutput([{ credentialId: 'AQID', prfSalt: 'AQID' }]), + ).rejects.toThrow('This authenticator did not return a key') +}) + +test('Passkey - a cancelled assertion is refused', async () => { + mockCredentials({ create: jest.fn(), get: jest.fn(async () => null) }) + + await expect( + getPrfOutput([{ credentialId: 'AQID', prfSalt: 'AQID' }]), + ).rejects.toThrow('Passkey verification was cancelled') +}) + +test('Passkey - enrolment follows creation with an assertion for the first PRF output', async () => { + const create = jest.fn(async () => credentialWith({ prf: { enabled: true } })) + const get = jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create, get }) + + const result = await enroll('Savings') + + expect(create).toHaveBeenCalledTimes(1) + expect(get).toHaveBeenCalledTimes(1) + expect(result.credentialId).toBe(toBase64Url(CREDENTIAL_ID)) + expect(result.prfOutput).toStrictEqual([...PRF_OUTPUT]) + expect(fromBase64Url(result.prfSalt).length).toBe(32) +}) + +test('Passkey - every enrolment generates a fresh salt', async () => { + mockCredentials({ + create: jest.fn(async () => credentialWith({ prf: { enabled: true } })), + get: jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })), + }) + + const first = await enroll('Savings') + const second = await enroll('Savings') + + expect(first.prfSalt).not.toBe(second.prfSalt) +}) + +test('Passkey - the assertion sends the RP id on Firefox', async () => { + setUserAgent('Mozilla/5.0 Firefox/150.0') + const get = jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create: jest.fn(), get }) + + await getPrfOutput([{ credentialId: 'AQID', prfSalt: 'AQID' }]) + + expect(get.mock.calls[0][0].publicKey.rpId).toBe(FIREFOX_RP_ID) +}) + +test('Passkey - base64url encoding matches the values a stored record holds', () => { + const bytes = new Uint8Array([251, 255, 190, 0, 1, 2]) + + expect(toBase64Url(bytes)).toBe('-_--AAEC') + expect(fromBase64Url('-_--AAEC')).toStrictEqual(bytes) + expect(toBase64Url(new Uint8Array([1]))).toBe('AQ') + expect(fromBase64Url('AQ')).toStrictEqual(new Uint8Array([1])) +}) diff --git a/src/services/Crypto/Worker/WorkerContract.js b/src/services/Crypto/Worker/WorkerContract.js new file mode 100644 index 00000000..955e2ea1 --- /dev/null +++ b/src/services/Crypto/Worker/WorkerContract.js @@ -0,0 +1,23 @@ +const WORKER_ERROR = '__workerError' + +const toWorkerError = (error) => ({ [WORKER_ERROR]: error.message }) + +const getWorkerError = (data) => + typeof data === 'object' && data !== null ? data[WORKER_ERROR] : undefined + +const registerWorkerJobs = (jobs) => { + self.onmessage = async ({ data }) => { + const name = data?.job + + if (!name || !Object.hasOwn(jobs, name)) + return postMessage(toWorkerError(new Error(`Unknown job: ${name}`))) + + try { + postMessage(await jobs[name](data.data)) + } catch (error) { + postMessage(toWorkerError(error)) + } + } +} + +export { WORKER_ERROR, toWorkerError, getWorkerError, registerWorkerJobs } diff --git a/src/services/Crypto/Worker/WorkerContract.test.js b/src/services/Crypto/Worker/WorkerContract.test.js new file mode 100644 index 00000000..25f88997 --- /dev/null +++ b/src/services/Crypto/Worker/WorkerContract.test.js @@ -0,0 +1,81 @@ +import { WORKER_ERROR, toWorkerError, getWorkerError } from './WorkerContract' + +test('WorkerContract - toWorkerError carries the message under the marker', () => { + const envelope = toWorkerError(new Error('Incorrect password')) + + expect(envelope).toStrictEqual({ [WORKER_ERROR]: 'Incorrect password' }) + expect(getWorkerError(envelope)).toBe('Incorrect password') +}) + +test('WorkerContract - real job results are never mistaken for errors', () => { + const results = [ + { key: [1, 2, 3], salt: 'aabb' }, + { encryptedData: 'x', iv: 'y', tag: 'z' }, + new Uint8Array([1, 2, 3]), + [1, 2, 3], + 'a mnemonic phrase', + 0, + null, + undefined, + ] + + results.forEach((result) => expect(getWorkerError(result)).toBeUndefined()) +}) + +test('WorkerContract - a legacy payload with a plain error field is not an error envelope', () => { + expect(getWorkerError({ error: 'Incorrect password' })).toBeUndefined() +}) + +describe('the cipher worker answers every message', () => { + const reply = (message) => + new Promise((resolve) => { + const post = globalThis.postMessage + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: (data) => { + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: post, + }) + resolve(data) + }, + }) + + self.onmessage({ data: message }) + }) + + beforeAll(() => require('src/services/Crypto/Cipher/Cipher.worker')) + + test('a valid job posts its result', async () => { + const result = await reply({ + job: 'GENERATE_PBKDF2_KEY', + data: { password: 'p', salt: 'aabb', version: 1 }, + }) + + expect(getWorkerError(result)).toBeUndefined() + expect(result.key.length).toBe(16) + }) + + test('a failing job posts an error envelope', async () => { + const result = await reply({ + job: 'DECRYPT_AES', + data: { data: 'x', key: new Array(32).fill(1), iv: 'i', tag: 't' }, + }) + + expect(getWorkerError(result)).toBe('Incorrect password') + }) + + test('an unknown job posts an error envelope instead of hanging', async () => { + const result = await reply({ job: 'NOPE' }) + + expect(getWorkerError(result)).toBe('Unknown job: NOPE') + }) + + test('a missing job posts an error envelope', async () => { + const result = await reply({}) + + expect(getWorkerError(result)).toBe('Unknown job: undefined') + }) +}) diff --git a/src/services/Crypto/index.js b/src/services/Crypto/index.js index d82f48a8..899b251f 100644 --- a/src/services/Crypto/index.js +++ b/src/services/Crypto/index.js @@ -5,11 +5,13 @@ import BTC_ADDRESS_TYPE_MAP, { BTC_ADDRESS_TYPE_ENUM, } from './BTC/BTC.addressType' import * as Cipher from './Cipher/Cipher' +import * as Passkey from './Passkey/Passkey' export { BTC, ML, Cipher, + Passkey, BTCTransaction, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM, diff --git a/src/services/Database/IndexedDB/IndexedDB.js b/src/services/Database/IndexedDB/IndexedDB.js index df7311a3..0f1dcf0a 100644 --- a/src/services/Database/IndexedDB/IndexedDB.js +++ b/src/services/Database/IndexedDB/IndexedDB.js @@ -1,7 +1,7 @@ import { - accountsMigration_01_add_mlwallet_private_keys, - accountsMigration_02_add_htls_secrets_field, -} from '../migrations/migrations' + ACCOUNT_MIGRATIONS, + migrateAccount, +} from 'src/services/Database/migrations/migrations' const glob = typeof window !== 'undefined' ? window : self /* istanbul ignore next */ @@ -11,12 +11,13 @@ const IDB = glob.webkitIndexedDB || glob.msIndexedDB -const SCHEMAVERSION = 3 +const SCHEMAVERSION = ACCOUNT_MIGRATIONS.length + 1 const DATABASENAME = 'mojito' const ACCOUNTSSTORENAME = 'accounts' const createOrUpdateDatabase = (event) => { const db = event.target.result + const { oldVersion } = event if (!db.objectStoreNames.contains(ACCOUNTSSTORENAME)) { const objectStore = db.createObjectStore(ACCOUNTSSTORENAME, { @@ -27,9 +28,20 @@ const createOrUpdateDatabase = (event) => { // Create an index on the 'name' property objectStore.createIndex('name', 'name', { unique: false }) } - // Apply migrations here - accountsMigration_01_add_mlwallet_private_keys() - accountsMigration_02_add_htls_secrets_field() + + if (oldVersion === 0) return + + // Apply migrations here, inside the upgrade transaction + const store = event.target.transaction.objectStore(ACCOUNTSSTORENAME) + const request = store.getAll() + + request.onsuccess = () => { + request.result.forEach((account) => { + const migrated = migrateAccount(account, oldVersion) + + if (migrated !== account) store.put(migrated) + }) + } } const openDatabase = (DB = IDB) => { diff --git a/src/services/Database/IndexedDB/IndexedDB.test.js b/src/services/Database/IndexedDB/IndexedDB.test.js index a01b123c..eb01b907 100644 --- a/src/services/Database/IndexedDB/IndexedDB.test.js +++ b/src/services/Database/IndexedDB/IndexedDB.test.js @@ -44,6 +44,7 @@ test('IndexedDB basic functions - createOrUpdateDatabase', async () => { }) const event = { + oldVersion: 0, target: { result: { createObjectStore, @@ -55,6 +56,56 @@ test('IndexedDB basic functions - createOrUpdateDatabase', async () => { } createOrUpdateDatabase(event) + + expect(createObjectStore).toHaveBeenCalled() + expect(createIndex).toHaveBeenCalled() +}) + +test('IndexedDB basic functions - createOrUpdateDatabase skips migrations on a new database', () => { + const objectStore = jest.fn() + + createOrUpdateDatabase({ + oldVersion: 0, + target: { + result: { + createObjectStore: jest.fn(() => ({ createIndex: jest.fn() })), + objectStoreNames: { contains: jest.fn().mockReturnValue(false) }, + }, + transaction: { objectStore }, + }, + }) + + expect(objectStore).not.toHaveBeenCalled() +}) + +test('IndexedDB basic functions - createOrUpdateDatabase migrates an existing database', () => { + const request = {} + const put = jest.fn() + const getAll = jest.fn(() => request) + + createOrUpdateDatabase({ + oldVersion: 1, + target: { + result: { + createObjectStore: jest.fn(), + objectStoreNames: { contains: jest.fn().mockReturnValue(true) }, + }, + transaction: { objectStore: jest.fn(() => ({ getAll, put })) }, + }, + }) + + expect(getAll).toHaveBeenCalled() + + request.result = [{ id: 1, iv: 'iv', tag: 'tag', seed: 'seed' }] + request.onsuccess() + + expect(put).toHaveBeenCalledWith({ + id: 1, + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + seed: { btcEncryptedSeed: 'seed' }, + htlsSecrets: {}, + }) }) test('IndexedDB basic functions - openDatabase', async () => { diff --git a/src/services/Database/migrations/migrations.js b/src/services/Database/migrations/migrations.js index 3d28b1fa..d696a79c 100644 --- a/src/services/Database/migrations/migrations.js +++ b/src/services/Database/migrations/migrations.js @@ -1,51 +1,45 @@ -import { IndexedDB } from '@Databases' - -const accountsMigration_01_add_mlwallet_private_keys = async () => { - // Load the old accounts - const store = await IndexedDB.loadAccounts() - const accounts = await IndexedDB.getAll(store) - - // Transform the old accounts to the new structure - if (!accounts || accounts.length <= 0) return - const newAccounts = accounts.map((account) => { - // Add the encrypted private keys to the account - return { - ...account, - iv: { btcIv: account.iv }, - tag: { btcTag: account.tag }, - seed: { btcEncryptedSeed: account.seed }, - } - }) - - await IndexedDB.saveAccounts(newAccounts) +const accountsMigration_01_add_mlwallet_private_keys = (account) => ({ + ...account, + iv: { btcIv: account.iv }, + tag: { btcTag: account.tag }, + seed: { btcEncryptedSeed: account.seed }, +}) + +const accountsMigration_02_add_htls_secrets_field = (account) => { + const hasSecrets = + typeof account.htlsSecrets === 'object' && account.htlsSecrets !== null + + return hasSecrets ? account : { ...account, htlsSecrets: {} } } -const accountsMigration_02_add_htls_secrets_field = async () => { - const store = await IndexedDB.loadAccounts() - const accounts = await IndexedDB.getAll(store) +const unnest = (field, key) => + field && typeof field[key] === 'object' && field[key] !== null + ? field[key] + : field - if (!accounts || accounts.length <= 0) return - - const needsUpdate = accounts.some( - (account) => - account.htlsSecrets === undefined || account.htlsSecrets === null, - ) - if (!needsUpdate) return +const accountsMigration_03_repair_double_nesting = (account) => ({ + ...account, + iv: unnest(account.iv, 'btcIv'), + tag: unnest(account.tag, 'btcTag'), + seed: unnest(account.seed, 'btcEncryptedSeed'), +}) - const newAccounts = accounts.map((account) => ({ - ...account, - htlsSecrets: - account && - typeof account.htlsSecrets === 'object' && - account.htlsSecrets !== null - ? account.htlsSecrets - : {}, - })) +const ACCOUNT_MIGRATIONS = [ + accountsMigration_01_add_mlwallet_private_keys, + accountsMigration_02_add_htls_secrets_field, + accountsMigration_03_repair_double_nesting, +] - await IndexedDB.saveAccounts(newAccounts) -} +const migrateAccount = (account, oldVersion) => + ACCOUNT_MIGRATIONS.slice(oldVersion - 1).reduce( + (migrated, migrate) => migrate(migrated), + account, + ) export { accountsMigration_01_add_mlwallet_private_keys, accountsMigration_02_add_htls_secrets_field, + accountsMigration_03_repair_double_nesting, + ACCOUNT_MIGRATIONS, + migrateAccount, } diff --git a/src/services/Database/migrations/migrations.test.js b/src/services/Database/migrations/migrations.test.js new file mode 100644 index 00000000..8882838b --- /dev/null +++ b/src/services/Database/migrations/migrations.test.js @@ -0,0 +1,272 @@ +import { + DATABASENAME, + ACCOUNTSSTORENAME, + SCHEMAVERSION, + loadAccounts, + getAll, + save, +} from 'src/services/Database/IndexedDB/IndexedDB' +import { ACCOUNT_MIGRATIONS, migrateAccount } from './migrations' + +const deleteDatabase = () => + new Promise((resolve) => { + const request = indexedDB.deleteDatabase(DATABASENAME) + request.onsuccess = resolve + request.onerror = resolve + request.onblocked = resolve + }) + +const seedDatabaseAtVersion = (version, accounts) => + new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASENAME, version) + + request.onupgradeneeded = (event) => { + const db = event.target.result + + if (!db.objectStoreNames.contains(ACCOUNTSSTORENAME)) { + const objectStore = db.createObjectStore(ACCOUNTSSTORENAME, { + keyPath: 'id', + autoIncrement: true, + }) + objectStore.createIndex('name', 'name', { unique: false }) + } + + const store = event.target.transaction.objectStore(ACCOUNTSSTORENAME) + accounts.forEach((account) => store.put(account)) + } + + request.onsuccess = (event) => { + event.target.result.close() + resolve() + } + request.onerror = reject + }) + +const readAccounts = async () => getAll(await loadAccounts()) + +beforeEach(deleteDatabase) +afterAll(deleteDatabase) + +test('migrations - a v1 account is reshaped and gains htlsSecrets', async () => { + await seedDatabaseAtVersion(1, [ + { + id: 1, + name: 'Legacy', + salt: 'aabb', + iv: 'flat-iv', + tag: 'flat-tag', + seed: 'flat-seed', + }, + ]) + + const [account] = await readAccounts() + + expect(account.iv).toStrictEqual({ btcIv: 'flat-iv' }) + expect(account.tag).toStrictEqual({ btcTag: 'flat-tag' }) + expect(account.seed).toStrictEqual({ btcEncryptedSeed: 'flat-seed' }) + expect(account.htlsSecrets).toStrictEqual({}) + expect(account.name).toBe('Legacy') +}) + +test('migrations - a v2 account only gains htlsSecrets and is not reshaped again', async () => { + const nested = { + id: 1, + name: 'Nested', + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + } + + await seedDatabaseAtVersion(2, [nested]) + + const [account] = await readAccounts() + + expect(account.iv).toStrictEqual(nested.iv) + expect(account.tag).toStrictEqual(nested.tag) + expect(account.seed).toStrictEqual(nested.seed) + expect(account.htlsSecrets).toStrictEqual({}) +}) + +test('migrations - existing htlsSecrets are preserved', async () => { + const secrets = { + abc: { encryptedHtlsSecret: 'x', htlsIv: 'y', htlsTag: 'z' }, + } + + await seedDatabaseAtVersion(2, [ + { + id: 1, + name: 'WithSecrets', + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + seed: { btcEncryptedSeed: 'seed' }, + htlsSecrets: secrets, + }, + ]) + + const [account] = await readAccounts() + + expect(account.htlsSecrets).toStrictEqual(secrets) +}) + +test('migrations - do not touch an account saved into a freshly created database', async () => { + const account = { + name: 'Fresh', + salt: 'aabb', + encryptionVersion: 3, + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + htlsSecrets: {}, + } + + await save(await loadAccounts(), account) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const [stored] = await readAccounts() + + expect(stored.seed).toStrictEqual(account.seed) + expect(stored.iv).toStrictEqual(account.iv) + expect(stored.tag).toStrictEqual(account.tag) +}) + +test('migrations - a v1 database with no accounts upgrades cleanly', async () => { + await seedDatabaseAtVersion(1, []) + + await expect(readAccounts()).resolves.toStrictEqual([]) +}) + +test('migrations - a record double-nested by the old migration is repaired', async () => { + const inner = { + btcIv: 'iv', + mlTestnetPrivKeyIv: 'iv2', + mlMainnetPrivKeyIv: 'iv3', + } + const innerTag = { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + } + const innerSeed = { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + } + + await seedDatabaseAtVersion(3, [ + { + id: 1, + name: 'Corrupted', + encryptionVersion: 3, + iv: { btcIv: inner }, + tag: { btcTag: innerTag }, + seed: { btcEncryptedSeed: innerSeed }, + htlsSecrets: {}, + }, + ]) + + const [account] = await readAccounts() + + expect(account.iv).toStrictEqual(inner) + expect(account.tag).toStrictEqual(innerTag) + expect(account.seed).toStrictEqual(innerSeed) +}) + +test('migrations - a healthy record is not changed by the repair', async () => { + const healthy = { + id: 1, + name: 'Healthy', + encryptionVersion: 3, + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + htlsSecrets: {}, + } + + await seedDatabaseAtVersion(3, [healthy]) + + const [account] = await readAccounts() + + expect(account).toStrictEqual(healthy) +}) + +test('migrations - a v4 envelope record survives the schema upgrade untouched', async () => { + const envelope = { + id: 1, + name: 'Envelope', + salt: 'aabb', + encryptionVersion: 4, + wrappedDek: { + password: { encryptedData: 'w', iv: 'wi', tag: 'wt' }, + passkeys: [], + }, + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + htlsSecrets: {}, + } + + await seedDatabaseAtVersion(3, [envelope]) + + const [account] = await readAccounts() + + expect(account).toStrictEqual(envelope) +}) + +test('migrations - the schema version and the ladder cannot drift apart', () => { + expect(SCHEMAVERSION).toBe(ACCOUNT_MIGRATIONS.length + 1) +}) + +test('migrations - an account only runs the migrations above its own version', () => { + const v3Account = { + seed: { btcEncryptedSeed: 'seed' }, + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + htlsSecrets: { abc: {} }, + } + + expect(migrateAccount(v3Account, 3)).toStrictEqual(v3Account) + expect(migrateAccount(v3Account, 4)).toBe(v3Account) +}) + +test('migrations - a flat v1 account walks the whole ladder', () => { + const migrated = migrateAccount({ seed: 'blob', iv: 'iv', tag: 'tag' }, 1) + + expect(migrated).toStrictEqual({ + seed: { btcEncryptedSeed: 'blob' }, + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + htlsSecrets: {}, + }) +}) diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index 2a5e9088..41c47cc2 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -1,21 +1,112 @@ import { BTC, ML, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' import { IndexedDB } from '@Databases' import { AppInfo } from '@Constants' +import * as Passkey from 'src/services/Crypto/Passkey/Passkey' import { - getEncryptedPrivateKeys, + getEnvelopeEncryptedPrivateKeys, + buildPasskeyWrapper, getEncryptedHtlsSecret, } from './AccountHelpers' import { BTC as BtcHelpers } from '@Helpers' import loadAccountSubRoutines from './loadWorkers' import { LocalStorageService } from '@Storage' -import { CURRENT_ENCRYPTION_VERSION } from '../../Crypto/Cipher/Cipher' +import { + CURRENT_ENCRYPTION_VERSION, + ENVELOPE_ENCRYPTION_VERSION, + generateDek, + wrapDek, + unwrapDek, + deriveKekFromPrf, + contentAad, + wrapperAad, + htlsAad, +} from 'src/services/Crypto/Cipher/Cipher' const getAccountVersion = (account) => account.encryptionVersion || 1 +const accountLocks = new Map() + +const withAccountLock = (id, task) => { + const previous = accountLocks.get(id) ?? Promise.resolve() + const next = previous.then(task, task) + + accountLocks.set( + id, + next.catch(() => {}), + ) + + return next +} + +const isEnvelope = (version) => version >= ENVELOPE_ENCRYPTION_VERSION + +const aadFor = (account, aad) => + isEnvelope(getAccountVersion(account)) ? aad : undefined + +const PASSWORD_CREDENTIAL = 'password' +const PASSKEY_CREDENTIAL = 'passkey' + +const toCredential = (credential) => + typeof credential === 'string' + ? { kind: PASSWORD_CREDENTIAL, password: credential } + : credential + +const getWrapper = (account, credential) => { + if (credential.kind === PASSWORD_CREDENTIAL) + return account.wrappedDek?.password + + return account.wrappedDek?.passkeys?.find( + (entry) => entry.credentialId === credential.credentialId, + ) +} + +const getWrappingKey = async (account, credential) => { + if (credential.kind === PASSKEY_CREDENTIAL) + return deriveKekFromPrf(credential.prfOutput) + + const { generateEncryptionKey } = await loadAccountSubRoutines() + const { key } = await generateEncryptionKey({ + password: credential.password, + salt: account.salt, + version: getAccountVersion(account), + }) + + return key +} + +const getContentKey = async (account, credential) => { + const unlock = toCredential(credential) + const version = getAccountVersion(account) + + if (!isEnvelope(version)) { + if (unlock.kind !== PASSWORD_CREDENTIAL) + throw new Error('This account can only be unlocked with a password') + + return getWrappingKey(account, unlock) + } + + const wrapper = getWrapper(account, unlock) + const wrappingKey = await getWrappingKey(account, unlock) + const wrapperId = + unlock.kind === PASSWORD_CREDENTIAL + ? PASSWORD_CREDENTIAL + : unlock.credentialId + + return unwrapDek({ + data: wrapper?.encryptedData, + iv: wrapper?.iv, + tag: wrapper?.tag, + wrappingKey, + aad: wrapperAad(wrapperId), + }) +} + const saveAccount = async (data) => { const { name, password, mnemonic, walletType, walletsToCreate } = data const { salt, + encryptionVersion, + wrappedDek, encryptedMlTestnetPrivateKey, encryptedMlMainnetPrivateKey, btcEncryptedSeed, @@ -25,12 +116,13 @@ const saveAccount = async (data) => { mlTestnetPrivKeyTag, mlMainnetPrivKeyTag, btcTag, - } = await getEncryptedPrivateKeys(password, undefined, mnemonic) + } = await getEnvelopeEncryptedPrivateKeys(password, undefined, mnemonic) const account = { name, salt, - encryptionVersion: CURRENT_ENCRYPTION_VERSION, + encryptionVersion, + wrappedDek, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -81,25 +173,22 @@ const restoreAccountFromJSON = async (json) => { } const checkPasswordValidity = async (id, password) => { - const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() + const { decryptSeed } = await loadAccountSubRoutines() try { const account = await getAccount(id) if (!account?.salt || !account?.seed?.btcEncryptedSeed) return false - const { key } = await generateEncryptionKey({ - password, - salt: account.salt, - version: getAccountVersion(account), - }) + const key = await getContentKey(account, password) const decrypted = await decryptSeed({ data: account.seed.btcEncryptedSeed, iv: account.iv.btcIv, tag: account.tag.btcTag, key, + aad: aadFor(account, contentAad('btcEncryptedSeed')), }) - if (!decrypted || decrypted.error) return false + if (!decrypted) return false return true } catch { return false @@ -107,68 +196,205 @@ const checkPasswordValidity = async (id, password) => { } const unlockHtlsSecret = async ({ accountId, password, hash }) => { - const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() + const { decryptSeed } = await loadAccountSubRoutines() const account = await getAccount(accountId) - const isPasswordValid = await checkPasswordValidity(accountId, password) - if (!isPasswordValid) return Promise.reject('Invalid password') if (!account) return Promise.reject('Account not found') if (!account.htlsSecrets || !account.htlsSecrets[hash]) return Promise.reject('No secret found for the provided hash') - const { key } = await generateEncryptionKey({ - password, - salt: account.salt, - version: getAccountVersion(account), - }) + let key + + try { + key = await getContentKey(account, password) + } catch { + return Promise.reject('Invalid password') + } const data = account.htlsSecrets[hash] - const decrypted = await decryptSeed({ - data: data.encryptedHtlsSecret, - iv: data.htlsIv, - tag: data.htlsTag, - key, - }) - if (!decrypted || decrypted.error) + try { + const decrypted = await decryptSeed({ + data: data.encryptedHtlsSecret, + iv: data.htlsIv, + tag: data.htlsTag, + key, + aad: aadFor(account, htlsAad(hash)), + }) + + if (!decrypted) throw new Error('Empty secret') + + return new TextDecoder().decode(decrypted) + } catch { return Promise.reject( 'Failed to decrypt the secret. Possibly wrong password.', ) + } +} + +const saveProvidedHtlsSecret = async ({ accountId, password, data }) => + withAccountLock(accountId, async () => { + const account = await getAccount(accountId) + if (!account) return Promise.reject('Account not found') - return new TextDecoder().decode(decrypted) + let key + + try { + key = await getContentKey(account, password) + } catch { + return Promise.reject('Invalid password') + } + + const { encryptedHtlsSecret, htlsIv, htlsTag } = + await getEncryptedHtlsSecret( + key, + data.secret, + aadFor(account, htlsAad(data.hash)), + ) + + const current = await getAccount(accountId) + + await updateAccount(accountId, { + htlsSecrets: { + ...current.htlsSecrets, + [data.hash]: { + encryptedHtlsSecret, + htlsIv, + htlsTag, + txHash: data.txHash, + }, + }, + }) + }) + +const getPasskeys = async (accountId) => { + const account = await getAccount(accountId) + + return (account?.wrappedDek?.passkeys ?? []).map( + ({ credentialId, label, createdAt }) => ({ + credentialId, + label, + createdAt, + }), + ) } -const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { +const enrollPasskey = async ({ accountId, password, label }) => { + if (!Passkey.isSupported()) + return Promise.reject('Passkeys are not available in this browser') + const account = await getAccount(accountId) - const isPasswordValid = await checkPasswordValidity(accountId, password) - if (!isPasswordValid) return Promise.reject('Invalid password') if (!account) return Promise.reject('Account not found') + if (!isEnvelope(getAccountVersion(account))) + return Promise.reject('Unlock this account with your password first') - const { encryptedHtlsSecret, htlsIv, htlsTag } = await getEncryptedHtlsSecret( - password, - account.salt, - data.secret, - getAccountVersion(account), + const dek = await getContentKey(account, password) + const { credentialId, prfSalt, prfOutput } = await Passkey.enroll( + account.name, ) - const updatedHtlsSecrets = { - ...account.htlsSecrets, - [data.hash]: { encryptedHtlsSecret, htlsIv, htlsTag, txHash: data.txHash }, - } + return withAccountLock(accountId, async () => { + const current = await getAccount(accountId) + + if ( + (current.wrappedDek?.passkeys ?? []).some( + (entry) => entry.credentialId === credentialId, + ) + ) + return Promise.reject('This passkey is already enrolled') + + const wrapper = await buildPasskeyWrapper({ + dek, + credentialId, + prfSalt, + prfOutput, + label, + }) + + const stored = await getAccount(accountId) + + await updateAccount(accountId, { + wrappedDek: { + ...stored.wrappedDek, + passkeys: [...(stored.wrappedDek?.passkeys ?? []), wrapper], + }, + }) + + return { credentialId, label } + }) +} + +const removePasskey = async ({ accountId, credentialId }) => + withAccountLock(accountId, async () => { + const account = await getAccount(accountId) + if (!account) return Promise.reject('Account not found') + + const passkeys = account.wrappedDek?.passkeys ?? [] - await updateAccount(accountId, { htlsSecrets: updatedHtlsSecrets }) + if (!passkeys.some((entry) => entry.credentialId === credentialId)) + return Promise.reject('Passkey not found') + + await updateAccount(accountId, { + wrappedDek: { + ...account.wrappedDek, + passkeys: passkeys.filter( + (entry) => entry.credentialId !== credentialId, + ), + }, + }) + }) + +const unlockAccountWithPasskey = async (id, options) => { + const account = await getAccount(id) + const passkeys = account?.wrappedDek?.passkeys ?? [] + + if (!passkeys.length) + return Promise.reject('No passkey is enrolled for this account') + + const { credentialId, prfOutput } = await Passkey.getPrfOutput(passkeys) + + return unlockAccount( + id, + { kind: PASSKEY_CREDENTIAL, credentialId, prfOutput }, + options, + ) } -const reEncryptAccount = async (id, password, account, decryptedSeeds) => { +const reEncryptAccount = async ({ + id, + password, + account, + decryptedSeeds, + contentKey, +}) => { + // A credential descriptor would be stringified into the KDF, and enrolled + // passkeys cannot be re-wrapped without their PRF outputs. Both must fail loudly + // rather than silently re-key the wallet to something nobody can reproduce. + if (typeof password !== 'string') + throw new Error('Re-encryption needs the account password') + + if (account.wrappedDek?.passkeys?.length) + throw new Error( + 'Remove the enrolled passkeys before migrating this account', + ) + const { generateEncryptionKey, encryptSeed, decryptSeed } = await loadAccountSubRoutines() - const { key: newKey, salt: newSalt } = await generateEncryptionKey({ + const { key: wrappingKey, salt: newSalt } = await generateEncryptionKey({ password, version: CURRENT_ENCRYPTION_VERSION, }) - const reEncrypt = async (data) => { - const { encryptedData, iv, tag } = await encryptSeed({ data, key: newKey }) + const newKey = await generateDek() + + const reEncrypt = async (data, aad) => { + if (!data) return {} + + const { encryptedData, iv, tag } = await encryptSeed({ + data, + key: newKey, + aad, + }) return { encryptedData, iv, tag } } @@ -176,53 +402,66 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { encryptedData: btcEncryptedSeed, iv: btcIv, tag: btcTag, - } = await reEncrypt(decryptedSeeds.seed) + } = await reEncrypt(decryptedSeeds.seed, contentAad('btcEncryptedSeed')) const { encryptedData: encryptedMlTestnetPrivateKey, iv: mlTestnetPrivKeyIv, tag: mlTestnetPrivKeyTag, - } = await reEncrypt(decryptedSeeds.mlTestnetPrivateKey) + } = await reEncrypt( + decryptedSeeds.mlTestnetPrivateKey, + contentAad('encryptedMlTestnetPrivateKey'), + ) const { encryptedData: encryptedMlMainnetPrivateKey, iv: mlMainnetPrivKeyIv, tag: mlMainnetPrivKeyTag, - } = await reEncrypt(decryptedSeeds.mlMainnetPrivateKey) + } = await reEncrypt( + decryptedSeeds.mlMainnetPrivateKey, + contentAad('encryptedMlMainnetPrivateKey'), + ) - // Re-encrypt HTLS secrets if any const updatedHtlsSecrets = {} - if (account.htlsSecrets) { - const { key: oldKey } = await generateEncryptionKey({ - password, - salt: account.salt, - version: getAccountVersion(account), - }) - for (const [hash, data] of Object.entries(account.htlsSecrets)) { + for (const [hash, data] of Object.entries(account.htlsSecrets ?? {})) { + // A secret that cannot be read is carried over untouched: it was already + // unreadable, and it must never cost the user access to the seed. + try { const decryptedSecret = await decryptSeed({ data: data.encryptedHtlsSecret, iv: data.htlsIv, tag: data.htlsTag, - key: oldKey, + key: contentKey, + aad: aadFor(account, htlsAad(hash)), }) const { encryptedData: encryptedHtlsSecret, iv: htlsIv, tag: htlsTag, - } = await reEncrypt(decryptedSecret) + } = await reEncrypt(decryptedSecret, htlsAad(hash)) updatedHtlsSecrets[hash] = { encryptedHtlsSecret, htlsIv, htlsTag, txHash: data.txHash, } + } catch (e) { + console.error(`Could not re-encrypt the HTLS secret ${hash}:`, e) + updatedHtlsSecrets[hash] = data } } + const passwordWrapper = await wrapDek({ + dek: newKey, + wrappingKey, + aad: wrapperAad(PASSWORD_CREDENTIAL), + }) + await updateAccount(id, { salt: newSalt, encryptionVersion: CURRENT_ENCRYPTION_VERSION, + wrappedDek: { password: passwordWrapper, passkeys: [] }, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -237,7 +476,7 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { const unlockAccount = async (id, password, { wallets } = {}) => { const storedNetworkType = LocalStorageService.getItem('networkType') - const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() + const { decryptSeed } = await loadAccountSubRoutines() const addresses = {} try { @@ -245,41 +484,43 @@ const unlockAccount = async (id, password, { wallets } = {}) => { const walletsToCreate = AppInfo.DEFAULT_WALLETS_TO_CREATE if (!account.walletsToCreate) - updateAccount(id, { walletsToCreate: AppInfo.DEFAULT_WALLETS_TO_CREATE }) + await updateAccount(id, { + walletsToCreate: AppInfo.DEFAULT_WALLETS_TO_CREATE, + }) const accountVersion = getAccountVersion(account) - const { key } = await generateEncryptionKey({ - password, - salt: account.salt, - version: accountVersion, - }) + const key = await getContentKey(account, password) const seed = await decryptSeed({ data: account.seed.btcEncryptedSeed, iv: account.iv.btcIv, tag: account.tag.btcTag, key, + aad: aadFor(account, contentAad('btcEncryptedSeed')), }) - const mlTestnetPrivateKey = await decryptSeed({ - data: account.seed.encryptedMlTestnetPrivateKey, - iv: account.iv.mlTestnetPrivKeyIv, - tag: account.tag.mlTestnetPrivKeyTag, - key, - }) - - const mlMainnetPrivateKey = await decryptSeed({ - data: account.seed.encryptedMlMainnetPrivateKey, - iv: account.iv.mlMainnetPrivKeyIv, - tag: account.tag.mlMainnetPrivKeyTag, - key, - }) - // this error just exists if the jobe was run in a worker - /* istanbul ignore next */ + const mlTestnetPrivateKey = account.seed.encryptedMlTestnetPrivateKey + ? await decryptSeed({ + data: account.seed.encryptedMlTestnetPrivateKey, + iv: account.iv.mlTestnetPrivKeyIv, + tag: account.tag.mlTestnetPrivKeyTag, + key, + aad: aadFor(account, contentAad('encryptedMlTestnetPrivateKey')), + }) + : undefined + + const mlMainnetPrivateKey = account.seed.encryptedMlMainnetPrivateKey + ? await decryptSeed({ + data: account.seed.encryptedMlMainnetPrivateKey, + iv: account.iv.mlMainnetPrivKeyIv, + tag: account.tag.mlMainnetPrivKeyTag, + key, + aad: aadFor(account, contentAad('encryptedMlMainnetPrivateKey')), + }) + : undefined const btcAddressType = account.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT - if (seed.error) throw new Error(seed.error) const walletsToUnlock = wallets || walletsToCreate @@ -319,13 +560,30 @@ const unlockAccount = async (id, password, { wallets } = {}) => { } } - // Migrate old accounts to current encryption version in the background + // Migrating is opportunistic: a failure must leave the account on its old + // version rather than deny access to a wallet that just decrypted fine. if (accountVersion !== CURRENT_ENCRYPTION_VERSION) { - reEncryptAccount(id, password, account, { - seed, - mlTestnetPrivateKey, - mlMainnetPrivateKey, - }).catch((e) => console.error('Encryption migration failed:', e)) + try { + await withAccountLock(id, async () => { + const current = await getAccount(id) + + if (getAccountVersion(current) === CURRENT_ENCRYPTION_VERSION) return + + await reEncryptAccount({ + id, + password, + account: current, + contentKey: key, + decryptedSeeds: { + seed, + mlTestnetPrivateKey, + mlMainnetPrivateKey, + }, + }) + }) + } catch (e) { + console.error('Encryption migration failed:', e) + } } return { @@ -348,6 +606,10 @@ const unlockAccount = async (id, password, { wallets } = {}) => { export { saveAccount, unlockAccount, + unlockAccountWithPasskey, + getPasskeys, + enrollPasskey, + removePasskey, updateAccount, getAccount, deleteAccount, diff --git a/src/services/Entity/Account/Account.test.js b/src/services/Entity/Account/Account.test.js index 9a75074c..45990ffc 100644 --- a/src/services/Entity/Account/Account.test.js +++ b/src/services/Entity/Account/Account.test.js @@ -1,93 +1,788 @@ -/* eslint-disable no-unused-vars */ +import { BTC, ML, Cipher, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { LocalStorageService } from '@Storage' +import { IndexedDB } from '@Databases' +import { AppInfo } from '@Constants' +import initWasm from 'src/tests/helpers/initWasm' import loadAccountSubRoutines from './loadWorkers' -// import { saveAccount, unlockAccount } from './Account' -// import { BTC, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' -import { BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { + saveAccount, + unlockAccount, + updateAccount, + getAccount, + unlockHtlsSecret, + saveProvidedHtlsSecret, + checkPasswordValidity, +} from './Account' +import { buildPasskeyWrapper } from './AccountHelpers' -// TODO: The tests had been disabled to avoid the error from wasm-crypto on the JEST environment, need to be fixed later - -const ENTROPY_DATA = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] const accountName = 'Savings' const password = 'pass' const defaultWalletsToCreate = ['btc'] -// const customWalletsToCreate2 = ['btc', 'ml'] - -// test('Account creation and restoring', async () => { -// const { generateNewAccountMnemonic } = await loadAccountSubRoutines() -// const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) -// const [pubKey] = BTC.generateKeysFromMnemonic(mnemonic) -// const originalAddress = -// BTC_ADDRESS_TYPE_MAP[BTC_ADDRESS_TYPE_ENUM.LEGACY].getAddressFromPubKey( -// pubKey, -// ) -// const data = { -// name: accountName, -// password, -// mnemonic, -// walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, -// walletsToCreate: defaultWalletsToCreate, -// } -// const id = await saveAccount(data) -// const { addresses, name } = await unlockAccount(id, password) - -// expect(addresses.btcMainnetAddress).toStrictEqual(originalAddress) -// expect(name).toBe(accountName) -// }) - -test('Account creation and restoring - error', async () => { - jest.spyOn(console, 'error').mockImplementation((message) => { - expect(typeof message).toBe('string') - console.error.mockRestore() - }) + +const btcOnly = { wallets: ['btc'] } + +beforeAll(() => initWasm()) + +beforeEach(() => { + LocalStorageService.setItem('networkType', 'testnet') +}) + +const newMnemonic = async () => { const { generateNewAccountMnemonic } = await loadAccountSubRoutines() - const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) + return generateNewAccountMnemonic() +} + +const createAccount = async (overrides = {}) => { + const mnemonic = overrides.mnemonic || (await newMnemonic()) - const data = { + const id = await saveAccount({ name: accountName, password, mnemonic, walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, walletsToCreate: defaultWalletsToCreate, + ...overrides, + }) + + return { id, mnemonic } +} + +const saveLegacyAccount = async (version, mnemonic) => { + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version }) + const encrypt = (data) => Cipher.encryptAES({ data, key }) + + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await encrypt(seed) + const mlTestnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + const mlMainnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) + const secret = await encrypt('legacy-htls-secret') + + return IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: version, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: mlTestnet.iv, + mlMainnetPrivKeyIv: mlMainnet.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: mlTestnet.tag, + mlMainnetPrivKeyTag: mlMainnet.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: mlTestnet.encryptedData, + encryptedMlMainnetPrivateKey: mlMainnet.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets: { + abc: { + encryptedHtlsSecret: secret.encryptedData, + htlsIv: secret.iv, + htlsTag: secret.tag, + txHash: 'tx', + }, + }, + }) +} + +const receivingAddressesOf = (addresses) => + addresses.btcAddresses.btcReceivingAddresses.map( + (entry) => Object.keys(entry)[0], + ) + +test('Account - creation and unlocking returns the account name and addresses', async () => { + const { id } = await createAccount() + + const { addresses, name } = await unlockAccount(id, password, btcOnly) + + expect(name).toBe(accountName) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + expect(addresses.btcAddresses.btcChangeAddresses.length).toBeGreaterThan(0) +}) + +test('Account - unlocking with a wrong password is rejected', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const { id } = await createAccount() + + await expect(unlockAccount(id, 'pasz', btcOnly)).rejects.toStrictEqual({ + addresses: {}, + btcPrivateKeys: { btcHDWallet: null, btcAddressData: null }, + name: '', + mlPrivKeys: { mlMainnetPrivateKey: '', mlTestnetPrivateKey: '' }, + }) + + console.error.mockRestore() +}) + +test('Account - a new account is stored as a v4 envelope', async () => { + const { id } = await createAccount() + const account = await getAccount(id) + + expect(account.encryptionVersion).toBe(4) + expect(account.salt).toMatch(/^[0-9a-f]{32}$/) + expect(account.seed.btcEncryptedSeed).toBeDefined() + expect(account.seed.encryptedMlTestnetPrivateKey).toBeDefined() + expect(account.seed.encryptedMlMainnetPrivateKey).toBeDefined() + + expect(account.wrappedDek.passkeys).toStrictEqual([]) + expect(Object.keys(account.wrappedDek.password)).toStrictEqual([ + 'encryptedData', + 'iv', + 'tag', + ]) +}) + +test('Account - an account saved without walletsToCreate still unlocks', async () => { + const { id } = await createAccount({ walletsToCreate: undefined }) + const account = await getAccount(id) + + expect(account.walletsToCreate).toBeUndefined() + + const { addresses } = await unlockAccount(id, password, btcOnly) + + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) +}) + +test('Account - walletsToCreate is kept when provided', async () => { + const { id } = await createAccount({ walletsToCreate: ['btc', 'ml'] }) + const account = await getAccount(id) + + expect(account.walletsToCreate).toStrictEqual(['btc', 'ml']) +}) + +test('Account - unlocking derives a batch of BTC addresses', async () => { + const { id } = await createAccount() + + const { addresses, btcPrivateKeys } = await unlockAccount( + id, + password, + btcOnly, + ) + + const receiving = receivingAddressesOf(addresses) + + expect(receiving.length).toBeGreaterThan(1) + expect(new Set(receiving).size).toBe(receiving.length) + expect(btcPrivateKeys.btcHDWallet).not.toBeNull() + expect(btcPrivateKeys.btcAddressData).not.toBeNull() +}) + +test('Account - the same mnemonic always unlocks to the same addresses', async () => { + const mnemonic = await newMnemonic() + const { id: firstId } = await createAccount({ mnemonic }) + const { id: secondId } = await createAccount({ mnemonic, name: 'Second' }) + + const first = await unlockAccount(firstId, password, btcOnly) + const second = await unlockAccount(secondId, password, btcOnly) + + expect(receivingAddressesOf(first.addresses)).toStrictEqual( + receivingAddressesOf(second.addresses), + ) +}) + +test('Account - two accounts from the same mnemonic use different salts', async () => { + const mnemonic = await newMnemonic() + const { id: firstId } = await createAccount({ mnemonic }) + const { id: secondId } = await createAccount({ mnemonic, name: 'Second' }) + + const first = await getAccount(firstId) + const second = await getAccount(secondId) + + expect(first.salt).not.toBe(second.salt) + expect(first.seed.btcEncryptedSeed).not.toBe(second.seed.btcEncryptedSeed) +}) + +test('Account - different mnemonics unlock to different addresses', async () => { + const { id: firstId } = await createAccount() + const { id: secondId } = await createAccount({ name: 'Second' }) + + const first = await unlockAccount(firstId, password, btcOnly) + const second = await unlockAccount(secondId, password, btcOnly) + + expect(receivingAddressesOf(first.addresses)).not.toStrictEqual( + receivingAddressesOf(second.addresses), + ) +}) + +test.each([1, 2, 3])( + 'Account - a v%i account migrates to the envelope on unlock', + async (version) => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(version, mnemonic) + + const before = await getAccount(id) + expect(before.encryptionVersion).toBe(version) + expect(before.wrappedDek).toBeUndefined() + + const { addresses } = await unlockAccount(id, password, btcOnly) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan( + 0, + ) + + const after = await getAccount(id) + expect(after.encryptionVersion).toBe(4) + expect(after.wrappedDek.passkeys).toStrictEqual([]) + expect(after.salt).not.toBe(before.salt) + expect(after.seed.btcEncryptedSeed).not.toBe(before.seed.btcEncryptedSeed) + }, +) + +test('Account - a migrated account still unlocks to the same addresses', async () => { + const mnemonic = await newMnemonic() + const legacyId = await saveLegacyAccount(3, mnemonic) + const { id: envelopeId } = await createAccount({ mnemonic }) + + const migrated = await unlockAccount(legacyId, password, btcOnly) + const fresh = await unlockAccount(envelopeId, password, btcOnly) + + expect(receivingAddressesOf(migrated.addresses)).toStrictEqual( + receivingAddressesOf(fresh.addresses), + ) + + const again = await unlockAccount(legacyId, password, btcOnly) + expect(receivingAddressesOf(again.addresses)).toStrictEqual( + receivingAddressesOf(migrated.addresses), + ) +}) + +test('Account - HTLS secrets survive the migration', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await unlockAccount(id, password, btcOnly) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'abc' }), + ).resolves.toBe('legacy-htls-secret') +}) + +test('Account - an HTLS secret can be stored and read back on an envelope account', async () => { + const { id } = await createAccount() + + await saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'def', secret: 'fresh-secret', txHash: 'tx' }, + }) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'def' }), + ).resolves.toBe('fresh-secret') +}) + +test('Account - a wrong password cannot read an HTLS secret', async () => { + const { id } = await createAccount() + + await saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'def', secret: 'fresh-secret', txHash: 'tx' }, + }) + + await expect( + unlockHtlsSecret({ accountId: id, password: 'pasz', hash: 'def' }), + ).rejects.toBe('Invalid password') +}) + +test('Account - an account whose ML blobs cannot be decrypted is left untouched', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const mnemonic = await newMnemonic() + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const otherKey = await Cipher.generateDek() + + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await Cipher.encryptAES({ data: seed, key }) + const mlTestnet = await Cipher.encryptAES({ + data: 'ml-testnet', + key: otherKey, + }) + const mlMainnet = await Cipher.encryptAES({ + data: 'ml-mainnet', + key: otherKey, + }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 3, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: mlTestnet.iv, + mlMainnetPrivKeyIv: mlMainnet.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: mlTestnet.tag, + mlMainnetPrivKeyTag: mlMainnet.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: mlTestnet.encryptedData, + encryptedMlMainnetPrivateKey: mlMainnet.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets: {}, + }) + + const before = await getAccount(id) + + await expect(unlockAccount(id, password, btcOnly)).rejects.toBeDefined() + + const after = await getAccount(id) + + expect(after.encryptionVersion).toBe(3) + expect(after.salt).toBe(before.salt) + expect(after.seed).toStrictEqual(before.seed) + expect(after.wrappedDek).toBeUndefined() + + console.error.mockRestore() +}) + +const CREDENTIAL_ID = 'Y3JlZC1pZC0x' + +const enrollPasskey = async (id, prfOutput) => { + const account = await getAccount(id) + + const { key: wrappingKey } = await Cipher.generatePBKDF2Key({ + password, + salt: account.salt, + version: account.encryptionVersion, + }) + const dek = await Cipher.unwrapDek({ + data: account.wrappedDek.password.encryptedData, + iv: account.wrappedDek.password.iv, + tag: account.wrappedDek.password.tag, + wrappingKey, + aad: Cipher.wrapperAad('password'), + }) + + const wrapper = await buildPasskeyWrapper({ + dek, + credentialId: CREDENTIAL_ID, + prfSalt: 'c2FsdA', + prfOutput, + label: 'Touch ID', + }) + + await updateAccount(id, { + wrappedDek: { ...account.wrappedDek, passkeys: [wrapper] }, + }) + + return wrapper +} + +const passkeyCredential = (prfOutput, credentialId = CREDENTIAL_ID) => ({ + kind: 'passkey', + credentialId, + prfOutput, +}) + +test('Account - the same account unlocks with both a password and a passkey', async () => { + const { id } = await createAccount() + const prfOutput = await Cipher.generateDek() + + await enrollPasskey(id, prfOutput) + + const byPassword = await unlockAccount(id, password, btcOnly) + const byPasskey = await unlockAccount( + id, + passkeyCredential(prfOutput), + btcOnly, + ) + + expect(receivingAddressesOf(byPasskey.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) +}) + +test('Account - enrolling a passkey does not re-encrypt the seed', async () => { + const { id } = await createAccount() + const before = await getAccount(id) + + await enrollPasskey(id, await Cipher.generateDek()) + + const after = await getAccount(id) + + expect(after.seed).toStrictEqual(before.seed) + expect(after.iv).toStrictEqual(before.iv) + expect(after.tag).toStrictEqual(before.tag) + expect(after.wrappedDek.password).toStrictEqual(before.wrappedDek.password) +}) + +test('Account - a passkey wrapper records the fields the enrolment UI needs', async () => { + const { id } = await createAccount() + + const wrapper = await enrollPasskey(id, await Cipher.generateDek()) + + expect(wrapper.credentialId).toBe(CREDENTIAL_ID) + expect(wrapper.prfSalt).toBe('c2FsdA') + expect(wrapper.kdf).toBe(Cipher.PASSKEY_KDF) + expect(wrapper.label).toBe('Touch ID') + expect(typeof wrapper.createdAt).toBe('number') +}) + +test('Account - a wrong PRF output cannot unlock', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const { id } = await createAccount() + + await enrollPasskey(id, await Cipher.generateDek()) + + await expect( + unlockAccount(id, passkeyCredential(await Cipher.generateDek()), btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - an unknown credential id cannot unlock', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const { id } = await createAccount() + const prfOutput = await Cipher.generateDek() + + await enrollPasskey(id, prfOutput) + + await expect( + unlockAccount(id, passkeyCredential(prfOutput, 'b3RoZXI'), btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - a passkey cannot unlock a legacy account', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await expect( + unlockAccount(id, passkeyCredential(await Cipher.generateDek()), btcOnly), + ).rejects.toBeDefined() + + const after = await getAccount(id) + expect(after.encryptionVersion).toBe(3) + + console.error.mockRestore() +}) + +test('Account - checkPasswordValidity still works on an account with a passkey', async () => { + const { id } = await createAccount() + + await enrollPasskey(id, await Cipher.generateDek()) + + await expect(checkPasswordValidity(id, password)).resolves.toBe(true) + await expect(checkPasswordValidity(id, 'pasz')).resolves.toBe(false) +}) + +const countDeriveBits = async (task) => { + const original = crypto.subtle.deriveBits.bind(crypto.subtle) + const spy = jest.fn(original) + crypto.subtle.deriveBits = spy + + try { + await task() + } finally { + crypto.subtle.deriveBits = original } - const wrongPass = 'pasz' - // const id = await saveAccount(data) - - // await expect(async () => { - // await unlockAccount(id, wrongPass) - // }).rejects.toThrow() -}) - -// test('Accouts wallets to create - default', async () => { -// const { generateNewAccountMnemonic } = await loadAccountSubRoutines() -// const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) -// const data = { -// name: accountName, -// password, -// mnemonic, -// walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, -// } -// const id = await saveAccount(data) -// const { addresses } = await unlockAccount(id, password) - -// expect(addresses.btcMainnetAddress).toBeDefined() -// expect(addresses.btcTestnetAddress).toBeDefined() -// }) - -// test('Accouts wallets to create - custom', async () => { -// const { generateNewAccountMnemonic } = await loadAccountSubRoutines() -// const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) -// const data = { -// name: accountName, -// password, -// mnemonic, -// walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, -// walletsToCreate: customWalletsToCreate2, -// } -// const id = await saveAccount(data) -// const { addresses } = await unlockAccount(id, password) - -// expect(addresses.btcMainnetAddress).toBeDefined() -// expect(addresses.btcTestnetAddress).toBeDefined() -// expect(addresses.mlMainnetAddress).toBeDefined() -// expect(addresses.mlTestnetAddress).toBeDefined() -// }) + + return spy.mock.calls.length +} + +test('Account - migrating an account derives the password key twice, not three times', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + const migrating = await countDeriveBits(() => + unlockAccount(id, password, btcOnly), + ) + const steadyState = await countDeriveBits(() => + unlockAccount(id, password, btcOnly), + ) + + expect(migrating).toBe(2) + expect(steadyState).toBe(1) +}) + +test('Account - concurrent unlocks migrate the account exactly once', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + const results = await Promise.all([ + unlockAccount(id, password, btcOnly), + unlockAccount(id, password, btcOnly), + unlockAccount(id, password, btcOnly), + ]) + + const account = await getAccount(id) + + expect(account.encryptionVersion).toBe(4) + expect(account.wrappedDek.passkeys).toStrictEqual([]) + + results.forEach((result) => + expect(receivingAddressesOf(result.addresses)).toStrictEqual( + receivingAddressesOf(results[0].addresses), + ), + ) + + await expect(unlockAccount(id, password, btcOnly)).resolves.toBeDefined() +}) + +test('Account - a secret saved while a migration runs is not lost', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await Promise.all([ + unlockAccount(id, password, btcOnly), + saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'concurrent', secret: 'concurrent-secret', txHash: 'tx' }, + }), + ]) + + const account = await getAccount(id) + expect(account.encryptionVersion).toBe(4) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'concurrent' }), + ).resolves.toBe('concurrent-secret') + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'abc' }), + ).resolves.toBe('legacy-htls-secret') +}) + +test('Account - a genuine v1 record (no mintlayer keys) unlocks and migrates', async () => { + const mnemonic = await newMnemonic() + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 1 }) + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await Cipher.encryptAES({ data: seed, key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 1, + iv: { btcIv: btc.iv }, + tag: { btcTag: btc.tag }, + seed: { btcEncryptedSeed: btc.encryptedData }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + const { addresses } = await unlockAccount(id, password, btcOnly) + + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + + const after = await getAccount(id) + + expect(after.encryptionVersion).toBe(4) + expect(after.seed.encryptedMlTestnetPrivateKey).toBeUndefined() + expect(after.seed.encryptedMlMainnetPrivateKey).toBeUndefined() + expect(after.wrappedDek.passkeys).toStrictEqual([]) + + await expect(unlockAccount(id, password, btcOnly)).resolves.toBeDefined() +}) + +test('Account - swapping two content blobs under the same DEK is rejected', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const { id } = await createAccount() + const account = await getAccount(id) + + await updateAccount(id, { + seed: { + ...account.seed, + encryptedMlTestnetPrivateKey: account.seed.encryptedMlMainnetPrivateKey, + encryptedMlMainnetPrivateKey: account.seed.encryptedMlTestnetPrivateKey, + }, + iv: { + ...account.iv, + mlTestnetPrivKeyIv: account.iv.mlMainnetPrivKeyIv, + mlMainnetPrivKeyIv: account.iv.mlTestnetPrivKeyIv, + }, + tag: { + ...account.tag, + mlTestnetPrivKeyTag: account.tag.mlMainnetPrivKeyTag, + mlMainnetPrivKeyTag: account.tag.mlTestnetPrivKeyTag, + }, + }) + + await expect(unlockAccount(id, password, btcOnly)).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - an HTLS secret is bound to its own hash', async () => { + const { id } = await createAccount() + + await saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'first', secret: 'first-secret', txHash: 'tx' }, + }) + + const account = await getAccount(id) + + await updateAccount(id, { + htlsSecrets: { + ...account.htlsSecrets, + second: account.htlsSecrets.first, + }, + }) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'first' }), + ).resolves.toBe('first-secret') + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'second' }), + ).rejects.toBe('Failed to decrypt the secret. Possibly wrong password.') +}) + +const saveLegacyAccountWithSecrets = async (mnemonic, secrets) => { + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const encrypt = (data, withKey = key) => + Cipher.encryptAES({ data, key: withKey }) + + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await encrypt(seed) + const mlTestnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + const mlMainnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) + + const htlsSecrets = {} + for (const [hash, { value, readable }] of Object.entries(secrets)) { + const blob = await encrypt( + value, + readable ? key : await Cipher.generateDek(), + ) + htlsSecrets[hash] = { + encryptedHtlsSecret: blob.encryptedData, + htlsIv: blob.iv, + htlsTag: blob.tag, + txHash: 'tx', + } + } + + return IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 3, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: mlTestnet.iv, + mlMainnetPrivKeyIv: mlMainnet.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: mlTestnet.tag, + mlMainnetPrivKeyTag: mlMainnet.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: mlTestnet.encryptedData, + encryptedMlMainnetPrivateKey: mlMainnet.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets, + }) +} + +test('Account - one unreadable HTLS secret does not block the wallet', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const mnemonic = await newMnemonic() + const id = await saveLegacyAccountWithSecrets(mnemonic, { + good: { value: 'good-secret', readable: true }, + broken: { value: 'lost-secret', readable: false }, + }) + + const { addresses } = await unlockAccount(id, password, btcOnly) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + + const migrated = await getAccount(id) + expect(migrated.encryptionVersion).toBe(4) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'good' }), + ).resolves.toBe('good-secret') + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'broken' }), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - a failing migration still lets the wallet open', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + const generateDek = jest + .spyOn(Cipher, 'generateDek') + .mockRejectedValueOnce(new Error('no entropy')) + + const { addresses } = await unlockAccount(id, password, btcOnly) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + + const account = await getAccount(id) + expect(account.encryptionVersion).toBe(3) + + generateDek.mockRestore() + + await expect(unlockAccount(id, password, btcOnly)).resolves.toBeDefined() + await expect(getAccount(id)).resolves.toMatchObject({ encryptionVersion: 4 }) + + console.error.mockRestore() +}) + +test('Account - both ml private keys decrypt to the keys the mnemonic derives', async () => { + const { id, mnemonic } = await createAccount({ + walletsToCreate: ['btc', 'ml'], + }) + + const { mlPrivKeys } = await unlockAccount(id, password, btcOnly) + + expect(mlPrivKeys.mlTestnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + expect(mlPrivKeys.mlMainnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) + expect(mlPrivKeys.mlTestnetPrivateKey).not.toStrictEqual( + mlPrivKeys.mlMainnetPrivateKey, + ) +}) + +test('Account - a migrated legacy account still yields the right ml keys', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await unlockAccount(id, password, btcOnly) + + expect((await getAccount(id)).encryptionVersion).toBe(4) + + const { mlPrivKeys } = await unlockAccount(id, password, btcOnly) + + expect(mlPrivKeys.mlTestnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + expect(mlPrivKeys.mlMainnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) +}) diff --git a/src/services/Entity/Account/Account.worker.js b/src/services/Entity/Account/Account.worker.js index 96f527fc..fb3a3b39 100644 --- a/src/services/Entity/Account/Account.worker.js +++ b/src/services/Entity/Account/Account.worker.js @@ -1,89 +1,64 @@ -import { CipherWorkerEnum } from '../../Crypto/Cipher/Cipher.worker' -import { WalletWorkerEnum } from '../../Crypto/BTC/BTC.worker' +import { CipherWorkerEnum } from 'src/services/Crypto/Cipher/Cipher.worker' +import { WalletWorkerEnum } from 'src/services/Crypto/BTC/BTC.worker' +import { getWorkerError } from 'src/services/Crypto/Worker/WorkerContract' const getWalletWorker = () => new Worker(new URL('../../Crypto/BTC/BTC.worker', import.meta.url)) const getCipherWorker = () => new Worker(new URL('../../Crypto/Cipher/Cipher.worker', import.meta.url)) -const generateNewAccountMnemonic = () => { - return new Promise((resolve) => { - const worker = getWalletWorker() - worker.postMessage({ - job: WalletWorkerEnum.GENERATE_MNEMONIC, - }) +const runJob = (createWorker, message) => + new Promise((resolve, reject) => { + const worker = createWorker() + worker.onmessage = ({ data }) => { worker.terminate() + + const error = getWorkerError(data) + if (error) return reject(new Error(error)) + resolve(data) } - }) -} -const generateSeed = (mnemonic) => { - return new Promise((resolve) => { - const worker = getWalletWorker() - worker.postMessage({ - job: WalletWorkerEnum.GET_SEED_FROM_MNEMONIC, - data: mnemonic, - }) - worker.onmessage = ({ data }) => { + worker.onerror = (event) => { worker.terminate() - resolve(data) + reject(new Error(event.message || 'Worker failed')) } + + worker.postMessage(message) }) -} -const generateEncryptionKey = async (password) => { - return new Promise((resolve) => { - const worker = getCipherWorker() - worker.postMessage({ - job: CipherWorkerEnum.GENERATE_PBKDF2_KEY, - data: password, - }) - worker.onmessage = ({ data }) => { - worker.terminate() - resolve(data) - } +const generateNewAccountMnemonic = () => + runJob(getWalletWorker, { job: WalletWorkerEnum.GENERATE_MNEMONIC }) + +const generateSeed = (mnemonic) => + runJob(getWalletWorker, { + job: WalletWorkerEnum.GET_SEED_FROM_MNEMONIC, + data: mnemonic, }) -} -const encryptSeed = async ({ data, key }) => { - return new Promise((resolve) => { - const worker = getCipherWorker() - worker.postMessage({ - job: CipherWorkerEnum.ENCRYPT_AES, - data: { - data, - key, - }, - }) - worker.onmessage = ({ data }) => { - worker.terminate() - resolve(data) - } +// These forward their whole payload on purpose: re-listing the fields here is how +// the aad argument was silently dropped before. +const generateEncryptionKey = async (payload) => + runJob(getCipherWorker, { + job: CipherWorkerEnum.GENERATE_PBKDF2_KEY, + data: payload, }) -} -const decryptSeed = async ({ data, key, iv, tag }) => { - return new Promise((resolve) => { - const worker = getCipherWorker() - worker.postMessage({ - job: CipherWorkerEnum.DECRYPT_AES, - data: { - data, - key, - iv, - tag, - }, - }) - worker.onmessage = ({ data }) => { - worker.terminate() - resolve(data) - } +const encryptSeed = async (payload) => + runJob(getCipherWorker, { + job: CipherWorkerEnum.ENCRYPT_AES, + data: payload, + }) + +const decryptSeed = async (payload) => + runJob(getCipherWorker, { + job: CipherWorkerEnum.DECRYPT_AES, + data: payload, }) -} export { + runJob, generateNewAccountMnemonic, generateSeed, generateEncryptionKey, diff --git a/src/services/Entity/Account/Account.worker.test.js b/src/services/Entity/Account/Account.worker.test.js new file mode 100644 index 00000000..46fb1dd6 --- /dev/null +++ b/src/services/Entity/Account/Account.worker.test.js @@ -0,0 +1,129 @@ +import { toWorkerError } from 'src/services/Crypto/Worker/WorkerContract' +import { runJob } from './Account.worker' + +const fakeWorker = (respond) => { + const worker = { + terminate: jest.fn(), + postMessage: jest.fn(() => { + Promise.resolve().then(() => respond(worker)) + }), + } + + return worker +} + +test('Account.worker - resolves with the payload the worker posts', async () => { + const payload = { key: [1, 2, 3], salt: 'aabb' } + const worker = fakeWorker((w) => w.onmessage({ data: payload })) + + await expect(runJob(() => worker, { job: 'ANY' })).resolves.toStrictEqual( + payload, + ) + expect(worker.postMessage).toHaveBeenCalledWith({ job: 'ANY' }) + expect(worker.terminate).toHaveBeenCalled() +}) + +test('Account.worker - rejects when the worker posts an error envelope', async () => { + const worker = fakeWorker((w) => + w.onmessage({ data: toWorkerError(new Error('Incorrect password')) }), + ) + + await expect(runJob(() => worker, { job: 'ANY' })).rejects.toThrow( + 'Incorrect password', + ) + expect(worker.terminate).toHaveBeenCalled() +}) + +test('Account.worker - rejects when the worker itself fails', async () => { + const worker = fakeWorker((w) => w.onerror({ message: 'boom' })) + + await expect(runJob(() => worker, { job: 'ANY' })).rejects.toThrow('boom') + expect(worker.terminate).toHaveBeenCalled() +}) + +test('Account.worker - rejects with a fallback message when the failure has none', async () => { + const worker = fakeWorker((w) => w.onerror({})) + + await expect(runJob(() => worker, { job: 'ANY' })).rejects.toThrow( + 'Worker failed', + ) +}) + +describe('the job builders forward every field they are given', () => { + let posted + let worker + + beforeEach(async () => { + posted = [] + worker = { + terminate: jest.fn(), + postMessage: (message) => { + posted.push(message) + Promise.resolve().then(() => worker.onmessage({ data: 'ok' })) + }, + } + global.Worker = jest.fn(() => worker) + }) + + afterEach(() => { + delete global.Worker + }) + + test('encryptSeed carries the additional data', async () => { + const { encryptSeed } = await import('./Account.worker') + + await encryptSeed({ + data: 'plain', + key: [1, 2], + aad: 'mojito/v4/content/x', + }) + + expect(posted[0]).toStrictEqual({ + job: 'ENCRYPT_AES', + data: { data: 'plain', key: [1, 2], aad: 'mojito/v4/content/x' }, + }) + }) + + test('decryptSeed carries the additional data', async () => { + const { decryptSeed } = await import('./Account.worker') + + await decryptSeed({ + data: 'cipher', + key: [1, 2], + iv: 'iv', + tag: 'tag', + aad: 'mojito/v4/content/x', + }) + + expect(posted[0]).toStrictEqual({ + job: 'DECRYPT_AES', + data: { + data: 'cipher', + key: [1, 2], + iv: 'iv', + tag: 'tag', + aad: 'mojito/v4/content/x', + }, + }) + }) + + test('generateEncryptionKey carries salt and version', async () => { + const { generateEncryptionKey } = await import('./Account.worker') + + await generateEncryptionKey({ password: 'p', salt: 'aabb', version: 4 }) + + expect(posted[0]).toStrictEqual({ + job: 'GENERATE_PBKDF2_KEY', + data: { password: 'p', salt: 'aabb', version: 4 }, + }) + }) + + test('every cipher payload field reaches the worker untouched', async () => { + const { encryptSeed } = await import('./Account.worker') + const payload = { data: 'a', key: [1], aad: 'b', future: 'c' } + + await encryptSeed(payload) + + expect(posted[0].data).toStrictEqual(payload) + }) +}) diff --git a/src/services/Entity/Account/Account.workerPath.test.js b/src/services/Entity/Account/Account.workerPath.test.js new file mode 100644 index 00000000..afa2dfcc --- /dev/null +++ b/src/services/Entity/Account/Account.workerPath.test.js @@ -0,0 +1,233 @@ +import { Cipher, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { LocalStorageService } from '@Storage' +import { IndexedDB } from '@Databases' +import initWasm from 'src/tests/helpers/initWasm' + +jest.mock('src/utils/Constants/EnvironmentVars/EnvironmentVars', () => ({ + ...jest.requireActual('src/utils/Constants/EnvironmentVars/EnvironmentVars'), + USE_WEB_WORKERS: true, +})) + +const PASSWORD = 'pass' +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const btcOnly = { wallets: ['btc'] } + +const handlers = {} + +const captureHandler = (name, load) => { + self.onmessage = undefined + load() + handlers[name] = self.onmessage +} + +const makeWorker = (handler) => { + const worker = { + terminate: jest.fn(), + postMessage: (message) => { + const original = globalThis.postMessage + const restore = () => + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: original, + }) + + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: (data) => { + restore() + worker.onmessage({ data }) + }, + }) + + Promise.resolve(handler({ data: message })).catch(restore) + }, + } + + return worker +} + +let Account + +beforeAll(async () => { + initWasm() + + captureHandler('cipher', () => + require('src/services/Crypto/Cipher/Cipher.worker'), + ) + captureHandler('btc', () => require('src/services/Crypto/BTC/BTC.worker')) + + global.Worker = jest.fn((url) => + makeWorker(String(url).includes('BTC') ? handlers.btc : handlers.cipher), + ) + + Account = await import('./Account') +}) + +afterAll(() => { + delete global.Worker +}) + +beforeEach(() => { + LocalStorageService.setItem('networkType', 'testnet') +}) + +const createAccount = async (mnemonic = MNEMONIC) => + Account.saveAccount({ + name: 'Savings', + password: PASSWORD, + mnemonic, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + }) + +const receivingAddressesOf = (addresses) => + addresses.btcAddresses.btcReceivingAddresses.map( + (entry) => Object.keys(entry)[0], + ) + +test('worker path - the subroutines really come from the worker module', async () => { + const loadAccountSubRoutines = (await import('./loadWorkers')).default + const { encryptSeed } = await loadAccountSubRoutines() + const workers = await import('./Account.worker') + + expect(encryptSeed).toBe(workers.encryptSeed) +}) + +test('worker path - an account can be created and unlocked', async () => { + const id = await createAccount() + + const { addresses, name } = await Account.unlockAccount(id, PASSWORD, btcOnly) + + expect(name).toBe('Savings') + expect(receivingAddressesOf(addresses).length).toBeGreaterThan(0) +}) + +test('worker path - a wrong password rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const id = await createAccount() + + await expect( + Account.unlockAccount(id, 'wrong', btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('worker path - additional data really protects the stored blobs', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const id = await createAccount() + const account = await Account.getAccount(id) + + // The two mintlayer blobs have identical shape, so without the aad binding the + // swap decrypts cleanly and the unlock succeeds. + await Account.updateAccount(id, { + seed: { + ...account.seed, + encryptedMlTestnetPrivateKey: account.seed.encryptedMlMainnetPrivateKey, + encryptedMlMainnetPrivateKey: account.seed.encryptedMlTestnetPrivateKey, + }, + iv: { + ...account.iv, + mlTestnetPrivKeyIv: account.iv.mlMainnetPrivKeyIv, + mlMainnetPrivKeyIv: account.iv.mlTestnetPrivKeyIv, + }, + tag: { + ...account.tag, + mlTestnetPrivKeyTag: account.tag.mlMainnetPrivKeyTag, + mlMainnetPrivKeyTag: account.tag.mlTestnetPrivKeyTag, + }, + }) + + await expect( + Account.unlockAccount(id, PASSWORD, btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('worker path - a legacy account migrates to the envelope', async () => { + const { key, salt } = await Cipher.generatePBKDF2Key({ + password: PASSWORD, + version: 3, + }) + const seed = await ( + await import('@Cryptos') + ).BTC.getSeedFromMnemonic(MNEMONIC) + const btc = await Cipher.encryptAES({ data: seed, key }) + const ml = await Cipher.encryptAES({ data: 'ml-key', key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: 'Legacy', + salt, + encryptionVersion: 3, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: ml.iv, + mlMainnetPrivKeyIv: ml.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: ml.tag, + mlMainnetPrivKeyTag: ml.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: ml.encryptedData, + encryptedMlMainnetPrivateKey: ml.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + await Account.unlockAccount(id, PASSWORD, btcOnly) + + const migrated = await Account.getAccount(id) + + expect(migrated.encryptionVersion).toBe(4) + expect(migrated.wrappedDek.passkeys).toStrictEqual([]) + + await expect( + Account.unlockAccount(id, PASSWORD, btcOnly), + ).resolves.toBeDefined() +}) + +test('worker path - a legacy btc-only account without ml keys still unlocks', async () => { + const { key, salt } = await Cipher.generatePBKDF2Key({ + password: PASSWORD, + version: 3, + }) + const seed = await ( + await import('@Cryptos') + ).BTC.getSeedFromMnemonic(MNEMONIC) + const btc = await Cipher.encryptAES({ data: seed, key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: 'BtcOnly', + salt, + encryptionVersion: 3, + iv: { btcIv: btc.iv }, + tag: { btcTag: btc.tag }, + seed: { btcEncryptedSeed: btc.encryptedData }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + const { addresses } = await Account.unlockAccount(id, PASSWORD, btcOnly) + + expect(receivingAddressesOf(addresses).length).toBeGreaterThan(0) + + const migrated = await Account.getAccount(id) + + expect(migrated.encryptionVersion).toBe(4) + expect(migrated.seed.encryptedMlTestnetPrivateKey).toBeUndefined() + + await expect( + Account.unlockAccount(id, PASSWORD, btcOnly), + ).resolves.toBeDefined() +}) diff --git a/src/services/Entity/Account/AccountHelpers.js b/src/services/Entity/Account/AccountHelpers.js index 9fa13d3c..f5029885 100644 --- a/src/services/Entity/Account/AccountHelpers.js +++ b/src/services/Entity/Account/AccountHelpers.js @@ -1,18 +1,26 @@ import { AppInfo } from '@Constants' -import { ML } from '@Cryptos' +import { ML, Cipher } from '@Cryptos' import loadAccountSubRoutines from './loadWorkers' -const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { +const getEnvelopeEncryptedPrivateKeys = async (password, salt, mnemonic) => { const { generateSeed, generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key, salt: usedSalt } = await generateEncryptionKey({ + + const { key: wrappingKey, salt: usedSalt } = await generateEncryptionKey({ password, salt, + version: Cipher.ENVELOPE_ENCRYPTION_VERSION, }) + + const dek = await Cipher.generateDek() const seed = await generateSeed(mnemonic) - const encryptData = async (data) => { - const { encryptedData, iv, tag } = await encryptSeed({ data, key }) + const encryptData = async (data, field) => { + const { encryptedData, iv, tag } = await encryptSeed({ + data, + key: dek, + aad: Cipher.contentAad(field), + }) return { encryptedData, iv, tag } } @@ -29,22 +37,30 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { encryptedData: encryptedMlTestnetPrivateKey, iv: mlTestnetPrivKeyIv, tag: mlTestnetPrivKeyTag, - } = await encryptData(mlTestnetPrivateKey) + } = await encryptData(mlTestnetPrivateKey, 'encryptedMlTestnetPrivateKey') const { encryptedData: encryptedMlMainnetPrivateKey, iv: mlMainnetPrivKeyIv, tag: mlMainnetPrivKeyTag, - } = await encryptData(mlMainnetPrivateKey) + } = await encryptData(mlMainnetPrivateKey, 'encryptedMlMainnetPrivateKey') const { encryptedData: btcEncryptedSeed, iv: btcIv, tag: btcTag, - } = await encryptData(seed) + } = await encryptData(seed, 'btcEncryptedSeed') + + const passwordWrapper = await Cipher.wrapDek({ + dek, + wrappingKey, + aad: Cipher.wrapperAad('password'), + }) return { salt: usedSalt, + encryptionVersion: Cipher.ENVELOPE_ENCRYPTION_VERSION, + wrappedDek: { password: passwordWrapper, passkeys: [] }, encryptedMlTestnetPrivateKey, encryptedMlMainnetPrivateKey, btcEncryptedSeed, @@ -57,17 +73,46 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { } } -const getEncryptedHtlsSecret = async (password, salt, secret, version) => { - const { generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key } = await generateEncryptionKey({ password, salt, version }) +const buildPasskeyWrapper = async ({ + dek, + credentialId, + prfSalt, + prfOutput, + label, +}) => { + const wrappingKey = await Cipher.deriveKekFromPrf(prfOutput) + const { encryptedData, iv, tag } = await Cipher.wrapDek({ + dek, + wrappingKey, + aad: Cipher.wrapperAad(credentialId), + }) + + return { + credentialId, + prfSalt, + kdf: Cipher.PASSKEY_KDF, + label, + createdAt: Date.now(), + encryptedData, + iv, + tag, + } +} + +const getEncryptedHtlsSecret = async (key, secret, aad) => { + const { encryptSeed } = await loadAccountSubRoutines() const { encryptedData: encryptedHtlsSecret, iv: htlsIv, tag: htlsTag, - } = await encryptSeed({ data: secret, key }) + } = await encryptSeed({ data: secret, key, aad }) return { encryptedHtlsSecret, htlsIv, htlsTag } } -export { getEncryptedPrivateKeys, getEncryptedHtlsSecret } +export { + getEnvelopeEncryptedPrivateKeys, + buildPasskeyWrapper, + getEncryptedHtlsSecret, +} diff --git a/src/services/Entity/Account/AccountHelpers.test.js b/src/services/Entity/Account/AccountHelpers.test.js new file mode 100644 index 00000000..981f4db7 --- /dev/null +++ b/src/services/Entity/Account/AccountHelpers.test.js @@ -0,0 +1,345 @@ +import { ML, Cipher, BTC } from '@Cryptos' +import { AppInfo } from '@Constants' +import initWasm from 'src/tests/helpers/initWasm' +import { + getEnvelopeEncryptedPrivateKeys, + getEncryptedHtlsSecret, +} from './AccountHelpers' + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const PASSWORD = 'MyStr0ng!Pass' + +beforeAll(() => initWasm()) + +const unwrapWith = (envelope, wrappingKey) => + Cipher.unwrapDek({ + data: envelope.wrappedDek.password.encryptedData, + iv: envelope.wrappedDek.password.iv, + tag: envelope.wrappedDek.password.tag, + wrappingKey, + aad: Cipher.wrapperAad('password'), + }) + +const decryptWith = (data, iv, tag, key, field) => + Cipher.decryptAES({ + data, + iv, + tag, + key, + aad: field ? Cipher.contentAad(field) : undefined, + }) + +const deriveWrappingKey = ( + salt, + version = Cipher.ENVELOPE_ENCRYPTION_VERSION, +) => Cipher.generatePBKDF2Key({ password: PASSWORD, salt, version }) + +test('AccountHelpers - envelope has the v4 shape', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + expect(envelope.encryptionVersion).toBe(Cipher.ENVELOPE_ENCRYPTION_VERSION) + expect(envelope.salt).toMatch(/^[0-9a-f]{32}$/) + + expect(Object.keys(envelope.wrappedDek)).toStrictEqual([ + 'password', + 'passkeys', + ]) + expect(envelope.wrappedDek.passkeys).toStrictEqual([]) + expect(Object.keys(envelope.wrappedDek.password)).toStrictEqual([ + 'encryptedData', + 'iv', + 'tag', + ]) + expect(envelope.wrappedDek.password.iv.length).toBe(Cipher.IVSIZE) + expect(envelope.wrappedDek.password.tag.length).toBe(16) + + const blobs = [ + 'btcEncryptedSeed', + 'encryptedMlTestnetPrivateKey', + 'encryptedMlMainnetPrivateKey', + 'btcIv', + 'mlTestnetPrivKeyIv', + 'mlMainnetPrivKeyIv', + 'btcTag', + 'mlTestnetPrivKeyTag', + 'mlMainnetPrivKeyTag', + ] + blobs.forEach((field) => expect(typeof envelope[field]).toBe('string')) +}) + +test('AccountHelpers - the DEK decrypts every stored blob', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + const dek = await unwrapWith(envelope, wrappingKey) + + expect(dek.length).toBe(Cipher.DEKSIZE) + + const seed = await decryptWith( + envelope.btcEncryptedSeed, + envelope.btcIv, + envelope.btcTag, + dek, + 'btcEncryptedSeed', + ) + const testnetKey = await decryptWith( + envelope.encryptedMlTestnetPrivateKey, + envelope.mlTestnetPrivKeyIv, + envelope.mlTestnetPrivKeyTag, + dek, + 'encryptedMlTestnetPrivateKey', + ) + const mainnetKey = await decryptWith( + envelope.encryptedMlMainnetPrivateKey, + envelope.mlMainnetPrivKeyIv, + envelope.mlMainnetPrivKeyTag, + dek, + 'encryptedMlMainnetPrivateKey', + ) + + const expectedSeed = await BTC.getSeedFromMnemonic(MNEMONIC) + const expectedTestnetKey = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.TESTNET, + ) + const expectedMainnetKey = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.MAINNET, + ) + + expect(Buffer.from(seed)).toStrictEqual(Buffer.from(expectedSeed)) + expect(Buffer.from(testnetKey)).toStrictEqual(Buffer.from(expectedTestnetKey)) + expect(Buffer.from(mainnetKey)).toStrictEqual(Buffer.from(expectedMainnetKey)) +}) + +test('AccountHelpers - blobs are not readable with the password key', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + + await expect( + decryptWith( + envelope.btcEncryptedSeed, + envelope.btcIv, + envelope.btcTag, + wrappingKey, + ), + ).rejects.toThrow('Incorrect password') +}) + +test('AccountHelpers - a wrong password cannot unwrap the DEK', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrongKey } = await Cipher.generatePBKDF2Key({ + password: 'WrongPass', + salt: envelope.salt, + version: Cipher.ENVELOPE_ENCRYPTION_VERSION, + }) + + await expect(unwrapWith(envelope, wrongKey)).rejects.toThrow( + 'Incorrect password', + ) +}) + +test('AccountHelpers - the wrapping key is derived at v4, not a legacy version', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: legacyKey } = await deriveWrappingKey(envelope.salt, 1) + expect(legacyKey.length).toBe(16) + + await expect(unwrapWith(envelope, legacyKey)).rejects.toThrow( + 'Invalid wrapping key', + ) + + const { key: v4Key } = await deriveWrappingKey(envelope.salt) + await expect(unwrapWith(envelope, v4Key)).resolves.toHaveLength( + Cipher.DEKSIZE, + ) +}) + +test('AccountHelpers - reuses a provided salt and generates one otherwise', async () => { + const providedSalt = await Cipher.generateSalt(16) + + const withSalt = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + providedSalt, + MNEMONIC, + ) + const withoutSalt = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + expect(withSalt.salt).toBe(providedSalt) + expect(withoutSalt.salt).not.toBe(providedSalt) + expect(withoutSalt.salt).toMatch(/^[0-9a-f]{32}$/) +}) + +test('AccountHelpers - every call uses a fresh DEK', async () => { + const salt = await Cipher.generateSalt(16) + + const first = await getEnvelopeEncryptedPrivateKeys(PASSWORD, salt, MNEMONIC) + const second = await getEnvelopeEncryptedPrivateKeys(PASSWORD, salt, MNEMONIC) + + const { key: wrappingKey } = await deriveWrappingKey(salt) + const firstDek = await unwrapWith(first, wrappingKey) + const secondDek = await unwrapWith(second, wrappingKey) + + expect(firstDek).not.toStrictEqual(secondDek) + expect(first.btcEncryptedSeed).not.toBe(second.btcEncryptedSeed) + + const firstSeed = await decryptWith( + first.btcEncryptedSeed, + first.btcIv, + first.btcTag, + firstDek, + 'btcEncryptedSeed', + ) + const secondSeed = await decryptWith( + second.btcEncryptedSeed, + second.btcIv, + second.btcTag, + secondDek, + 'btcEncryptedSeed', + ) + + expect(Buffer.from(firstSeed)).toStrictEqual(Buffer.from(secondSeed)) +}) + +test('AccountHelpers - one DEK is shared by all three blobs', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const ivs = [ + envelope.btcIv, + envelope.mlTestnetPrivKeyIv, + envelope.mlMainnetPrivKeyIv, + ] + expect(new Set(ivs).size).toBe(3) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + const dek = await unwrapWith(envelope, wrappingKey) + + await expect( + decryptWith( + envelope.encryptedMlTestnetPrivateKey, + envelope.mlTestnetPrivKeyIv, + envelope.mlTestnetPrivKeyTag, + dek, + 'encryptedMlTestnetPrivateKey', + ), + ).resolves.toBeDefined() +}) + +test('AccountHelpers - stores distinct testnet and mainnet mintlayer keys', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + const dek = await unwrapWith(envelope, wrappingKey) + + const testnetKey = await decryptWith( + envelope.encryptedMlTestnetPrivateKey, + envelope.mlTestnetPrivKeyIv, + envelope.mlTestnetPrivKeyTag, + dek, + 'encryptedMlTestnetPrivateKey', + ) + const mainnetKey = await decryptWith( + envelope.encryptedMlMainnetPrivateKey, + envelope.mlMainnetPrivKeyIv, + envelope.mlMainnetPrivKeyTag, + dek, + 'encryptedMlMainnetPrivateKey', + ) + + expect(testnetKey.length).toBeGreaterThan(0) + expect(Buffer.from(testnetKey)).not.toStrictEqual(Buffer.from(mainnetKey)) +}) + +test('AccountHelpers - the envelope survives a JSON round trip', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + const restored = JSON.parse(JSON.stringify(envelope)) + + const { key: wrappingKey } = await deriveWrappingKey(restored.salt) + const dek = await unwrapWith(restored, wrappingKey) + + const seed = await decryptWith( + restored.btcEncryptedSeed, + restored.btcIv, + restored.btcTag, + dek, + 'btcEncryptedSeed', + ) + const expectedSeed = await BTC.getSeedFromMnemonic(MNEMONIC) + + expect(Buffer.from(seed)).toStrictEqual(Buffer.from(expectedSeed)) +}) + +test('AccountHelpers - HTLS secret is encrypted with the key it is given', async () => { + const key = await Cipher.generateDek() + const secret = 'htls-secret-value' + + const first = await getEncryptedHtlsSecret(key, secret) + const second = await getEncryptedHtlsSecret(key, secret) + + expect(first.htlsIv).not.toBe(second.htlsIv) + expect(first.encryptedHtlsSecret).not.toBe(second.encryptedHtlsSecret) + + const decrypted = await decryptWith( + first.encryptedHtlsSecret, + first.htlsIv, + first.htlsTag, + key, + ) + + expect(Buffer.from(decrypted).toString()).toBe(secret) +}) + +test('AccountHelpers - an HTLS secret cannot be read with another key', async () => { + const key = await Cipher.generateDek() + const otherKey = await Cipher.generateDek() + + const encrypted = await getEncryptedHtlsSecret(key, 'htls-secret-value') + + await expect( + decryptWith( + encrypted.encryptedHtlsSecret, + encrypted.htlsIv, + encrypted.htlsTag, + otherKey, + ), + ).rejects.toThrow('Incorrect password') +}) diff --git a/src/services/Entity/Account/AccountPasskey.test.js b/src/services/Entity/Account/AccountPasskey.test.js new file mode 100644 index 00000000..811796d1 --- /dev/null +++ b/src/services/Entity/Account/AccountPasskey.test.js @@ -0,0 +1,359 @@ +import { Cipher, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { LocalStorageService } from '@Storage' +import initWasm from 'src/tests/helpers/initWasm' + +jest.mock('src/services/Crypto/Passkey/Passkey', () => ({ + __esModule: true, + isSupported: jest.fn(() => true), + enroll: jest.fn(), + getPrfOutput: jest.fn(), +})) + +const accountName = 'Savings' +const password = 'pass' +const btcOnly = { wallets: ['btc'] } +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +let Passkey +let Account + +beforeAll(async () => { + initWasm() + Passkey = await import('src/services/Crypto/Passkey/Passkey') + Account = await import('./Account') +}) + +beforeEach(() => { + LocalStorageService.setItem('networkType', 'testnet') + jest.clearAllMocks() + Passkey.isSupported.mockReturnValue(true) +}) + +const createAccount = async () => + Account.saveAccount({ + name: accountName, + password, + mnemonic: MNEMONIC, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + }) + +const stubCeremony = async (credentialId = 'cred-1') => { + const prfOutput = await Cipher.generateDek() + const prfSalt = 'c2FsdA' + + Passkey.enroll.mockResolvedValue({ credentialId, prfSalt, prfOutput }) + Passkey.getPrfOutput.mockResolvedValue({ credentialId, prfOutput }) + + return { credentialId, prfSalt, prfOutput } +} + +const receivingAddressesOf = (addresses) => + addresses.btcAddresses.btcReceivingAddresses.map( + (entry) => Object.keys(entry)[0], + ) + +test('AccountPasskey - enrolling stores a wrapper and unlocking uses it', async () => { + const id = await createAccount() + const { credentialId, prfSalt } = await stubCeremony() + + const result = await Account.enrollPasskey({ + accountId: id, + password, + label: 'Touch ID', + }) + + expect(result).toStrictEqual({ credentialId, label: 'Touch ID' }) + expect(Passkey.enroll).toHaveBeenCalledWith(accountName) + + const account = await Account.getAccount(id) + const [wrapper] = account.wrappedDek.passkeys + + expect(wrapper.credentialId).toBe(credentialId) + expect(wrapper.prfSalt).toBe(prfSalt) + + const byPasskey = await Account.unlockAccountWithPasskey(id, btcOnly) + const byPassword = await Account.unlockAccount(id, password, btcOnly) + + expect(Passkey.getPrfOutput).toHaveBeenCalledWith([ + expect.objectContaining({ credentialId, prfSalt }), + ]) + expect(receivingAddressesOf(byPasskey.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) +}) + +test('AccountPasskey - enrolling does not touch the seed or the password wrapper', async () => { + const id = await createAccount() + await stubCeremony() + + const before = await Account.getAccount(id) + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + const after = await Account.getAccount(id) + + expect(after.seed).toStrictEqual(before.seed) + expect(after.wrappedDek.password).toStrictEqual(before.wrappedDek.password) +}) + +test('AccountPasskey - a wrong password cannot enroll a passkey', async () => { + const id = await createAccount() + await stubCeremony() + + await expect( + Account.enrollPasskey({ + accountId: id, + password: 'pasz', + label: 'Touch ID', + }), + ).rejects.toBeDefined() + + expect(Passkey.enroll).not.toHaveBeenCalled() + + const account = await Account.getAccount(id) + expect(account.wrappedDek.passkeys).toStrictEqual([]) +}) + +test('AccountPasskey - enrolment is refused when the browser has no passkey support', async () => { + const id = await createAccount() + Passkey.isSupported.mockReturnValue(false) + + await expect( + Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }), + ).rejects.toBe('Passkeys are not available in this browser') +}) + +test('AccountPasskey - the same passkey cannot be enrolled twice', async () => { + const id = await createAccount() + await stubCeremony() + + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + + await expect( + Account.enrollPasskey({ accountId: id, password, label: 'Again' }), + ).rejects.toBe('This passkey is already enrolled') +}) + +test('AccountPasskey - several passkeys can be enrolled and listed', async () => { + const id = await createAccount() + + await stubCeremony('cred-1') + await Account.enrollPasskey({ accountId: id, password, label: 'Laptop' }) + + await stubCeremony('cred-2') + await Account.enrollPasskey({ accountId: id, password, label: 'Phone' }) + + const passkeys = await Account.getPasskeys(id) + + expect(passkeys.map((entry) => entry.label)).toStrictEqual([ + 'Laptop', + 'Phone', + ]) + expect(passkeys[0]).not.toHaveProperty('encryptedData') + expect(passkeys[0]).not.toHaveProperty('prfSalt') +}) + +test('AccountPasskey - removing a passkey leaves the password working', async () => { + const id = await createAccount() + const { credentialId } = await stubCeremony() + + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + await Account.removePasskey({ accountId: id, credentialId }) + + await expect(Account.getPasskeys(id)).resolves.toStrictEqual([]) + await expect( + Account.unlockAccount(id, password, btcOnly), + ).resolves.toBeDefined() + await expect(Account.unlockAccountWithPasskey(id, btcOnly)).rejects.toBe( + 'No passkey is enrolled for this account', + ) +}) + +test('AccountPasskey - removing an unknown passkey is refused', async () => { + const id = await createAccount() + + await expect( + Account.removePasskey({ accountId: id, credentialId: 'nope' }), + ).rejects.toBe('Passkey not found') +}) + +test('AccountPasskey - a legacy account must be unlocked with a password first', async () => { + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const blob = await Cipher.encryptAES({ data: 'x', key }) + + const { IndexedDB } = await import('@Databases') + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 3, + iv: { btcIv: blob.iv }, + tag: { btcTag: blob.tag }, + seed: { btcEncryptedSeed: blob.encryptedData }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + await expect( + Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }), + ).rejects.toBe('Unlock this account with your password first') +}) + +test('AccountPasskey - the second enrolled passkey can unlock too', async () => { + const id = await createAccount() + + const first = await stubCeremony('cred-1') + await Account.enrollPasskey({ accountId: id, password, label: 'Laptop' }) + + const second = await stubCeremony('cred-2') + await Account.enrollPasskey({ accountId: id, password, label: 'Phone' }) + + const byPassword = await Account.unlockAccount(id, password, btcOnly) + + Passkey.getPrfOutput.mockResolvedValue({ + credentialId: second.credentialId, + prfOutput: second.prfOutput, + }) + const bySecond = await Account.unlockAccountWithPasskey(id, btcOnly) + + Passkey.getPrfOutput.mockResolvedValue({ + credentialId: first.credentialId, + prfOutput: first.prfOutput, + }) + const byFirst = await Account.unlockAccountWithPasskey(id, btcOnly) + + expect(receivingAddressesOf(bySecond.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) + expect(receivingAddressesOf(byFirst.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) +}) + +test('AccountPasskey - every enrolled passkey is offered to the authenticator', async () => { + const id = await createAccount() + + await stubCeremony('cred-1') + await Account.enrollPasskey({ accountId: id, password, label: 'Laptop' }) + + const second = await stubCeremony('cred-2') + await Account.enrollPasskey({ accountId: id, password, label: 'Phone' }) + + Passkey.getPrfOutput.mockResolvedValue({ + credentialId: second.credentialId, + prfOutput: second.prfOutput, + }) + await Account.unlockAccountWithPasskey(id, btcOnly) + + const offered = Passkey.getPrfOutput.mock.calls.at(-1)[0] + + expect(offered.map((entry) => entry.credentialId)).toStrictEqual([ + 'cred-1', + 'cred-2', + ]) +}) + +const writeDuringWrap = (id, buildPatch) => { + const AccountHelpers = require('./AccountHelpers') + const original = AccountHelpers.buildPasskeyWrapper + + return jest + .spyOn(AccountHelpers, 'buildPasskeyWrapper') + .mockImplementation(async (args) => { + const current = await Account.getAccount(id) + await Account.updateAccount(id, buildPatch(current)) + + return original(args) + }) +} + +test('AccountPasskey - a re-key landing mid-write is not clobbered', async () => { + const id = await createAccount() + await stubCeremony() + + const rekeyed = { encryptedData: 'fresh', iv: 'fresh-iv', tag: 'fresh-tag' } + const spy = writeDuringWrap(id, (current) => ({ + wrappedDek: { ...current.wrappedDek, password: rekeyed }, + })) + + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + spy.mockRestore() + + const account = await Account.getAccount(id) + + expect(account.wrappedDek.password).toStrictEqual(rekeyed) + expect(account.wrappedDek.passkeys).toHaveLength(1) +}) + +test('AccountPasskey - a passkey added mid-write is not lost', async () => { + const id = await createAccount() + await stubCeremony('cred-late') + + const other = { credentialId: 'cred-other', label: 'Other', prfSalt: 'x' } + const spy = writeDuringWrap(id, (current) => ({ + wrappedDek: { + ...current.wrappedDek, + passkeys: [...(current.wrappedDek?.passkeys ?? []), other], + }, + })) + + await Account.enrollPasskey({ accountId: id, password, label: 'Late' }) + spy.mockRestore() + + const passkeys = await Account.getPasskeys(id) + + expect(passkeys.map((entry) => entry.credentialId)).toStrictEqual([ + 'cred-other', + 'cred-late', + ]) +}) + +test('AccountPasskey - migrating an account with passkeys is refused, not silent', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const { BTC } = await import('@Cryptos') + const { IndexedDB } = await import('@Databases') + + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const seed = await BTC.getSeedFromMnemonic(MNEMONIC) + const btc = await Cipher.encryptAES({ data: seed, key }) + const ml = await Cipher.encryptAES({ data: 'ml-key', key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: 'Legacy with passkeys', + salt, + encryptionVersion: 3, + wrappedDek: { + passkeys: [{ credentialId: 'cred-1', prfSalt: 'c2FsdA' }], + }, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: ml.iv, + mlMainnetPrivKeyIv: ml.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: ml.tag, + mlMainnetPrivKeyTag: ml.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: ml.encryptedData, + encryptedMlMainnetPrivateKey: ml.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + await expect( + Account.unlockAccount(id, password, btcOnly), + ).resolves.toBeDefined() + + const after = await Account.getAccount(id) + + expect(after.encryptionVersion).toBe(3) + expect(after.wrappedDek.passkeys).toHaveLength(1) + + console.error.mockRestore() +}) diff --git a/src/setupTests.js b/src/setupTests.js index 72aeee58..c1b7cad1 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -26,6 +26,26 @@ if (typeof global.crypto === 'undefined') { // Buffer polyfill global.Buffer = Buffer +const deepClone = (value) => { + if (value === null || typeof value !== 'object') return value + if (ArrayBuffer.isView(value)) return new value.constructor(value) + if (value instanceof ArrayBuffer) return value.slice(0) + if (value instanceof Date) return new Date(value) + if (value instanceof RegExp) return new RegExp(value) + if (value instanceof Map) + return new Map([...value].map(([k, v]) => [deepClone(k), deepClone(v)])) + if (value instanceof Set) return new Set([...value].map(deepClone)) + if (Array.isArray(value)) return value.map(deepClone) + + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, deepClone(v)]), + ) +} + +if (typeof global.structuredClone === 'undefined') { + global.structuredClone = deepClone +} + // Save the original fetch for integration tests global.originalFetch = global.fetch diff --git a/src/tests/helpers/initWasm.js b/src/tests/helpers/initWasm.js new file mode 100644 index 00000000..fa20f4a3 --- /dev/null +++ b/src/tests/helpers/initWasm.js @@ -0,0 +1,17 @@ +import fs from 'fs' +import path from 'path' +import { initSync } from 'src/services/Crypto/Mintlayer/@mintlayerlib-js/wasm_wrappers.js' + +const WASM_PATH = path.resolve( + process.cwd(), + 'src/services/Crypto/Mintlayer/@mintlayerlib-js/wasm_wrappers_bg.wasm', +) + +let compiled + +const initWasm = () => { + if (!compiled) compiled = new WebAssembly.Module(fs.readFileSync(WASM_PATH)) + initSync({ module: compiled }) +} + +export default initWasm diff --git a/src/tests/mock/wasmCrypro/wasmCrypto.js b/src/tests/mock/wasmCrypro/wasmCrypto.js deleted file mode 100644 index ccf2068a..00000000 --- a/src/tests/mock/wasmCrypro/wasmCrypto.js +++ /dev/null @@ -1,1627 +0,0 @@ -/* eslint-disable no-new-func */ -/* eslint-disable eqeqeq */ -/* eslint-disable no-restricted-globals */ -/* eslint-disable max-depth */ -/* eslint-disable no-undef */ -/* eslint-disable max-params */ -let wasm - -const cachedTextDecoder = - typeof TextDecoder !== 'undefined' - ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) - : { - decode: () => { - throw Error('TextDecoder not available') - }, - } - -if (typeof TextDecoder !== 'undefined') { - cachedTextDecoder.decode() -} - -let cachedUint8Memory0 = null - -function getUint8Memory0() { - if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) { - cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer) - } - return cachedUint8Memory0 -} - -function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0 - return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)) -} - -const heap = new Array(128).fill(undefined) - -heap.push(undefined, null, true, false) - -let heap_next = heap.length - -function addHeapObject(obj) { - if (heap_next === heap.length) heap.push(heap.length + 1) - const idx = heap_next - heap_next = heap[idx] - - heap[idx] = obj - return idx -} - -function getObject(idx) { - return heap[idx] -} - -function dropObject(idx) { - if (idx < 132) return - heap[idx] = heap_next - heap_next = idx -} - -function takeObject(idx) { - const ret = getObject(idx) - dropObject(idx) - return ret -} - -let WASM_VECTOR_LEN = 0 - -const cachedTextEncoder = - typeof TextEncoder !== 'undefined' - ? new TextEncoder('utf-8') - : { - encode: () => { - throw Error('TextEncoder not available') - }, - } - -const encodeString = - typeof cachedTextEncoder.encodeInto === 'function' - ? function (arg, view) { - return cachedTextEncoder.encodeInto(arg, view) - } - : function (arg, view) { - const buf = cachedTextEncoder.encode(arg) - view.set(buf) - return { - read: arg.length, - written: buf.length, - } - } - -function passStringToWasm0(arg, malloc, realloc) { - if (realloc === undefined) { - const buf = cachedTextEncoder.encode(arg) - const ptr = malloc(buf.length, 1) >>> 0 - getUint8Memory0() - .subarray(ptr, ptr + buf.length) - .set(buf) - WASM_VECTOR_LEN = buf.length - return ptr - } - - let len = arg.length - let ptr = malloc(len, 1) >>> 0 - - const mem = getUint8Memory0() - - let offset = 0 - - for (; offset < len; offset++) { - const code = arg.charCodeAt(offset) - if (code > 0x7f) break - mem[ptr + offset] = code - } - - if (offset !== len) { - if (offset !== 0) { - arg = arg.slice(offset) - } - ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0 - const view = getUint8Memory0().subarray(ptr + offset, ptr + len) - const ret = encodeString(arg, view) - - offset += ret.written - ptr = realloc(ptr, len, offset, 1) >>> 0 - } - - WASM_VECTOR_LEN = offset - return ptr -} - -function isLikeNone(x) { - return x === undefined || x === null -} - -let cachedInt32Memory0 = null - -function getInt32Memory0() { - if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) { - cachedInt32Memory0 = new Int32Array(wasm.memory.buffer) - } - return cachedInt32Memory0 -} - -function passArray8ToWasm0(arg, malloc) { - const ptr = malloc(arg.length * 1, 1) >>> 0 - getUint8Memory0().set(arg, ptr / 1) - WASM_VECTOR_LEN = arg.length - return ptr -} - -function getArrayU8FromWasm0(ptr, len) { - ptr = ptr >>> 0 - return getUint8Memory0().subarray(ptr / 1, ptr / 1 + len) -} -/** - * A utxo can either come from a transaction or a block reward. - * Given a source id, whether from a block reward or transaction, this function - * takes a generic id with it, and returns serialized binary data of the id - * with the given source id. - * @param {Uint8Array} id - * @param {SourceId} source - * @returns {Uint8Array} - */ -export function encode_outpoint_source_id(id, source) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(id, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.encode_outpoint_source_id(retptr, ptr0, len0, source) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Generates a new, random private key from entropy - * @returns {Uint8Array} - */ -export function make_private_key() { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.make_private_key(retptr) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Create the default account's extended private key for a given mnemonic - * derivation path: 44'/mintlayer_coin_type'/0' - * @param {string} mnemonic - * @param {Network} network - * @returns {Uint8Array} - */ -export function make_default_account_privkey(mnemonic, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - mnemonic, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - wasm.make_default_account_privkey(retptr, ptr0, len0, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * From an extended private key create a receiving private key for a given key index - * derivation path: 44'/mintlayer_coin_type'/0'/0/key_index - * @param {Uint8Array} private_key_bytes - * @param {number} key_index - * @returns {Uint8Array} - */ -export function make_receiving_address(private_key_bytes, key_index) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.make_receiving_address(retptr, ptr0, len0, key_index) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * From an extended private key create a change private key for a given key index - * derivation path: 44'/mintlayer_coin_type'/0'/1/key_index - * @param {Uint8Array} private_key_bytes - * @param {number} key_index - * @returns {Uint8Array} - */ -export function make_change_address(private_key_bytes, key_index) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.make_change_address(retptr, ptr0, len0, key_index) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a public key (as bytes) and a network type (mainnet, testnet, etc), - * return the address public key hash from that public key as an address - * @param {Uint8Array} public_key_bytes - * @param {Network} network - * @returns {string} - */ -export function pubkey_to_pubkeyhash_address(public_key_bytes, network) { - let deferred3_0 - let deferred3_1 - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(public_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.pubkey_to_pubkeyhash_address(retptr, ptr0, len0, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - var ptr2 = r0 - var len2 = r1 - if (r3) { - ptr2 = 0 - len2 = 0 - throw takeObject(r2) - } - deferred3_0 = ptr2 - deferred3_1 = len2 - return getStringFromWasm0(ptr2, len2) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1) - } -} - -/** - * Given a private key, as bytes, return the bytes of the corresponding public key - * @param {Uint8Array} private_key - * @returns {Uint8Array} - */ -export function public_key_from_private_key(private_key) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.public_key_from_private_key(retptr, ptr0, len0) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a message and a private key, sign the message with the given private key - * This kind of signature is to be used when signing spend requests, such as transaction - * input witness. - * @param {Uint8Array} private_key - * @param {Uint8Array} message - * @returns {Uint8Array} - */ -export function sign_message_for_spending(private_key, message) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(message, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.sign_message_for_spending(retptr, ptr0, len0, ptr1, len1) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a digital signature, a public key and a message. Verify that - * the signature is produced by signing the message with the private key - * that derived the given public key. - * Note that this function is used for verifying messages related to spending, - * such as transaction input witness. - * @param {Uint8Array} public_key - * @param {Uint8Array} signature - * @param {Uint8Array} message - * @returns {boolean} - */ -export function verify_signature_for_spending(public_key, signature, message) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(public_key, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(signature, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(message, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - wasm.verify_signature_for_spending( - retptr, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - if (r2) { - throw takeObject(r1) - } - return r0 !== 0 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -function _assertClass(instance, klass) { - if (!(instance instanceof klass)) { - throw new Error(`expected instance of ${klass.name}`) - } - return instance.ptr -} -/** - * Given a destination address, an amount and a network type (mainnet, testnet, etc), this function - * creates an output of type Transfer, and returns it as bytes. - * @param {Amount} amount - * @param {string} address - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_transfer(amount, address, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - const ptr1 = passStringToWasm0( - address, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_transfer(retptr, ptr0, ptr1, len1, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given the current block height and a network type (mainnet, testnet, etc), - * this function returns the number of blocks, after which a pool that decommissioned, - * will have its funds unlocked and available for spending. - * The current block height information is used in case a network upgrade changed the value. - * @param {bigint} current_block_height - * @param {Network} network - * @returns {bigint} - */ -export function staking_pool_spend_maturity_block_count( - current_block_height, - network, -) { - const ret = wasm.staking_pool_spend_maturity_block_count( - current_block_height, - network, - ) - return BigInt.asUintN(64, ret) -} - -/** - * Given a number of blocks, this function returns the output timelock - * which is used in locked outputs to lock an output for a given number of blocks - * since that output's transaction is included the blockchain - * @param {bigint} block_count - * @returns {Uint8Array} - */ -export function encode_lock_for_block_count(block_count) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_for_block_count(retptr, block_count) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a number of clock seconds, this function returns the output timelock - * which is used in locked outputs to lock an output for a given number of seconds - * since that output's transaction is included in the blockchain - * @param {bigint} total_seconds - * @returns {Uint8Array} - */ -export function encode_lock_for_seconds(total_seconds) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_for_seconds(retptr, total_seconds) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a timestamp represented by as unix timestamp, i.e., number of seconds since unix epoch, - * this function returns the output timelock which is used in locked outputs to lock an output - * until the given timestamp - * @param {bigint} timestamp_since_epoch_in_seconds - * @returns {Uint8Array} - */ -export function encode_lock_until_time(timestamp_since_epoch_in_seconds) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_until_time(retptr, timestamp_since_epoch_in_seconds) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a block height, this function returns the output timelock which is used in - * locked outputs to lock an output until that block height is reached. - * @param {bigint} block_height - * @returns {Uint8Array} - */ -export function encode_lock_until_height(block_height) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_until_height(retptr, block_height) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a valid receiving address, and a locking rule as bytes (available in this file), - * and a network type (mainnet, testnet, etc), this function creates an output of type - * LockThenTransfer with the parameters provided. - * @param {Amount} amount - * @param {string} address - * @param {Uint8Array} lock - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_lock_then_transfer( - amount, - address, - lock, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - const ptr1 = passStringToWasm0( - address, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(lock, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - wasm.encode_output_lock_then_transfer( - retptr, - ptr0, - ptr1, - len1, - ptr2, - len2, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v4 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v4 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given an amount, this function creates an output (as bytes) to burn a given amount of coins - * @param {Amount} amount - * @returns {Uint8Array} - */ -export function encode_output_coin_burn(amount) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - wasm.encode_output_coin_burn(retptr, ptr0) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a pool id as string, an owner address and a network type (mainnet, testnet, etc), - * this function returns an output (as bytes) to create a delegation to the given pool. - * The owner address is the address that is authorized to withdraw from that delegation. - * @param {string} pool_id - * @param {string} owner_address - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_create_delegation( - pool_id, - owner_address, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - pool_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passStringToWasm0( - owner_address, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_create_delegation( - retptr, - ptr0, - len0, - ptr1, - len1, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a delegation id (as string, in address form), an amount and a network type (mainnet, testnet, etc), - * this function returns an output (as bytes) that would delegate coins to be staked in the specified delegation id. - * @param {Amount} amount - * @param {string} delegation_id - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_delegate_staking(amount, delegation_id, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - const ptr1 = passStringToWasm0( - delegation_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_delegate_staking(retptr, ptr0, ptr1, len1, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * This function returns the staking pool data needed to create a staking pool in an output as bytes, - * given its parameters and the network type (testnet, mainnet, etc). - * @param {Amount} value - * @param {string} staker - * @param {string} vrf_public_key - * @param {string} decommission_key - * @param {number} margin_ratio_per_thousand - * @param {Amount} cost_per_block - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_stake_pool_data( - value, - staker, - vrf_public_key, - decommission_key, - margin_ratio_per_thousand, - cost_per_block, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(value, Amount) - var ptr0 = value.__destroy_into_raw() - const ptr1 = passStringToWasm0( - staker, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passStringToWasm0( - vrf_public_key, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len2 = WASM_VECTOR_LEN - const ptr3 = passStringToWasm0( - decommission_key, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len3 = WASM_VECTOR_LEN - _assertClass(cost_per_block, Amount) - var ptr4 = cost_per_block.__destroy_into_raw() - wasm.encode_stake_pool_data( - retptr, - ptr0, - ptr1, - len1, - ptr2, - len2, - ptr3, - len3, - margin_ratio_per_thousand, - ptr4, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v6 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v6 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a pool id, staking data as bytes and the network type (mainnet, testnet, etc), - * this function returns an output that creates that staking pool. - * Note that the pool id is mandated to be taken from the hash of the first input. - * It's not arbitrary. - * @param {string} pool_id - * @param {Uint8Array} pool_data - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_create_stake_pool(pool_id, pool_data, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - pool_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(pool_data, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_create_stake_pool( - retptr, - ptr0, - len0, - ptr1, - len1, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given the parameters needed to issue a fungible token, and a network type (mainnet, testnet, etc), - * this function creates an output that issues that token. - * @param {string} authority - * @param {Uint8Array} token_ticker - * @param {Uint8Array} metadata_uri - * @param {number} number_of_decimals - * @param {TotalSupply} total_supply - * @param {Amount | undefined} supply_amount - * @param {FreezableToken} is_token_freezable - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_issue_fungible_token( - authority, - token_ticker, - metadata_uri, - number_of_decimals, - total_supply, - supply_amount, - is_token_freezable, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - authority, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(token_ticker, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(metadata_uri, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - let ptr3 = 0 - if (!isLikeNone(supply_amount)) { - _assertClass(supply_amount, Amount) - ptr3 = supply_amount.__destroy_into_raw() - } - wasm.encode_output_issue_fungible_token( - retptr, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - number_of_decimals, - total_supply, - ptr3, - is_token_freezable, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v5 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v5 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given data to be deposited in the blockchain, this function provides the output that deposits this data - * @param {Uint8Array} data - * @returns {Uint8Array} - */ -export function encode_output_data_deposit(data) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.encode_output_data_deposit(retptr, ptr0, len0) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given an output source id as bytes, and an output index, together representing a utxo, - * this function returns the input that puts them together, as bytes. - * @param {Uint8Array} outpoint_source_id - * @param {number} output_index - * @returns {Uint8Array} - */ -export function encode_input_for_utxo(outpoint_source_id, output_index) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(outpoint_source_id, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.encode_input_for_utxo(retptr, ptr0, len0, output_index) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a delegation id, an amount and a network type (mainnet, testnet, etc), this function - * creates an input that withdraws from a delegation. - * A nonce is needed because this spends from an account. The nonce must be in sequence for everything in that account. - * @param {string} delegation_id - * @param {Amount} amount - * @param {bigint} nonce - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_input_for_withdraw_from_delegation( - delegation_id, - amount, - nonce, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - delegation_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - _assertClass(amount, Amount) - var ptr1 = amount.__destroy_into_raw() - wasm.encode_input_for_withdraw_from_delegation( - retptr, - ptr0, - len0, - ptr1, - nonce, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -let cachedUint32Memory0 = null - -function getUint32Memory0() { - if (cachedUint32Memory0 === null || cachedUint32Memory0.byteLength === 0) { - cachedUint32Memory0 = new Uint32Array(wasm.memory.buffer) - } - return cachedUint32Memory0 -} - -function passArrayJsValueToWasm0(array, malloc) { - const ptr = malloc(array.length * 4, 4) >>> 0 - const mem = getUint32Memory0() - for (let i = 0; i < array.length; i++) { - mem[ptr / 4 + i] = addHeapObject(array[i]) - } - WASM_VECTOR_LEN = array.length - return ptr -} -/** - * Given inputs, each input's destination (from the UTXO or Account) and outputs, estimate the transaction size. - * @param {Uint8Array} inputs - * @param {(string)[]} input_destinations - * @param {Uint8Array} outputs - * @param {Network} network - * @returns {number} - */ -export function estimate_transaction_size( - inputs, - input_destinations, - outputs, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(inputs, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArrayJsValueToWasm0( - input_destinations, - wasm.__wbindgen_malloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(outputs, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - wasm.estimate_transaction_size( - retptr, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - if (r2) { - throw takeObject(r1) - } - return r0 >>> 0 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given inputs as bytes, outputs as bytes, and flags settings, this function returns - * the transaction that contains them all, as bytes. - * @param {Uint8Array} inputs - * @param {Uint8Array} outputs - * @param {bigint} flags - * @returns {Uint8Array} - */ -export function encode_transaction(inputs, outputs, flags) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(inputs, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(outputs, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.encode_transaction(retptr, ptr0, len0, ptr1, len1, flags) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Encode an input witness of the variant that contains no signature. - * @returns {Uint8Array} - */ -export function encode_witness_no_signature() { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_witness_no_signature(retptr) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a private key, inputs and an input number to sign, and the destination that owns that output (through the utxo), - * and a network type (mainnet, testnet, etc), this function returns a witness to be used in a signed transaction, as bytes. - * @param {SignatureHashType} sighashtype - * @param {Uint8Array} private_key_bytes - * @param {string} input_owner_destination - * @param {Uint8Array} transaction_bytes - * @param {Uint8Array} inputs - * @param {number} input_num - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_witness( - sighashtype, - private_key_bytes, - input_owner_destination, - transaction_bytes, - inputs, - input_num, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passStringToWasm0( - input_owner_destination, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(transaction_bytes, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - const ptr3 = passArray8ToWasm0(inputs, wasm.__wbindgen_malloc) - const len3 = WASM_VECTOR_LEN - wasm.encode_witness( - retptr, - sighashtype, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - ptr3, - len3, - input_num, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v5 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v5 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given an unsigned transaction, and signatures, this function returns a SignedTransaction object as bytes. - * @param {Uint8Array} transaction_bytes - * @param {Uint8Array} signatures - * @returns {Uint8Array} - */ -export function encode_signed_transaction(transaction_bytes, signatures) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(transaction_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(signatures, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.encode_signed_transaction(retptr, ptr0, len0, ptr1, len1) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Calculate the "effective balance" of a pool, given the total pool balance and pledge by the pool owner/staker. - * The effective balance is how the influence of a pool is calculated due to its balance. - * @param {Network} network - * @param {Amount} pledge_amount - * @param {Amount} pool_balance - * @returns {Amount} - */ -export function effective_pool_balance(network, pledge_amount, pool_balance) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(pledge_amount, Amount) - var ptr0 = pledge_amount.__destroy_into_raw() - _assertClass(pool_balance, Amount) - var ptr1 = pool_balance.__destroy_into_raw() - wasm.effective_pool_balance(retptr, network, ptr0, ptr1) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - if (r2) { - throw takeObject(r1) - } - return Amount.__wrap(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -function handleError(f, args) { - try { - return f.apply(this, args) - } catch (e) { - wasm.__wbindgen_exn_store(addHeapObject(e)) - } -} -/** - * The token supply of a specific token, set on issuance - */ -export const TotalSupply = Object.freeze({ - /** - * Can be issued with no limit, but then can be locked to have a fixed supply. - */ - Lockable: 0, - 0: 'Lockable', - /** - * Unlimited supply, no limits except for numeric limits due to u128 - */ - Unlimited: 1, - 1: 'Unlimited', - /** - * On issuance, the total number of coins is fixed - */ - Fixed: 2, - 2: 'Fixed', -}) -/** - * Indicates whether a token can be frozen - */ -export const FreezableToken = Object.freeze({ - No: 0, - 0: 'No', - Yes: 1, - 1: 'Yes', -}) -/** - * The network, for which an operation to be done. Mainnet, testnet, etc. - */ -export const Network = Object.freeze({ - Mainnet: 0, - 0: 'Mainnet', - Testnet: 1, - 1: 'Testnet', - Regtest: 2, - 2: 'Regtest', - Signet: 3, - 3: 'Signet', -}) -/** - * A utxo can either come from a transaction or a block reward. This enum signifies that. - */ -export const SourceId = Object.freeze({ - Transaction: 0, - 0: 'Transaction', - BlockReward: 1, - 1: 'BlockReward', -}) -/** - * The part of the transaction that will be committed in the signature. Similar to bitcoin's sighash. - */ -export const SignatureHashType = Object.freeze({ - ALL: 0, - 0: 'ALL', - NONE: 1, - 1: 'NONE', - SINGLE: 2, - 2: 'SINGLE', - ANYONECANPAY: 3, - 3: 'ANYONECANPAY', -}) - -const AmountFinalization = - typeof FinalizationRegistry === 'undefined' - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry((ptr) => wasm.__wbg_amount_free(ptr >>> 0)) -/** - * Amount type abstraction. The amount type is stored in a string - * since JavaScript number type cannot fit 128-bit integers. - * The amount is given as an integer in units of "atoms". - * Atoms are the smallest, indivisible amount of a coin or token. - */ -export class Amount { - static __wrap(ptr) { - ptr = ptr >>> 0 - const obj = Object.create(Amount.prototype) - obj.__wbg_ptr = ptr - AmountFinalization.register(obj, obj.__wbg_ptr, obj) - return obj - } - - __destroy_into_raw() { - const ptr = this.__wbg_ptr - this.__wbg_ptr = 0 - AmountFinalization.unregister(this) - return ptr - } - - free() { - const ptr = this.__destroy_into_raw() - wasm.__wbg_amount_free(ptr) - } - /** - * @param {string} atoms - * @returns {Amount} - */ - static from_atoms(atoms) { - const ptr0 = passStringToWasm0( - atoms, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ret = wasm.amount_from_atoms(ptr0, len0) - return Amount.__wrap(ret) - } - /** - * @returns {string} - */ - atoms() { - let deferred1_0 - let deferred1_1 - try { - const ptr = this.__destroy_into_raw() - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.amount_atoms(retptr, ptr) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - deferred1_0 = r0 - deferred1_1 = r1 - return getStringFromWasm0(r0, r1) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - wasm.__wbindgen_free(deferred1_0, deferred1_1, 1) - } - } -} - -async function __wbg_load(module, imports) { - if (typeof Response === 'function' && module instanceof Response) { - if (typeof WebAssembly.instantiateStreaming === 'function') { - try { - return await WebAssembly.instantiateStreaming(module, imports) - } catch (e) { - if (module.headers.get('Content-Type') != 'application/wasm') { - console.warn( - '`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n', - e, - ) - } else { - throw e - } - } - } - - const bytes = await module.arrayBuffer() - return await WebAssembly.instantiate(bytes, imports) - } else { - const instance = await WebAssembly.instantiate(module, imports) - - if (instance instanceof WebAssembly.Instance) { - return { instance, module } - } else { - return instance - } - } -} - -function __wbg_get_imports() { - const imports = {} - imports.wbg = {} - imports.wbg.__wbindgen_string_new = function (arg0, arg1) { - const ret = getStringFromWasm0(arg0, arg1) - return addHeapObject(ret) - } - imports.wbg.__wbindgen_object_drop_ref = function (arg0) { - takeObject(arg0) - } - imports.wbg.__wbg_crypto_d05b68a3572bb8ca = function (arg0) { - const ret = getObject(arg0).crypto - return addHeapObject(ret) - } - imports.wbg.__wbindgen_is_object = function (arg0) { - const val = getObject(arg0) - const ret = typeof val === 'object' && val !== null - return ret - } - imports.wbg.__wbg_process_b02b3570280d0366 = function (arg0) { - const ret = getObject(arg0).process - return addHeapObject(ret) - } - imports.wbg.__wbg_versions_c1cb42213cedf0f5 = function (arg0) { - const ret = getObject(arg0).versions - return addHeapObject(ret) - } - imports.wbg.__wbg_node_43b1089f407e4ec2 = function (arg0) { - const ret = getObject(arg0).node - return addHeapObject(ret) - } - imports.wbg.__wbindgen_is_string = function (arg0) { - const ret = typeof getObject(arg0) === 'string' - return ret - } - imports.wbg.__wbg_require_9a7e0f667ead4995 = function () { - return handleError(function () { - const ret = module.require - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_msCrypto_10fc94afee92bd76 = function (arg0) { - const ret = getObject(arg0).msCrypto - return addHeapObject(ret) - } - imports.wbg.__wbindgen_is_function = function (arg0) { - const ret = typeof getObject(arg0) === 'function' - return ret - } - imports.wbg.__wbg_randomFillSync_b70ccbdf4926a99d = function () { - return handleError(function (arg0, arg1) { - getObject(arg0).randomFillSync(takeObject(arg1)) - }, arguments) - } - imports.wbg.__wbg_getRandomValues_7e42b4fb8779dc6d = function () { - return handleError(function (arg0, arg1) { - getObject(arg0).getRandomValues(getObject(arg1)) - }, arguments) - } - imports.wbg.__wbg_newnoargs_e258087cd0daa0ea = function (arg0, arg1) { - const ret = new Function(getStringFromWasm0(arg0, arg1)) - return addHeapObject(ret) - } - imports.wbg.__wbg_call_27c0f87801dedf93 = function () { - return handleError(function (arg0, arg1) { - const ret = getObject(arg0).call(getObject(arg1)) - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbindgen_object_clone_ref = function (arg0) { - const ret = getObject(arg0) - return addHeapObject(ret) - } - imports.wbg.__wbg_self_ce0dbfc45cf2f5be = function () { - return handleError(function () { - const ret = self.self - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_window_c6fb939a7f436783 = function () { - return handleError(function () { - const ret = window.window - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_globalThis_d1e6af4856ba331b = function () { - return handleError(function () { - const ret = globalThis.globalThis - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_global_207b558942527489 = function () { - return handleError(function () { - const ret = global.global - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbindgen_is_undefined = function (arg0) { - const ret = getObject(arg0) === undefined - return ret - } - imports.wbg.__wbg_call_b3ca7c6051f9bec1 = function () { - return handleError(function (arg0, arg1, arg2) { - const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)) - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_buffer_12d079cc21e14bdb = function (arg0) { - const ret = getObject(arg0).buffer - return addHeapObject(ret) - } - imports.wbg.__wbg_newwithbyteoffsetandlength_aa4a17c33a06e5cb = function ( - arg0, - arg1, - arg2, - ) { - const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0) - return addHeapObject(ret) - } - imports.wbg.__wbg_new_63b92bc8671ed464 = function (arg0) { - const ret = new Uint8Array(getObject(arg0)) - return addHeapObject(ret) - } - imports.wbg.__wbg_set_a47bac70306a19a7 = function (arg0, arg1, arg2) { - getObject(arg0).set(getObject(arg1), arg2 >>> 0) - } - imports.wbg.__wbg_newwithlength_e9b4878cebadb3d3 = function (arg0) { - const ret = new Uint8Array(arg0 >>> 0) - return addHeapObject(ret) - } - imports.wbg.__wbg_subarray_a1f73cd4b5b42fe1 = function (arg0, arg1, arg2) { - const ret = getObject(arg0).subarray(arg1 >>> 0, arg2 >>> 0) - return addHeapObject(ret) - } - imports.wbg.__wbindgen_string_get = function (arg0, arg1) { - const obj = getObject(arg1) - const ret = typeof obj === 'string' ? obj : undefined - var ptr1 = isLikeNone(ret) - ? 0 - : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc) - var len1 = WASM_VECTOR_LEN - getInt32Memory0()[arg0 / 4 + 1] = len1 - getInt32Memory0()[arg0 / 4 + 0] = ptr1 - } - imports.wbg.__wbindgen_throw = function (arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)) - } - imports.wbg.__wbindgen_memory = function () { - const ret = wasm.memory - return addHeapObject(ret) - } - - return imports -} - -function __wbg_init_memory(imports, maybe_memory) {} - -function __wbg_finalize_init(instance, module) { - wasm = instance.exports - __wbg_init.__wbindgen_wasm_module = module - cachedInt32Memory0 = null - cachedUint32Memory0 = null - cachedUint8Memory0 = null - - return wasm -} - -function initSync(module) { - if (wasm !== undefined) return wasm - - const imports = __wbg_get_imports() - - __wbg_init_memory(imports) - - if (!(module instanceof WebAssembly.Module)) { - module = new WebAssembly.Module(module) - } - - const instance = new WebAssembly.Instance(module, imports) - - return __wbg_finalize_init(instance, module) -} - -async function __wbg_init(input) { - if (wasm !== undefined) return wasm - - if (typeof input === 'undefined') { - input = new URL('wasm_wrappers_bg.wasm', '') - } - const imports = __wbg_get_imports() - - if ( - typeof input === 'string' || - (typeof Request === 'function' && input instanceof Request) || - (typeof URL === 'function' && input instanceof URL) - ) { - input = fetch(input) - } - - __wbg_init_memory(imports) - - const { instance, module } = await __wbg_load(await input, imports) - - return __wbg_finalize_init(instance, module) -} - -export { initSync } -export default __wbg_init