diff --git a/.env.defaults b/.env.defaults index c1f270262b..5d7fd8d9fa 100644 --- a/.env.defaults +++ b/.env.defaults @@ -32,4 +32,4 @@ SUPPORT_SWAP_QUOTE_REFRESH=false SUPPORT_ACHIEVEMENTS_BANNER=false SUPPORT_NFT_SEND=false USE_MAINNET_FORK=false -ENABLE_UPDATED_DAPP_CONNECTIONS=false +ENABLE_UPDATED_DAPP_CONNECTIONS=false \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 6d756aadfa..bfc7c4a14f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -92,7 +92,13 @@ module.exports = { ], }, ], - "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], "no-unused-vars": "off", }, ignorePatterns: [ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5ab3ab0246..d8d1aa12d5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,8 @@ -# Any changes to keyring code deserve extra scrutiny to prevent key +# Any changes to internal-signer service code deserve extra scrutiny to prevent key # exfiltration and general "roll your own crypto" mistakes. Newer -# contributions to keyring code should be assumed insecure, requiring +# contributions to internal-signer code should be assumed insecure, requiring # agreement across the team to merge. -/background/services/keyring/* @tahowallet/extension-security-auditors +/background/services/internal-signer/* @tahowallet/extension-security-auditors # Any changes to dependencies deserve extra scrutiny to help prevent supply # chain attacks yarn.lock @tahowallet/extension-dependency-auditors diff --git a/.github/ISSUE_TEMPLATE/BUG.yml b/.github/ISSUE_TEMPLATE/BUG.yml index 96a55dd634..03d74716c1 100644 --- a/.github/ISSUE_TEMPLATE/BUG.yml +++ b/.github/ISSUE_TEMPLATE/BUG.yml @@ -51,6 +51,10 @@ body: label: Version description: What version of the extension are you running? options: + - v0.40.2 + - v0.40.1 + - v0.40.0 + - v0.39.0 - v0.38.1 - v0.38.0 - v0.37.0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 29cbd4c1f9..39d4466d6e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,6 +98,8 @@ jobs: node-version: "${{ steps.nvm.outputs.NVMRC }}" cache: "yarn" - run: yarn install --frozen-lockfile + - run: yarn install --frozen-lockfile + working-directory: .github/workflows/pledge-signer-sync - run: yarn lint detect-if-flag-changed: runs-on: ubuntu-latest @@ -114,11 +116,8 @@ jobs: path-filter: - '.env.defaults' e2e-tests: - if: | - github.ref == 'refs/heads/main' - || contains(github.head_ref, 'e2e') - || needs.detect-if-flag-changed.outputs.path-filter == 'true' needs: [build, detect-if-flag-changed] + if: ${{ !startsWith(github.ref, 'refs/tags/') }} timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -139,10 +138,21 @@ jobs: name: extension-builds-${{ github.event.number || github.event.head_commit.id }} - name: Extract extension run: unzip -o chrome.zip -d dist/chrome - - name: Run Playwright tests - run: xvfb-run npx playwright test + # Some tests that we have configured in the `e2e-tests` folder may require + # spending funds. Although they're desined to not spend much, with + # frequent execution that can accumulate. We don't want to execute such + # tests on every PR update. We'll tag those tests with the `@slow` tag. + - name: Run free Playwright tests + run: xvfb-run npx playwright test --grep-invert @slow #env: # DEBUG: pw:api* + # TODO: Uncomment once we have some `@slow` tagged tests in our test set. + # - name: Run costing Playwright tests + # if: | + # github.ref == 'refs/heads/main' + # || contains(github.head_ref, 'e2e') + # || needs.detect-if-flag-changed.outputs.path-filter == 'true' + # run: xvfb-run npx playwright test --grep @slow - uses: actions/upload-artifact@v3 if: failure() with: diff --git a/.github/workflows/pledge-signer-sync/package.json b/.github/workflows/pledge-signer-sync/package.json new file mode 100644 index 0000000000..be96d8ab34 --- /dev/null +++ b/.github/workflows/pledge-signer-sync/package.json @@ -0,0 +1,13 @@ +{ + "name": "@tallyho/pledge-signer-sync", + "type": "module", + "version": "1.0.0", + "private": true, + "engines": { + "node": ">=16.0.0 <17.0.0" + }, + "dependencies": { + "firebase": "^9.9.0", + "node-fetch": "3.3.1" + } +} diff --git a/.github/workflows/pledge-signer-sync/pledge-sync.js b/.github/workflows/pledge-signer-sync/pledge-sync.js new file mode 100644 index 0000000000..729d105d44 --- /dev/null +++ b/.github/workflows/pledge-signer-sync/pledge-sync.js @@ -0,0 +1,142 @@ +// @ts-check +/* eslint-disable no-console */ // need logging +/* eslint-disable no-await-in-loop */ // need to process items in sequence +import { getAuth, signInWithEmailAndPassword } from "firebase/auth" +import { initializeApp } from "firebase/app" +import { + getFirestore, + query, + orderBy, + limit, + getDocs, + collection, + startAfter, + where, +} from "firebase/firestore" +import fetch from "node-fetch" + +const { GALXE_ACCESS_TOKEN, FIRESTORE_USER, FIRESTORE_PASSWORD } = process.env + +if (!GALXE_ACCESS_TOKEN || !FIRESTORE_USER || !FIRESTORE_PASSWORD) { + console.error("Missing credentials") + process.exit(1) +} + +// Limit sync range to last 4 days ( 2 days from last sync + 2 days from now ) +const TARGET_DATE = new Date(Date.now() - 4 * 24 * 60 * 60_000) + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)) + +const getAddresses = async () => { + const app = initializeApp({ + apiKey: "AIzaSyAa78OwfLesUAW8hdzbhUkc5U8LSVH3y7s", + authDomain: "tally-prd.firebaseapp.com", + projectId: "tally-prd", + storageBucket: "tally-prd.appspot.com", + messagingSenderId: "567502050788", + appId: "1:567502050788:web:bb953a931a98e396d363f1", + }) + + await signInWithEmailAndPassword( + getAuth(app), + FIRESTORE_USER, + FIRESTORE_PASSWORD + ) + + const db = getFirestore(app) + const dbCollection = collection(db, "address") + + const CHUNK_SIZE = 5_000 + + const fetchNextBatch = async (offset) => { + let currentQuery = query( + dbCollection, + orderBy("signedManifesto.timestamp", "desc"), + limit(CHUNK_SIZE), + where("signedManifesto.timestamp", ">=", TARGET_DATE) + ) + + if (offset) { + currentQuery = query(currentQuery, startAfter(offset)) + } + + return getDocs(currentQuery).then((snapshot) => snapshot.docs) + } + + const allDocs = [] + + let offsetDoc + let nextBatch = [] + + do { + nextBatch = await fetchNextBatch(offsetDoc) + + offsetDoc = nextBatch[nextBatch.length - 1] + + allDocs.push(...nextBatch) + + console.log("Retrieved:", allDocs.length, "addresses") + + await wait(1500) + } while (nextBatch.length === CHUNK_SIZE) + + return allDocs.map((doc) => doc.id) +} + +const syncGalxe = async () => { + const CHUNK_SIZE = 3_000 + + const addresses = await getAddresses().catch(console.error) + + if (!addresses) { + throw new Error("Unable to retrieve pledge signers addresses") + } + + for (let i = 0; i < addresses.length; i += CHUNK_SIZE) { + const batch = addresses.slice(i, i + CHUNK_SIZE) + + console.log("Syncing addresses...", batch.length, "of", addresses.length) + + const payload = { + operationName: "credentialItems", + query: ` + mutation credentialItems($credId: ID!, $operation: Operation!, $items: [String!]!) + { + credentialItems(input: { + credId: $credId + operation: $operation + items: $items + }) + { + name + } + } + `, + variables: { + credId: "194531900883902464", + operation: "APPEND", + items: batch, + }, + } + + try { + await fetch("https://graphigo.prd.galaxy.eco/query", { + headers: { + "access-token": GALXE_ACCESS_TOKEN, + "content-type": "application/json", + }, + method: "POST", + body: JSON.stringify(payload), + }) + } catch (error) { + throw new Error("Unable to sync with galxe") + } + + await wait(3000) + } +} + +syncGalxe().then(() => { + console.log("Sync complete!") + process.exit(0) +}) diff --git a/.github/workflows/pledge-signer-sync/yarn.lock b/.github/workflows/pledge-signer-sync/yarn.lock new file mode 100644 index 0000000000..3bb870e17a --- /dev/null +++ b/.github/workflows/pledge-signer-sync/yarn.lock @@ -0,0 +1,795 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@firebase/analytics-compat@0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.6.tgz#50063978c42f13eb800e037e96ac4b17236841f4" + integrity sha512-4MqpVLFkGK7NJf/5wPEEP7ePBJatwYpyjgJ+wQHQGHfzaCDgntOnl9rL2vbVGGKCnRqWtZDIWhctB86UWXaX2Q== + dependencies: + "@firebase/analytics" "0.10.0" + "@firebase/analytics-types" "0.8.0" + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.0.tgz#551e744a29adbc07f557306530a2ec86add6d410" + integrity sha512-iRP+QKI2+oz3UAh4nPEq14CsEjrjD6a5+fuypjScisAh9kXKFvdJOZJDwk7kikLvWVLGEs9+kIUS4LPQV7VZVw== + +"@firebase/analytics@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.0.tgz#9c6986acd573c6c6189ffb52d0fd63c775db26d7" + integrity sha512-Locv8gAqx0e+GX/0SI3dzmBY5e9kjVDtD+3zCFLJ0tH2hJwuCAiL+5WkHuxKj92rqQj/rvkBUCfA1ewlX2hehg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.3.7.tgz#e150f61d653a0f2043a34dcb995616a717161839" + integrity sha512-cW682AxsyP1G+Z0/P7pO/WT2CzYlNxoNe5QejVarW2o5ZxeWSSPAiVEwpEpQR/bUlUmdeWThYTMvBWaopdBsqw== + dependencies: + "@firebase/app-check" "0.8.0" + "@firebase/app-check-types" "0.5.0" + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.0.tgz#b27ea1397cb80427f729e4bbf3a562f2052955c4" + integrity sha512-xAxHPZPIgFXnI+vb4sbBjZcde7ZluzPPaSK7Lx3/nmuVk4TjZvnL8ONnkd4ERQKL8WePQySU+pRcWkh8rDf5Sg== + +"@firebase/app-check-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.0.tgz#1b02826213d7ce6a1cf773c329b46ea1c67064f4" + integrity sha512-uwSUj32Mlubybw7tedRzR24RP8M8JUVR3NPiMk3/Z4bCmgEKTlQBwMXrehDAZ2wF+TsBq0SN1c6ema71U/JPyQ== + +"@firebase/app-check@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.8.0.tgz#b531ec40900af9c3cf1ec63de9094a0ddd733d6a" + integrity sha512-dRDnhkcaC2FspMiRK/Vbp+PfsOAEP6ZElGm9iGFJ9fDqHoPs0HOPn7dwpJ51lCFi1+2/7n5pRPGhqF/F03I97g== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-compat@0.2.13": + version "0.2.13" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.2.13.tgz#c42d392f45f2c9fef1631cb3ae36d53296aa6407" + integrity sha512-j6ANZaWjeVy5zg6X7uiqh6lM6o3n3LD1+/SJFNs9V781xyryyZWXe+tmnWNWPkP086QfJoNkWN9pMQRqSG4vMg== + dependencies: + "@firebase/app" "0.9.13" + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/app-types@0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.0.tgz#35b5c568341e9e263b29b3d2ba0e9cfc9ec7f01e" + integrity sha512-AeweANOIo0Mb8GiYm3xhTEBVCmPwTYAu9Hcd2qSkLuga/6+j9b1Jskl5bpiSQWy9eJ/j5pavxj6eYogmnuzm+Q== + +"@firebase/app@0.9.13": + version "0.9.13" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.9.13.tgz#b1d3ad63d52f235a0d70a9b4261cabb3a24690d7" + integrity sha512-GfiI1JxJ7ecluEmDjPzseRXk/PX31hS7+tjgBopL7XjB2hLUdR+0FTMXy2Q3/hXezypDvU6or7gVFizDESrkXw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.4.2.tgz#cb65edc2fbd5f72fff32310409f2fd702b5145e7" + integrity sha512-Q30e77DWXFmXEt5dg5JbqEDpjw9y3/PcP9LslDPR7fARmAOTIY9MM6HXzm9KC+dlrKH/+p6l8g9ifJiam9mc4A== + dependencies: + "@firebase/auth" "0.23.2" + "@firebase/auth-types" "0.12.0" + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.1.tgz#78884f24fa539e34a06c03612c75f222fcc33742" + integrity sha512-VOaGzKp65MY6P5FI84TfYKBXEPi6LmOCSMMzys6o2BN2LOsqy7pCuZCup7NYnfbk5OkkQKzvIfHOzTm0UDpkyg== + +"@firebase/auth-types@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.12.0.tgz#f28e1b68ac3b208ad02a15854c585be6da3e8e79" + integrity sha512-pPwaZt+SPOshK8xNoiQlK5XIrS97kFYc3Rc7xmy373QsOJ9MmqXxLaYssP5Kcds4wd2qK//amx/c+A8O2fVeZA== + +"@firebase/auth@0.23.2": + version "0.23.2" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-0.23.2.tgz#9e6d8dd550a28053c1825fb98c7dc9b37119254d" + integrity sha512-dM9iJ0R6tI1JczuGSxXmQbXAgtYie0K4WvKcuyuSTCu9V8eEDiz4tfa1sO3txsfvwg7nOY3AjoCyMYEdqZ8hdg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/component@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.6.4.tgz#8981a6818bd730a7554aa5e0516ffc9b1ae3f33d" + integrity sha512-rLMyrXuO9jcAUCaQXCMjCMUsWrba5fzHlNK24xz5j2W6A/SRmK8mZJ/hn7V0fViLbxC0lPMtrK1eYzk6Fg03jA== + dependencies: + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/database-compat@0.3.4": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-0.3.4.tgz#4e57932f7a5ba761cd5ac946ab6b6ab3f660522c" + integrity sha512-kuAW+l+sLMUKBThnvxvUZ+Q1ZrF/vFJ58iUY9kAcbX48U03nVzIF6Tmkf0p3WVQwMqiXguSgtOPIB6ZCeF+5Gg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/database" "0.14.4" + "@firebase/database-types" "0.10.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/database-types@0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-0.10.4.tgz#47ba81113512dab637abace61cfb65f63d645ca7" + integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ== + dependencies: + "@firebase/app-types" "0.9.0" + "@firebase/util" "1.9.3" + +"@firebase/database@0.14.4": + version "0.14.4" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-0.14.4.tgz#9e7435a16a540ddfdeb5d99d45618e6ede179aa6" + integrity sha512-+Ea/IKGwh42jwdjCyzTmeZeLM3oy1h0mFPsTy6OqCWzcu/KFqRAr5Tt1HRCOBlNOdbh84JPZC47WLU18n2VbxQ== + dependencies: + "@firebase/auth-interop-types" "0.2.1" + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.3.12": + version "0.3.12" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.3.12.tgz#c08b24c76da7af75598f3c28432b6eb22f959b56" + integrity sha512-mazuNGAx5Kt9Nph0pm6ULJFp/+j7GSsx+Ncw1GrnKl+ft1CQ4q2LcUssXnjqkX2Ry0fNGqUzC1mfIUrk9bYtjQ== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/firestore" "3.13.0" + "@firebase/firestore-types" "2.5.1" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/firestore-types@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-2.5.1.tgz#464b2ee057956599ca34de50eae957c30fdbabb7" + integrity sha512-xG0CA6EMfYo8YeUxC8FeDzf6W3FX1cLlcAGBYV6Cku12sZRI81oWcu61RSKM66K6kUENP+78Qm8mvroBcm1whw== + +"@firebase/firestore@3.13.0": + version "3.13.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-3.13.0.tgz#f924a3bb462bc3ac666dc5d375f3f8c4e1a72345" + integrity sha512-NwcnU+madJXQ4fbLkGx1bWvL612IJN/qO6bZ6dlPmyf7QRyu5azUosijdAN675r+bOOJxMtP1Bv981bHBXAbUg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + "@firebase/webchannel-wrapper" "0.10.1" + "@grpc/grpc-js" "~1.7.0" + "@grpc/proto-loader" "^0.6.13" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/functions-compat@0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.3.5.tgz#7a532d3a9764c6d5fbc1ec5541a989a704326647" + integrity sha512-uD4jwgwVqdWf6uc3NRKF8cSZ0JwGqSlyhPgackyUPe+GAtnERpS4+Vr66g0b3Gge0ezG4iyHo/EXW/Hjx7QhHw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/functions" "0.10.0" + "@firebase/functions-types" "0.6.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.0.tgz#ccd7000dc6fc668f5acb4e6a6a042a877a555ef2" + integrity sha512-hfEw5VJtgWXIRf92ImLkgENqpL6IWpYaXVYiRkFY1jJ9+6tIhWM7IzzwbevwIIud/jaxKVdRzD7QBWfPmkwCYw== + +"@firebase/functions@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.10.0.tgz#c630ddf12cdf941c25bc8d554e30c3226cd560f6" + integrity sha512-2U+fMNxTYhtwSpkkR6WbBcuNMOVaI7MaH3cZ6UAeNfj7AgEwHwMIFLPpC13YNZhno219F0lfxzTAA0N62ndWzA== + dependencies: + "@firebase/app-check-interop-types" "0.3.0" + "@firebase/auth-interop-types" "0.2.1" + "@firebase/component" "0.6.4" + "@firebase/messaging-interop-types" "0.2.0" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.4.tgz#b5557c897b4cd3635a59887a8bf69c3731aaa952" + integrity sha512-LI9dYjp0aT9Njkn9U4JRrDqQ6KXeAmFbRC0E7jI7+hxl5YmRWysq5qgQl22hcWpTk+cm3es66d/apoDU/A9n6Q== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/installations-types" "0.5.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.0.tgz#2adad64755cd33648519b573ec7ec30f21fb5354" + integrity sha512-9DP+RGfzoI2jH7gY4SlzqvZ+hr7gYzPODrbzVD82Y12kScZ6ZpRg/i3j6rleto8vTFC8n6Len4560FnV1w2IRg== + +"@firebase/installations@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.4.tgz#20382e33e6062ac5eff4bede8e468ed4c367609e" + integrity sha512-u5y88rtsp7NYkCHC3ElbFBrPtieUybZluXyzl7+4BsIz4sqb4vSAuwHEUgCgCeaQhvsnxDEU6icly8U9zsJigA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + idb "7.0.1" + tslib "^2.1.0" + +"@firebase/logger@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.4.0.tgz#15ecc03c452525f9d47318ad9491b81d1810f113" + integrity sha512-eRKSeykumZ5+cJPdxxJRgAC3G5NknY2GwEbKfymdnXtnT0Ucm4pspfR6GT4MUQEDuJwRVbVcSx85kgJulMoFFA== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.4.tgz#323ca48deef77065b4fcda3cfd662c4337dffcfd" + integrity sha512-lyFjeUhIsPRYDPNIkYX1LcZMpoVbBWXX4rPl7c/rqc7G+EUea7IEtSt4MxTvh6fDfPuzLn7+FZADfscC+tNMfg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/messaging" "0.12.4" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.0.tgz#6056f8904a696bf0f7fdcf5f2ca8f008e8f6b064" + integrity sha512-ujA8dcRuVeBixGR9CtegfpU4YmZf3Lt7QYkcj693FFannwNuZgfAYaTmbJ40dtjB81SAu6tbFPL9YLNT15KmOQ== + +"@firebase/messaging@0.12.4": + version "0.12.4" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.12.4.tgz#ccb49df5ab97d5650c9cf5b8c77ddc34daafcfe0" + integrity sha512-6JLZct6zUaex4g7HI3QbzeUrg9xcnmDAPTWpkoMpd/GoSVWH98zDoWXMGrcvHeCAIsLpFMe4MPoZkJbrPhaASw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/messaging-interop-types" "0.2.0" + "@firebase/util" "1.9.3" + idb "7.0.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.4.tgz#95cbf32057b5d9f0c75d804bc50e6ed3ba486274" + integrity sha512-nnHUb8uP9G8islzcld/k6Bg5RhX62VpbAb/Anj7IXs/hp32Eb2LqFPZK4sy3pKkBUO5wcrlRWQa6wKOxqlUqsg== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/performance" "0.6.4" + "@firebase/performance-types" "0.2.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.0.tgz#400685f7a3455970817136d9b48ce07a4b9562ff" + integrity sha512-kYrbr8e/CYr1KLrLYZZt2noNnf+pRwDq2KK9Au9jHrBMnb0/C9X9yWSXmZkFt4UIdsQknBq8uBB7fsybZdOBTA== + +"@firebase/performance@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.6.4.tgz#0ad766bfcfab4f386f4fe0bef43bbcf505015069" + integrity sha512-HfTn/bd8mfy/61vEqaBelNiNnvAbUtME2S25A67Nb34zVuCSCRIX4SseXY6zBnOFj3oLisaEqhVcJmVPAej67g== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/remote-config-compat@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.4.tgz#1f494c81a6c9560b1f9ca1b4fbd4bbbe47cf4776" + integrity sha512-FKiki53jZirrDFkBHglB3C07j5wBpitAaj8kLME6g8Mx+aq7u9P7qfmuSRytiOItADhWUj7O1JIv7n9q87SuwA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/remote-config" "0.4.4" + "@firebase/remote-config-types" "0.3.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.3.0.tgz#689900dcdb3e5c059e8499b29db393e4e51314b4" + integrity sha512-RtEH4vdcbXZuZWRZbIRmQVBNsE7VDQpet2qFvq6vwKLBIQRQR5Kh58M4ok3A3US8Sr3rubYnaGqZSurCwI8uMA== + +"@firebase/remote-config@0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.4.4.tgz#6a496117054de58744bc9f382d2a6d1e14060c65" + integrity sha512-x1ioTHGX8ZwDSTOVp8PBLv2/wfwKzb4pxi0gFezS5GCJwbLlloUH4YYZHHS83IPxnua8b6l0IXUaWd0RgbWwzQ== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/installations" "0.6.4" + "@firebase/logger" "0.4.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/storage-compat@0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.3.2.tgz#51a97170fd652a516f729f82b97af369e5a2f8d7" + integrity sha512-wvsXlLa9DVOMQJckbDNhXKKxRNNewyUhhbXev3t8kSgoCotd1v3MmqhKKz93ePhDnhHnDs7bYHy+Qa8dRY6BXw== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/storage" "0.11.2" + "@firebase/storage-types" "0.8.0" + "@firebase/util" "1.9.3" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.0.tgz#f1e40a5361d59240b6e84fac7fbbbb622bfaf707" + integrity sha512-isRHcGrTs9kITJC0AVehHfpraWFui39MPaU7Eo8QfWlqW7YPymBmRgjDrlOgFdURh6Cdeg07zmkLP5tzTKRSpg== + +"@firebase/storage@0.11.2": + version "0.11.2" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.11.2.tgz#c5e0316543fe1c4026b8e3910f85ad73f5b77571" + integrity sha512-CtvoFaBI4hGXlXbaCHf8humajkbXhs39Nbh6MbNxtwJiCqxPy9iH3D3CCfXAvP0QvAAwmJUTK3+z9a++Kc4nkA== + dependencies: + "@firebase/component" "0.6.4" + "@firebase/util" "1.9.3" + node-fetch "2.6.7" + tslib "^2.1.0" + +"@firebase/util@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.9.3.tgz#45458dd5cd02d90e55c656e84adf6f3decf4b7ed" + integrity sha512-DY02CRhOZwpzO36fHpuVysz6JZrscPiBXD0fXp6qSrL9oNOx5KWICKdR95C0lSITzxp0TZosVyHqzatE8JbcjA== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.10.1.tgz#60bb2aaf129f9e00621f8d698722ddba6ee1f8ac" + integrity sha512-Dq5rYfEpdeel0bLVN+nfD1VWmzCkK+pJbSjIawGE+RY4+NIJqhbUDDQjvV0NUK84fMfwxvtFoCtEe70HfZjFcw== + +"@grpc/grpc-js@~1.7.0": + version "1.7.3" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.7.3.tgz#f2ea79f65e31622d7f86d4b4c9ae38f13ccab99a" + integrity sha512-H9l79u4kJ2PVSxUNA08HMYAnUBLj9v6KjYQ7SQ71hOZcEXhShE/y5iQCesP8+6/Ik/7i2O0a10bPquIcYfufog== + dependencies: + "@grpc/proto-loader" "^0.7.0" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.6.13": + version "0.6.13" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.6.13.tgz#008f989b72a40c60c96cd4088522f09b05ac66bc" + integrity sha512-FjxPYDRTn6Ec3V0arm1FtSpmP6V50wuph2yILpyvTKzjc76oDdoihXqM1DzOW5ubvCC8GivfCnNtfaRE8myJ7g== + dependencies: + "@types/long" "^4.0.1" + lodash.camelcase "^4.3.0" + long "^4.0.0" + protobufjs "^6.11.3" + yargs "^16.2.0" + +"@grpc/proto-loader@^0.7.0": + version "0.7.7" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.7.tgz#d33677a77eea8407f7c66e2abd97589b60eb4b21" + integrity sha512-1TIeXOi8TuSCQprPItwoMymZXxWT0CPxUhkrkeCUH+D8U7QDwQ6b7SUz2MaLuWM2llT+J/TVFLmQI5KtML3BhQ== + dependencies: + "@types/long" "^4.0.1" + lodash.camelcase "^4.3.0" + long "^4.0.0" + protobufjs "^7.0.0" + yargs "^17.7.2" + +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + +"@types/long@^4.0.1": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a" + integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== + +"@types/node@>=12.12.47", "@types/node@>=13.7.0": + version "20.3.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.2.tgz#fa6a90f2600e052a03c18b8cb3fd83dd4e599898" + integrity sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +firebase@^9.9.0: + version "9.23.0" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-9.23.0.tgz#71fea60d704bfed8e92162911544fd6564a04d0e" + integrity sha512-/4lUVY0lUvBDIaeY1q6dUYhS8Sd18Qb9CgWkPZICUo9IXpJNCEagfNZXBBFCkMTTN5L5gx2Hjr27y21a9NzUcA== + dependencies: + "@firebase/analytics" "0.10.0" + "@firebase/analytics-compat" "0.2.6" + "@firebase/app" "0.9.13" + "@firebase/app-check" "0.8.0" + "@firebase/app-check-compat" "0.3.7" + "@firebase/app-compat" "0.2.13" + "@firebase/app-types" "0.9.0" + "@firebase/auth" "0.23.2" + "@firebase/auth-compat" "0.4.2" + "@firebase/database" "0.14.4" + "@firebase/database-compat" "0.3.4" + "@firebase/firestore" "3.13.0" + "@firebase/firestore-compat" "0.3.12" + "@firebase/functions" "0.10.0" + "@firebase/functions-compat" "0.3.5" + "@firebase/installations" "0.6.4" + "@firebase/installations-compat" "0.2.4" + "@firebase/messaging" "0.12.4" + "@firebase/messaging-compat" "0.2.4" + "@firebase/performance" "0.6.4" + "@firebase/performance-compat" "0.2.4" + "@firebase/remote-config" "0.4.4" + "@firebase/remote-config-compat" "0.2.4" + "@firebase/storage" "0.11.2" + "@firebase/storage-compat" "0.3.2" + "@firebase/util" "1.9.3" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +http-parser-js@>=0.5.1: + version "0.5.8" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" + integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== + +idb@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.0.1.tgz#d2875b3a2f205d854ee307f6d196f246fea590a7" + integrity sha512-UUxlE7vGWK5RfB/fDwEGgRf84DY/ieqNha6msMV99UsEMQhJ1RwbCd8AYBj3QMgnE3VZnfQvm4oKVCJTYlqIgg== + +idb@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +long@^5.0.0: + version "5.2.3" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" + integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== + +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +node-fetch@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.1.tgz#b3eea7b54b3a48020e46f4f88b9c5a7430d20b2e" + integrity sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +protobufjs@^6.11.3: + version "6.11.3" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.3.tgz#637a527205a35caa4f3e2a9a4a13ddffe0e7af74" + integrity sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/long" "^4.0.1" + "@types/node" ">=13.7.0" + long "^4.0.0" + +protobufjs@^7.0.0: + version "7.2.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.4.tgz#3fc1ec0cdc89dd91aef9ba6037ba07408485c3ae" + integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +safe-buffer@>=5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +tslib@^2.1.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" + integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== + +web-streams-polyfill@^3.0.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" diff --git a/.github/workflows/sync-pledge.yml b/.github/workflows/sync-pledge.yml new file mode 100644 index 0000000000..7aa175377d --- /dev/null +++ b/.github/workflows/sync-pledge.yml @@ -0,0 +1,31 @@ +name: Sync pledge + +on: + schedule: + - cron: "0 0 */3 * *" + workflow_dispatch: + +jobs: + cron: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Read .nvmrc + run: echo "NVMRC=$(cat ./.nvmrc)" >> $GITHUB_OUTPUT + id: nvm + - name: Use Node + Yarn + uses: actions/setup-node@v3 + with: + node-version: "${{ steps.nvm.outputs.NVMRC }}" + cache: "yarn" + - run: yarn install --frozen-lockfile + working-directory: .github/workflows/pledge-signer-sync + - name: Sync pledge addresses + run: node pledge-sync.js + working-directory: .github/workflows/pledge-signer-sync + env: + GALXE_ACCESS_TOKEN: ${{ secrets.GALXE_ACCESS_TOKEN }} + FIRESTORE_USER: ${{ vars.FIRESTORE_USER }} + FIRESTORE_PASSWORD: ${{ secrets.FIRESTORE_PASSWORD }} diff --git a/.prettierignore b/.prettierignore index 70bb504b19..6a98a61986 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,6 @@ dist # Ignore weblate json translation formatting ui/_locales/**/*.json !.github -ci/cache \ No newline at end of file +ci/cache +.vscode +size-plugin.json \ No newline at end of file diff --git a/README.md b/README.md index 2ae55a9ec2..e0a1aa6d3d 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ services (in the API package) and the interface and browser notifications: │ │ - On-chain prices ┃ │ └─────────────────┘ │ │ ┃ │ │ │ ┃ │ ┌────────────────┐ -│ │ Keyring ┃ │ │ │ +│ │ Internal Signer ┃ │ │ │ │ ├──────list accounts, sign tx, sign message───────▶ - Native ────────────────╋─────┼──────▶ Extension │ │ │ - Remote ┃ │ │ Storage API │ │ ┌──────────┴──────────┐ ┃ │ │ │ diff --git a/background/lib/posthog.ts b/background/lib/posthog.ts index 0730521af5..c55e112275 100644 --- a/background/lib/posthog.ts +++ b/background/lib/posthog.ts @@ -12,6 +12,8 @@ export enum AnalyticsEvent { NEW_ACCOUNT_TO_TRACK = "Address added to tracking on network", CUSTOM_CHAIN_ADDED = "Custom chain added", DAPP_CONNECTED = "Dapp Connected", + VAULT_MIGRATION = "Migrate to newer vault version", + VAULT_MIGRATION_FAILED = "Vault version migration failed", } export enum OneTimeAnalyticsEvent { diff --git a/background/lib/token-lists.ts b/background/lib/token-lists.ts index b19dc97730..c8e7cdeb8c 100644 --- a/background/lib/token-lists.ts +++ b/background/lib/token-lists.ts @@ -22,7 +22,7 @@ import { DeepWriteable } from "../types" const cleanTokenListResponse = (json: any, url: string) => { if (url.includes("api-polygon-tokens.polygon.technology")) { if (typeof json === "object" && json !== null && "tags" in json) { - const { tags, ...cleanedJson } = json + const { tags: _, ...cleanedJson } = json return cleanedJson } } diff --git a/background/lib/utils/index.ts b/background/lib/utils/index.ts index e2c6cc44a2..4983671051 100644 --- a/background/lib/utils/index.ts +++ b/background/lib/utils/index.ts @@ -108,14 +108,14 @@ export function gweiToWei(value: number | bigint): bigint { return BigInt(utils.parseUnits(value.toString(), "gwei").toString()) } -export function convertToEth(value: string | number | bigint): string { +export function convertToEth(value: bigint): string { if (value && value >= 1) { return utils.formatUnits(BigInt(value)) } return "" } -export function weiToGwei(value: string | number | bigint): string { +export function weiToGwei(value: bigint): string { if (value && value >= 1) { return truncateDecimalAmount(utils.formatUnits(BigInt(value), "gwei"), 2) } diff --git a/background/lib/validate/prices.ts b/background/lib/validate/prices.ts index 724eed5dda..b20f9ee5b7 100644 --- a/background/lib/validate/prices.ts +++ b/background/lib/validate/prices.ts @@ -27,7 +27,7 @@ export const coingeckoPriceSchema: JSONSchemaType = { additionalProperties: { type: "number", nullable: true }, nullable: true, }, -} +} as const export type CoingeckoPriceData = { [coinId: string]: diff --git a/background/main.ts b/background/main.ts index cdc1bfc8df..b2eab862b8 100644 --- a/background/main.ts +++ b/background/main.ts @@ -23,7 +23,7 @@ import { EnrichmentService, IndexingService, InternalEthereumProviderService, - KeyringService, + InternalSignerService, NameService, PreferenceService, ProviderBridgeService, @@ -38,7 +38,7 @@ import { getNoopService, } from "./services" -import { HexString, KeyringTypes, NormalizedEVMAddress } from "./types" +import { HexString, NormalizedEVMAddress } from "./types" import { SignedTransaction } from "./networks" import { AccountBalance, AddressOnNetwork, NameOnNetwork } from "./accounts" import { Eligible } from "./services/doggo/types" @@ -65,12 +65,12 @@ import { setReferrerStats, } from "./redux-slices/claim" import { - emitter as keyringSliceEmitter, - keyringLocked, - keyringUnlocked, - updateKeyrings, + emitter as internalSignerSliceEmitter, + internalSignerLocked, + internalSignerUnlocked, + updateInternalSigners, setKeyringToVerify, -} from "./redux-slices/keyrings" +} from "./redux-slices/internal-signer" import { blockSeen, setEVMNetworks } from "./redux-slices/networks" import { initializationLoadingTimeHitLimit, @@ -83,6 +83,7 @@ import { toggleCollectAnalytics, setShowAnalyticsNotification, setSelectedNetwork, + setAutoLockInterval, setShownDismissableItems, dismissableItemMarkedAsShown, } from "./redux-slices/ui" @@ -191,6 +192,10 @@ import { isBuiltInNetworkBaseAsset, isSameAsset, } from "./redux-slices/utils/asset-utils" +import { + SignerImportMetadata, + SignerInternalTypes, +} from "./services/internal-signer" import { getPricePoint, getTokenPrices } from "./lib/prices" import { DismissableItem } from "./services/preferences" @@ -298,8 +303,16 @@ export default class Main extends BaseService { static create: ServiceCreatorFunction = async () => { const preferenceService = PreferenceService.create() - const keyringService = KeyringService.create() - const chainService = ChainService.create(preferenceService, keyringService) + const analyticsService = AnalyticsService.create(preferenceService) + + const internalSignerService = InternalSignerService.create( + preferenceService, + analyticsService + ) + const chainService = ChainService.create( + preferenceService, + internalSignerService + ) const indexingService = IndexingService.create( preferenceService, chainService @@ -323,13 +336,11 @@ export default class Main extends BaseService { const ledgerService = LedgerService.create() const signingService = SigningService.create( - keyringService, + internalSignerService, ledgerService, chainService ) - const analyticsService = AnalyticsService.create(preferenceService) - const nftsService = NFTsService.create(chainService) const abilitiesService = AbilitiesService.create( @@ -380,7 +391,7 @@ export default class Main extends BaseService { await chainService, await enrichmentService, await indexingService, - await keyringService, + await internalSignerService, await nameService, await internalEthereumProviderService, await providerBridgeService, @@ -418,11 +429,11 @@ export default class Main extends BaseService { */ private indexingService: IndexingService, /** - * A promise to the keyring service, which stores key material, derives - * accounts, and signs messagees and transactions. The promise will be + * A promise to the internal signer service, which stores key material, derives + * accounts, and signs messages and transactions. The promise will be * resolved when the service is initialized. */ - private keyringService: KeyringService, + private internalSignerService: InternalSignerService, /** * A promise to the name service, responsible for resolving names to * addresses and content. @@ -533,7 +544,7 @@ export default class Main extends BaseService { this.chainService.startService(), this.indexingService.startService(), this.enrichmentService.startService(), - this.keyringService.startService(), + this.internalSignerService.startService(), this.nameService.startService(), this.internalEthereumProviderService.startService(), this.providerBridgeService.startService(), @@ -556,7 +567,7 @@ export default class Main extends BaseService { this.chainService.stopService(), this.indexingService.stopService(), this.enrichmentService.stopService(), - this.keyringService.stopService(), + this.internalSignerService.stopService(), this.nameService.stopService(), this.internalEthereumProviderService.stopService(), this.providerBridgeService.stopService(), @@ -576,7 +587,7 @@ export default class Main extends BaseService { async initializeRedux(): Promise { this.connectIndexingService() - this.connectKeyringService() + this.connectInternalSignerService() this.connectNameService() this.connectInternalEthereumProviderService() this.connectProviderBridgeService() @@ -1071,7 +1082,7 @@ export default class Main extends BaseService { } async connectSigningService(): Promise { - this.keyringService.emitter.on("address", (address) => + this.internalSignerService.emitter.on("address", (address) => this.signingService.addTrackedAddress(address, "keyring") ) @@ -1110,12 +1121,12 @@ export default class Main extends BaseService { }) } - async connectKeyringService(): Promise { - this.keyringService.emitter.on("keyrings", (keyrings) => { - this.store.dispatch(updateKeyrings(keyrings)) + async connectInternalSignerService(): Promise { + this.internalSignerService.emitter.on("internalSigners", (signers) => { + this.store.dispatch(updateInternalSigners(signers)) }) - this.keyringService.emitter.on("address", async (address) => { + this.internalSignerService.emitter.on("address", async (address) => { const trackedNetworks = await this.chainService.getTrackedNetworks() trackedNetworks.forEach((network) => { // Mark as loading and wire things up. @@ -1134,48 +1145,41 @@ export default class Main extends BaseService { }) }) - this.keyringService.emitter.on("locked", async (isLocked) => { + this.internalSignerService.emitter.on("locked", async (isLocked) => { if (isLocked) { - this.store.dispatch(keyringLocked()) + this.store.dispatch(internalSignerLocked()) } else { - this.store.dispatch(keyringUnlocked()) + this.store.dispatch(internalSignerUnlocked()) } }) - keyringSliceEmitter.on("createPassword", async (password) => { - await this.keyringService.unlock(password, true) + internalSignerSliceEmitter.on("createPassword", async (password) => { + await this.internalSignerService.unlock(password, true) }) - keyringSliceEmitter.on("lockKeyrings", async () => { - await this.keyringService.lock() + internalSignerSliceEmitter.on("lockInternalSigners", async () => { + await this.internalSignerService.lock() }) - keyringSliceEmitter.on("deriveAddress", async (keyringID) => { + internalSignerSliceEmitter.on("deriveAddress", async (keyringID) => { await this.signingService.deriveAddress({ type: "keyring", keyringID, }) }) - keyringSliceEmitter.on("generateNewKeyring", async (path) => { + internalSignerSliceEmitter.on("generateNewKeyring", async (path) => { // TODO move unlocking to a reasonable place in the initialization flow const generated: { id: string mnemonic: string[] - } = await this.keyringService.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256, + } = await this.internalSignerService.generateNewKeyring( + SignerInternalTypes.mnemonicBIP39S256, path ) this.store.dispatch(setKeyringToVerify(generated)) }) - - keyringSliceEmitter.on( - "importKeyring", - async ({ mnemonic, path, source }) => { - await this.keyringService.importKeyring(mnemonic, source, path) - } - ) } async connectInternalEthereumProviderService(): Promise { @@ -1502,6 +1506,14 @@ export default class Main extends BaseService { } ) + this.preferenceService.emitter.on( + "updateAutoLockInterval", + async (newTimerValue) => { + await this.internalSignerService.updateAutoLockInterval() + this.store.dispatch(setAutoLockInterval(newTimerValue)) + } + ) + this.preferenceService.emitter.on( "initializeShownDismissableItems", async (dismissableItems) => { @@ -1670,8 +1682,20 @@ export default class Main extends BaseService { }) } - async unlockKeyrings(password: string): Promise { - return this.keyringService.unlock(password) + async unlockInternalSigners(password: string): Promise { + return this.internalSignerService.unlock(password) + } + + async exportMnemonic(address: HexString): Promise { + return this.internalSignerService.exportMnemonic(address) + } + + async exportPrivateKey(address: HexString): Promise { + return this.internalSignerService.exportPrivateKey(address) + } + + async importSigner(signerRaw: SignerImportMetadata): Promise { + return this.internalSignerService.importSigner(signerRaw) } async getActivityDetails(txHash: string): Promise { @@ -1713,7 +1737,7 @@ export default class Main extends BaseService { This event is fired when any address on a network is added to the tracked list. Note: this does not track recovery phrase(ish) import! But when an address is used - on a network for the first time (read-only or recovery phrase/ledger/keyring). + on a network for the first time (read-only or recovery phrase/ledger/keyring/private key). `, } ) @@ -1773,6 +1797,10 @@ export default class Main extends BaseService { this.analyticsService.sendAnalyticsEvent(event) } }) + + uiSliceEmitter.on("updateAutoLockInterval", async (newTimerValue) => { + await this.preferenceService.updateAutoLockInterval(newTimerValue) + }) } async updateAssetMetadata( diff --git a/background/package.json b/background/package.json index 9583eca21e..a37bdda3d4 100644 --- a/background/package.json +++ b/background/package.json @@ -39,7 +39,7 @@ "@ledgerhq/hw-transport": "^6.20.0", "@ledgerhq/hw-transport-webusb": "^6.20.0", "@redux-devtools/remote": "^0.7.4", - "@tallyho/hd-keyring": "0.4.0", + "@tallyho/hd-keyring": "0.5.0", "@tallyho/provider-bridge-shared": "0.0.1", "@tallyho/window-provider": "0.0.1", "@types/w3c-web-usb": "^1.0.5", @@ -49,7 +49,9 @@ "@walletconnect/utils": "^2.1.4", "ajv": "^8.6.2", "ajv-formats": "^2.1.0", + "argon2-browser": "^1.18.0", "assert": "^2.0.0", + "base64-loader": "^1.0.0", "bnc-sdk": "^3.4.1", "dayjs": "^1.10.7", "dexie": "^3.0.3", @@ -64,6 +66,7 @@ }, "devDependencies": { "@reduxjs/toolkit": "^1.6.1", + "@types/argon2-browser": "^1.18.1", "@types/sinon": "^10.0.12", "@types/uuid": "^8.3.4", "@types/webextension-polyfill": "^0.8.0", diff --git a/background/redux-slices/abilities.ts b/background/redux-slices/abilities.ts index 9d63a596b1..21dcb968cf 100644 --- a/background/redux-slices/abilities.ts +++ b/background/redux-slices/abilities.ts @@ -1,7 +1,7 @@ import { createSlice } from "@reduxjs/toolkit" import { Ability, ABILITY_TYPES_ENABLED } from "../abilities" import { HexString, NormalizedEVMAddress } from "../types" -import { KeyringsState } from "./keyrings" +import { InternalSignerState } from "./internal-signer" import { LedgerState } from "./ledger" import { createBackgroundAsyncThunk } from "./utils" @@ -16,10 +16,20 @@ const isLedgerAccount = ( .includes(address) const isImportOrInternalAccount = ( - keyrings: KeyringsState, + internalSigner: InternalSignerState, address: NormalizedEVMAddress ): boolean => - keyrings.keyrings.flatMap(({ addresses }) => addresses).includes(address) + internalSigner.keyrings + .flatMap(({ addresses }) => addresses) + .includes(address) + +const isPrivateKeyAccount = ( + internalSigner: InternalSignerState, + address: NormalizedEVMAddress +): boolean => + internalSigner.privateKeys + .flatMap(({ addresses }) => addresses) + .includes(address) export type State = "open" | "completed" | "expired" | "deleted" | "all" @@ -169,13 +179,14 @@ export const initAbilities = createBackgroundAsyncThunk( address: NormalizedEVMAddress, { dispatch, getState, extra: { main } } ) => { - const { ledger, keyrings, abilities } = getState() as { + const { ledger, internalSigner, abilities } = getState() as { ledger: LedgerState - keyrings: KeyringsState + internalSigner: InternalSignerState abilities: AbilitiesState } if ( - isImportOrInternalAccount(keyrings, address) || + isImportOrInternalAccount(internalSigner, address) || + isPrivateKeyAccount(internalSigner, address) || isLedgerAccount(ledger, address) ) { await main.pollForAbilities(address) diff --git a/background/redux-slices/accounts.ts b/background/redux-slices/accounts.ts index 126532658c..2a3fdca0ce 100644 --- a/background/redux-slices/accounts.ts +++ b/background/redux-slices/accounts.ts @@ -31,11 +31,20 @@ import { convertFixedPoint } from "../lib/fixed-point" */ export const enum AccountType { ReadOnly = "read-only", + PrivateKey = "private-key", Imported = "imported", Ledger = "ledger", Internal = "internal", } +export const accountTypes = [ + AccountType.Internal, + AccountType.Imported, + AccountType.PrivateKey, + AccountType.Ledger, + AccountType.ReadOnly, +] + export const DEFAULT_ACCOUNT_NAMES = [ "Phoenix", "Matilda", diff --git a/background/redux-slices/index.ts b/background/redux-slices/index.ts index 13d6a396b7..2e45e6c9ad 100644 --- a/background/redux-slices/index.ts +++ b/background/redux-slices/index.ts @@ -3,7 +3,7 @@ import { combineReducers } from "redux" import accountsReducer from "./accounts" import assetsReducer from "./assets" import activitiesReducer from "./activities" -import keyringsReducer from "./keyrings" +import internalSignerReducer from "./internal-signer" import networksReducer from "./networks" import swapReducer from "./0x-swap" import abilitiesReducer from "./abilities" @@ -20,7 +20,7 @@ const mainReducer = combineReducers({ account: accountsReducer, assets: assetsReducer, activities: activitiesReducer, - keyrings: keyringsReducer, + internalSigner: internalSignerReducer, networks: networksReducer, swap: swapReducer, transactionConstruction: transactionConstructionReducer, diff --git a/background/redux-slices/internal-signer.ts b/background/redux-slices/internal-signer.ts new file mode 100644 index 0000000000..6dc285eb50 --- /dev/null +++ b/background/redux-slices/internal-signer.ts @@ -0,0 +1,174 @@ +import { createSlice } from "@reduxjs/toolkit" +import Emittery from "emittery" + +import { createBackgroundAsyncThunk } from "./utils" +import { + Keyring, + PrivateKey, + SignerImportMetadata, + SignerImportSource, +} from "../services/internal-signer/index" +import { HexString } from "../types" +import { UIState, setNewSelectedAccount } from "./ui" + +type KeyringToVerify = { + id: string + mnemonic: string[] +} | null + +export type InternalSignerState = { + keyrings: Keyring[] + privateKeys: PrivateKey[] + metadata: { + [keyringId: string]: { source: SignerImportSource } + } + status: "locked" | "unlocked" | "uninitialized" + keyringToVerify: KeyringToVerify +} + +export const initialState: InternalSignerState = { + keyrings: [], + privateKeys: [], + metadata: {}, + status: "uninitialized", + keyringToVerify: null, +} + +export type Events = { + createPassword: string + lockInternalSigners: never + generateNewKeyring: string | undefined + deriveAddress: string +} + +export const emitter = new Emittery() + +export const importSigner = createBackgroundAsyncThunk( + "internalSigner/importSigner", + async ( + signerRaw: SignerImportMetadata, + { getState, dispatch, extra: { main } } + ): Promise<{ success: boolean; errorMessage?: string }> => { + const address = await main.importSigner(signerRaw) + + if (!address) { + return { + success: false, + errorMessage: "Unexpected error during account import.", + } + } + + const { ui } = getState() as { + ui: UIState + } + + dispatch( + setNewSelectedAccount({ + address, + network: ui.selectedAccount.network, + }) + ) + + return { success: true } + } +) + +const internalSignerSlice = createSlice({ + name: "internalSigner", + initialState, + reducers: { + internalSignerLocked: (state) => ({ ...state, status: "locked" }), + internalSignerUnlocked: (state) => ({ ...state, status: "unlocked" }), + updateInternalSigners: ( + state, + { + payload: { privateKeys, keyrings, metadata }, + }: { + payload: { + privateKeys: PrivateKey[] + keyrings: Keyring[] + metadata: { [keyringId: string]: { source: SignerImportSource } } + } + } + ) => { + // When the InternalSigner service is locked, we receive `updateInternalSigners` with + // `privateKeys` and `keyrings` being empty lists as the InternalSigner service clears + // the in-memory keyrings and private keys. + // For UI purposes, however, we want to continue tracking the metadata, + // so we ignore an empty list if the service is locked. + if (state.status === "locked") { + return state + } + + return { + ...state, + keyrings, + privateKeys, + metadata, + } + }, + setKeyringToVerify: (state, { payload }: { payload: KeyringToVerify }) => ({ + ...state, + keyringToVerify: payload, + }), + }, +}) + +export const { + updateInternalSigners, + internalSignerLocked, + internalSignerUnlocked, + setKeyringToVerify, +} = internalSignerSlice.actions + +export default internalSignerSlice.reducer + +// Async thunk to bubble the generateNewKeyring action from store to emitter. +export const generateNewKeyring = createBackgroundAsyncThunk( + "internalSigner/generateNewKeyring", + async (path?: string) => { + await emitter.emit("generateNewKeyring", path) + } +) + +export const deriveAddress = createBackgroundAsyncThunk( + "internalSigner/deriveAddress", + async (id: string) => { + await emitter.emit("deriveAddress", id) + } +) + +export const unlockInternalSigners = createBackgroundAsyncThunk( + "internalSigner/unlockInternalSigners", + async (password: string, { extra: { main } }) => { + return { success: await main.unlockInternalSigners(password) } + } +) + +export const lockInternalSigners = createBackgroundAsyncThunk( + "internalSigner/lockInternalSigners", + async () => { + await emitter.emit("lockInternalSigners") + } +) + +export const createPassword = createBackgroundAsyncThunk( + "internalSigner/createPassword", + async (password: string) => { + await emitter.emit("createPassword", password) + } +) + +export const exportMnemonic = createBackgroundAsyncThunk( + "internalSigner/exportMnemonic", + async (address: HexString, { extra: { main } }) => { + return main.exportMnemonic(address) + } +) + +export const exportPrivateKey = createBackgroundAsyncThunk( + "internalSigner/exportPrivateKey", + async (address: HexString, { extra: { main } }) => { + return main.exportPrivateKey(address) + } +) diff --git a/background/redux-slices/keyrings.ts b/background/redux-slices/keyrings.ts deleted file mode 100644 index 9b74c373fb..0000000000 --- a/background/redux-slices/keyrings.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit" -import Emittery from "emittery" - -import { setNewSelectedAccount, UIState } from "./ui" -import { createBackgroundAsyncThunk } from "./utils" -import { Keyring, KeyringMetadata } from "../services/keyring/index" - -type KeyringToVerify = { - id: string - mnemonic: string[] -} | null - -export type KeyringsState = { - keyrings: Keyring[] - keyringMetadata: { - [keyringId: string]: KeyringMetadata - } - importing: false | "pending" | "done" - status: "locked" | "unlocked" | "uninitialized" - keyringToVerify: KeyringToVerify -} - -export const initialState: KeyringsState = { - keyrings: [], - keyringMetadata: {}, - importing: false, - status: "uninitialized", - keyringToVerify: null, -} - -export type Events = { - createPassword: string - lockKeyrings: never - generateNewKeyring: string | undefined - deriveAddress: string - importKeyring: ImportKeyring -} - -export const emitter = new Emittery() - -interface ImportKeyring { - mnemonic: string - source: "internal" | "import" - path?: string -} - -// Async thunk to bubble the importKeyring action from store to emitter. -export const importKeyring = createBackgroundAsyncThunk( - "keyrings/importKeyring", - async ({ mnemonic, source, path }: ImportKeyring, { getState, dispatch }) => { - await emitter.emit("importKeyring", { mnemonic, path, source }) - - const { keyrings, ui } = getState() as { - keyrings: KeyringsState - ui: UIState - } - // Set the selected account as the first address of the last added keyring, - // which will correspond to the last imported keyring, AKA this one. Note that - // this does rely on the KeyringService's behavior of pushing new keyrings to - // the end of the keyring list. - dispatch( - setNewSelectedAccount({ - address: keyrings.keyrings.slice(-1)[0].addresses[0], - network: ui.selectedAccount.network, - }) - ) - } -) - -const keyringsSlice = createSlice({ - name: "keyrings", - initialState, - reducers: { - keyringLocked: (state) => ({ ...state, status: "locked" }), - keyringUnlocked: (state) => ({ ...state, status: "unlocked" }), - updateKeyrings: ( - state, - { - payload: { keyrings, keyringMetadata }, - }: { - payload: { - keyrings: Keyring[] - keyringMetadata: { [keyringId: string]: KeyringMetadata } - } - } - ) => { - // When the keyrings are locked, we receive updateKeyrings with an empty - // list as the keyring service clears the in-memory keyrings. For UI - // purposes, however, we want to continue tracking the keyring metadata, - // so we ignore an empty list if the keyrings are locked. - if (keyrings.length === 0 && state.status === "locked") { - return state - } - - return { - ...state, - keyrings, - keyringMetadata, - } - }, - setKeyringToVerify: (state, { payload }: { payload: KeyringToVerify }) => ({ - ...state, - keyringToVerify: payload, - }), - }, - extraReducers: (builder) => { - builder - .addCase(importKeyring.pending, (state) => { - return { - ...state, - importing: "pending", - } - }) - .addCase(importKeyring.fulfilled, (state) => { - return { - ...state, - importing: "done", - keyringToVerify: null, - } - }) - }, -}) - -export const { - updateKeyrings, - keyringLocked, - keyringUnlocked, - setKeyringToVerify, -} = keyringsSlice.actions - -export default keyringsSlice.reducer - -// Async thunk to bubble the generateNewKeyring action from store to emitter. -export const generateNewKeyring = createBackgroundAsyncThunk( - "keyrings/generateNewKeyring", - async (path?: string) => { - await emitter.emit("generateNewKeyring", path) - } -) - -export const deriveAddress = createBackgroundAsyncThunk( - "keyrings/deriveAddress", - async (id: string) => { - await emitter.emit("deriveAddress", id) - } -) - -export const unlockKeyrings = createBackgroundAsyncThunk( - "keyrings/unlockKeyrings", - async (password: string, { extra: { main } }) => { - return { success: await main.unlockKeyrings(password) } - } -) - -export const lockKeyrings = createBackgroundAsyncThunk( - "keyrings/lockKeyrings", - async () => { - await emitter.emit("lockKeyrings") - } -) - -export const createPassword = createBackgroundAsyncThunk( - "keyrings/createPassword", - async (password: string) => { - await emitter.emit("createPassword", password) - } -) diff --git a/background/redux-slices/migrations/index.ts b/background/redux-slices/migrations/index.ts index e68823df87..8f4e1867d0 100644 --- a/background/redux-slices/migrations/index.ts +++ b/background/redux-slices/migrations/index.ts @@ -28,12 +28,14 @@ import to28 from "./to-28" import to29 from "./to-29" import to30 from "./to-30" import to31 from "./to-31" +import to32 from "./to-32" +import to33 from "./to-33" /** * The version of persisted Redux state the extension is expecting. Any previous * state without this version, or with a lower version, ought to be migrated. */ -export const REDUX_STATE_VERSION = 31 +export const REDUX_STATE_VERSION = 33 /** * Common type for all migration functions. @@ -74,6 +76,8 @@ const allMigrations: { [targetVersion: string]: Migration } = { 29: to29, 30: to30, 31: to31, + 32: to32, + 33: to33, } /** diff --git a/background/redux-slices/migrations/to-14.ts b/background/redux-slices/migrations/to-14.ts index 3ebc42843c..3495855d80 100644 --- a/background/redux-slices/migrations/to-14.ts +++ b/background/redux-slices/migrations/to-14.ts @@ -5,7 +5,7 @@ export default ( prevState: Record ): Record => { - const { assets, ...newState } = prevState + const { assets: _, ...newState } = prevState // Clear assets collection; these should be immediately repopulated by the // IndexingService in startService. diff --git a/background/redux-slices/migrations/to-16.ts b/background/redux-slices/migrations/to-16.ts index d409011a43..94d359c9d4 100644 --- a/background/redux-slices/migrations/to-16.ts +++ b/background/redux-slices/migrations/to-16.ts @@ -96,8 +96,11 @@ export default (prevState: Record): NewState => { : {}, } - const { spenderName, spenderAddress, ...oldAnnotationProps } = - annotation + const { + spenderName: __, + spenderAddress: ___, + ...oldAnnotationProps + } = annotation newState.activities[address][chainID].entities[ activityItem.hash diff --git a/background/redux-slices/migrations/to-19.ts b/background/redux-slices/migrations/to-19.ts index 236d36caf6..f2be1db7c5 100644 --- a/background/redux-slices/migrations/to-19.ts +++ b/background/redux-slices/migrations/to-19.ts @@ -1,7 +1,7 @@ export default ( prevState: Record ): Record => { - const { activities, ...newState } = prevState + const { activities: _, ...newState } = prevState // Clear activities slice as we now have new activities slice instead newState.activities = {} diff --git a/background/redux-slices/migrations/to-22.ts b/background/redux-slices/migrations/to-22.ts index 3ebc42843c..3495855d80 100644 --- a/background/redux-slices/migrations/to-22.ts +++ b/background/redux-slices/migrations/to-22.ts @@ -5,7 +5,7 @@ export default ( prevState: Record ): Record => { - const { assets, ...newState } = prevState + const { assets: _, ...newState } = prevState // Clear assets collection; these should be immediately repopulated by the // IndexingService in startService. diff --git a/background/redux-slices/migrations/to-29.ts b/background/redux-slices/migrations/to-29.ts index b727bae737..19b79987ba 100644 --- a/background/redux-slices/migrations/to-29.ts +++ b/background/redux-slices/migrations/to-29.ts @@ -4,7 +4,7 @@ export default ( prevState: Record ): Record => { - const { assets, ...newState } = prevState + const { assets: _, ...newState } = prevState // Clear assets collection; these should be immediately repopulated by the // IndexingService in startService. diff --git a/background/redux-slices/migrations/to-3.ts b/background/redux-slices/migrations/to-3.ts index 3ebc42843c..3495855d80 100644 --- a/background/redux-slices/migrations/to-3.ts +++ b/background/redux-slices/migrations/to-3.ts @@ -5,7 +5,7 @@ export default ( prevState: Record ): Record => { - const { assets, ...newState } = prevState + const { assets: _, ...newState } = prevState // Clear assets collection; these should be immediately repopulated by the // IndexingService in startService. diff --git a/background/redux-slices/migrations/to-30.ts b/background/redux-slices/migrations/to-30.ts index 04e0fc0ed6..2a0aab83e4 100644 --- a/background/redux-slices/migrations/to-30.ts +++ b/background/redux-slices/migrations/to-30.ts @@ -11,7 +11,7 @@ type NewState = { // Remove old nfts slice and rename updated nfts slice export default (prevState: Record): NewState => { - const { nfts, nftsUpdate, ...otherState } = prevState as OldState + const { nfts: _, nftsUpdate, ...otherState } = prevState as OldState return { ...otherState, diff --git a/background/redux-slices/migrations/to-31.ts b/background/redux-slices/migrations/to-31.ts index 22651e20a5..a7b514891a 100644 --- a/background/redux-slices/migrations/to-31.ts +++ b/background/redux-slices/migrations/to-31.ts @@ -3,12 +3,14 @@ type PrevState = { accountsData: { evm: { [chainID: string]: { - [address: string]: { - balances: { - [symbol: string]: unknown - } - [other: string]: unknown - } + [address: string]: + | "loading" + | { + balances: { + [symbol: string]: unknown + } + [other: string]: unknown + } } } } @@ -21,12 +23,14 @@ type NewState = { accountsData: { evm: { [chainID: string]: { - [address: string]: { - balances: { - [assetID: string]: unknown - } - [other: string]: unknown - } + [address: string]: + | "loading" + | { + balances: { + [assetID: string]: unknown + } + [other: string]: unknown + } } } } @@ -43,8 +47,12 @@ export default (prevState: Record): NewState => { Object.keys(accountsData.evm).forEach((chainID) => Object.keys(accountsData.evm[chainID]).forEach((address) => { - // Clear all accounts cached balances - accountsData.evm[chainID][address].balances = {} + const account = accountsData.evm[chainID][address] + + if (account !== "loading") { + // Clear all accounts cached balances + account.balances = {} + } }) ) diff --git a/background/redux-slices/migrations/to-32.ts b/background/redux-slices/migrations/to-32.ts new file mode 100644 index 0000000000..d9d750d466 --- /dev/null +++ b/background/redux-slices/migrations/to-32.ts @@ -0,0 +1,40 @@ +type OldState = { + keyrings: { + keyringMetadata: { + [keyringId: string]: { + source: "import" | "internal" + } + } + importing: false | "pending" | "done" | "failed" + [sliceKey: string]: unknown + } +} + +type NewState = { + internalSigner: { + metadata: { + [keyringId: string]: { + source: "import" | "internal" + } + } + privateKeys: { type: "single#secp256k1"; path: null; addresses: [string] }[] + [sliceKey: string]: unknown + } +} + +export default (prevState: Record): NewState => { + const oldState = prevState as OldState + const { + keyrings: { keyringMetadata, importing: _, ...keyringsState }, + ...stateWithoutKeyrings + } = oldState + + return { + ...stateWithoutKeyrings, + internalSigner: { + ...keyringsState, + metadata: keyringMetadata, + privateKeys: [], + }, + } +} diff --git a/background/redux-slices/migrations/to-33.ts b/background/redux-slices/migrations/to-33.ts new file mode 100644 index 0000000000..955dab8c1b --- /dev/null +++ b/background/redux-slices/migrations/to-33.ts @@ -0,0 +1,39 @@ +import { MINUTE } from "../../constants" + +const DEFAULT_AUTOLOCK_INTERVAL = 60 * MINUTE + +type OldState = { + ui: { + settings: { + [settingsKey: string]: unknown + } + [sliceKey: string]: unknown + } + [otherSlice: string]: unknown +} + +type NewState = { + ui: { + settings: { + [settingsKey: string]: unknown + autoLockInterval: number + } + [sliceKey: string]: unknown + } + [otherSlice: string]: unknown +} + +export default (prevState: Record): NewState => { + const typedPrevState = prevState as OldState + + return { + ...prevState, + ui: { + ...typedPrevState.ui, + settings: { + ...typedPrevState.ui.settings, + autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, + }, + }, + } +} diff --git a/background/redux-slices/migrations/to-4.ts b/background/redux-slices/migrations/to-4.ts index 39907c8ac5..2fc70507d8 100644 --- a/background/redux-slices/migrations/to-4.ts +++ b/background/redux-slices/migrations/to-4.ts @@ -39,7 +39,7 @@ export default ( }, } - const { blocks, ...oldStateAccountWithoutBlocks } = oldState.account ?? { + const { blocks: _, ...oldStateAccountWithoutBlocks } = oldState.account ?? { blocks: undefined, } diff --git a/background/redux-slices/selectors/accountsSelectors.ts b/background/redux-slices/selectors/accountsSelectors.ts index 18b01c7150..b9b9dd5224 100644 --- a/background/redux-slices/selectors/accountsSelectors.ts +++ b/background/redux-slices/selectors/accountsSelectors.ts @@ -35,17 +35,14 @@ import { selectAccountSignersByAddress } from "./signingSelectors" import { selectKeyringsByAddresses, selectSourcesByAddress, -} from "./keyringsSelectors" +} from "./internalSignerSelectors" import { AccountBalance, AddressOnNetwork } from "../../accounts" import { EVMNetwork, sameNetwork } from "../../networks" import { NETWORK_BY_CHAIN_ID, TEST_NETWORK_BY_CHAIN_ID } from "../../constants" import { DOGGO } from "../../constants/assets" import { FeatureFlags, isEnabled } from "../../features" -import { - AccountSigner, - ReadOnlyAccountSigner, - SignerType, -} from "../../services/signing" +import { AccountSigner, SignerType } from "../../services/signing" +import { SignerImportSource } from "../../services/internal-signer" import { assertUnreachable } from "../../lib/utils/type-guards" // TODO What actual precision do we want here? Probably more than 2 @@ -333,6 +330,8 @@ export type AccountTotal = AddressOnNetwork & { */ function signerIdFor(accountSigner: AccountSigner): string | null { switch (accountSigner.type) { + case "private-key": + return accountSigner.walletID case "keyring": return accountSigner.keyringID case "ledger": @@ -348,6 +347,7 @@ export type CategorizedAccountTotals = { [key in AccountType]?: AccountTotal[] } const signerTypeToAccountType: Record = { keyring: AccountType.Imported, + "private-key": AccountType.PrivateKey, ledger: AccountType.Ledger, "read-only": AccountType.ReadOnly, } @@ -356,19 +356,19 @@ const getAccountType = ( address: string, signer: AccountSigner, addressSources: { - [address: string]: "import" | "internal" + [address: string]: SignerImportSource } ): AccountType => { - if (signer === ReadOnlyAccountSigner) { - return AccountType.ReadOnly - } - if (signerTypeToAccountType[signer.type] === "ledger") { - return AccountType.Ledger - } - if (addressSources[address] === "import") { - return AccountType.Imported + switch (true) { + case signerTypeToAccountType[signer.type] === AccountType.ReadOnly: + case signerTypeToAccountType[signer.type] === AccountType.Ledger: + case signerTypeToAccountType[signer.type] === AccountType.PrivateKey: + return signerTypeToAccountType[signer.type] + case addressSources[address] === SignerImportSource.import: + return AccountType.Imported + default: + return AccountType.Internal } - return AccountType.Internal } const getTotalBalance = ( diff --git a/background/redux-slices/selectors/index.ts b/background/redux-slices/selectors/index.ts index 0cbcbd3dc5..72ee38a1ca 100644 --- a/background/redux-slices/selectors/index.ts +++ b/background/redux-slices/selectors/index.ts @@ -1,6 +1,6 @@ export * from "./activitiesSelectors" export * from "./accountsSelectors" -export * from "./keyringsSelectors" +export * from "./internalSignerSelectors" export * from "./signingSelectors" export * from "./dappSelectors" export * from "./uiSelectors" diff --git a/background/redux-slices/selectors/keyringsSelectors.ts b/background/redux-slices/selectors/internalSignerSelectors.ts similarity index 60% rename from background/redux-slices/selectors/keyringsSelectors.ts rename to background/redux-slices/selectors/internalSignerSelectors.ts index 9c4f0151fe..2bc0dd8b03 100644 --- a/background/redux-slices/selectors/keyringsSelectors.ts +++ b/background/redux-slices/selectors/internalSignerSelectors.ts @@ -1,10 +1,14 @@ import { createSelector, OutputSelector } from "@reduxjs/toolkit" import { RootState } from ".." -import { Keyring } from "../../services/keyring" +import { + Keyring, + PrivateKey, + SignerImportSource, +} from "../../services/internal-signer" import { HexString } from "../../types" -export const selectKeyringStatus = createSelector( - (state: RootState) => state.keyrings.status, +export const selectInternalSignerStatus = createSelector( + (state: RootState) => state.internalSigner.status, (status) => status ) @@ -16,7 +20,7 @@ export const selectKeyringByAddress = ( (res: Keyring[]) => Keyring | undefined > => createSelector( - [(state: RootState) => state.keyrings.keyrings], + [(state: RootState) => state.internalSigner.keyrings], (keyrings) => { const kr = keyrings.find((keyring) => keyring.addresses.includes(address)) return kr @@ -24,7 +28,7 @@ export const selectKeyringByAddress = ( ) export const selectKeyringsByAddresses = createSelector( - (state: RootState) => state.keyrings.keyrings, + (state: RootState) => state.internalSigner.keyrings, ( keyrings ): { @@ -37,14 +41,20 @@ export const selectKeyringsByAddresses = createSelector( ) ) +export const selectPrivateKeyWalletsByAddress = createSelector( + (state: RootState) => state.internalSigner.privateKeys, + (pkWallets): { [address: HexString]: PrivateKey } => + Object.fromEntries(pkWallets.map((wallet) => [wallet.addresses[0], wallet])) +) + export const selectSourcesByAddress = createSelector( - (state: RootState) => state.keyrings.keyrings, - (state: RootState) => state.keyrings.keyringMetadata, + (state: RootState) => state.internalSigner.keyrings, + (state: RootState) => state.internalSigner.metadata, ( keyrings, - keyringMetadata + metadata ): { - [address: HexString]: "import" | "internal" + [address: HexString]: SignerImportSource } => Object.fromEntries( keyrings @@ -55,7 +65,7 @@ export const selectSourcesByAddress = createSelector( address, // Guaranteed to exist by the filter above // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - keyringMetadata[keyring.id!]?.source, + metadata[keyring.id!]?.source, ]) ) ) diff --git a/background/redux-slices/selectors/nftsSelectors.ts b/background/redux-slices/selectors/nftsSelectors.ts index 62da6a3db2..5e351f3c14 100644 --- a/background/redux-slices/selectors/nftsSelectors.ts +++ b/background/redux-slices/selectors/nftsSelectors.ts @@ -3,7 +3,6 @@ import { RootState } from ".." import { normalizeEVMAddress } from "../../lib/utils" import { Filter } from "../nfts" import { - AccountData, getAdditionalDataForFilter, getFilteredCollections, getNFTsCount, @@ -46,7 +45,7 @@ export const selectEnrichedNFTFilters = createSelector( const accounts = filters.accounts.reduce((acc, filter) => { const additionalData = getAdditionalDataForFilter( filter.id, - accountTotals as AccountData[] + accountTotals ) if (Object.keys(additionalData).length > 0) { return [ diff --git a/background/redux-slices/selectors/signingSelectors.ts b/background/redux-slices/selectors/signingSelectors.ts index d3a30973c4..adf657e2ce 100644 --- a/background/redux-slices/selectors/signingSelectors.ts +++ b/background/redux-slices/selectors/signingSelectors.ts @@ -1,11 +1,17 @@ import { createSelector } from "@reduxjs/toolkit" import { RootState } from ".." import { isDefined } from "../../lib/utils/type-guards" -import { KeyringAccountSigner } from "../../services/keyring" +import { + KeyringAccountSigner, + PrivateKeyAccountSigner, +} from "../../services/internal-signer" import { LedgerAccountSigner } from "../../services/ledger" import { AccountSigner, ReadOnlyAccountSigner } from "../../services/signing" import { HexString } from "../../types" -import { selectKeyringsByAddresses } from "./keyringsSelectors" +import { + selectKeyringsByAddresses, + selectPrivateKeyWalletsByAddress, +} from "./internalSignerSelectors" import { selectCurrentAccount } from "./uiSelectors" // FIXME: This has a duplicate in `accountSelectors.ts`, but importing causes a dependency cycle @@ -24,7 +30,13 @@ export const selectAccountSignersByAddress = createSelector( getAllAddresses, (state: RootState) => state.ledger.devices, selectKeyringsByAddresses, - (allAddresses, ledgerDevices, keyringsByAddress) => { + selectPrivateKeyWalletsByAddress, + ( + allAddresses, + ledgerDevices, + keyringsByAddress, + privateKeyWalletsByAddress + ) => { const allAccountsSeen = new Set() const ledgerEntries = Object.values(ledgerDevices).flatMap((device) => Object.values(device.accounts).flatMap( @@ -62,6 +74,28 @@ export const selectAccountSignersByAddress = createSelector( ) .filter(isDefined) + const privateKeyEntries = Object.entries(privateKeyWalletsByAddress) + .map( + ([address, wallet]): + | [HexString, PrivateKeyAccountSigner] + | undefined => { + if (wallet.id === null) { + return undefined + } + + allAccountsSeen.add(address) + + return [ + address, + { + type: "private-key", + walletID: wallet.id, + }, + ] + } + ) + .filter(isDefined) + const readOnlyEntries: [string, typeof ReadOnlyAccountSigner][] = allAddresses .filter((address) => !allAccountsSeen.has(address)) @@ -69,8 +103,9 @@ export const selectAccountSignersByAddress = createSelector( const entriesByPriority: [string, AccountSigner][] = [ ...readOnlyEntries, + ...privateKeyEntries, ...ledgerEntries, - // Give priority to keyring over Ledger, if an address is signable by + // Give priority to keyring over Ledger and private key, if an address is signable by // both. ...keyringEntries, ] diff --git a/background/redux-slices/ui.ts b/background/redux-slices/ui.ts index b5bfeb6ebb..5a0bdb9e07 100644 --- a/background/redux-slices/ui.ts +++ b/background/redux-slices/ui.ts @@ -10,6 +10,8 @@ import { AccountSignerWithId } from "../signing" import { AccountSignerSettings } from "../ui" import { AccountState, addAddressNetwork } from "./accounts" import { createBackgroundAsyncThunk } from "./utils" +import { UNIXTime } from "../types" +import { DEFAULT_AUTOLOCK_INTERVAL } from "../services/preferences/defaults" export const defaultSettings = { hideDust: false, @@ -19,6 +21,7 @@ export const defaultSettings = { showAnalyticsNotification: false, showUnverifiedAssets: false, hideBanners: false, + autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, } export type UIState = { @@ -35,6 +38,7 @@ export type UIState = { showAnalyticsNotification: boolean showUnverifiedAssets: boolean hideBanners: boolean + autoLockInterval: UNIXTime } snackbarMessage: string routeHistoryEntries?: Partial[] @@ -54,6 +58,7 @@ export type Events = { newSelectedNetwork: EVMNetwork updateAnalyticsPreferences: Partial addCustomNetworkResponse: [string, boolean] + updateAutoLockInterval: number } export const emitter = new Emittery() @@ -201,6 +206,12 @@ const uiSlice = createSlice({ ) => { return { ...state, accountSignerSettings: payload } }, + setAutoLockInterval: (state, { payload }: { payload: number }) => { + return { + ...state, + settings: { ...state.settings, autoLockInterval: payload }, + } + }, }, }) @@ -222,6 +233,7 @@ export const { setRouteHistoryEntries, setSlippageTolerance, setAccountsSignerSettings, + setAutoLockInterval, } = uiSlice.actions export default uiSlice.reducer @@ -295,6 +307,19 @@ export const addNetworkUserResponse = createBackgroundAsyncThunk( } ) +export const updateAutoLockInterval = createBackgroundAsyncThunk( + "ui/updateAutoLockInterval", + async (newValue: string) => { + const parsedValue = parseInt(newValue, 10) + + if (Number.isNaN(parsedValue) || parsedValue <= 1) { + throw new Error("Invalid value for auto lock timer") + } + + emitter.emit("updateAutoLockInterval", parsedValue) + } +) + export const userActivityEncountered = createBackgroundAsyncThunk( "ui/userActivityEncountered", async (addressNetwork: AddressOnNetwork) => { @@ -348,6 +373,11 @@ export const selectHideDust = createSelector( (settings) => settings?.hideDust ) +export const selectAutoLockTimer = createSelector( + selectSettings, + (settings) => settings.autoLockInterval +) + export const selectSnackbarMessage = createSelector( selectUI, (ui) => ui.snackbarMessage diff --git a/background/redux-slices/utils.ts b/background/redux-slices/utils.ts index 574cf499e1..f572b32ea6 100644 --- a/background/redux-slices/utils.ts +++ b/background/redux-slices/utils.ts @@ -68,6 +68,16 @@ const asyncThunkProperties = (() => { return exhaustiveList })() +// Extracts a @reduxjs/toolkit internal type for type alignment in the below +// function types. +type AsyncThunkConfig = ReturnType extends AsyncThunk< + infer _, + infer __, + infer T +> + ? T + : never + /** * Create an async thunk action that will always run in the background script, * and dispatches lifecycle actions (pending, fulfilled, rejected) on the @@ -108,7 +118,7 @@ export function createBackgroundAsyncThunk< TypePrefix extends string, Returned, ThunkArg = void, - ThunkApiConfig = { extra: { main: Main } } + ThunkApiConfig extends AsyncThunkConfig = { extra: { main: Main } } >( typePrefix: TypePrefix, payloadCreator: AsyncThunkPayloadCreator, diff --git a/background/redux-slices/utils/nfts-utils.ts b/background/redux-slices/utils/nfts-utils.ts index 72f0482f5b..b01053fc80 100644 --- a/background/redux-slices/utils/nfts-utils.ts +++ b/background/redux-slices/utils/nfts-utils.ts @@ -7,16 +7,11 @@ import { NFTCollectionCached, SortType, } from "../nfts" +import { AccountTotal } from "../selectors/accountsSelectors" import { enrichAssetAmountWithMainCurrencyValues } from "./asset-utils" const ETH_SYMBOLS = ["ETH", "WETH"] -export type AccountData = { - address: string - name: string - avatarURL: string -} - type NFTCollectionEnriched = NFTCollectionCached & { floorPrice?: { value: number @@ -35,10 +30,10 @@ const isETHPrice = (collection: NFTCollectionCached): boolean => { export const getAdditionalDataForFilter = ( id: string, - accounts: AccountData[] + accounts: AccountTotal[] ): { name?: string; thumbnailURL?: string } => { const a = accounts.find(({ address }) => address === id) - return a ? { name: a.name, thumbnailURL: a.avatarURL } : {} + return a ? { name: a.name ?? a.address, thumbnailURL: a.avatarURL } : {} } /* Items are sorted by price in USD. All other elements are added at the end. */ diff --git a/background/services/chain/index.ts b/background/services/chain/index.ts index ba3488df79..7c1f6e1fed 100644 --- a/background/services/chain/index.ts +++ b/background/services/chain/index.ts @@ -67,7 +67,7 @@ import { OPTIMISM_GAS_ORACLE_ABI, OPTIMISM_GAS_ORACLE_ADDRESS, } from "./utils/optimismGasPriceOracle" -import KeyringService from "../keyring" +import InternalSignerService, { SignerImportSource } from "../internal-signer" import type { ValidatedAddEthereumChainParameter } from "../provider-bridge/utils" // The number of blocks to query at a time for historic asset transfers. @@ -112,10 +112,7 @@ interface Events extends ServiceLifecycleEvents { transactions: Transaction[] account: AddressOnNetwork } - newAccountToTrack: { - addressOnNetwork: AddressOnNetwork - source: "import" | "internal" | null - } + newAccountToTrack: AddressOnNetwork supportedNetworks: EVMNetwork[] accountsWithBalances: { /** @@ -231,9 +228,13 @@ export default class ChainService extends BaseService { static create: ServiceCreatorFunction< Events, ChainService, - [Promise, Promise] - > = async (preferenceService, keyringService) => { - return new this(createDB(), await preferenceService, await keyringService) + [Promise, Promise] + > = async (preferenceService, internalSignerService) => { + return new this( + createDB(), + await preferenceService, + await internalSignerService + ) } supportedNetworks: EVMNetwork[] = [] @@ -245,7 +246,7 @@ export default class ChainService extends BaseService { private constructor( private db: ChainDatabase, private preferenceService: PreferenceService, - private keyringService: KeyringService + private internalSignerService: InternalSignerService ) { super({ queuedTransactions: { @@ -922,7 +923,7 @@ export default class ChainService extends BaseService { } async addAccountToTrack(addressNetwork: AddressOnNetwork): Promise { - const source = await this.keyringService.getKeyringSourceForAddress( + const source = this.internalSignerService.getSignerSourceForAddress( addressNetwork.address ) const isAccountOnNetworkAlreadyTracked = @@ -930,10 +931,7 @@ export default class ChainService extends BaseService { if (!isAccountOnNetworkAlreadyTracked) { // Skip save, emit and savedTransaction emission on resubmission await this.db.addAccountToTrack(addressNetwork) - this.emitter.emit("newAccountToTrack", { - addressOnNetwork: addressNetwork, - source, - }) + this.emitter.emit("newAccountToTrack", addressNetwork) } this.emitSavedTransactions(addressNetwork) this.subscribeToAccountTransactions(addressNetwork).catch((e) => { @@ -948,7 +946,7 @@ export default class ChainService extends BaseService { e ) }) - if (source !== "internal") { + if (source !== SignerImportSource.internal) { this.loadHistoricAssetTransfers(addressNetwork).catch((e) => { logger.error( "chainService/addAccountToTrack: Error loading historic asset transfers", @@ -1644,7 +1642,7 @@ export default class ChainService extends BaseService { // Don't override an already-persisted successful status with // an expiration-based failed status, but do set status to // failure if no transaction was seen. - { status: 0, ...existingTransaction }, + { status: 0, ...existingTransaction } as AnyEVMTransaction, "local" ) } @@ -1712,6 +1710,9 @@ export default class ChainService extends BaseService { logger.error(`Error emitting tx ${finalTransaction}`, error) } if (error) { + // We don't control the errors in the whole stack, but we do want to + // rethrow them regardless. + // eslint-disable-next-line @typescript-eslint/no-throw-literal throw error } } diff --git a/background/services/doggo/index.ts b/background/services/doggo/index.ts index b86e4a7e5e..fd44ae490e 100644 --- a/background/services/doggo/index.ts +++ b/background/services/doggo/index.ts @@ -80,12 +80,9 @@ export default class DoggoService extends BaseService { // Track referrals for all added accounts and any new ones that are added // after load. - this.chainService.emitter.on( - "newAccountToTrack", - ({ addressOnNetwork }) => { - this.trackReferrals(addressOnNetwork) - } - ) + this.chainService.emitter.on("newAccountToTrack", (addressOnNetwork) => { + this.trackReferrals(addressOnNetwork) + }) ;(await this.chainService.getAccountsToTrack()).forEach( (addressOnNetwork) => { this.trackReferrals(addressOnNetwork) diff --git a/background/services/index.ts b/background/services/index.ts index 098cfc279b..d3d7097775 100644 --- a/background/services/index.ts +++ b/background/services/index.ts @@ -10,7 +10,7 @@ export { default as BaseService } from "./base" export { default as ChainService } from "./chain" export { default as EnrichmentService } from "./enrichment" export { default as IndexingService } from "./indexing" -export { default as KeyringService } from "./keyring" +export { default as InternalSignerService } from "./internal-signer" export { default as NameService } from "./name" export { default as PreferenceService } from "./preferences" export { default as ProviderBridgeService } from "./provider-bridge" diff --git a/background/services/indexing/index.ts b/background/services/indexing/index.ts index 46a86543dc..6f90ce7218 100644 --- a/background/services/indexing/index.ts +++ b/background/services/indexing/index.ts @@ -457,7 +457,7 @@ export default class IndexingService extends BaseService { this.chainService.emitter.on( "newAccountToTrack", - async ({ addressOnNetwork }) => { + async (addressOnNetwork) => { // whenever a new account is added, get token balances from Alchemy's // default list and add any non-zero tokens to the tracking list const balances = await this.retrieveTokenBalances(addressOnNetwork) diff --git a/background/services/keyring/encryption.ts b/background/services/internal-signer/encryption.ts similarity index 75% rename from background/services/keyring/encryption.ts rename to background/services/internal-signer/encryption.ts index cc004e979c..64831852ba 100644 --- a/background/services/keyring/encryption.ts +++ b/background/services/internal-signer/encryption.ts @@ -1,3 +1,4 @@ +import argon2 from "argon2-browser" /** * An encrypted vault which can be safely serialized and stored. */ @@ -10,6 +11,11 @@ export type EncryptedVault = { cipherText: string } +export enum VaultVersion { + PBKDF2 = 1, + Argon2 = 2, +} + /* * A key with a salt that can be combined with a password to re-derive the key. * @@ -21,7 +27,7 @@ export type SaltedKey = { key: CryptoKey } -function bufferToBase64(array: Uint8Array): string { +function bufferToBase64(array: Uint8Array | ArrayBuffer): string { return Buffer.from(array).toString("base64") } @@ -64,7 +70,7 @@ function requireCryptoGlobal(message?: string) { * material using AES GCM mode, as well as the salt required to derive * the key again later. */ -export async function deriveSymmetricKeyFromPassword( +export async function deprecatedDerivePbkdf2KeyFromPassword( password: string, existingSalt?: string ): Promise { @@ -101,6 +107,48 @@ export async function deriveSymmetricKeyFromPassword( } } +export async function deriveArgon2KeyFromPassword( + password: string, + existingSalt?: string +): Promise { + const { crypto } = global + + const salt = existingSalt || (await generateSalt()) + + const { hash } = await argon2.hash({ + pass: password, + salt, + hashLen: 32, + }) + + const key = await crypto.subtle.importKey( + "raw", + hash, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ) + + return { + key, + salt, + } +} + +export async function deriveSymmetricKeyFromPassword( + version: VaultVersion, + password: string, + existingSalt?: string +): Promise { + switch (version) { + case VaultVersion.PBKDF2: + return deprecatedDerivePbkdf2KeyFromPassword(password, existingSalt) + case VaultVersion.Argon2: + return deriveArgon2KeyFromPassword(password, existingSalt) + default: + throw new Error(`Unsupported vault version: ${version}`) + } +} /** * Encrypt a JSON-serializable object with a supplied password using AES GCM * mode. @@ -111,16 +159,18 @@ export async function deriveSymmetricKeyFromPassword( * @returns the ciphertext and all non-password material required for later * decryption, including the salt and AES initialization vector. */ -export async function encryptVault( - vault: V, +export async function encryptVault(vaultData: { + vault: V passwordOrSaltedKey: string | SaltedKey -): Promise { + version: VaultVersion +}): Promise { requireCryptoGlobal("Encrypting a vault") const { crypto } = global + const { vault, passwordOrSaltedKey, version } = vaultData const { key, salt } = typeof passwordOrSaltedKey === "string" - ? await deriveSymmetricKeyFromPassword(passwordOrSaltedKey) + ? await deriveSymmetricKeyFromPassword(version, passwordOrSaltedKey) : passwordOrSaltedKey const encoder = new TextEncoder() @@ -159,10 +209,12 @@ export async function encryptVault( * most objects `decryptVault(encryptVault(o, password), password)` * should deeply equal `o`. */ -export async function decryptVault( - vault: EncryptedVault, +export async function decryptVault(vaultData: { + vault: EncryptedVault passwordOrSaltedKey: string | SaltedKey -): Promise { + version: VaultVersion +}): Promise { + const { vault, passwordOrSaltedKey } = vaultData requireCryptoGlobal("Decrypting a vault") const { crypto } = global @@ -170,7 +222,11 @@ export async function decryptVault( const { key } = typeof passwordOrSaltedKey === "string" - ? await deriveSymmetricKeyFromPassword(passwordOrSaltedKey, salt) + ? await deriveSymmetricKeyFromPassword( + vaultData.version, + passwordOrSaltedKey, + salt + ) : passwordOrSaltedKey const plaintext = await crypto.subtle.decrypt( diff --git a/background/services/internal-signer/index.ts b/background/services/internal-signer/index.ts new file mode 100644 index 0000000000..a612012800 --- /dev/null +++ b/background/services/internal-signer/index.ts @@ -0,0 +1,1085 @@ +import { parse as parseRawTransaction } from "@ethersproject/transactions" + +import HDKeyring, { SerializedHDKeyring } from "@tallyho/hd-keyring" + +import { arrayify } from "ethers/lib/utils" +import { Wallet } from "ethers" +import { normalizeEVMAddress, sameEVMAddress } from "../../lib/utils" +import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" +import { + getEncryptedVaults, + migrateVaultsToLatestVersion, + writeLatestEncryptedVault, +} from "./storage" +import { + decryptVault, + deriveSymmetricKeyFromPassword, + encryptVault, + SaltedKey, + VaultVersion, +} from "./encryption" +import { HexString, EIP712TypedData, UNIXTime } from "../../types" +import { SignedTransaction, TransactionRequestWithNonce } from "../../networks" + +import BaseService from "../base" +import { FORK } from "../../constants" +import { ethersTransactionFromTransactionRequest } from "../chain/utils" +import { FeatureFlags, isEnabled } from "../../features" +import { AddressOnNetwork } from "../../accounts" +import logger from "../../lib/logger" +import PreferenceService from "../preferences" +import { DEFAULT_AUTOLOCK_INTERVAL } from "../preferences/defaults" +import AnalyticsService from "../analytics" +import { AnalyticsEvent } from "../../lib/posthog" + +export enum SignerInternalTypes { + mnemonicBIP39S128 = "mnemonic#bip39:128", + mnemonicBIP39S256 = "mnemonic#bip39:256", + metamaskMnemonic = "mnemonic#metamask", + singleSECP = "single#secp256k1", +} + +export enum SignerImportSource { + import = "import", + internal = "internal", +} + +export enum SignerSourceTypes { + privateKey = "privateKey", + jsonFile = "jsonFile", + keyring = "keyring", +} + +export type Keyring = { + type: SignerInternalTypes + id: string + path: string | null + addresses: string[] +} +export type PrivateKey = Keyring & { + type: SignerInternalTypes.singleSECP + path: null + addresses: [string] +} + +export type KeyringAccountSigner = { + type: "keyring" + keyringID: string +} + +export type PrivateKeyAccountSigner = { + type: "private-key" + walletID: string +} + +type SerializedPrivateKey = { + version: number + id: string + privateKey: string +} + +type ImportMetadataHDKeyring = { + type: SignerSourceTypes.keyring + mnemonic: string + source: SignerImportSource + path?: string +} +type ImportMetadataPrivateKey = { + type: SignerSourceTypes.privateKey + privateKey: string +} +type ImportMetadataJSONPrivateKey = { + type: SignerSourceTypes.jsonFile + jsonFile: string + password: string +} +export type SignerImportMetadata = + | ImportMetadataPrivateKey + | ImportMetadataHDKeyring + | ImportMetadataJSONPrivateKey + +type InternalSignerHDKeyring = { + type: SignerSourceTypes.keyring + signer: HDKeyring +} +type InternalSignerPrivateKey = { + type: SignerSourceTypes.privateKey + signer: Wallet +} +type InternalSignerWithType = InternalSignerPrivateKey | InternalSignerHDKeyring + +interface SerializedKeyringData { + privateKeys: SerializedPrivateKey[] + keyrings: SerializedHDKeyring[] + metadata: { [signerId: string]: { source: SignerImportSource } } + hiddenAccounts: { [address: HexString]: boolean } +} + +interface Events extends ServiceLifecycleEvents { + locked: boolean + internalSigners: { + privateKeys: PrivateKey[] + keyrings: Keyring[] + metadata: { + [signerId: string]: { source: SignerImportSource } + } + } + address: string + // TODO message was signed + signedTx: SignedTransaction + signedData: string +} + +const isPrivateKey = ( + signer: InternalSignerWithType +): signer is InternalSignerPrivateKey => + signer.type === SignerSourceTypes.privateKey + +const isKeyring = ( + signer: InternalSignerWithType +): signer is InternalSignerHDKeyring => + signer.type === SignerSourceTypes.keyring + +/* + * InternalSignerService is responsible for all key material, as well as applying the + * material to sign messages, sign transactions, and derive child keypairs. + * + * The service can be in two states, locked or unlocked, and starts up locked. + * Keys are persisted in encrypted form when the service is locked. + * + * When unlocked, the service automatically locks itself after it has not seen + * activity for a certain amount of time. The service can be notified of + * outside activity that should be considered for the purposes of keeping the + * service unlocked. No keyring or keys activity for 30 minutes causes the service to + * lock, while no outside activity for 30 minutes has the same effect. + */ +export default class InternalSignerService extends BaseService { + #cachedKey: SaltedKey | null = null + + #cachedVaultVersion: VaultVersion = VaultVersion.PBKDF2 + + #keyrings: HDKeyring[] = [] + + #privateKeys: Wallet[] = [] + + #signerMetadata: { [signerId: string]: { source: SignerImportSource } } = {} + + #hiddenAccounts: { [address: HexString]: boolean } = {} + + /** + * The last time an internal signer took an action that required the service to be + * unlocked (signing, adding a keyring, etc). + */ + lastActivity: UNIXTime | undefined + + /** + * The last time the service was notified of an outside activity. + * {@see markOutsideActivity} + */ + lastOutsideActivity: UNIXTime | undefined + + #internalAutoLockInterval: UNIXTime = DEFAULT_AUTOLOCK_INTERVAL + + static create: ServiceCreatorFunction< + Events, + InternalSignerService, + [Promise, Promise] + > = async (preferenceService, analyticsService) => { + return new this(await preferenceService, await analyticsService) + } + + private constructor( + private preferenceService: PreferenceService, + private analyticsService: AnalyticsService + ) { + super({ + autolock: { + schedule: { + periodInMinutes: 1, + }, + handler: () => { + this.autolockIfNeeded() + }, + }, + }) + } + + override async internalStartService(): Promise { + // Emit locked status on startup. Should always be locked, but the main + // goal is to have external viewers synced to internal state no matter what + // it is. Don't emit if there are no vaults to unlock. + await super.internalStartService() + + this.#internalAutoLockInterval = + await this.preferenceService.getAutoLockInterval() + + if ((await getEncryptedVaults()).vaults.length > 0) { + this.emitter.emit("locked", this.locked()) + } + } + + override async internalStopService(): Promise { + await this.lock() + + await super.internalStopService() + } + + async updateAutoLockInterval(): Promise { + this.#internalAutoLockInterval = + await this.preferenceService.getAutoLockInterval() + + await this.autolockIfNeeded() + } + + /** + * @return True if the service is locked, false if it is unlocked. + */ + locked(): boolean { + return this.#cachedKey === null + } + + /** + * Update activity timestamps and emit unlocked event. + */ + #unlock(): void { + this.lastActivity = Date.now() + this.lastOutsideActivity = Date.now() + this.emitter.emit("locked", false) + } + + /** + * Unlock the service with a provided password, initializing from the most + * recently persisted keys vault if one exists. + * + * @param password A user-chosen string used to encrypt keys vaults. + * Unlocking will fail if an existing vault is found, and this password + * can't decrypt it. + * + * Note that losing this password means losing access to any key + * material stored in a vault. + * @param ignoreExistingVaults If true, ignore any existing, previously + * persisted vaults on unlock, instead starting with a clean slate. + * This option makes sense if a user has lost their password, and needs + * to generate a new vault. + * + * Note that old vaults aren't deleted, and can still be recovered + * later in an emergency. + * @returns true if the service was successfully unlocked using the password, + * and false otherwise. + */ + async unlock( + password: string, + ignoreExistingVaults = false + ): Promise { + if (!this.locked()) { + logger.warn("InternalSignerService is already unlocked!") + this.#unlock() + return true + } + + const { + encryptedData: { vaults, version }, + ...migrationResults + } = await migrateVaultsToLatestVersion(password) + this.#cachedVaultVersion = version + + if (migrationResults.migrated) { + this.analyticsService.sendAnalyticsEvent(AnalyticsEvent.VAULT_MIGRATION, { + version, + }) + } else if (migrationResults.errorMessage !== undefined) { + this.analyticsService.sendAnalyticsEvent( + AnalyticsEvent.VAULT_MIGRATION_FAILED, + { error: migrationResults.errorMessage } + ) + } + + if (!ignoreExistingVaults) { + const currentEncryptedVault = vaults.slice(-1)[0]?.vault + if (currentEncryptedVault) { + // attempt to load the vault + const saltedKey = await deriveSymmetricKeyFromPassword( + version, + password, + currentEncryptedVault.salt + ) + let plainTextVault: SerializedKeyringData + try { + plainTextVault = await decryptVault({ + version, + vault: currentEncryptedVault, + passwordOrSaltedKey: saltedKey, + }) + this.#cachedKey = saltedKey + } catch (err) { + // if we weren't able to load the vault, don't unlock + return false + } + // hooray! vault is loaded, import any serialized keys + this.#keyrings = [] + this.#signerMetadata = {} + this.#privateKeys = [] + plainTextVault.keyrings.forEach((kr) => { + this.#keyrings.push(HDKeyring.deserialize(kr)) + }) + + plainTextVault.privateKeys?.forEach((pk) => + this.#privateKeys.push(new Wallet(pk.privateKey)) + ) + + this.#signerMetadata = { + ...plainTextVault.metadata, + } + + this.#hiddenAccounts = { + ...plainTextVault.hiddenAccounts, + } + + this.#emitInternalSigners() + } + } + + // if there's no vault or we want to force a new vault, generate a new key + // and unlock + if (!this.#cachedKey) { + this.#cachedKey = await deriveSymmetricKeyFromPassword(version, password) + await this.#persistInternalSigners() + } + + this.#unlock() + return true + } + + /** + * Lock the service, deleting references to the cached vault + * encryption keys and keyrings. + */ + async lock(): Promise { + this.lastActivity = undefined + this.lastOutsideActivity = undefined + this.#cachedKey = null + this.#keyrings = [] + this.#signerMetadata = {} + this.#privateKeys = [] + this.emitter.emit("locked", true) + this.#emitInternalSigners() + } + + /** + * Notifies the service that an outside activity occurred. Outside activities + * are used to delay autolocking. + */ + markOutsideActivity(): void { + if (typeof this.lastOutsideActivity !== "undefined") { + this.lastOutsideActivity = Date.now() + } + } + + // Locks the service if the time since last service or outside activity + // exceeds preset levels. + private async autolockIfNeeded(): Promise { + if ( + typeof this.lastActivity === "undefined" || + typeof this.lastOutsideActivity === "undefined" + ) { + // Normally both activity counters should be undefined only if the service + // is locked, otherwise they should both be set; regardless, fail safe if + // either is undefined and the service is unlocked. + if (!this.locked()) { + await this.lock() + } + + return + } + + const now = Date.now() + const timeSinceLastActivity = now - this.lastActivity + const timeSinceLastOutsideActivity = now - this.lastOutsideActivity + + if (timeSinceLastActivity >= this.#internalAutoLockInterval) { + this.lock() + } else if (timeSinceLastOutsideActivity >= this.#internalAutoLockInterval) { + this.lock() + } + } + + // Throw if the service is not unlocked; if it is, update the last service + // activity timestamp. + private requireUnlocked(): void { + if (this.locked()) { + throw new Error("InternalSignerService must be unlocked.") + } + + this.lastActivity = Date.now() + this.markOutsideActivity() + } + + /** + * Generate a new keyring + * + * @param type - the type of keyring to generate. Currently only supports 256- + * bit HD keys. + * @returns An object containing the string ID of the new keyring and the + * mnemonic for the new keyring. Note that the mnemonic can only be + * accessed at generation time through this return value. + */ + async generateNewKeyring( + type: SignerInternalTypes, + path?: string + ): Promise<{ id: string; mnemonic: string[] }> { + this.requireUnlocked() + + if (type !== SignerInternalTypes.mnemonicBIP39S256) { + throw new Error( + "InternalSignerService only supports generating 256-bit HD key trees" + ) + } + + const options: { strength: number; path?: string } = { strength: 256 } + + if (path) { + options.path = path + } + + const newKeyring = new HDKeyring(options) + + const { mnemonic } = newKeyring.serializeSync() + + return { id: newKeyring.id, mnemonic: mnemonic.split(" ") } + } + + /** + * Import new internal signer + * + * @param signerMetadata any signer with type and metadata + * @returns null | string - if new account was added or existing account was found then returns an address + */ + async importSigner( + signerMetadata: SignerImportMetadata + ): Promise { + this.requireUnlocked() + try { + let address: HexString | null + + if (signerMetadata.type === SignerSourceTypes.privateKey) { + address = this.#importPrivateKey(signerMetadata.privateKey) + } else if (signerMetadata.type === SignerSourceTypes.jsonFile) { + const { jsonFile, password } = signerMetadata + address = await this.#importJSON(jsonFile, password) + } else { + const { mnemonic, source, path } = signerMetadata + address = this.#importKeyring(mnemonic, source, path) + } + + this.#hiddenAccounts[address] = false + await this.#persistInternalSigners() + this.emitter.emit("address", address) + this.#emitInternalSigners() + + return address + } catch (error) { + logger.error("Signer import failed:", error) + return null + } + } + + /** + * Import keyring and pull the first address from that + * keyring for system use. + * + * @param signerMetadata - keyring metadata - path, source, mnemonic + * @returns string - address of the first account from the HD keyring + */ + #importKeyring( + mnemonic: string, + source: SignerImportSource, + path?: string + ): string { + const newKeyring = path + ? new HDKeyring({ mnemonic, path }) + : new HDKeyring({ mnemonic }) + + const existingKeyring = this.#keyrings.find((kr) => kr.id === newKeyring.id) + + if (existingKeyring) { + const [address] = existingKeyring.getAddressesSync() + return address + } + this.#keyrings.push(newKeyring) + const [address] = newKeyring.addAddressesSync(1) + + // If address was previously imported as a private key then remove it + if (this.#findPrivateKey(address)) { + this.#removePrivateKey(address) + } + + this.#signerMetadata[newKeyring.id] = { source } + + return address + } + + /** + * Import private key with a string + * @param privateKey - string + * @returns string - address of imported or existing account + */ + #importPrivateKey(privateKey: string): string { + const newWallet = new Wallet(privateKey) + const normalizedAddress = normalizeEVMAddress(newWallet.address) + + if (this.#findSigner(normalizedAddress)) { + return normalizedAddress + } + + this.#privateKeys.push(newWallet) + this.#signerMetadata[normalizedAddress] = { + source: SignerImportSource.import, + } + return normalizedAddress + } + + /** + * Import private key with JSON file + * @param jsonFile - stringified JSON file + * @param password - string + * @returns string - address of imported or existing account + */ + async #importJSON(jsonFile: string, password: string): Promise { + const newWallet = await Wallet.fromEncryptedJson(jsonFile, password) + const normalizedAddress = normalizeEVMAddress(newWallet.address) + + if (this.#findSigner(normalizedAddress)) { + return normalizedAddress + } + + this.#privateKeys.push(newWallet) + this.#signerMetadata[normalizedAddress] = { + source: SignerImportSource.import, + } + return normalizedAddress + } + + /** + * Return the source of a given address' signer if it exists. If an + * address does not have a internal signer associated with it - returns null. + */ + getSignerSourceForAddress(address: string): SignerImportSource | null { + const signerWithType = this.#findSigner(address) + + if (!signerWithType) return null + + if (isKeyring(signerWithType)) { + return this.#signerMetadata[signerWithType.signer.id].source + } + return this.#signerMetadata[ + normalizeEVMAddress(signerWithType.signer.address) + ].source + } + + /** + * Return an array of keyring representations that can safely be stored and + * used outside the extension. + */ + getKeyrings(): Keyring[] { + this.requireUnlocked() + + return this.#keyrings.map((kr) => ({ + // TODO this type is meanlingless from the library's perspective. + // Reconsider, or explicitly track which keyrings have been generated vs + // imported as well as their strength + type: SignerInternalTypes.mnemonicBIP39S256, + addresses: [ + ...kr + .getAddressesSync() + .filter((address) => this.#hiddenAccounts[address] !== true), + ], + id: kr.id, + path: kr.path, + })) + } + + /** + * Returns and array of private keys representations that can safely be stored + * and used outside the extension + */ + getPrivateKeys(): PrivateKey[] { + this.requireUnlocked() + + return this.#privateKeys.map((wallet) => ({ + type: SignerInternalTypes.singleSECP, + addresses: [normalizeEVMAddress(wallet.address)], + id: wallet.publicKey, + path: null, + })) + } + + /** + * Derive and return the next address for a KeyringAccountSigner representing + * an HDKeyring. + * + * @param keyringAccountSigner - A KeyringAccountSigner representing the + * given keyring. + */ + async deriveAddress({ keyringID }: KeyringAccountSigner): Promise { + this.requireUnlocked() + + // find the keyring using a linear search + const keyring = this.#keyrings.find((kr) => kr.id === keyringID) + if (!keyring) { + throw new Error("Keyring not found.") + } + + const keyringAddresses = keyring.getAddressesSync() + + // If There are any hidden addresses, show those first before adding new ones. + const newAddress = + keyringAddresses.find( + (address) => this.#hiddenAccounts[address] === true + ) ?? keyring.addAddressesSync(1)[0] + + this.#hiddenAccounts[newAddress] = false + + // If address was previously imported as a private key then remove it + if (this.#findPrivateKey(newAddress)) { + this.#removePrivateKey(newAddress) + } + + await this.#persistInternalSigners() + + this.emitter.emit("address", newAddress) + this.#emitInternalSigners() + + return newAddress + } + + /** + * Remove signer from the service's memory. + * If it was imported with a private key then it will be completely removed from the service. + * If address belongs to the keyring then we will hide it without removing from underlying keyring. + * If that address is the last one from a given keyring then we will remove whole keyring. + * + * @param address account to be removed from UI + */ + async removeAccount(address: HexString): Promise { + this.#hiddenAccounts[address] = true + + const keyringSigner = this.#findKeyring(address) + const privateKeySigner = this.#findPrivateKey(address) + + if (keyringSigner === null && privateKeySigner === null) return + + if (keyringSigner !== null) { + const keyringAddresses = await keyringSigner.getAddresses() + + if ( + keyringAddresses.every( + (keyringAddress) => this.#hiddenAccounts[keyringAddress] === true + ) + ) { + keyringAddresses.forEach((keyringAddress) => { + delete this.#hiddenAccounts[keyringAddress] + }) + this.#removeKeyring(keyringSigner.id) + } + } + + if (privateKeySigner !== null) { + this.#removePrivateKey(address) + } + + await this.#persistInternalSigners() + this.#emitInternalSigners() + } + + #removeKeyring(keyringId: string): HDKeyring[] { + const filteredKeyrings = this.#keyrings.filter( + (keyring) => keyring.id !== keyringId + ) + + if (filteredKeyrings.length === this.#keyrings.length) { + throw new Error( + `Attempting to remove keyring that does not exist. id: (${keyringId})` + ) + } + this.#keyrings = filteredKeyrings + delete this.#signerMetadata[keyringId] + + return filteredKeyrings + } + + #removePrivateKey(address: HexString): Wallet[] { + const filteredPrivateKeys = this.#privateKeys.filter( + (wallet) => !sameEVMAddress(wallet.address, address) + ) + + if (filteredPrivateKeys.length === this.#privateKeys.length) { + throw new Error( + `Attempting to remove wallet that does not exist. Address: (${address})` + ) + } + + this.#privateKeys = filteredPrivateKeys + delete this.#signerMetadata[normalizeEVMAddress(address)] + + return filteredPrivateKeys + } + + /** + * Export private key - supprts exporting from both private key wallet signers and + * HD Wallet's specific accounts + * + * @param address + * @returns string | null - private key string if it was exported successfully + */ + async exportPrivateKey(address: HexString): Promise { + this.requireUnlocked() + + const signerWithType = this.#findSigner(address) + + if (!signerWithType) { + logger.error(`Export private key for address ${address} failed`) + return null + } + + if (isPrivateKey(signerWithType)) { + return signerWithType.signer.privateKey + } + + return signerWithType.signer.exportPrivateKey( + address, + "I solemnly swear that I am treating this private key material with great care." + ) + } + + /** + * Export mnemonic from HD wallet + * + * @param address + * @returns string | null - mnemonic string if it was exported successfully + */ + async exportMnemonic(address: HexString): Promise { + this.requireUnlocked() + + const keyring = this.#findKeyring(address) + + if (!keyring) { + logger.error(`Export mnemonic for address ${address} failed.`) + return null + } + + const { mnemonic } = await keyring.serialize() + return mnemonic + } + + /** + * Find keyring associated with an account. + * + * @param account - the account address desired to search the keyring for. + * @returns HD keyring object + */ + #findKeyring(account: HexString): HDKeyring | null { + const keyring = this.#keyrings.find((kr) => + kr.getAddressesSync().includes(normalizeEVMAddress(account)) + ) + + return keyring ?? null + } + + /** + * Find a wallet imported with a private key + * + * @param account - the account address desired to search the wallet for. + * @returns Ether's Wallet object + */ + #findPrivateKey(account: HexString): Wallet | null { + const privateKey = this.#privateKeys.find((item) => + sameEVMAddress(item.address, account) + ) + + return privateKey ?? null + } + + /** + * Find a signer object associated with a given account address + */ + #findSigner(account: HexString): InternalSignerWithType | null { + const keyring = this.#findKeyring(account) + + if (keyring) { + return { + signer: keyring, + type: SignerSourceTypes.keyring, + } + } + + const privateKey = this.#findPrivateKey(account) + + if (privateKey) { + return { + signer: privateKey, + type: SignerSourceTypes.privateKey, + } + } + + return null + } + + /** + * Sign a transaction. + * + * @param account - the account desired to sign the transaction + * @param txRequest + */ + async signTransaction( + addressOnNetwork: AddressOnNetwork, + txRequest: TransactionRequestWithNonce + ): Promise { + this.requireUnlocked() + + const { address: account, network } = addressOnNetwork + + // find the signer using a linear search + const signerWithType = this.#findSigner(account) + + if (!signerWithType) { + throw new Error( + `Signing transaction failed. Signer for address ${account} was not found.` + ) + } + + // ethers has a looser / slightly different request type + const ethersTxRequest = ethersTransactionFromTransactionRequest(txRequest) + + let signedRawTx: string + + // unfortunately, ethers gives us a serialized signed tx here + if (isPrivateKey(signerWithType)) { + signedRawTx = await signerWithType.signer.signTransaction(ethersTxRequest) + } else { + signedRawTx = await signerWithType.signer.signTransaction( + account, + ethersTxRequest + ) + } + + // parse the tx, then unpack it as best we can + const tx = parseRawTransaction(signedRawTx) + + if (!tx.hash || !tx.from || !tx.r || !tx.s || typeof tx.v === "undefined") { + throw new Error("Transaction doesn't appear to have been signed.") + } + + const { + to, + gasPrice, + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + hash, + from, + nonce, + data, + value, + type, + r, + s, + v, + } = tx + + if ( + typeof maxPriorityFeePerGas === "undefined" || + typeof maxFeePerGas === "undefined" || + type !== 2 + ) { + const signedTx = { + hash, + from, + to, + nonce, + input: data, + value: value.toBigInt(), + type: type as 0, + gasPrice: gasPrice?.toBigInt() ?? null, + gasLimit: gasLimit.toBigInt(), + maxFeePerGas: null, + maxPriorityFeePerGas: null, + r, + s, + v, + blockHash: null, + blockHeight: null, + asset: network.baseAsset, + network: isEnabled(FeatureFlags.USE_MAINNET_FORK) ? FORK : network, + } + return signedTx + } + + // TODO move this to a helper function + const signedTx: SignedTransaction = { + hash, + from, + to, + nonce, + input: data, + value: value.toBigInt(), + type, + gasPrice: null, + maxFeePerGas: maxFeePerGas.toBigInt(), + maxPriorityFeePerGas: maxPriorityFeePerGas.toBigInt(), + gasLimit: gasLimit.toBigInt(), + r, + s, + v, + blockHash: null, + blockHeight: null, + asset: network.baseAsset, + network: isEnabled(FeatureFlags.USE_MAINNET_FORK) ? FORK : network, + } + + return signedTx + } + + /** + * Sign typed data based on EIP-712 with the usage of eth_signTypedData_v4 method, + * more information about the EIP can be found at https://eips.ethereum.org/EIPS/eip-712 + * + * @param typedData - the data to be signed + * @param account - signers account address + */ + async signTypedData({ + typedData, + account, + }: { + typedData: EIP712TypedData + account: HexString + }): Promise { + this.requireUnlocked() + const { domain, types, message } = typedData + const signerWithType = this.#findSigner(account) + + if (!signerWithType) { + throw new Error( + `Signing data failed. Signer for address ${account} was not found.` + ) + } + + // When signing we should not include EIP712Domain type + const { EIP712Domain: _, ...typesForSigning } = types + try { + let signature: string + if (isPrivateKey(signerWithType)) { + // eslint-disable-next-line no-underscore-dangle + signature = await signerWithType.signer._signTypedData( + domain, + typesForSigning, + message + ) + } else { + signature = await signerWithType.signer.signTypedData( + account, + domain, + typesForSigning, + message + ) + } + + return signature + } catch (error) { + throw new Error(`Signing data failed`) + } + } + + /** + * Sign data based on EIP-191 with the usage of personal_sign method, + * more information about the EIP can be found at https://eips.ethereum.org/EIPS/eip-191 + * + * @param signingData - the data to be signed + * @param account - signers account address + */ + async personalSign({ + signingData, + account, + }: { + signingData: HexString + account: HexString + }): Promise { + this.requireUnlocked() + const signerWithType = this.#findSigner(account) + + if (!signerWithType) { + throw new Error( + `Personal sign failed. Signer for address ${account} was not found.` + ) + } + + const messageBytes = arrayify(signingData) + try { + let signature: string + if (isPrivateKey(signerWithType)) { + signature = await signerWithType.signer.signMessage(messageBytes) + } else { + signature = await signerWithType.signer.signMessageBytes( + account, + messageBytes + ) + } + + return signature + } catch (error) { + throw new Error("Signing data failed") + } + } + + #emitInternalSigners(): void { + if (this.locked()) { + this.emitter.emit("internalSigners", { + privateKeys: [], + keyrings: [], + metadata: {}, + }) + } else { + const keyrings = this.getKeyrings() + const privateKeys = this.getPrivateKeys() + this.emitter.emit("internalSigners", { + privateKeys, + keyrings, + metadata: { ...this.#signerMetadata }, + }) + } + } + + /** + * Serialize, encrypt, and persist all HDKeyrings and private keys. + */ + async #persistInternalSigners(): Promise { + this.requireUnlocked() + + // This if guard will always pass due to requireUnlocked, but statically + // prove it to TypeScript. + if (this.#cachedKey !== null) { + const serializedKeyrings: SerializedHDKeyring[] = this.#keyrings.map( + (kr) => kr.serializeSync() + ) + const serializedPrivateKeys: SerializedPrivateKey[] = + this.#privateKeys.map((wallet) => ({ + version: 1, + id: wallet.publicKey, + privateKey: wallet.privateKey, + })) + const hiddenAccounts = { ...this.#hiddenAccounts } + const metadata = { ...this.#signerMetadata } + serializedKeyrings.sort((a, b) => (a.id > b.id ? 1 : -1)) + const vault = await encryptVault({ + version: this.#cachedVaultVersion, + passwordOrSaltedKey: this.#cachedKey, + vault: { + keyrings: serializedKeyrings, + privateKeys: serializedPrivateKeys, + metadata, + hiddenAccounts, + }, + }) + await writeLatestEncryptedVault(vault) + } + } +} diff --git a/background/services/internal-signer/storage.ts b/background/services/internal-signer/storage.ts new file mode 100644 index 0000000000..7866485511 --- /dev/null +++ b/background/services/internal-signer/storage.ts @@ -0,0 +1,180 @@ +import browser from "webextension-polyfill" + +import { + EncryptedVault, + VaultVersion, + decryptVault, + deprecatedDerivePbkdf2KeyFromPassword, + deriveArgon2KeyFromPassword, + encryptVault, +} from "./encryption" +import { UNIXTime } from "../../types" +import logger from "../../lib/logger" + +export type SerializedEncryptedVault = { + timeSaved: UNIXTime + vault: EncryptedVault +} + +type SerializedEncryptedVaults = { + version: VaultVersion + vaults: SerializedEncryptedVault[] +} + +/** + * Retrieve all serialized encrypted vaults from extension storage. + * + * @returns a schema version and array of serialized vaults + */ +export async function getEncryptedVaults(): Promise { + const data = await browser.storage.local.get("tallyVaults") + if (!("tallyVaults" in data)) { + return { + version: VaultVersion.Argon2, + vaults: [], + } + } + const { tallyVaults } = data + if ( + "version" in tallyVaults && + "vaults" in tallyVaults && + (tallyVaults.version === VaultVersion.PBKDF2 || + tallyVaults.version === VaultVersion.Argon2) && + Array.isArray(tallyVaults.vaults) + ) { + return tallyVaults as SerializedEncryptedVaults + } + throw new Error("Encrypted vaults are using an unkown serialization format") +} + +function equalVaults(vault1: EncryptedVault, vault2: EncryptedVault): boolean { + if (vault1.salt !== vault2.salt) { + return false + } + if (vault1.initializationVector !== vault2.initializationVector) { + return false + } + if (vault1.cipherText !== vault2.cipherText) { + return false + } + return true +} + +/** + * Write an encryptedVault to extension storage if and only if it's different + * than the most recently saved vault. + * + * @param encryptedVault - an encrypted keyring vault + */ +export async function writeLatestEncryptedVault( + encryptedVault: EncryptedVault +): Promise { + const serializedVaults = await getEncryptedVaults() + const vaults = [...serializedVaults.vaults] + const currentLatest = vaults.reduce( + (newestVault, nextVault) => + newestVault && newestVault.timeSaved > nextVault.timeSaved + ? newestVault + : nextVault, + null + ) + const oldVault = currentLatest && currentLatest.vault + // if there's been a change, persist the vault + if (!oldVault || !equalVaults(oldVault, encryptedVault)) { + await browser.storage.local.set({ + tallyVaults: { + ...serializedVaults, + vaults: [ + ...serializedVaults.vaults, + { + timeSaved: Date.now(), + vault: encryptedVault, + }, + ], + }, + }) + } +} + +export async function migrateVaultsToLatestVersion(password: string): Promise< + | { + encryptedData: SerializedEncryptedVaults + migrated: true + } + | { + encryptedData: SerializedEncryptedVaults + migrated: false + errorMessage?: string + } +> { + const serializedVaults = await getEncryptedVaults() + if (serializedVaults.version === VaultVersion.Argon2) { + return { encryptedData: serializedVaults, migrated: false } + } + + const { vaults } = serializedVaults + try { + const newVaults = await Promise.all( + vaults.map(async ({ vault, timeSaved }) => { + const deprecatedSaltedKey = await deprecatedDerivePbkdf2KeyFromPassword( + password, + vault.salt + ) + const newSaltedKey = await deriveArgon2KeyFromPassword( + password, + vault.salt + ) + + const deprecatedDecryptedVault = await decryptVault({ + version: VaultVersion.PBKDF2, + vault, + passwordOrSaltedKey: deprecatedSaltedKey, + }) + const newEncryptedVault = await encryptVault({ + version: VaultVersion.Argon2, + vault: deprecatedDecryptedVault, + passwordOrSaltedKey: newSaltedKey, + }) + + // try to decrypt the new vault to make sure it's valid + const newDecryptedVault = await decryptVault({ + version: VaultVersion.Argon2, + vault: newEncryptedVault, + passwordOrSaltedKey: newSaltedKey, + }) + + if ( + JSON.stringify(newDecryptedVault) !== + JSON.stringify(deprecatedDecryptedVault) + ) { + throw new Error( + "Failed to migrate vaults to Argon2. Decrypted vaults do not match." + ) + } + + return { + timeSaved, + vault: newEncryptedVault, + } + }) + ) + + const newSerializedVaults = { + version: VaultVersion.Argon2, + vaults: newVaults, + } + + await browser.storage.local.set({ + tallyVaults: newSerializedVaults, + }) + + return { encryptedData: newSerializedVaults, migrated: true } + } catch (error) { + logger.error("Failed to migrate vaults to Argon2", error) + return { + encryptedData: serializedVaults, + migrated: false, + errorMessage: String(error), + } + } +} diff --git a/background/services/internal-signer/tests/encryption.unit.test.ts b/background/services/internal-signer/tests/encryption.unit.test.ts new file mode 100644 index 0000000000..118e326a23 --- /dev/null +++ b/background/services/internal-signer/tests/encryption.unit.test.ts @@ -0,0 +1,137 @@ +import { + encryptVault, + decryptVault, + deriveSymmetricKeyFromPassword, + VaultVersion, +} from "../encryption" + +describe.each([VaultVersion.PBKDF2, VaultVersion.Argon2])( + "Encryption utils", + (vaultVersion) => { + it("derives symmetric keys", async () => { + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < 5; i += 1) { + const password = Buffer.from( + global.crypto.getRandomValues(new Uint8Array(16)) + ).toString("base64") + const newSalt = Buffer.from( + global.crypto.getRandomValues(new Uint8Array(16)) + ).toString("base64") + + const { key, salt } = await deriveSymmetricKeyFromPassword( + vaultVersion, + password, + newSalt + ) + expect(salt).toEqual(newSalt) + + expect(new Set(key.usages)).toEqual(new Set(["encrypt", "decrypt"])) + } + /* eslint-enable no-await-in-loop */ + }) + + it("doesn't throw when encrypting a vault with a password", async () => { + const vault = { a: 1 } + const password = "this-is-a-poor-password" + await encryptVault({ + version: vaultVersion, + vault, + passwordOrSaltedKey: password, + }) + }) + + it("avoids couple common footguns when encrypting a vault with a password", async () => { + const vault = { thisIsAnInterestingKey: "sentinel" } + const password = "this-is-a-poor-password" + const newVault = await encryptVault({ + version: vaultVersion, + vault, + passwordOrSaltedKey: password, + }) + // ensure sensitive plaintext isn't in the output, with a couple simple + // transformations. Note this *doesn't* show correctness of encryption — a + // simple substitution cipher would still pass this — it's just a smoke test. + const importantPlaintext = [ + "thisIsAnInterestingKey", + "sentinel", + password, + ] + const serializedVault = JSON.stringify(newVault) + importantPlaintext.forEach((t) => { + expect(serializedVault).not.toContain(t) + expect(serializedVault).not.toContain(t.toLowerCase()) + expect(serializedVault).not.toContain(Buffer.from(t).toString("base64")) + }) + }) + + it("can decrypt a vault encrypted with a password", async () => { + const vault = { a: 1 } + const password = "this-is-a-poor-password" + const encryptedVault = await encryptVault({ + version: vaultVersion, + vault, + passwordOrSaltedKey: password, + }) + + const newVault = await decryptVault({ + version: vaultVersion, + vault: encryptedVault, + passwordOrSaltedKey: password, + }) + + expect(newVault).toEqual(vault) + }) + + it("can decrypt a complex vault encrypted with a password", async () => { + const vault = { + a: { b: [1, 2, 3] }, + c: null, + d: 123, + } + const password = "this-is-a-poor-password" + const encryptedVault = await encryptVault({ + version: vaultVersion, + vault, + passwordOrSaltedKey: password, + }) + + const newVault = await decryptVault({ + version: vaultVersion, + vault: encryptedVault, + passwordOrSaltedKey: password, + }) + + expect(newVault).toEqual(vault) + }) + + it("can decrypt a complex vault encrypted with a password", async () => { + const vault = { + a: { b: [1, 2, 3] }, + c: null, + d: 123, + } + const password = Buffer.from( + global.crypto.getRandomValues(new Uint8Array(16)) + ).toString("base64") + + const saltedKey = await deriveSymmetricKeyFromPassword( + vaultVersion, + password + ) + + const encryptedVault = await encryptVault({ + version: vaultVersion, + vault, + passwordOrSaltedKey: saltedKey, + }) + + const newVault = await decryptVault({ + version: vaultVersion, + vault: encryptedVault, + passwordOrSaltedKey: saltedKey, + }) + + expect(newVault).toEqual(vault) + }) + } +) diff --git a/background/tests/keyring-integration.test.ts b/background/services/internal-signer/tests/index.integration.test.ts similarity index 63% rename from background/tests/keyring-integration.test.ts rename to background/services/internal-signer/tests/index.integration.test.ts index d832bed11b..fd74724c57 100644 --- a/background/tests/keyring-integration.test.ts +++ b/background/services/internal-signer/tests/index.integration.test.ts @@ -1,25 +1,22 @@ -import { webcrypto } from "crypto" import browser from "webextension-polyfill" -import KeyringService, { +import InternalSignerService, { Keyring, - MAX_KEYRING_IDLE_TIME, - MAX_OUTSIDE_IDLE_TIME, -} from "../services/keyring" -import { KeyringTypes } from "../types" -import { EIP1559TransactionRequest } from "../networks" -import { ETH, ETHEREUM } from "../constants" -import logger from "../lib/logger" - -const originalCrypto = global.crypto -beforeEach(() => { - // polyfill the WebCrypto API - global.crypto = webcrypto as unknown as Crypto -}) - -afterEach(() => { - global.crypto = originalCrypto -}) + SignerImportSource, + SignerSourceTypes, + SignerInternalTypes, +} from ".." +import { ETHEREUM } from "../../../constants" +import logger from "../../../lib/logger" +import { + mockLocalStorage, + mockLocalStorageWithCalls, +} from "../../../tests/utils" +import { + createTransactionRequest, + createPreferenceService, + createAnalyticsService, +} from "../../../tests/factories" const validMnemonics = { metamask: [ @@ -36,36 +33,23 @@ const validMnemonics = { ], } -const validTransactionRequests: { - [key: string]: EIP1559TransactionRequest & { nonce: number } -} = { - simple: { - from: "0x0", - nonce: 0, - type: 2, - input: "0x", - value: 0n, - maxFeePerGas: 0n, - maxPriorityFeePerGas: 0n, - gasLimit: 0n, - chainID: "0", - network: { - name: "none", - chainID: "0", - baseAsset: ETH, - family: "EVM", - coingeckoPlatformID: "ethereum", - }, - }, -} +const validPrivateKey = [ + "252da775ac59bf1e3a3c2b3b2633e29f8b8236dc3054b7ce9d019c79166ccf14", +] const testPassword = "my password" // Default value that is clearly not correct for testing inspection. const dateNowValue = 1000000000000 -const startKeyringService = async () => { - const service = await KeyringService.create() +const startInternalSignerService = async () => { + const preferencesService = createPreferenceService() + const analyticsService = createAnalyticsService() + const service = await InternalSignerService.create( + preferencesService, + analyticsService + ) + await service.startService() return service @@ -87,39 +71,49 @@ const mockAlarms = () => { browser.alarms.onAlarm.addListener = jest.fn(() => ({})) } -describe("KeyringService when uninitialized", () => { - let service: KeyringService +describe("InternalSignerService when uninitialized", () => { + let service: InternalSignerService beforeEach(async () => { - browser.storage.local.get = jest.fn(() => Promise.resolve({})) - browser.storage.local.set = jest.fn(() => Promise.resolve()) + mockLocalStorage() mockAlarms() - service = await startKeyringService() + service = await startInternalSignerService() }) describe("and locked", () => { it("won't import or create accounts", async () => { await expect( - service.importKeyring(validMnemonics.metamask[0], "import") - ).rejects.toThrow("KeyringService must be unlocked.") + service.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic: validMnemonics.metamask[0], + source: SignerImportSource.import, + }) + ).rejects.toThrow("InternalSignerService must be unlocked.") await Promise.all( - Object.keys(KeyringTypes).map(async (keyringType) => + Object.keys(SignerInternalTypes).map(async (signerType) => expect( - service.generateNewKeyring(keyringType as KeyringTypes) - ).rejects.toThrow("KeyringService must be unlocked.") + service.generateNewKeyring(signerType as SignerInternalTypes) + ).rejects.toThrow("InternalSignerService must be unlocked.") ) ) + + await expect( + service.importSigner({ + type: SignerSourceTypes.privateKey, + privateKey: validPrivateKey[0], + }) + ).rejects.toThrow("InternalSignerService must be unlocked.") }) it("won't sign transactions", async () => { await expect( service.signTransaction( { address: "0x0", network: ETHEREUM }, - validTransactionRequests.simple + createTransactionRequest({ from: "0x0" }) ) - ).rejects.toThrow("KeyringService must be unlocked.") + ).rejects.toThrow("InternalSignerService must be unlocked.") }) }) @@ -131,20 +125,26 @@ describe("KeyringService when uninitialized", () => { it.each(validMnemonics.metamask)( "will import mnemonic '%s'", async (mnemonic) => { - return expect(service.importKeyring(mnemonic, "import")).resolves + return expect( + service.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic, + source: SignerImportSource.import, + }) + ).resolves } ) it("will create multiple distinct BIP-39 S256 accounts and expose mnemonics", async () => { const keyringOne = service.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256 + SignerInternalTypes.mnemonicBIP39S256 ) await expect(keyringOne).resolves.toMatchObject({ id: expect.stringMatching(/.+/), }) const keyringTwo = service.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256 + SignerInternalTypes.mnemonicBIP39S256 ) await expect(keyringTwo).resolves.toMatchObject({ id: expect.stringMatching(/.+/), @@ -161,38 +161,28 @@ describe("KeyringService when uninitialized", () => { }) }) -describe("KeyringService when initialized", () => { - let service: KeyringService +describe("InternalSignerService when initialized", () => { + let service: InternalSignerService let address: string beforeEach(async () => { mockAlarms() + mockLocalStorage() - let localStorage: Record> = {} - - browser.storage.local.get = jest.fn((key) => { - if (typeof key === "string" && key in localStorage) { - return Promise.resolve({ [key]: localStorage[key] } || {}) - } - return Promise.resolve({}) - }) - browser.storage.local.set = jest.fn((values) => { - localStorage = { - ...localStorage, - ...values, - } - return Promise.resolve() - }) - - service = await startKeyringService() + service = await startInternalSignerService() await service.unlock(testPassword) service.emitter.on("address", (emittedAddress) => { address = emittedAddress }) const { mnemonic } = await service.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256 + SignerInternalTypes.mnemonicBIP39S256 ) - await service.importKeyring(mnemonic.join(" "), "import") + await service.importSigner({ + type: SignerSourceTypes.keyring, + + mnemonic: mnemonic.join(" "), + source: SignerImportSource.import, + }) }) it("will return keyring IDs and addresses", async () => { @@ -232,31 +222,8 @@ describe("KeyringService when initialized", () => { }) }) - it("will sign a transaction", async () => { - const transactionWithFrom = { - ...validTransactionRequests.simple, - from: address, - } - - await expect( - service.signTransaction( - { address, network: ETHEREUM }, - transactionWithFrom - ) - ).resolves.toMatchObject({ - from: expect.stringMatching(new RegExp(address, "i")), // case insensitive match - r: expect.anything(), - s: expect.anything(), - v: expect.anything(), - }) - // TODO assert correct recovered address - }) - it("does not overwrite data if unlocked with the wrong password", async () => { - const transactionWithFrom = { - ...validTransactionRequests.simple, - from: address, - } + const transactionWithFrom = createTransactionRequest({ from: address }) await service.lock() @@ -277,44 +244,27 @@ describe("KeyringService when initialized", () => { it("successfully unlocks already unlocked wallet", async () => { jest.spyOn(logger, "warn").mockImplementation((arg) => { // We should log if we try to unlock an unlocked keyring - expect(arg).toEqual("KeyringService is already unlocked!") + expect(arg).toEqual("InternalSignerService is already unlocked!") }) expect(service.locked()).toEqual(false) expect(await service.unlock(testPassword)).toEqual(true) }) }) -describe("KeyringService when saving keyrings", () => { - let localStorage: Record> = {} +describe("InternalSignerService when saving keyrings", () => { let localStorageCalls: Record[] = [] beforeEach(() => { mockAlarms() - localStorage = {} - localStorageCalls = [] - - browser.storage.local.get = jest.fn((key) => { - if (typeof key === "string" && key in localStorage) { - return Promise.resolve({ [key]: localStorage[key] } || {}) - } - return Promise.resolve({}) - }) - browser.storage.local.set = jest.fn((values) => { - localStorage = { - ...localStorage, - ...values, - } - localStorageCalls.unshift(values) - - return Promise.resolve() - }) + const localStorageMock = mockLocalStorageWithCalls() + localStorageCalls = localStorageMock.localStorageCalls jest.spyOn(Date, "now").mockReturnValue(dateNowValue) }) it("saves data encrypted", async () => { - const service = await startKeyringService() + const service = await startInternalSignerService() await service.unlock(testPassword) expect(localStorageCalls.shift()).toMatchObject({ @@ -333,9 +283,14 @@ describe("KeyringService when saving keyrings", () => { }) const { mnemonic } = await service.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256 + SignerInternalTypes.mnemonicBIP39S256 ) - await service.importKeyring(mnemonic.join(" "), "import") + await service.importSigner({ + type: SignerSourceTypes.keyring, + + mnemonic: mnemonic.join(" "), + source: SignerImportSource.import, + }) expect(localStorageCalls.shift()).toMatchObject({ tallyVaults: expect.objectContaining({ @@ -362,7 +317,7 @@ describe("KeyringService when saving keyrings", () => { }) it("loads encrypted data at instantiation time", async () => { - localStorage = { + browser.storage.local.set({ tallyVaults: { version: 1, vaults: [ @@ -377,13 +332,13 @@ describe("KeyringService when saving keyrings", () => { }, ], }, - } + }) const storedKeyrings: Keyring[] = [] - const service = await startKeyringService() - service.emitter.on("keyrings", (keyringEvent) => { - storedKeyrings.push(...keyringEvent.keyrings) + const service = await startInternalSignerService() + service.emitter.on("internalSigners", (signers) => { + storedKeyrings.push(...signers.keyrings) return Promise.resolve() }) await service.unlock(testPassword) @@ -396,21 +351,20 @@ describe("KeyringService when saving keyrings", () => { ).resolves.toHaveLength(1) expect(storedKeyrings[0]).toMatchObject({ - type: KeyringTypes.mnemonicBIP39S256, + type: SignerInternalTypes.mnemonicBIP39S256, id: "0x77555a3b", addresses: ["0x3c10745391dfae50df6dc0ee17281f34bbda2fbf"], }) }) }) -describe("Keyring service when autolocking", () => { - let service: KeyringService +describe("InternalSignerService when autolocking", () => { + let service: InternalSignerService let address: string let callAutolockHandler: (timeSinceInitialMock: number) => void beforeEach(async () => { - browser.storage.local.get = jest.fn(() => Promise.resolve({})) - browser.storage.local.set = jest.fn(() => Promise.resolve()) + mockLocalStorage() browser.alarms.create = jest.fn(() => ({})) browser.alarms.onAlarm.addListener = jest.fn((handler) => { @@ -428,34 +382,45 @@ describe("Keyring service when autolocking", () => { jest.spyOn(Date, "now").mockReturnValue(dateNowValue) - service = await startKeyringService() + service = await startInternalSignerService() await service.unlock(testPassword) service.emitter.on("address", (emittedAddress) => { address = emittedAddress }) const { mnemonic } = await service.generateNewKeyring( - KeyringTypes.mnemonicBIP39S256 + SignerInternalTypes.mnemonicBIP39S256 ) - await service.importKeyring(mnemonic.join(" "), "import") + await service.importSigner({ + type: SignerSourceTypes.keyring, + + mnemonic: mnemonic.join(" "), + source: SignerImportSource.import, + }) }) it("will autolock after the keyring idle time but not sooner", async () => { + // eslint-disable-next-line @typescript-eslint/dot-notation + const maxIdleTime = await service["preferenceService"].getAutoLockInterval() + expect(service.locked()).toEqual(false) - callAutolockHandler(MAX_KEYRING_IDLE_TIME - 10) + callAutolockHandler(maxIdleTime - 10) expect(service.locked()).toEqual(false) - callAutolockHandler(MAX_KEYRING_IDLE_TIME) + callAutolockHandler(maxIdleTime) expect(service.locked()).toEqual(true) }) it("will autolock after the outside activity idle time but not sooner", async () => { + // eslint-disable-next-line @typescript-eslint/dot-notation + const maxIdleTime = await service["preferenceService"].getAutoLockInterval() + expect(service.locked()).toEqual(false) - callAutolockHandler(MAX_OUTSIDE_IDLE_TIME - 10) + callAutolockHandler(maxIdleTime - 10) expect(service.locked()).toEqual(false) - callAutolockHandler(MAX_OUTSIDE_IDLE_TIME) + callAutolockHandler(maxIdleTime) expect(service.locked()).toEqual(true) }) @@ -463,10 +428,7 @@ describe("Keyring service when autolocking", () => { { action: "signing a transaction", call: async () => { - const transactionWithFrom = { - ...validTransactionRequests.simple, - from: address, - } + const transactionWithFrom = createTransactionRequest({ from: address }) await service.signTransaction( { address, network: ETHEREUM }, @@ -477,60 +439,81 @@ describe("Keyring service when autolocking", () => { { action: "importing a keyring", call: async () => { - await service.importKeyring(validMnemonics.metamask[0], "import") + await service.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic: validMnemonics.metamask[0], + source: SignerImportSource.import, + }) }, }, { action: "generating a keyring", call: async () => { - await service.generateNewKeyring(KeyringTypes.mnemonicBIP39S256) + await service.generateNewKeyring(SignerInternalTypes.mnemonicBIP39S256) }, }, ])("will bump keyring activity idle time when $action", async ({ call }) => { - jest - .spyOn(Date, "now") - .mockReturnValue(dateNowValue + MAX_KEYRING_IDLE_TIME - 1) + // eslint-disable-next-line @typescript-eslint/dot-notation + const maxIdleTime = await service["preferenceService"].getAutoLockInterval() + + jest.spyOn(Date, "now").mockReturnValue(dateNowValue + maxIdleTime - 1) await call() // Bump the outside activity timer to make sure the service doesn't // autolock due to outside idleness. - jest - .spyOn(Date, "now") - .mockReturnValue(dateNowValue + MAX_OUTSIDE_IDLE_TIME - 1) + jest.spyOn(Date, "now").mockReturnValue(dateNowValue + maxIdleTime - 1) service.markOutsideActivity() - callAutolockHandler(MAX_KEYRING_IDLE_TIME) + callAutolockHandler(maxIdleTime) expect(service.locked()).toEqual(false) - callAutolockHandler(2 * MAX_KEYRING_IDLE_TIME - 10) + callAutolockHandler(2 * maxIdleTime - 10) expect(service.locked()).toEqual(false) - callAutolockHandler(2 * MAX_KEYRING_IDLE_TIME) + callAutolockHandler(2 * maxIdleTime) expect(service.locked()).toEqual(true) }) it("will bump the outside activity idle time when outside activity is marked", async () => { - jest - .spyOn(Date, "now") - .mockReturnValue(dateNowValue + MAX_OUTSIDE_IDLE_TIME - 1) + // eslint-disable-next-line @typescript-eslint/dot-notation + const maxIdleTime = await service["preferenceService"].getAutoLockInterval() + + jest.spyOn(Date, "now").mockReturnValue(dateNowValue + maxIdleTime - 1) service.markOutsideActivity() // Bump the keyring activity timer to make sure the service doesn't // autolock due to keyring idleness. - jest - .spyOn(Date, "now") - .mockReturnValue(dateNowValue + MAX_KEYRING_IDLE_TIME - 1) - await service.generateNewKeyring(KeyringTypes.mnemonicBIP39S256) + jest.spyOn(Date, "now").mockReturnValue(dateNowValue + maxIdleTime - 1) + await service.generateNewKeyring(SignerInternalTypes.mnemonicBIP39S256) - callAutolockHandler(MAX_OUTSIDE_IDLE_TIME) + callAutolockHandler(maxIdleTime) expect(service.locked()).toEqual(false) - callAutolockHandler(2 * MAX_OUTSIDE_IDLE_TIME - 10) + callAutolockHandler(2 * maxIdleTime - 10) expect(service.locked()).toEqual(false) - callAutolockHandler(2 * MAX_OUTSIDE_IDLE_TIME) + callAutolockHandler(2 * maxIdleTime) + expect(service.locked()).toEqual(true) + }) + + it("locks when auto-lock timer has been updated to be less than current idle time", async () => { + // eslint-disable-next-line @typescript-eslint/dot-notation, prefer-destructuring + const preferenceService = service["preferenceService"] + + const maxIdleTime = await preferenceService.getAutoLockInterval() + + await service.generateNewKeyring(SignerInternalTypes.mnemonicBIP39S256) + + expect(service.locked()).toBe(false) + + callAutolockHandler(maxIdleTime / 2) + + await preferenceService.updateAutoLockInterval(maxIdleTime / 2) + + await service.updateAutoLockInterval() + expect(service.locked()).toEqual(true) }) }) diff --git a/background/services/internal-signer/tests/index.unit.test.ts b/background/services/internal-signer/tests/index.unit.test.ts new file mode 100644 index 0000000000..cf11ebf0fe --- /dev/null +++ b/background/services/internal-signer/tests/index.unit.test.ts @@ -0,0 +1,284 @@ +import InternalSignerService, { + SignerInternalTypes, + SignerImportSource, + SignerSourceTypes, +} from ".." +import { ETHEREUM } from "../../../constants" +import { + createInternalSignerService, + createTransactionRequest, + createTypedData, +} from "../../../tests/factories" +import { mockLocalStorage } from "../../../tests/utils" + +const HD_WALLET_MOCK = { + mnemonic: + "input pulp truth gain expire kick castle voyage firm fee degree draft", + addresses: [ + "0x0cf98fd79eaf6d27679dc1a328621b5791bd874e", + "0xbfab971fdadcfebb1086dadf3e68d6edee81a6cf", + ], +} +const PK_WALLET_MOCK = { + address: "0x0cf98fd79eaf6d27679dc1a328621b5791bd874e", + privateKey: + "0x252da775ac59bf1e3a3c2b3b2633e29f8b8236dc3054b7ce9d019c79166ccf14", +} + +describe("InternalSignerService", () => { + let internalSignerService: InternalSignerService + + beforeEach(async () => { + mockLocalStorage() + + internalSignerService = await createInternalSignerService() + await internalSignerService.startService() + await internalSignerService.unlock("test") + }) + + afterEach(async () => { + await internalSignerService.stopService() + }) + + describe("generated HD wallet", () => { + it("should generate new HD wallet", async () => { + const keyring = await internalSignerService.generateNewKeyring( + SignerInternalTypes.mnemonicBIP39S256 + ) + + expect(keyring.id).toBeDefined() + expect(keyring.mnemonic.length).toBe(24) + + await internalSignerService.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic: keyring.mnemonic.join(" "), + source: SignerImportSource.internal, + }) + + const keyrings = internalSignerService.getKeyrings() + expect(keyrings.length).toBe(1) + expect(keyrings[0].id).toBe(keyring.id) + expect( + internalSignerService.getSignerSourceForAddress( + keyrings[0].addresses[0] + ) + ).toBe("internal") + }) + }) + + describe("imported HD wallet", () => { + beforeEach(async () => { + await internalSignerService.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic: HD_WALLET_MOCK.mnemonic, + source: SignerImportSource.import, + }) + }) + it("should add HD wallet to keyrings", () => { + const keyrings = internalSignerService.getKeyrings() + expect(keyrings.length).toBe(1) + }) + it("should classify HD wallet as imported", async () => { + expect( + internalSignerService.getSignerSourceForAddress( + HD_WALLET_MOCK.addresses[0] + ) + ).toBe("import") + }) + it("should be able to derive next address", async () => { + const [keyring] = internalSignerService.getKeyrings() + const [addressMock1, addressMock2] = HD_WALLET_MOCK.addresses + + expect(keyring.addresses.length).toBe(1) + + await internalSignerService.deriveAddress({ + type: "keyring", + keyringID: keyring.id ?? "", + }) + + const [updatedKeyring] = internalSignerService.getKeyrings() + + expect(updatedKeyring.addresses.length).toBe(2) + + const [address1, address2] = updatedKeyring.addresses + expect(address1).toBe(addressMock1) + expect(address2).toBe(addressMock2) + }) + it("should be able to hide address from HD wallet", async () => { + const [keyring] = internalSignerService.getKeyrings() + + const address = await internalSignerService.deriveAddress({ + type: "keyring", + keyringID: keyring.id ?? "", + }) + + await internalSignerService.removeAccount(address) + + const [updatedKeyring] = internalSignerService.getKeyrings() + + expect(updatedKeyring.addresses.length).toBe(1) + }) + it("should be able to remove HD wallet by hiding all addresses", async () => { + await internalSignerService.removeAccount(HD_WALLET_MOCK.addresses[0]) + const keyrings = internalSignerService.getKeyrings() + + expect(keyrings.length).toBe(0) + }) + it("should be able to remove HD wallet and add it again", async () => { + await internalSignerService.removeAccount(HD_WALLET_MOCK.addresses[0]) + await internalSignerService.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic: HD_WALLET_MOCK.mnemonic, + source: SignerImportSource.import, + }) + const keyrings = internalSignerService.getKeyrings() + + expect(keyrings.length).toBe(1) + }) + it("should be able to sign transaction", async () => { + const address = HD_WALLET_MOCK.addresses[0] + const signed = await internalSignerService.signTransaction( + { address, network: ETHEREUM }, + createTransactionRequest({ from: address }) + ) + + expect(signed).toMatchObject({ + from: expect.stringMatching(new RegExp(address, "i")), + r: expect.anything(), + s: expect.anything(), + v: expect.anything(), + }) + }) + it("should be able to sign typed data", async () => { + const address = HD_WALLET_MOCK.addresses[0] + const typedData = createTypedData() + const signed = await internalSignerService.signTypedData({ + typedData, + account: address, + }) + + expect(signed).toBeDefined() + }) + it("should be able to make a personal sign", async () => { + const address = HD_WALLET_MOCK.addresses[0] + const signingData = "0x1230" + const signed = await internalSignerService.personalSign({ + signingData, + account: address, + }) + + expect(signed).toBeDefined() + }) + }) + + describe("wallet imported with private key", () => { + beforeEach(async () => { + await internalSignerService.importSigner({ + type: SignerSourceTypes.privateKey, + privateKey: PK_WALLET_MOCK.privateKey, + }) + }) + it("should add pk wallet to wallets", () => { + const wallets = internalSignerService.getPrivateKeys() + expect(wallets.length).toBe(1) + }) + it("should classify pk wallet as imported", async () => { + expect( + internalSignerService.getSignerSourceForAddress(PK_WALLET_MOCK.address) + ).toBe("import") + }) + it("should be able to remove pk wallet and add it again", async () => { + await internalSignerService.removeAccount(PK_WALLET_MOCK.address) + expect(internalSignerService.getPrivateKeys().length).toBe(0) + + await internalSignerService.importSigner({ + type: SignerSourceTypes.privateKey, + privateKey: PK_WALLET_MOCK.privateKey, + }) + expect(internalSignerService.getPrivateKeys().length).toBe(1) + }) + it("should be able to sign transaction", async () => { + const { address } = PK_WALLET_MOCK + const signed = await internalSignerService.signTransaction( + { address, network: ETHEREUM }, + createTransactionRequest({ from: address }) + ) + + expect(signed).toMatchObject({ + from: expect.stringMatching(new RegExp(address, "i")), + r: expect.anything(), + s: expect.anything(), + v: expect.anything(), + }) + }) + it("should be able to sign typed data", async () => { + const { address } = PK_WALLET_MOCK + const typedData = createTypedData() + const signed = await internalSignerService.signTypedData({ + typedData, + account: address, + }) + + expect(signed).toBeDefined() + }) + it("should be able to make a personal sign", async () => { + const { address } = PK_WALLET_MOCK + const signingData = "0x1230" + const signed = await internalSignerService.personalSign({ + signingData, + account: address, + }) + + expect(signed).toBeDefined() + }) + }) + + describe("export secrets", () => { + beforeEach(async () => { + await internalSignerService.importSigner({ + type: SignerSourceTypes.privateKey, + privateKey: PK_WALLET_MOCK.privateKey, + }) + await internalSignerService.importSigner({ + type: SignerSourceTypes.keyring, + mnemonic: HD_WALLET_MOCK.mnemonic, + source: SignerImportSource.import, + }) + }) + it("should be able to export private key", async () => { + const privateKey = await internalSignerService.exportPrivateKey( + PK_WALLET_MOCK.address + ) + + expect(privateKey).toBe(PK_WALLET_MOCK.privateKey) + }) + it("should be able to export mnemonic", async () => { + const mnemonic = await internalSignerService.exportMnemonic( + HD_WALLET_MOCK.addresses[0] + ) + + expect(mnemonic).toBe(HD_WALLET_MOCK.mnemonic) + }) + it("should be able to export private key from HD wallet addresses", async () => { + const privateKey = await internalSignerService.exportPrivateKey( + HD_WALLET_MOCK.addresses[0] + ) + + expect(privateKey).toBe(PK_WALLET_MOCK.privateKey) // first address from both mocks is the same + }) + it("should require wallet to be unlocked to export secrets", async () => { + internalSignerService.lock() + + const errorMessage = "InternalSignerService must be unlocked." + const exportMnemonic = async () => { + await internalSignerService.exportMnemonic(HD_WALLET_MOCK.addresses[0]) + } + const exportPrivateKey = async () => { + await internalSignerService.exportPrivateKey(PK_WALLET_MOCK.address) + } + + expect(exportMnemonic()).rejects.toThrowError(errorMessage) + expect(exportPrivateKey()).rejects.toThrowError(errorMessage) + }) + }) +}) diff --git a/background/services/internal-signer/tests/storage.unit.test.ts b/background/services/internal-signer/tests/storage.unit.test.ts new file mode 100644 index 0000000000..4ca65dd0da --- /dev/null +++ b/background/services/internal-signer/tests/storage.unit.test.ts @@ -0,0 +1,135 @@ +import { + EncryptedVault, + VaultVersion, + decryptVault, + encryptVault, +} from "../encryption" +import { + getEncryptedVaults, + migrateVaultsToLatestVersion, + writeLatestEncryptedVault, +} from "../storage" + +const mockedPassword = "password" +const mockedVault = { text: "secret" } + +describe("Storage utils", () => { + let vaultEncryptedWithPBKDF2: EncryptedVault + let vaultEncryptedWithArgon2: EncryptedVault + + beforeAll(async () => { + vaultEncryptedWithPBKDF2 = await encryptVault({ + vault: mockedVault, + passwordOrSaltedKey: mockedPassword, + version: VaultVersion.PBKDF2, + }) + + vaultEncryptedWithArgon2 = await encryptVault({ + vault: mockedVault, + passwordOrSaltedKey: mockedPassword, + version: VaultVersion.Argon2, + }) + }) + + it("should be able to store and retrieve data encrypted with PBKDF2", async () => { + await browser.storage.local.set({ + tallyVaults: { + version: VaultVersion.PBKDF2, + vaults: [], + }, + }) + await writeLatestEncryptedVault(vaultEncryptedWithPBKDF2) + + const { vaults, version } = await getEncryptedVaults() + + expect(version).toEqual(VaultVersion.PBKDF2) + expect(vaults.length).toEqual(1) + expect(vaults[0].vault).toEqual(vaultEncryptedWithPBKDF2) + }) + + it("should be able to store and retrieve data encrypted with Argon2", async () => { + await browser.storage.local.set({ + tallyVaults: { + version: VaultVersion.Argon2, + vaults: [], + }, + }) + await writeLatestEncryptedVault(vaultEncryptedWithArgon2) + + const { vaults, version } = await getEncryptedVaults() + + expect(version).toEqual(VaultVersion.Argon2) + expect(vaults.length).toEqual(1) + expect(vaults[0].vault).toEqual(vaultEncryptedWithArgon2) + }) + + it("should migrate existing vaults to Argon2", async () => { + await browser.storage.local.set({ + tallyVaults: { + version: VaultVersion.PBKDF2, + vaults: [], + }, + }) + await writeLatestEncryptedVault(vaultEncryptedWithPBKDF2) + + const { + encryptedData: { vaults, version }, + migrated, + } = await migrateVaultsToLatestVersion(mockedPassword) + + expect(migrated).toEqual(true) + expect(version).toEqual(VaultVersion.Argon2) + expect(vaults.length).toEqual(1) + + const decryptedVault = await decryptVault({ + version: VaultVersion.Argon2, + vault: vaults[0].vault, + passwordOrSaltedKey: mockedPassword, + }) + expect(decryptedVault).toEqual(mockedVault) + }) + + it("should not migrate vaults if they are already encrypted with Argon2", async () => { + await browser.storage.local.set({ + tallyVaults: { + version: VaultVersion.Argon2, + vaults: [], + }, + }) + await writeLatestEncryptedVault(vaultEncryptedWithArgon2) + + const { + encryptedData: { vaults, version }, + ...migrationData + } = await migrateVaultsToLatestVersion(mockedPassword) + + expect(migrationData.migrated).toEqual(false) + expect( + migrationData.migrated === false && migrationData.errorMessage + ).toBeUndefined() + expect(version).toEqual(VaultVersion.Argon2) + expect(vaults[0].vault).toEqual(vaultEncryptedWithArgon2) + }) + + it("should report migration errors in the return value", async () => { + await browser.storage.local.set({ + tallyVaults: { + version: VaultVersion.PBKDF2, + vaults: [], + }, + }) + await writeLatestEncryptedVault(vaultEncryptedWithPBKDF2) + + const migrationData = await migrateVaultsToLatestVersion("wrong password") + + expect(migrationData.migrated).toEqual(false) + expect( + migrationData.migrated === false && migrationData.errorMessage + ).not.toBeUndefined() + expect( + migrationData.migrated === false && + migrationData.errorMessage !== undefined && + migrationData.errorMessage.length + ).toBeGreaterThan(1) + }) +}) diff --git a/background/services/keyring/index.ts b/background/services/keyring/index.ts deleted file mode 100644 index 793d6292e2..0000000000 --- a/background/services/keyring/index.ts +++ /dev/null @@ -1,683 +0,0 @@ -import { parse as parseRawTransaction } from "@ethersproject/transactions" - -import HDKeyring, { SerializedHDKeyring } from "@tallyho/hd-keyring" - -import { arrayify } from "ethers/lib/utils" -import { normalizeEVMAddress } from "../../lib/utils" -import { ServiceCreatorFunction, ServiceLifecycleEvents } from "../types" -import { getEncryptedVaults, writeLatestEncryptedVault } from "./storage" -import { - decryptVault, - deriveSymmetricKeyFromPassword, - encryptVault, - SaltedKey, -} from "./encryption" -import { HexString, KeyringTypes, EIP712TypedData, UNIXTime } from "../../types" -import { SignedTransaction, TransactionRequestWithNonce } from "../../networks" - -import BaseService from "../base" -import { FORK, MINUTE } from "../../constants" -import { ethersTransactionFromTransactionRequest } from "../chain/utils" -import { FeatureFlags, isEnabled } from "../../features" -import { AddressOnNetwork } from "../../accounts" -import logger from "../../lib/logger" - -export const MAX_KEYRING_IDLE_TIME = 60 * MINUTE -export const MAX_OUTSIDE_IDLE_TIME = 60 * MINUTE - -export type Keyring = { - type: KeyringTypes - id: string | null - path: string | null - addresses: string[] -} - -export type KeyringAccountSigner = { - type: "keyring" - keyringID: string -} - -export interface KeyringMetadata { - source: "import" | "internal" -} - -interface SerializedKeyringData { - keyrings: SerializedHDKeyring[] - metadata: { [keyringId: string]: KeyringMetadata } - hiddenAccounts: { [address: HexString]: boolean } -} - -interface Events extends ServiceLifecycleEvents { - locked: boolean - keyrings: { - keyrings: Keyring[] - keyringMetadata: { - [keyringId: string]: KeyringMetadata - } - } - address: string - // TODO message was signed - signedTx: SignedTransaction - signedData: string -} - -/* - * KeyringService is responsible for all key material, as well as applying the - * material to sign messages, sign transactions, and derive child keypairs. - * - * The service can be in two states, locked or unlocked, and starts up locked. - * Keyrings are persisted in encrypted form when the service is locked. - * - * When unlocked, the service automatically locks itself after it has not seen - * activity for a certain amount of time. The service can be notified of - * outside activity that should be considered for the purposes of keeping the - * service unlocked. No keyring activity for 30 minutes causes the service to - * lock, while no outside activity for 30 minutes has the same effect. - */ -export default class KeyringService extends BaseService { - #cachedKey: SaltedKey | null = null - - #keyrings: HDKeyring[] = [] - - #keyringMetadata: { [keyringId: string]: KeyringMetadata } = {} - - #hiddenAccounts: { [address: HexString]: boolean } = {} - - /** - * The last time a keyring took an action that required the service to be - * unlocked (signing, adding a keyring, etc). - */ - lastKeyringActivity: UNIXTime | undefined - - /** - * The last time the keyring was notified of an activity outside of the - * keyring. {@see markOutsideActivity} - */ - lastOutsideActivity: UNIXTime | undefined - - static create: ServiceCreatorFunction = - async () => { - return new this() - } - - private constructor() { - super({ - autolock: { - schedule: { - periodInMinutes: 1, - }, - handler: () => { - this.autolockIfNeeded() - }, - }, - }) - } - - override async internalStartService(): Promise { - // Emit locked status on startup. Should always be locked, but the main - // goal is to have external viewers synced to internal state no matter what - // it is. Don't emit if there are no keyrings to unlock. - await super.internalStartService() - if ((await getEncryptedVaults()).vaults.length > 0) { - this.emitter.emit("locked", this.locked()) - } - } - - override async internalStopService(): Promise { - await this.lock() - - await super.internalStopService() - } - - /** - * @return True if the keyring is locked, false if it is unlocked. - */ - locked(): boolean { - return this.#cachedKey === null - } - - /** - * Update activity timestamps and emit unlocked event. - */ - #unlock(): void { - this.lastKeyringActivity = Date.now() - this.lastOutsideActivity = Date.now() - this.emitter.emit("locked", false) - } - - /** - * Unlock the keyring with a provided password, initializing from the most - * recently persisted keyring vault if one exists. - * - * @param password A user-chosen string used to encrypt keyring vaults. - * Unlocking will fail if an existing vault is found, and this password - * can't decrypt it. - * - * Note that losing this password means losing access to any key - * material stored in a vault. - * @param ignoreExistingVaults If true, ignore any existing, previously - * persisted vaults on unlock, instead starting with a clean slate. - * This option makes sense if a user has lost their password, and needs - * to generate a new keyring. - * - * Note that old vaults aren't deleted, and can still be recovered - * later in an emergency. - * @returns true if the service was successfully unlocked using the password, - * and false otherwise. - */ - async unlock( - password: string, - ignoreExistingVaults = false - ): Promise { - if (!this.locked()) { - logger.warn("KeyringService is already unlocked!") - this.#unlock() - return true - } - - if (!ignoreExistingVaults) { - const { vaults } = await getEncryptedVaults() - const currentEncryptedVault = vaults.slice(-1)[0]?.vault - if (currentEncryptedVault) { - // attempt to load the vault - const saltedKey = await deriveSymmetricKeyFromPassword( - password, - currentEncryptedVault.salt - ) - let plainTextVault: SerializedKeyringData - try { - plainTextVault = await decryptVault( - currentEncryptedVault, - saltedKey - ) - this.#cachedKey = saltedKey - } catch (err) { - // if we weren't able to load the vault, don't unlock - return false - } - // hooray! vault is loaded, import any serialized keyrings - this.#keyrings = [] - this.#keyringMetadata = {} - plainTextVault.keyrings.forEach((kr) => { - this.#keyrings.push(HDKeyring.deserialize(kr)) - }) - - this.#keyringMetadata = { - ...plainTextVault.metadata, - } - - this.#hiddenAccounts = { - ...plainTextVault.hiddenAccounts, - } - - this.emitKeyrings() - } - } - - // if there's no vault or we want to force a new vault, generate a new key - // and unlock - if (!this.#cachedKey) { - this.#cachedKey = await deriveSymmetricKeyFromPassword(password) - await this.persistKeyrings() - } - - this.#unlock() - return true - } - - /** - * Lock the keyring service, deleting references to the cached vault - * encryption key and keyrings. - */ - async lock(): Promise { - this.lastKeyringActivity = undefined - this.lastOutsideActivity = undefined - this.#cachedKey = null - this.#keyrings = [] - this.#keyringMetadata = {} - this.emitter.emit("locked", true) - this.emitKeyrings() - } - - /** - * Notifies the keyring that an outside activity occurred. Outside activities - * are used to delay autolocking. - */ - markOutsideActivity(): void { - if (typeof this.lastOutsideActivity !== "undefined") { - this.lastOutsideActivity = Date.now() - } - } - - // Locks the keyring if the time since last keyring or outside activity - // exceeds preset levels. - private async autolockIfNeeded(): Promise { - if ( - typeof this.lastKeyringActivity === "undefined" || - typeof this.lastOutsideActivity === "undefined" - ) { - // Normally both activity counters should be undefined only if the keyring - // is locked, otherwise they should both be set; regardless, fail safe if - // either is undefined and the keyring is unlocked. - if (!this.locked()) { - await this.lock() - } - - return - } - - const now = Date.now() - const timeSinceLastKeyringActivity = now - this.lastKeyringActivity - const timeSinceLastOutsideActivity = now - this.lastOutsideActivity - - if (timeSinceLastKeyringActivity >= MAX_KEYRING_IDLE_TIME) { - this.lock() - } else if (timeSinceLastOutsideActivity >= MAX_OUTSIDE_IDLE_TIME) { - this.lock() - } - } - - // Throw if the keyring is not unlocked; if it is, update the last keyring - // activity timestamp. - private requireUnlocked(): void { - if (this.locked()) { - throw new Error("KeyringService must be unlocked.") - } - - this.lastKeyringActivity = Date.now() - this.markOutsideActivity() - } - - // /////////////////////////////////////////// - // METHODS THAT REQUIRE AN UNLOCKED SERVICE // - // /////////////////////////////////////////// - - /** - * Generate a new keyring - * - * @param type - the type of keyring to generate. Currently only supports 256- - * bit HD keys. - * @returns An object containing the string ID of the new keyring and the - * mnemonic for the new keyring. Note that the mnemonic can only be - * accessed at generation time through this return value. - */ - async generateNewKeyring( - type: KeyringTypes, - path?: string - ): Promise<{ id: string; mnemonic: string[] }> { - this.requireUnlocked() - - if (type !== KeyringTypes.mnemonicBIP39S256) { - throw new Error( - "KeyringService only supports generating 256-bit HD key trees" - ) - } - - const options: { strength: number; path?: string } = { strength: 256 } - - if (path) { - options.path = path - } - - const newKeyring = new HDKeyring(options) - - const { mnemonic } = newKeyring.serializeSync() - - return { id: newKeyring.id, mnemonic: mnemonic.split(" ") } - } - - /** - * Import keyring and pull the first address from that - * keyring for system use. - * - * @param mnemonic - a seed phrase - * @returns The string ID of the new keyring. - */ - async importKeyring( - mnemonic: string, - source: "import" | "internal", - path?: string - ): Promise { - this.requireUnlocked() - - const newKeyring = path - ? new HDKeyring({ mnemonic, path }) - : new HDKeyring({ mnemonic }) - - if (this.#keyrings.some((kr) => kr.id === newKeyring.id)) { - return newKeyring.id - } - this.#keyrings.push(newKeyring) - const [address] = newKeyring.addAddressesSync(1) - this.#keyringMetadata[newKeyring.id] = { source } - await this.persistKeyrings() - this.emitter.emit("address", address) - this.emitKeyrings() - return newKeyring.id - } - - /** - * Return the source of a given address' keyring if it exists. If an - * address does not have a keyring associated with it - returns null. - */ - async getKeyringSourceForAddress( - address: string - ): Promise<"import" | "internal" | null> { - try { - const keyring = await this.#findKeyring(address) - return this.#keyringMetadata[keyring.id].source - } catch (e) { - // Address is not associated with a keyring - return null - } - } - - /** - * Return an array of keyring representations that can safely be stored and - * used outside the extension. - */ - getKeyrings(): Keyring[] { - this.requireUnlocked() - - return this.#keyrings.map((kr) => ({ - // TODO this type is meanlingless from the library's perspective. - // Reconsider, or explicitly track which keyrings have been generated vs - // imported as well as their strength - type: KeyringTypes.mnemonicBIP39S256, - addresses: [ - ...kr - .getAddressesSync() - .filter((address) => this.#hiddenAccounts[address] !== true), - ], - id: kr.id, - path: kr.path, - })) - } - - /** - * Derive and return the next address for a KeyringAccountSigner representing - * an HDKeyring. - * - * @param keyringAccountSigner - A KeyringAccountSigner representing the - * given keyring. - */ - async deriveAddress({ keyringID }: KeyringAccountSigner): Promise { - this.requireUnlocked() - - // find the keyring using a linear search - const keyring = this.#keyrings.find((kr) => kr.id === keyringID) - if (!keyring) { - throw new Error("Keyring not found.") - } - - const keyringAddresses = keyring.getAddressesSync() - - // If There are any hidden addresses, show those first before adding new ones. - const newAddress = - keyringAddresses.find( - (address) => this.#hiddenAccounts[address] === true - ) ?? keyring.addAddressesSync(1)[0] - - this.#hiddenAccounts[newAddress] = false - - await this.persistKeyrings() - - this.emitter.emit("address", newAddress) - this.emitKeyrings() - - return newAddress - } - - async hideAccount(address: HexString): Promise { - this.#hiddenAccounts[address] = true - const keyring = await this.#findKeyring(address) - const keyringAddresses = await keyring.getAddresses() - if ( - keyringAddresses.every( - (keyringAddress) => this.#hiddenAccounts[keyringAddress] === true - ) - ) { - keyringAddresses.forEach((keyringAddress) => { - delete this.#hiddenAccounts[keyringAddress] - }) - this.#removeKeyring(keyring.id) - } - await this.persistKeyrings() - this.emitKeyrings() - } - - #removeKeyring(keyringId: string): HDKeyring[] { - const filteredKeyrings = this.#keyrings.filter( - (keyring) => keyring.id !== keyringId - ) - - if (filteredKeyrings.length === this.#keyrings.length) { - throw new Error( - `Attempting to remove keyring that does not exist. id: (${keyringId})` - ) - } - this.#keyrings = filteredKeyrings - return filteredKeyrings - } - - /** - * Find keyring associated with an account. - * - * @param account - the account desired to search the keyring for. - */ - async #findKeyring(account: HexString): Promise { - const keyring = this.#keyrings.find((kr) => - kr.getAddressesSync().includes(normalizeEVMAddress(account)) - ) - if (!keyring) { - throw new Error("Address keyring not found.") - } - return keyring - } - - /** - * Sign a transaction. - * - * @param account - the account desired to sign the transaction - * @param txRequest - - */ - async signTransaction( - addressOnNetwork: AddressOnNetwork, - txRequest: TransactionRequestWithNonce - ): Promise { - this.requireUnlocked() - - const { address: account, network } = addressOnNetwork - - // find the keyring using a linear search - const keyring = await this.#findKeyring(account) - - // ethers has a looser / slightly different request type - const ethersTxRequest = ethersTransactionFromTransactionRequest(txRequest) - - // unfortunately, ethers gives us a serialized signed tx here - const signed = await keyring.signTransaction(account, ethersTxRequest) - - // parse the tx, then unpack it as best we can - const tx = parseRawTransaction(signed) - - if (!tx.hash || !tx.from || !tx.r || !tx.s || typeof tx.v === "undefined") { - throw new Error("Transaction doesn't appear to have been signed.") - } - - const { - to, - gasPrice, - gasLimit, - maxFeePerGas, - maxPriorityFeePerGas, - hash, - from, - nonce, - data, - value, - type, - r, - s, - v, - } = tx - - if ( - typeof maxPriorityFeePerGas === "undefined" || - typeof maxFeePerGas === "undefined" || - type !== 2 - ) { - const signedTx = { - hash, - from, - to, - nonce, - input: data, - value: value.toBigInt(), - type: type as 0, - gasPrice: gasPrice?.toBigInt() ?? null, - gasLimit: gasLimit.toBigInt(), - maxFeePerGas: null, - maxPriorityFeePerGas: null, - r, - s, - v, - blockHash: null, - blockHeight: null, - asset: network.baseAsset, - network: isEnabled(FeatureFlags.USE_MAINNET_FORK) ? FORK : network, - } - return signedTx - } - - // TODO move this to a helper function - const signedTx: SignedTransaction = { - hash, - from, - to, - nonce, - input: data, - value: value.toBigInt(), - type, - gasPrice: null, - maxFeePerGas: maxFeePerGas.toBigInt(), - maxPriorityFeePerGas: maxPriorityFeePerGas.toBigInt(), - gasLimit: gasLimit.toBigInt(), - r, - s, - v, - blockHash: null, - blockHeight: null, - asset: network.baseAsset, - network: isEnabled(FeatureFlags.USE_MAINNET_FORK) ? FORK : network, - } - - return signedTx - } - /** - * Sign typed data based on EIP-712 with the usage of eth_signTypedData_v4 method, - * more information about the EIP can be found at https://eips.ethereum.org/EIPS/eip-712 - * - * @param typedData - the data to be signed - * @param account - signers account address - */ - - async signTypedData({ - typedData, - account, - }: { - typedData: EIP712TypedData - account: HexString - }): Promise { - this.requireUnlocked() - const { domain, types, message } = typedData - // find the keyring using a linear search - const keyring = await this.#findKeyring(account) - // When signing we should not include EIP712Domain type - const { EIP712Domain, ...typesForSigning } = types - try { - const signature = await keyring.signTypedData( - account, - domain, - typesForSigning, - message - ) - - return signature - } catch (error) { - throw new Error("Signing data failed") - } - } - - /** - * Sign data based on EIP-191 with the usage of personal_sign method, - * more information about the EIP can be found at https://eips.ethereum.org/EIPS/eip-191 - * - * @param signingData - the data to be signed - * @param account - signers account address - */ - - async personalSign({ - signingData, - account, - }: { - signingData: HexString - account: HexString - }): Promise { - this.requireUnlocked() - - // find the keyring using a linear search - const keyring = await this.#findKeyring(account) - try { - const signature = await keyring.signMessageBytes( - account, - arrayify(signingData) - ) - - return signature - } catch (error) { - throw new Error("Signing data failed") - } - } - - // ////////////////// - // PRIVATE METHODS // - // ////////////////// - - private emitKeyrings() { - if (this.locked()) { - this.emitter.emit("keyrings", { keyrings: [], keyringMetadata: {} }) - } else { - const keyrings = this.getKeyrings() - this.emitter.emit("keyrings", { - keyrings, - keyringMetadata: { ...this.#keyringMetadata }, - }) - } - } - - /** - * Serialize, encrypt, and persist all HDKeyrings. - */ - private async persistKeyrings() { - this.requireUnlocked() - - // This if guard will always pass due to requireUnlocked, but statically - // prove it to TypeScript. - if (this.#cachedKey !== null) { - const serializedKeyrings = this.#keyrings.map((kr) => kr.serializeSync()) - const hiddenAccounts = { ...this.#hiddenAccounts } - const keyringMetadata = { ...this.#keyringMetadata } - serializedKeyrings.sort((a, b) => (a.id > b.id ? 1 : -1)) - const vault = await encryptVault( - { - keyrings: serializedKeyrings, - metadata: keyringMetadata, - hiddenAccounts, - }, - this.#cachedKey - ) - await writeLatestEncryptedVault(vault) - } - } -} diff --git a/background/services/keyring/storage.ts b/background/services/keyring/storage.ts deleted file mode 100644 index 2158d00471..0000000000 --- a/background/services/keyring/storage.ts +++ /dev/null @@ -1,88 +0,0 @@ -import browser from "webextension-polyfill" - -import { EncryptedVault } from "./encryption" -import { UNIXTime } from "../../types" - -type SerializedEncryptedVault = { - timeSaved: UNIXTime - vault: EncryptedVault -} - -type SerializedEncryptedVaults = { - version: 1 - vaults: SerializedEncryptedVault[] -} - -/** - * Retrieve all serialized encrypted vaults from extension storage. - * - * @returns a schema version and array of serialized vaults - */ -export async function getEncryptedVaults(): Promise { - const data = await browser.storage.local.get("tallyVaults") - if (!("tallyVaults" in data)) { - return { - version: 1, - vaults: [], - } - } - const { tallyVaults } = data - if ( - "version" in tallyVaults && - tallyVaults.version === 1 && - "vaults" in tallyVaults && - Array.isArray(tallyVaults.vaults) - ) { - return tallyVaults as SerializedEncryptedVaults - } - throw new Error("Encrypted vaults are using an unkown serialization format") -} - -function equalVaults(vault1: EncryptedVault, vault2: EncryptedVault): boolean { - if (vault1.salt !== vault2.salt) { - return false - } - if (vault1.initializationVector !== vault2.initializationVector) { - return false - } - if (vault1.cipherText !== vault2.cipherText) { - return false - } - return true -} - -/** - * Write an encryptedVault to extension storage if and only if it's different - * than the most recently saved vault. - * - * @param encryptedVault - an encrypted keyring vault - */ -export async function writeLatestEncryptedVault( - encryptedVault: EncryptedVault -): Promise { - const serializedVaults = await getEncryptedVaults() - const vaults = [...serializedVaults.vaults] - const currentLatest = vaults.reduce( - (newestVault, nextVault) => - newestVault && newestVault.timeSaved > nextVault.timeSaved - ? newestVault - : nextVault, - null - ) - const oldVault = currentLatest && currentLatest.vault - // if there's been a change, persist the vault - if (!oldVault || !equalVaults(oldVault, encryptedVault)) { - await browser.storage.local.set({ - tallyVaults: { - ...serializedVaults, - vaults: [ - ...serializedVaults.vaults, - { - timeSaved: Date.now(), - vault: encryptedVault, - }, - ], - }, - }) - } -} diff --git a/background/services/ledger/index.ts b/background/services/ledger/index.ts index 9ece396e8c..6be273bef6 100644 --- a/background/services/ledger/index.ts +++ b/background/services/ledger/index.ts @@ -396,7 +396,7 @@ export default class LedgerService extends BaseService { if (!isEIP1559TransactionRequest(ethersTx)) { // Ethers does not permit "from" field when serializing legacy transaction requests - const { from, ...fieldsWithoutFrom } = ethersTx + const { from: _, ...fieldsWithoutFrom } = ethersTx serializableEthersTx = fieldsWithoutFrom } @@ -492,7 +492,7 @@ export default class LedgerService extends BaseService { } const eth = new Eth(this.transport) - const { EIP712Domain, ...typesForSigning } = typedData.types + const { EIP712Domain: _, ...typesForSigning } = typedData.types const hashedDomain = _TypedDataEncoder.hashDomain(typedData.domain) const hashedMessage = _TypedDataEncoder .from(typesForSigning) diff --git a/background/services/name/index.ts b/background/services/name/index.ts index 55c10515a6..2d51b9523e 100644 --- a/background/services/name/index.ts +++ b/background/services/name/index.ts @@ -134,20 +134,13 @@ export default class NameService extends BaseService { } ) - chainService.emitter.on( - "newAccountToTrack", - async ({ addressOnNetwork }) => { - try { - await this.lookUpName(addressOnNetwork) - } catch (error) { - logger.error( - "Error fetching name for address", - addressOnNetwork, - error - ) - } + chainService.emitter.on("newAccountToTrack", async (addressOnNetwork) => { + try { + await this.lookUpName(addressOnNetwork) + } catch (error) { + logger.error("Error fetching name for address", addressOnNetwork, error) } - ) + }) this.emitter.on("resolvedName", async ({ from: { addressOnNetwork } }) => { try { const avatar = await this.lookUpAvatar(addressOnNetwork) diff --git a/background/services/nfts/index.ts b/background/services/nfts/index.ts index 5722e3a677..561b3f3a88 100644 --- a/background/services/nfts/index.ts +++ b/background/services/nfts/index.ts @@ -89,7 +89,7 @@ export default class NFTsService extends BaseService { this.chainService.emitter.on( "newAccountToTrack", - async ({ addressOnNetwork }) => { + async (addressOnNetwork) => { this.emitter.emit("isReloadingNFTs", true) await this.initializeCollections([addressOnNetwork]) this.emitter.emit("isReloadingNFTs", false) diff --git a/background/services/preferences/db.ts b/background/services/preferences/db.ts index a3770c3630..ea6bd20bbf 100644 --- a/background/services/preferences/db.ts +++ b/background/services/preferences/db.ts @@ -3,11 +3,12 @@ import Dexie, { Transaction } from "dexie" import { FiatCurrency } from "../../assets" import { AddressOnNetwork } from "../../accounts" -import DEFAULT_PREFERENCES from "./defaults" +import DEFAULT_PREFERENCES, { DEFAULT_AUTOLOCK_INTERVAL } from "./defaults" import { AccountSignerSettings } from "../../ui" import { AccountSignerWithId } from "../../signing" import { AnalyticsPreferences } from "./types" import { NETWORK_BY_CHAIN_ID } from "../../constants" +import { UNIXTime } from "../../types" type SignerRecordId = `${AccountSignerWithId["type"]}/${string}` @@ -16,12 +17,37 @@ type SignerRecordId = `${AccountSignerWithId["type"]}/${string}` * in the form of "signerType/someId" e.g. "ledger/deviceId" */ const getSignerRecordId = (signer: AccountSignerWithId): SignerRecordId => { - const id = signer.type === "keyring" ? signer.keyringID : signer.deviceID - return `${signer.type}/${id}` + switch (signer.type) { + case "keyring": + return `${signer.type}/${signer.keyringID}` + case "private-key": + return `${signer.type}/${signer.walletID}` + default: + return `${signer.type}/${signer.deviceID}` + } +} + +/** + * Update Taho token list reference. + * Returns an updated URLs for the token list. + */ +const getNewUrlsForTokenList = ( + storedPreferences: Preferences, + oldPath: string, + newPath: string +): string[] => { + // Get rid of old Taho URL + const newURLs = storedPreferences.tokenLists.urls.filter( + (url) => !url.includes(oldPath) + ) + newURLs.push(`https://ipfs.io/ipfs/${newPath}`) + + return newURLs } // The idea is to use this interface to describe the data structure stored in indexedDb // In the future this might also have a runtime type check capability, but it's good enough for now. +// NOTE: Check if can be merged with preferences/types.ts export type Preferences = { id?: number savedAt: number @@ -34,6 +60,7 @@ export type Preferences = { isEnabled: boolean hasDefaultOnBeenTurnedOn: boolean } + autoLockInterval: UNIXTime } /** @@ -41,7 +68,9 @@ export type Preferences = { * manually dismissed. Manual dismissal can include closing a popover, or * selecting "Don't show again" on a popup before closing it. */ -export type ManuallyDismissableItem = "analytics-enabled-banner" +export type ManuallyDismissableItem = + | "analytics-enabled-banner" + | "copy-sensitive-material-warning" /** * Items that the user will see once and will not be auto-displayed again. Can * be used for tours, or for popups that can be retriggered but will not @@ -159,16 +188,12 @@ export class PreferenceDatabase extends Dexie { .table("preferences") .toCollection() .modify((storedPreferences: Preferences) => { - // Get rid of old tally URL - const newURLs = storedPreferences.tokenLists.urls.filter( - (url) => - !url.includes( - "bafybeicovpqvb533alo5scf7vg34z6fjspdytbzsa2es2lz35sw3ksh2la" - ) - ) - - newURLs.push( - "https://ipfs.io/ipfs/bafybeifeqadgtritd3p2qzf5ntzsgnph77hwt4tme2umiuxv2ez2jspife" + const newURLs = getNewUrlsForTokenList( + storedPreferences, + // Old path + "bafybeicovpqvb533alo5scf7vg34z6fjspdytbzsa2es2lz35sw3ksh2la", + // New path + "bafybeifeqadgtritd3p2qzf5ntzsgnph77hwt4tme2umiuxv2ez2jspife" ) // eslint-disable-next-line no-param-reassign @@ -233,16 +258,12 @@ export class PreferenceDatabase extends Dexie { .table("preferences") .toCollection() .modify((storedPreferences: Preferences) => { - // Get rid of old tally URL - const newURLs = storedPreferences.tokenLists.urls.filter( - (url) => - !url.includes( - "bafybeifeqadgtritd3p2qzf5ntzsgnph77hwt4tme2umiuxv2ez2jspife" - ) - ) - - newURLs.push( - "https://ipfs.io/ipfs/bafybeigtlpxobme7utbketsaofgxqalgqzowhx24wlwwrtbzolgygmqorm" + const newURLs = getNewUrlsForTokenList( + storedPreferences, + // Old path + "bafybeifeqadgtritd3p2qzf5ntzsgnph77hwt4tme2umiuxv2ez2jspife", + // New path + "bafybeigtlpxobme7utbketsaofgxqalgqzowhx24wlwwrtbzolgygmqorm" ) // eslint-disable-next-line no-param-reassign @@ -346,6 +367,42 @@ export class PreferenceDatabase extends Dexie { shownDismissableItems: "&id,shown", }) + this.version(18).upgrade((tx) => { + return tx + .table("preferences") + .toCollection() + .modify((storedPreferences: Preferences) => { + const newURLs = getNewUrlsForTokenList( + storedPreferences, + // Old path + "bafybeigtlpxobme7utbketsaofgxqalgqzowhx24wlwwrtbzolgygmqorm", + // New path + "bafybeihufwj43zej34itf66qyguq35k4f6s4ual4uk3iy643wn3xnff2ka" + ) + + // Param reassignment is the recommended way to use `modify` https://dexie.org/docs/Collection/Collection.modify() + // eslint-disable-next-line no-param-reassign + storedPreferences.tokenLists = { + ...storedPreferences.tokenLists, + urls: newURLs, + } + }) + }) + + // Updates preferences to allow custom auto lock timers + this.version(19).upgrade((tx) => { + return tx + .table("preferences") + .toCollection() + .modify((storedPreferences: Preferences) => { + const update: Partial = { + autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, + } + + Object.assign(storedPreferences, update) + }) + }) + // This is the old version for populate // https://dexie.org/docs/Dexie/Dexie.on.populate-(old-version) // The this does not behave according the new docs, but works @@ -362,6 +419,16 @@ export class PreferenceDatabase extends Dexie { return this.preferences.reverse().first() as Promise } + async setAutoLockInterval(newValue: number): Promise { + await this.preferences + .toCollection() + .modify((storedPreferences: Preferences) => { + const update: Partial = { autoLockInterval: newValue } + + Object.assign(storedPreferences, update) + }) + } + async upsertAnalyticsPreferences( analyticsPreferences: Partial ): Promise { diff --git a/background/services/preferences/defaults.ts b/background/services/preferences/defaults.ts index 49b15d7cab..a1876c9255 100644 --- a/background/services/preferences/defaults.ts +++ b/background/services/preferences/defaults.ts @@ -1,13 +1,15 @@ -import { ETHEREUM, USD } from "../../constants" +import { ETHEREUM, MINUTE, USD } from "../../constants" import { storageGatewayURL } from "../../lib/storage-gateway" import { Preferences } from "./types" +export const DEFAULT_AUTOLOCK_INTERVAL = 60 * MINUTE + const defaultPreferences: Preferences = { tokenLists: { autoUpdate: false, urls: [ storageGatewayURL( - "ipfs://bafybeigtlpxobme7utbketsaofgxqalgqzowhx24wlwwrtbzolgygmqorm" + "ipfs://bafybeihufwj43zej34itf66qyguq35k4f6s4ual4uk3iy643wn3xnff2ka" ).href, // the Taho community-curated list "https://gateway.ipfs.io/ipns/tokens.uniswap.org", // the Uniswap default list "https://meta.yearn.finance/api/tokens/list", // the Yearn list @@ -33,6 +35,7 @@ const defaultPreferences: Preferences = { isEnabled: false, hasDefaultOnBeenTurnedOn: false, }, + autoLockInterval: DEFAULT_AUTOLOCK_INTERVAL, } export default defaultPreferences diff --git a/background/services/preferences/index.ts b/background/services/preferences/index.ts index d813730788..6eac9dd805 100644 --- a/background/services/preferences/index.ts +++ b/background/services/preferences/index.ts @@ -12,7 +12,7 @@ import BaseService from "../base" import { normalizeEVMAddress } from "../../lib/utils" import { ETHEREUM, OPTIMISM, ARBITRUM_ONE } from "../../constants" import { EVMNetwork, sameNetwork } from "../../networks" -import { HexString } from "../../types" +import { HexString, UNIXTime } from "../../types" import { AccountSignerSettings } from "../../ui" import { AccountSignerWithId } from "../../signing" @@ -102,6 +102,7 @@ interface Events extends ServiceLifecycleEvents { updateAnalyticsPreferences: AnalyticsPreferences addressBookEntryModified: AddressBookEntry updatedSignerSettings: AccountSignerSettings[] + updateAutoLockInterval: UNIXTime dismissableItemMarkedAsShown: DismissableItem } @@ -271,6 +272,15 @@ export default class PreferenceService extends BaseService { return (await this.db.getPreferences())?.defaultWallet } + async getAutoLockInterval(): Promise { + return (await this.db.getPreferences()).autoLockInterval + } + + async updateAutoLockInterval(newValue: number): Promise { + await this.db.setAutoLockInterval(newValue) + this.emitter.emit("updateAutoLockInterval", newValue) + } + async setDefaultWalletValue(newDefaultWalletValue: boolean): Promise { return this.db.setDefaultWalletValue(newDefaultWalletValue) } diff --git a/background/services/preferences/types.ts b/background/services/preferences/types.ts index f1c4b9d8f6..b188be4a69 100644 --- a/background/services/preferences/types.ts +++ b/background/services/preferences/types.ts @@ -1,6 +1,7 @@ import { FiatCurrency } from "../../assets" import { AddressOnNetwork } from "../../accounts" import { AccountSignerSettings } from "../../ui" +import { UNIXTime } from "../../types" export interface TokenListPreferences { autoUpdate: boolean @@ -17,6 +18,7 @@ export interface Preferences { isEnabled: boolean hasDefaultOnBeenTurnedOn: boolean } + autoLockInterval: UNIXTime } export type AnalyticsPreferences = Preferences["analytics"] diff --git a/background/services/signing/index.ts b/background/services/signing/index.ts index aafba5ec3e..80ae12dd8e 100644 --- a/background/services/signing/index.ts +++ b/background/services/signing/index.ts @@ -1,5 +1,8 @@ import { StatusCodes, TransportStatusError } from "@ledgerhq/errors" -import KeyringService, { KeyringAccountSigner } from "../keyring" +import InternalSignerService, { + KeyringAccountSigner, + PrivateKeyAccountSigner, +} from "../internal-signer" import LedgerService, { LedgerAccountSigner } from "../ledger" import { SignedTransaction, @@ -53,6 +56,7 @@ export const ReadOnlyAccountSigner = { type: "read-only" } as const */ export type AccountSigner = | typeof ReadOnlyAccountSigner + | PrivateKeyAccountSigner | KeyringAccountSigner | HardwareAccountSigner export type HardwareAccountSigner = LedgerAccountSigner @@ -91,17 +95,21 @@ export default class SigningService extends BaseService { static create: ServiceCreatorFunction< Events, SigningService, - [Promise, Promise, Promise] - > = async (keyringService, ledgerService, chainService) => { + [ + Promise, + Promise, + Promise + ] + > = async (internalSignerService, ledgerService, chainService) => { return new this( - await keyringService, + await internalSignerService, await ledgerService, await chainService ) } private constructor( - private keyringService: KeyringService, + private internalSignerService: InternalSignerService, private ledgerService: LedgerService, private chainService: ChainService ) { @@ -118,7 +126,7 @@ export default class SigningService extends BaseService { } if (signerID.type === "keyring") { - return this.keyringService.deriveAddress(signerID) + return this.internalSignerService.deriveAddress(signerID) } throw new Error(`Unknown signerID: ${signerID}`) @@ -134,8 +142,9 @@ export default class SigningService extends BaseService { transactionWithNonce, accountSigner ) + case "private-key": case "keyring": - return this.keyringService.signTransaction( + return this.internalSignerService.signTransaction( { address: transactionWithNonce.from, network: transactionWithNonce.network, @@ -155,8 +164,9 @@ export default class SigningService extends BaseService { ): Promise { if (signerType) { switch (signerType) { + case "private-key": case "keyring": - await this.keyringService.hideAccount(address) + await this.internalSignerService.removeAccount(address) break case "ledger": await this.ledgerService.removeAddress(address) @@ -258,8 +268,9 @@ export default class SigningService extends BaseService { accountSigner ) break + case "private-key": case "keyring": - signedData = await this.keyringService.signTypedData({ + signedData = await this.internalSignerService.signTypedData({ typedData, account: account.address, }) @@ -303,8 +314,9 @@ export default class SigningService extends BaseService { hexDataToSign ) break + case "private-key": case "keyring": - signedData = await this.keyringService.personalSign({ + signedData = await this.internalSignerService.personalSign({ signingData: hexDataToSign, account: addressOnNetwork.address, }) diff --git a/background/services/signing/tests/index.unit.test.ts b/background/services/signing/tests/index.unit.test.ts index eae8b24eb0..f444927a78 100644 --- a/background/services/signing/tests/index.unit.test.ts +++ b/background/services/signing/tests/index.unit.test.ts @@ -1,6 +1,6 @@ import sinon from "sinon" import { - createKeyringService, + createInternalSignerService, createLedgerService, createSigningService, } from "../../../tests/factories" @@ -43,13 +43,13 @@ describe("Signing Service Unit", () => { }) it("should use keyring service to derive from a keyring account", async () => { - const keyringService = await createKeyringService() + const internalSignerService = await createInternalSignerService() const deriveAddressStub = sandbox - .stub(keyringService, "deriveAddress") + .stub(internalSignerService, "deriveAddress") .callsFake(async () => "") signingService = await createSigningService({ - keyringService: Promise.resolve(keyringService), + internalSignerService: Promise.resolve(internalSignerService), }) await signingService.startService() diff --git a/background/tests/factories.ts b/background/tests/factories.ts index 264aeec06f..456c251f48 100644 --- a/background/tests/factories.ts +++ b/background/tests/factories.ts @@ -34,6 +34,8 @@ import { AnyEVMBlock, BlockPrices, NetworkBaseAsset, + EIP1559TransactionRequest, + TransactionRequestWithNonce, } from "../networks" import { AccountData, CompleteAssetAmount } from "../redux-slices/accounts" import { @@ -41,7 +43,7 @@ import { ChainService, IndexingService, InternalEthereumProviderService, - KeyringService, + InternalSignerService, LedgerService, NameService, PreferenceService, @@ -53,6 +55,7 @@ import { PriorityQueuedTxToRetrieve, QueuedTxToRetrieve, } from "../services/chain" +import { EIP712TypedData } from "../types" // We don't want the chain service to use a real provider in tests jest.mock("../services/chain/serial-fallback-provider") @@ -64,13 +67,32 @@ export const createPreferenceService = async (): Promise => { return PreferenceService.create() } -export const createKeyringService = async (): Promise => { - return KeyringService.create() +export async function createAnalyticsService(overrides?: { + chainService?: Promise + preferenceService?: Promise +}): Promise { + const preferenceService = + overrides?.preferenceService ?? createPreferenceService() + return AnalyticsService.create(preferenceService) +} + +type CreateInternalSignerServiceOverrides = { + preferenceService?: Promise + analyticsService?: Promise +} + +export const createInternalSignerService = async ( + overrides: CreateInternalSignerServiceOverrides = {} +): Promise => { + return InternalSignerService.create( + overrides.preferenceService ?? createPreferenceService(), + overrides.analyticsService ?? createAnalyticsService() + ) } type CreateChainServiceOverrides = { preferenceService?: Promise - keyringService?: Promise + internalSignerService?: Promise } export const createChainService = async ( @@ -78,7 +100,7 @@ export const createChainService = async ( ): Promise => { return ChainService.create( overrides.preferenceService ?? createPreferenceService(), - overrides.keyringService ?? createKeyringService() + overrides.internalSignerService ?? createInternalSignerService() ) } @@ -114,7 +136,7 @@ export const createLedgerService = async (): Promise => { } type CreateSigningServiceOverrides = { - keyringService?: Promise + internalSignerService?: Promise ledgerService?: Promise chainService?: Promise } @@ -134,20 +156,11 @@ type CreateInternalEthereumProviderServiceOverrides = { preferenceService?: Promise } -export async function createAnalyticsService(overrides?: { - chainService?: Promise - preferenceService?: Promise -}): Promise { - const preferenceService = - overrides?.preferenceService ?? createPreferenceService() - return AnalyticsService.create(preferenceService) -} - export const createSigningService = async ( overrides: CreateSigningServiceOverrides = {} ): Promise => { return SigningService.create( - overrides.keyringService ?? createKeyringService(), + overrides.internalSignerService ?? createInternalSignerService(), overrides.ledgerService ?? createLedgerService(), overrides.chainService ?? createChainService() ) @@ -183,6 +196,62 @@ export const createProviderBridgeService = async ( ) } +export const createTypedData = ( + overrides: Partial = {} +): EIP712TypedData => { + // Example values from ethers docs + return { + domain: { + name: "Ether Mail", + version: "1", + chainId: ETHEREUM.chainID, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + types: { + Person: [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, + ], + Mail: [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ], + }, + message: { + from: { + name: "Cow", + wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + }, + to: { + name: "Bob", + wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + contents: "Hello, Bob!", + }, + primaryType: "Person", + ...overrides, + } +} + +export const createTransactionRequest = ( + overrides: Partial = {} +): TransactionRequestWithNonce => { + return { + nonce: 0, + from: "0x208e94d5661a73360d9387d3ca169e5c130090cd", + type: 2, + input: "0x", + value: 0n, + maxFeePerGas: 0n, + maxPriorityFeePerGas: 0n, + gasLimit: 0n, + chainID: "0", + network: ETHEREUM, + ...overrides, + } +} + // Copied from a legacy Optimism transaction generated with our test wallet. export const createLegacyTransactionRequest = ( overrides: Partial = {} diff --git a/background/tests/keyring-persistence.test.ts b/background/tests/keyring-persistence.test.ts deleted file mode 100644 index 0563dedfee..0000000000 --- a/background/tests/keyring-persistence.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { webcrypto } from "crypto" -import { - encryptVault, - decryptVault, - deriveSymmetricKeyFromPassword, -} from "../services/keyring/encryption" - -const originalCrypto = global.crypto -beforeEach(() => { - // polyfill the WebCrypto API - global.crypto = webcrypto as unknown as Crypto -}) - -afterEach(() => { - global.crypto = originalCrypto -}) - -test("derives symmetric keys", async () => { - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < 5; i += 1) { - const password = Buffer.from( - global.crypto.getRandomValues(new Uint8Array(16)) - ).toString("base64") - const newSalt = Buffer.from( - global.crypto.getRandomValues(new Uint8Array(16)) - ).toString("base64") - - const { key, salt } = await deriveSymmetricKeyFromPassword( - password, - newSalt - ) - expect(salt).toEqual(newSalt) - - expect(new Set(key.usages)).toEqual(new Set(["encrypt", "decrypt"])) - } - /* eslint-enable no-await-in-loop */ -}) - -test("doesn't throw when encrypting a vault with a password", async () => { - const vault = { a: 1 } - const password = "this-is-a-poor-password" - await encryptVault(vault, password) -}) - -test("avoids couple common footguns when encrypting a vault with a password", async () => { - const vault = { thisIsAnInterestingKey: "sentinel" } - const password = "this-is-a-poor-password" - const newVault = await encryptVault(vault, password) - // ensure sensitive plaintext isn't in the output, with a couple simple - // transformations. Note this *doesn't* show correctness of encryption — a - // simple substitution cipher would still pass this — it's just a smoke test. - const importantPlaintext = ["thisIsAnInterestingKey", "sentinel", password] - const serializedVault = JSON.stringify(newVault) - importantPlaintext.forEach((t) => { - expect(serializedVault).not.toContain(t) - expect(serializedVault).not.toContain(t.toLowerCase()) - expect(serializedVault).not.toContain(Buffer.from(t).toString("base64")) - }) -}) - -test("can decrypt a vault encrypted with a password", async () => { - const vault = { a: 1 } - const password = "this-is-a-poor-password" - const encryptedVault = await encryptVault(vault, password) - - const newVault = await decryptVault(encryptedVault, password) - - expect(newVault).toEqual(vault) -}) - -test("can decrypt a complex vault encrypted with a password", async () => { - const vault = { - a: { b: [1, 2, 3] }, - c: null, - d: 123, - } - const password = "this-is-a-poor-password" - const encryptedVault = await encryptVault(vault, password) - - const newVault = await decryptVault(encryptedVault, password) - - expect(newVault).toEqual(vault) -}) - -test("can decrypt a complex vault encrypted with a password", async () => { - const vault = { - a: { b: [1, 2, 3] }, - c: null, - d: 123, - } - const password = Buffer.from( - global.crypto.getRandomValues(new Uint8Array(16)) - ).toString("base64") - - const saltedKey = await deriveSymmetricKeyFromPassword(password) - - const encryptedVault = await encryptVault(vault, saltedKey) - - const newVault = await decryptVault(encryptedVault, saltedKey) - - expect(newVault).toEqual(vault) -}) diff --git a/background/tests/utils.ts b/background/tests/utils.ts new file mode 100644 index 0000000000..0260aab98b --- /dev/null +++ b/background/tests/utils.ts @@ -0,0 +1,49 @@ +import browser from "webextension-polyfill" + +type LocalStorageMock = Record> + +export function mockLocalStorage(): LocalStorageMock { + let localStorage: LocalStorageMock = {} + + browser.storage.local.get = jest.fn((key) => { + if (typeof key === "string" && key in localStorage) { + return Promise.resolve({ [key]: localStorage[key] } || {}) + } + return Promise.resolve({}) + }) + browser.storage.local.set = jest.fn((values) => { + localStorage = { + ...localStorage, + ...values, + } + return Promise.resolve() + }) + + return localStorage +} + +export function mockLocalStorageWithCalls(): { + localStorage: LocalStorageMock + localStorageCalls: Record[] +} { + let localStorage: Record> = {} + const localStorageCalls: Record[] = [] + + browser.storage.local.get = jest.fn((key) => { + if (typeof key === "string" && key in localStorage) { + return Promise.resolve({ [key]: localStorage[key] } || {}) + } + return Promise.resolve({}) + }) + browser.storage.local.set = jest.fn((values) => { + localStorage = { + ...localStorage, + ...values, + } + localStorageCalls.unshift(values) + + return Promise.resolve() + }) + + return { localStorage, localStorageCalls } +} diff --git a/background/types.ts b/background/types.ts index 703507c811..a6aa051fba 100644 --- a/background/types.ts +++ b/background/types.ts @@ -52,15 +52,6 @@ export type NormalizedEVMAddress = Opaque< */ export type UNIXTime = number -// KEY TYPES - -export enum KeyringTypes { - mnemonicBIP39S128 = "mnemonic#bip39:128", - mnemonicBIP39S256 = "mnemonic#bip39:256", - metamaskMnemonic = "mnemonic#metamask", - singleSECP = "single#secp256k1", -} - export type EIP712DomainType = { name?: string version?: string diff --git a/background/utils/internal-signer.ts b/background/utils/internal-signer.ts new file mode 100644 index 0000000000..89ac445cdf --- /dev/null +++ b/background/utils/internal-signer.ts @@ -0,0 +1,16 @@ +/* eslint-disable import/prefer-default-export */ +import { isHexString } from "ethers/lib/utils" + +export function validatePrivateKey(privateKey = ""): boolean { + try { + const paddedKey = privateKey.startsWith("0x") + ? privateKey + : `0x${privateKey}` + // valid pk has 32 bytes -> 64 hex characters + return ( + isHexString(paddedKey) && BigInt(paddedKey).toString(16).length === 64 + ) + } catch (e) { + return false + } +} diff --git a/background/utils/signing.ts b/background/utils/signing.ts index b6f8c86c17..3c083cd818 100644 --- a/background/utils/signing.ts +++ b/background/utils/signing.ts @@ -146,6 +146,9 @@ export const isSameAccountSignerWithId = ( if (signerA.type !== signerB.type) return false switch (signerB.type) { + case "private-key": + return signerB.walletID === (signerA as typeof signerB).walletID + case "keyring": return signerB.keyringID === (signerA as typeof signerB).keyringID diff --git a/e2e-tests/nfts.spec.ts b/e2e-tests/nfts.spec.ts index 45daf73aaf..edf18b3085 100644 --- a/e2e-tests/nfts.spec.ts +++ b/e2e-tests/nfts.spec.ts @@ -1,5 +1,6 @@ import { wait } from "@tallyho/tally-background/lib/utils" import { test, expect } from "./utils" +import { account1Address, account1Name } from "./utils/onboarding" test.describe("NFTs", () => { test("User can view nft collections, poaps and badges", async ({ @@ -36,9 +37,7 @@ test.describe("NFTs", () => { } }) - await walletPageHelper.onboarding.addReadOnlyAccount( - "0x6f1b1f1feb01235e15a7962f16c389c7f8218ed6" - ) + await walletPageHelper.onboarding.addReadOnlyAccount(account1Address) await walletPageHelper.goToStartPage() await walletPageHelper.setViewportSize() @@ -80,7 +79,7 @@ test.describe("NFTs", () => { await page .getByTestId("nft_account_filters") .filter({ - hasText: /^(Phoenix|Matilda|Sirius|Topa|Atos|Sport|Lola|Foz)$/, + hasText: account1Name, }) .getByRole("checkbox") .click() @@ -107,7 +106,7 @@ test.describe("NFTs", () => { .getByTestId("nft_account_filters") .getByTestId("toggle_item") .filter({ - hasText: /^(Phoenix|Matilda|Sirius|Topa|Atos|Sport|Lola|Foz)$/, + hasText: account1Name, }) .getByRole("checkbox") .click() diff --git a/e2e-tests/utils/onboarding.ts b/e2e-tests/utils/onboarding.ts index c622272d48..18983143d0 100644 --- a/e2e-tests/utils/onboarding.ts +++ b/e2e-tests/utils/onboarding.ts @@ -3,28 +3,28 @@ import { BrowserContext, test as base, expect, Page } from "@playwright/test" export const getOnboardingPage = async ( context: BrowserContext ): Promise => { - await expect(async () => { + const getOnboardingOrThrow = () => { const pages = context.pages() + const onboarding = pages.find((page) => /onboarding/.test(page.url())) if (!onboarding) { throw new Error("Unable to find onboarding tab") } - expect(onboarding).toHaveURL(/onboarding/) - }).toPass() - - const onboarding = context.pages().at(-1) - - if (!onboarding) { - // Should never happen - throw new Error("Onboarding page closed too early") + return onboarding } - return onboarding + await expect(async () => getOnboardingOrThrow()).toPass() + + return getOnboardingOrThrow() } const DEFAULT_PASSWORD = "12345678" +// The account1 is a 3rd address associated with the testertesting.eth account. +// It owns some NFTs/badges. +export const account1Address = "0x6f1b1f1feb01235e15a7962f16c389c7f8218ed6" +export const account1Name = /^e2e\.testertesting\.eth$/ export default class OnboardingHelper { constructor( @@ -33,30 +33,6 @@ export default class OnboardingHelper { public readonly context: BrowserContext ) {} - async getOnboardingPage(): Promise { - await expect(async () => { - const pages = this.context.pages() - const onboarding = pages.find((page) => /onboarding/.test(page.url())) - - if (!onboarding) { - throw new Error("Unable to find onboarding tab") - } - - expect(onboarding).toHaveURL(/onboarding/) - }).toPass() - - const onboarding = this.context - .pages() - .find((page) => /onboarding/.test(page.url())) - - if (!onboarding) { - // Should never happen - throw new Error("Onboarding page closed too early") - } - - return onboarding - } - async addReadOnlyAccount( addressOrName: string, onboardingPage?: Page diff --git a/manifest/manifest.development.json b/manifest/manifest.development.json index 61874e5372..6754d5a6d2 100644 --- a/manifest/manifest.development.json +++ b/manifest/manifest.development.json @@ -1,5 +1,5 @@ { - "content_security_policy": "object-src 'self'; script-src 'self' http://localhost:*;", + "content_security_policy": "object-src 'self'; script-src 'self' 'wasm-eval' http://localhost:*;", "background": { "scripts": ["dev-utils/extension-reload.js"] } diff --git a/manifest/manifest.json b/manifest/manifest.json index dac47dfd5d..172013aba1 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -1,11 +1,11 @@ { "name": "Taho", - "version": "0.38.1", + "version": "0.40.2", "description": "The community owned and operated Web3 wallet.", "homepage_url": "https://taho.xyz", "author": "https://taho.xyz", "manifest_version": 2, - "content_security_policy": "object-src 'self'; script-src 'self';", + "content_security_policy": "object-src 'self'; script-src 'self' 'wasm-eval';", "web_accessible_resources": ["*.js", "*.json"], "content_scripts": [ { diff --git a/package.json b/package.json index cb19400bc3..66cedf661e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,22 @@ { "name": "@tallyho/tally-extension", "private": true, - "version": "0.38.1", + "version": "0.40.2", "description": "Taho, the community owned and operated Web3 wallet.", "main": "index.js", "repository": "git@github.com:thesis/tally-extension.git", "author": "Matt Luongo ", "license": "GPL-3.0", "jest": { + "reporters": [ + [ + "github-actions", + { + "silent": false + } + ], + "default" + ], "setupFiles": [ "fake-indexeddb/auto", "jest-webextension-mock", @@ -17,7 +26,17 @@ "./setupJest.env.ts", "./ui/setupJest.env.ts" ], - "testEnvironment": "jsdom" + "testEnvironment": "jsdom", + "transformIgnorePatterns": [ + "node_modules/(?!@walletconnect/)" + ], + "moduleNameMapper": { + "^dexie$": "/node_modules/dexie", + "^@walletconnect/utils$": "/node_modules/@walletconnect/utils", + "^@walletconnect/((?!types))$": "/node_modules/@walletconnect/$1", + "^multiformats(.*)": "/node_modules/multiformats$1", + "^uint8arrays(.*)": "/node_modules/uint8arrays$1" + } }, "keywords": [ "ethereum", @@ -85,7 +104,7 @@ "@types/copy-webpack-plugin": "^8.0.0", "@types/dotenv-webpack": "^7.0.3", "@types/firefox-webext-browser": "^82.0.0", - "@types/jest": "^27.0.2", + "@types/jest": "^29.5.0", "@types/react-router-dom": "^5.3.1", "@types/remote-redux-devtools": "^0.5.5", "@types/terser-webpack-plugin": "^5.0.3", @@ -111,7 +130,8 @@ "eslint-plugin-react-hooks": "^4.2.0", "fork-ts-checker-webpack-plugin": "^6.3.2", "install": "^0.13.0", - "jest": "^27.2.2", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", "npm": "^7.5.6", "npm-run-all": "^4.1.5", "patch-package": "^6.4.7", @@ -122,7 +142,7 @@ "styled-jsx": "^3.4.4", "terser-webpack-plugin": "^5.1.1", "ts-loader": "^9.2.3", - "typescript": "^4.3.2", + "typescript": "^5.0.4", "webpack": "^5.58.1", "webpack-cli": "4.5.0", "webpack-livereload-plugin": "^3.0.1", diff --git a/rfb/rfb-4-one-off-keyring-design.adoc b/rfb/rfb-4-one-off-keyring-design.adoc new file mode 100644 index 0000000000..2c703795ce --- /dev/null +++ b/rfb/rfb-4-one-off-keyring-design.adoc @@ -0,0 +1,232 @@ +:toc: macro + += RFB 4: One-off Keyring Design + +== Background + +The Taho wallet allows users to both view data associated with an account +of theirs, and sign transactions on behalf of that account using private key +material. Users can set up new accounts rooted in fresh private key material, +and accounts can derive many addresses via derivation paths as specified in +BIP32. + +To properly interact with key material, the wallet has an underlying +abstraction called the **keyring**. The abstraction was designed to be safe, +minimize the possibility to lose key material, and minimize the possibility of +exfiltrating key material unintentionally. Its structure is fully described in +RFB 1: Keyring Design. + +The keyring service was designed to manage BIP32 compatible hierarchical +deterministic (HD) wallets defined by BIP39 mnemonics, including allowing for +the derivation of new addresses at the address index depth defined in BIP44's +multi-account hierarchy. + +However, many Ethereum users (and some on L2s and other chains) don't use HD +wallets or mnemonics, opting instead to use one-off private keys that don't +directly support further address derivation. Users can choose to do this due to +having generated legacy private key wallets in the past and being locked into +their use, because their tool of choice for wallet generation deals in private +keys, or for security reasons to only import individual private keys while +maintaining control of the base mnemonic for secure derivation in a medium +other than their primary hot wallet. + +Additionally, the original design of the keyring service did not expose private +key material to callers in any way. While more secure, this approach meant that +key material generated in the extension could never be extracted, leading to +concerns around data loss and lock-in. + +== Proposal + +=== Goal + +The keyring service, originally focused on ``HDKeyring``s, should be expanded to +handle both HD wallets and private key-based key material. This key material +should support both primary source types for private key material: a raw hex +format, and the JSON Crowdsale and JSON Keystore format historically used by +various wallets and other key material sources. + +Additionally, the service should provide the ability to export private key +material. In the case where an address's export is requested whose +corresponding key material was imported directly as a private key, the private +key should be exportable as such. In the case where the address was derived +from an underlying HDKeyring, the service should allow exporting the private +key corresponding to that particular address. Finally, the service should allow +direct export of the underlying mnemonic for HDKeyrings. + +Note that the service will explicitly NOT support exporting any of the +historical JSON key formats, it will only allow for exporting the raw private +key. + +=== Implementation + +The existing service's functionality is decomposed into the service side and an +underlying library, `@tallycash/hd-keyring`, whose focus is entirely around +managing HDKeyring material. To support private key export, the hd-keyring package +must add export capabilities. + +Additionally, the keyring service, which was primarily charged with dealing +with HDKeyring objects, will be renamed to `InternalSignerService`, to reflect +the fact that it is managing two different types of signers on behalf of the +extension: mnemonic-based keyrings and raw private keys. + +The sections below cover changes to each. + +==== Core Keyring + +The hd-keyring package exports two primary constructs: the HDKeyring and the +SerializedHDKeyring. The HDKeyring is designed to maintain the privacy of all +sensitive data and only allow access through a defined interface. To this end, +it leverages ECMAScript private variables to ensure that callers cannot access +internal data through JavaScript escape hatches. The only way to access +mnemonic information is through the serialize function, which returns a +SerializedHDKeyring. + +In addition to the hd-keyring package, Taho uses the Ethers library to manage +private-key-only signers. + +None of this changes for the purposes of this RFB. Instead, the export +capabilities are layered on in the form of one new method, `exportPrivateKey`. +To make it clear that calling this method is dangerous, a static string is +required to be passed in asserting that the caller is aware the returned key +needs to be treated with care. + +==== Service + +===== Taho Services Abstraction + +(This is a repeat of the same section provided in RFB 1.) + +Taho services are runtime singletons that are charged with managing a +single slice of functionality for the extension. They manage data storage and +interactions with other services, as well as maintaining internal state. +Triggering a service’s functionality is currently done by invoking a method on +the service; for example, the KeyringService has an unlock method that is used +to unlock the extension’s current keyrings. + +The service abstraction is intended to prevent leakage of the service’s +internal storage requirements, as well as to expose a clear availability +lifecycle for consumers. Generally, services can be created, during which phase +any asynchronous starting data such as storage and deserialization is resolved. +Once a service is created, it can be started and stopped. Currently services +can only walk through their lifecycle once, so once a service is stopped, it +can no longer be restarted. + +Taho services communicate data outwards in two ways: + +* All services have a set of events they may broadcast. These are expected to + be viewable by any external entity, and should only carry public (to the rest + of the extension) data. +* Service method calls may return data. This data is expected to only be + viewable by the caller, though generally any outsider is expected to be able + to call into the service. This means the restriction on returned data is + effectively the same; namely, the caller should only receive sensitive data + they have proven they have access to. + +==== The `InternalSignerService` + +`InternalSignerService` provides access to zero or more internal keyrings +(``HDKeyring``s from the core keyring package) and zero or more raw private +keys. It also persists these keys and keyrings when necessary and loads them +from storage at unlock time. The internal signer service can be locked or +unlocked. When unlocked, it has direct access to `HDKeyring` instances and +their data (including serialized mnemonics), as well as Ethers `Wallet` +instances and their data (including private keys) and mediates access to those +signers by the rest of the extension. When locked, the internal signer service +clears all references to keyrings and private keys. + +`InternalSignerService` stores serialized keyrings encrypted by a key derived +from a user-specified password. Encryption is performed using the +browser-provided Web Crypto tools, and is designed to avoid hand-rolled +encryption. Both the key derivation from the password and the encryption of +serialized keyring data is performed using Web Crypto. Decryption is similarly +managed by Web Crypto. Previous audits have flagged that the PBKDF2 algorithm used +with Web Crypto would ideally be replaced by Argon2, but the resources to dig +into integrating a WebAssembly distribution of Argon2 have not been available +yet. Web Crypto is likely at least a year out and probably more from directly +supporting Argon2, unfortunately; see +https://github.com/WICG/proposals/issues/59 for the proposal that could add +this. + +As with `HDKeyring`, `InternalSignerService` protects access to cached key +information and keyrings by using ECMAScript private variables so external +observers cannot use JavaScript features to read the data. + +===== Importing and generating keys and keyrings + +`InternalSignerService` only allows generating keyrings; private keys cannot be +generated directly, though they can be exported from keyring-derived addresses. + +Private keys and keyrings can both be imported. In the event that a private key +is imported and corresponds to an address already controlled by a keyring, it +is not added as a separate private key, but instead the keyring is left to manage +that private key material. + +Importing a keyring after a private key for one of its derived addresses has +been imported could result in dual tracking of the underlying key material: the +private key is managed explicitly, while the keyring itself can also derive the +relevant address's key. To keep account provenance clear, when a keyring is +used to derive an address that has an existing private key associated with it, +the private key is removed and the keyring is considered the canonical source +of the key. + +Private key imports can be done with either a raw format or a JSON format. The +JSON format uses a password to encrypt the underlying data, while the raw +format is an unencrypted hex-encoded private key. In each case, an Ethers +`Wallet` instance is created and then added to the underlying private key +tracking variables. + +NOTE:: In the case that an address X is, say, the 5th derived address of a +keyring, and is imported via an explicit private key, it will be managed by the +explicit private key unless and until the keyring has its 5th address derived +explicitly. Only when the 5th address derivation is explicitly requested will +the private key be removed. If the 5th address is already derived and the +private key is imported, the address will continue to be managed by the +keyring. + +===== Exporting keys and mnemonics + +`InternalSignerService` allows exporting both private keys and mnemonics. In +both cases, the export request is done by specifying the address whose material +is being requested. + +If an export private key call is made for an address with explicit private key +material, that material is used. If an export private key call is made for an +address with no explicit private key material, the keyring's export is used. +Finally, if an export private key call is made with an address whose key +material or mnemonic is not known, nothing is returned. + +For mnemonics, only an address that has an associated keyring can export a +mnemonic. An address with explicit private key underlying it will return +nothing. + +The security expectations of the `InternalSignerService` are as follows: + +* When locked, the service should have no access to key material. +* When unlocked, the service should permit unlimited access to signing + requests. +* When unlocked, the service should never expose mnemonic or private key + information, via method call or event, with the three exceptions below. +* When a new keyring is generated, the service should provide one-time access + to the mnemonic to the caller of generateNewKeyring . This mnemonic should + not be emitted in an event. +* When unlocked, the service should expose a mnemonic when the `exportMnemonic` + method is called. +* When unlocked, the service should expose a private key when the + `exportPrivateKey` method is called. +* No interaction with the keyring service should lead to the loss of + previously-used key material. In particular, persisting keys should never + override previously-persisted keys in a way that could lose old key material. + Currently the service does not provide a way to recover older key material, + but losing it is strictly avoided by the code. +* Persisted key material should always be encrypted. + +[bibliography] +== Related Links + +* https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki[BIP32: + Hierarchical Deterministic Wallets] +* https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki[BIP39: + Mnemonic code for generating deterministic keys] +* https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki[BIP44: + Multi-Account Hierarchy for Deterministic Wallets] +* https://w3c.github.io/webcrypto/[Web Cryptography API] diff --git a/setupJest.ts b/setupJest.ts index c036c1319e..138782955e 100644 --- a/setupJest.ts +++ b/setupJest.ts @@ -1,6 +1,8 @@ import * as util from "util" import Dexie from "dexie" import logger, { LogLevel } from "@tallyho/tally-background/lib/logger" +import { readFileSync } from "fs" +import { webcrypto } from "crypto" const IS_CI = process.env.CI === "true" @@ -36,11 +38,31 @@ Object.defineProperty(Dexie.dependencies, "indexedDB", { get: () => indexedDB, }) -// Stub fetch calls +// Stub fetch calls but allow wasm files to be loaded. Object.defineProperty(window, "fetch", { writable: true, - value: (url: string) => { + value: async ( + url: string + ): Promise<{ status: number; body: string | Buffer } | undefined> => { + if (url.endsWith(".wasm")) { + const file = readFileSync(url) + return { + status: 200, + body: file, + } + } // eslint-disable-next-line no-console console.warn("Uncaught fetch call to: \n", url) + return undefined }, }) + +// Below, we replace any existing crypto.subtle implementation with the Node +// one. jsdom ships with an ad hoc, informally-specified, bug-ridden +// implementation of half of WebCrypto, but that includes securing the `crypto` +// variable to prevent it from being replaced, which manes we have to dig into +// the object instead of being able to replace the top-level variable. +Object.defineProperty(globalThis.crypto, "subtle", { + writable: true, + value: (webcrypto as unknown as Crypto).subtle, +}) diff --git a/ui/_locales/en/messages.json b/ui/_locales/en/messages.json index 17e0d38f27..dd60a8e9ad 100644 --- a/ui/_locales/en/messages.json +++ b/ui/_locales/en/messages.json @@ -13,7 +13,54 @@ "lastAccountWarningBody": "Are you sure you want to proceed?", "removeAddress": "Remove address", "copyAddress": "Copy address", - "removeConfirm": "Yes, I want to remove it" + "removeConfirm": "Yes, I want to remove it", + "showPrivateKey": { + "header": "Show Private Key", + "warningMessage": "Anybody that has your Private Key can move your assets.", + "privateKeyInfo": "What is a Private Key?", + "privateKey": "Private key", + "exportingPrivateKey": { + "header": "Exporting Private Key for:", + "confirmationDesc": "I understand that other Private Keys, Recovery Phrases, Ledger or Read-only accounts will not be saved with this recovery phrase.", + "invalidMessage": "Check the above box to confirm", + "showBtn": "Show Private Key", + "copyBtn": "Copy Private key to clipboard", + "copySuccess": "Copied!" + }, + "explainer": { + "header": "What is a private key?", + "text1": "A private key is also known as a secret key is a string of letter and numbers that allow you to access and manage your assets on one address.", + "text2": "A private key only unlocks one address, while a recovery phrase unlocks all addresses that are tied to that phrase.", + "text3": "If you don’t use recovery phrase, you need to save a private key for each address." + } + }, + "showMnemonic": { + "header": "Show Recovery Phrase", + "warningMessage": "Anybody that has your recovery phrase can move your assets.", + "mnemonicInfo": "Why do i need a recovery phrase?", + "exportingMnemonic": { + "confirmationDesc": "I understand that other Recovery Phrases, Ledger, Private Keys or Read-only accounts will not be saved with this recovery phrase.", + "invalidMessage": "Check the above box to confirm", + "showBtn": "Show recovery phrase", + "copyBtn": "Copy phrase to clipboard", + "copySuccess": "Copied!", + "address_one": "address", + "address_other": "addresses" + }, + "explainer": { + "header": "Why do i need a recovery phrase?", + "text1": "You need a recovery phrase in case you lose access to your computer, wallet or you forget your password.", + "text2": "Recovery phrase is the only way to regain access to your funds stored on those addresses.", + "text3": "Taho has the option to import or create multiple recovery phrases. If you have multiple, make sure that you have safely save and store them all." + } + }, + "copyWarning": { + "header": "Copying to clipboard", + "title": "Careful copying content to clipboard", + "description": "It is dangerous to copy your recovery phrase or private key to clipboard, this can result in somebody gaining access to it", + "dontAsk": "Don’t ask me again", + "submitBtn": "Copy to clipboard" + } }, "notificationPanel": { "accountPanelName": "Accounts", @@ -23,6 +70,7 @@ "import": "Import", "internal": "Taho", "ledger": "Ledger", + "privateKey": "Private key", "category": { "readOnly": "Preview", "ledger": "Hardware wallets", @@ -104,7 +152,7 @@ } }, "ledger": { - "checkLedger": "Check Ledger", + "checkLedger": "Check Error", "onlyRejectFromLedger": "Tx can only be Rejected from Ledger", "onboarding": { "connecting": "Connecting...", @@ -142,7 +190,11 @@ "disconnected": "Ledger is disconnected", "wrongLedger": "Wrong Ledger connected", "multipleLedgers": "Multiple ledgers connected", - "busy": "Ledger is busy" + "busy": "Ledger is busy", + "state.connected": "Connected", + "state.disconnected": "Disconnected", + "state.error": "Check error", + "state.unknown": "Unknown" }, "activation": { "title": "Activate blind signing", @@ -338,6 +390,7 @@ "newWalletTitle": "New wallet", "options": { "importSeed": "Import recovery phrase", + "importPrivateKey": "Import private key", "ledger": "Connect to Ledger", "readOnly": "Read-only address", "createNew": "Create new wallet" @@ -353,6 +406,27 @@ "invalidPhraseError": "Invalid recovery phrase" } }, + "importPrivateKey": { + "title": "Import private key", + "subtitle": "Importing a private key does not associate it to a secret recovery phrase, but it’s still protected by the same password", + "inputLabel": "Paste private key string", + "submit": "Import account", + "error": "Invalid private key", + "privateKey": "Private key", + "password": "JSON file password", + "json": "JSON", + "browseFiles": "Browse files or drag and drop your {{type}} file", + "uploading": "Uploading...", + "uploadFail": "Upload failed, try again", + "wrongFile": "Wrong file, only {{type}} accepted", + "decrypt": "Decrypt file", + "decrypting": "Decrypting file", + "decryptingTime": "this may take up to 1 minute", + "finalize": "Finalize", + "completed": "Completed!", + "address": "address", + "wrongPassword": "Wrong password or incorrect file. Ensure file is correct and enter the password you used when encrypting it." + }, "viewOnly": { "title": "Read-only address", "subtitle": "Add an Ethereum address or ENS name to view an existing wallet in Taho", @@ -494,7 +568,10 @@ "connectedWebsites": "Connected websites", "addCustomAsset": "Custom assets", "analytics": "Analytics", - "needHelp": "Need help?", + "autoLockTimer.label": "Auto-lock timer", + "autoLockTimer.tooltip": "How long after your last interaction should we lock the wallet?", + "autoLockTimer.interval": "{{time}} min", + "needHelp": "Need Help?", "customNetworks": "Custom networks (beta)", "showBanners": "Show achievement banners", "versionLabel": "Version {{version}}", @@ -604,10 +681,12 @@ "showPasswordHint": "Show Password", "hidePasswordHint": "Hide Password", "close": "Close", + "readMore": "Read more", "modalClose": "Background close", "accountItemSummary": { "connectedStatus": "Connected" }, + "mouseOverToShow": "Mouse over to show", "selectToken": "Select token", "backButtonText": "Back", "saveBtn": "Save", @@ -955,7 +1034,8 @@ "SUPPORT_SWAP_QUOTE_REFRESH": "Enable automatic swap quote updates", "SUPPORT_CUSTOM_NETWORKS": "Show custom network page on settings panel", "SUPPORT_CUSTOM_RPCS": "Enable adding custom RPCs", - "SUPPORT_UNVERIFIED_ASSET": "Enable assets verification" + "SUPPORT_UNVERIFIED_ASSET": "Enable assets verification", + "SUPPORT_CUSTOM_AUTOLOCK": "Enable custom auto-lock timer" } } }, @@ -967,4 +1047,4 @@ "earn": "Earn", "settings": "Settings" } -} +} \ No newline at end of file diff --git a/ui/components/AccountItem/AccountItemOptionsMenu.tsx b/ui/components/AccountItem/AccountItemOptionsMenu.tsx index 9da1ad24b2..4340aef0bf 100644 --- a/ui/components/AccountItem/AccountItemOptionsMenu.tsx +++ b/ui/components/AccountItem/AccountItemOptionsMenu.tsx @@ -2,18 +2,28 @@ import { AccountTotal } from "@tallyho/tally-background/redux-slices/selectors" import { setSnackbarMessage } from "@tallyho/tally-background/redux-slices/ui" import React, { ReactElement, useCallback, useState } from "react" import { useTranslation } from "react-i18next" +import { AccountType } from "@tallyho/tally-background/redux-slices/accounts" import { useBackgroundDispatch } from "../../hooks" import SharedDropdown from "../Shared/SharedDropDown" import SharedSlideUpMenu from "../Shared/SharedSlideUpMenu" import AccountItemEditName from "./AccountItemEditName" import AccountItemRemovalConfirm from "./AccountItemRemovalConfirm" +import ShowPrivateKey from "../AccountsBackup/ShowPrivateKey" type AccountItemOptionsMenuProps = { accountTotal: AccountTotal + accountType: AccountType } +const allowExportPrivateKeys = [ + AccountType.PrivateKey, + AccountType.Imported, + AccountType.Internal, +] + export default function AccountItemOptionsMenu({ accountTotal, + accountType, }: AccountItemOptionsMenuProps): ReactElement { const { t } = useTranslation("translation", { keyPrefix: "accounts.accountItem", @@ -22,6 +32,7 @@ export default function AccountItemOptionsMenu({ const { address, network } = accountTotal const [showAddressRemoveConfirm, setShowAddressRemoveConfirm] = useState(false) + const [showPrivateKeyMenu, setShowPrivateKeyMenu] = useState(false) const [showEditName, setShowEditName] = useState(false) const copyAddress = useCallback(() => { @@ -29,6 +40,8 @@ export default function AccountItemOptionsMenu({ dispatch(setSnackbarMessage("Address copied to clipboard")) }, [address, dispatch]) + const canExportPrivateKey = allowExportPrivateKeys.includes(accountType) + return (
+ { + e?.stopPropagation() + setShowPrivateKeyMenu(false) + }} + > +
e.stopPropagation()} + style={{ cursor: "default" }} + > + +
+
( +
+ +
)} - - + +
+ + {t("submit")} + + {!isEnabled(FeatureFlags.HIDE_IMPORT_DERIVATION_PATH) && ( + + )} +
+ + - + ) } diff --git a/ui/pages/Onboarding/Tabbed/Ledger/Ledger.tsx b/ui/pages/Onboarding/Tabbed/Ledger/Ledger.tsx index 4f7685e453..eb4282a609 100644 --- a/ui/pages/Onboarding/Tabbed/Ledger/Ledger.tsx +++ b/ui/pages/Onboarding/Tabbed/Ledger/Ledger.tsx @@ -84,7 +84,7 @@ export default function Ledger(): ReactElement { )} {phase === "2-connect" && !device && connecting && ( )} diff --git a/ui/pages/Onboarding/Tabbed/Ledger/LedgerImportAccounts.tsx b/ui/pages/Onboarding/Tabbed/Ledger/LedgerImportAccounts.tsx index 1a454c2ac3..e8f2bc5e14 100644 --- a/ui/pages/Onboarding/Tabbed/Ledger/LedgerImportAccounts.tsx +++ b/ui/pages/Onboarding/Tabbed/Ledger/LedgerImportAccounts.tsx @@ -5,7 +5,6 @@ import { importLedgerAccounts, LedgerDeviceState, } from "@tallyho/tally-background/redux-slices/ledger" -import classNames from "classnames" import React, { ReactElement, useEffect, useState } from "react" import { selectCurrentNetwork } from "@tallyho/tally-background/redux-slices/selectors" import { EVMNetwork } from "@tallyho/tally-background/networks" @@ -16,6 +15,7 @@ import LedgerContinueButton from "../../../../components/Ledger/LedgerContinueBu import LedgerPanelContainer from "../../../../components/Ledger/LedgerPanelContainer" import OnboardingDerivationPathSelectAlt from "../../../../components/Onboarding/OnboardingDerivationPathSelect" import { blockExplorer } from "../../../../utils/constants" +import SharedCheckbox from "../../../../components/Shared/SharedCheckbox" const addressesPerPage = 6 @@ -139,24 +139,12 @@ function LedgerAccountList({ {pageData.items.map( ({ path, address, balance, isSelected, setSelected }) => (
-