├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── build-test.yml │ ├── create-release-pr.yml │ ├── publish-docs.yml │ ├── publish-main-docs.yml │ ├── publish-rc-docs.yml │ ├── publish-release.yml │ └── security-code-scanner.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-allow-scripts.cjs └── releases │ └── yarn-3.2.2.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── scripts ├── get.sh └── prepack.sh ├── src ├── __snapshots__ │ └── sign-typed-data.test.ts.snap ├── encryption.test.ts ├── encryption.ts ├── index.test.ts ├── index.ts ├── personal-sign.test.ts ├── personal-sign.ts ├── sign-eip7702-authorization.test.ts ├── sign-eip7702-authorization.ts ├── sign-typed-data.test.ts ├── sign-typed-data.ts ├── utils.test.ts └── utils.ts ├── tsconfig.json ├── typedoc.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | indent_size = 4 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@metamask/eslint-config', '@metamask/eslint-config-nodejs'], 4 | overrides: [ 5 | { 6 | files: ['*.ts'], 7 | extends: ['@metamask/eslint-config-typescript'], 8 | }, 9 | { 10 | files: ['*.test.ts'], 11 | extends: ['@metamask/eslint-config-jest'], 12 | }, 13 | ], 14 | rules: { 15 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 16 | camelcase: [ 17 | 'error', 18 | { 19 | allow: [ 20 | 'nacl_decodeHex', 21 | 'recoverTypedSignature_v4', 22 | 'signTypedData_v4', 23 | ], 24 | }, 25 | ], 26 | 'id-denylist': 'off', 27 | 'id-length': 'off', 28 | 'no-param-reassign': 'off', 29 | }, 30 | ignorePatterns: ['!.eslintrc.js', 'test/*.js', 'dist'], 31 | }; 32 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | # Reviewing the lockfile contents is an important step in verifying that 4 | # we're using the dependencies we expect to be using 5 | package-lock.json linguist-generated=false 6 | yarn.lock linguist-generated=false 7 | 8 | # yarn v3 9 | # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 10 | /.yarn/releases/** binary 11 | /.yarn/plugins/** binary 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | * @MetaMask/devs 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'daily' 10 | time: '06:00' 11 | allow: 12 | - dependency-name: '@metamask/*' 13 | target-branch: 'main' 14 | versioning-strategy: 'increase-if-necessary' 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build, Lint, and Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | prepare: 10 | name: Prepare 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: 15 | - 18.x 16 | - 20.x 17 | - 22.x 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'yarn' 25 | - name: Install Yarn dependencies 26 | run: yarn --immutable 27 | build: 28 | name: Build 29 | runs-on: ubuntu-latest 30 | needs: 31 | - prepare 32 | strategy: 33 | matrix: 34 | node-version: 35 | - 18.x 36 | - 20.x 37 | - 22.x 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Use Node.js ${{ matrix.node-version }} 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | cache: 'yarn' 45 | - run: yarn --immutable 46 | - run: yarn build 47 | - name: Require clean working directory 48 | shell: bash 49 | run: | 50 | if ! git diff --exit-code; then 51 | echo "Working tree dirty at end of job" 52 | exit 1 53 | fi 54 | lint: 55 | name: Lint 56 | runs-on: ubuntu-latest 57 | needs: 58 | - prepare 59 | steps: 60 | - uses: actions/checkout@v3 61 | - name: Set up Node.js 62 | uses: actions/setup-node@v4 63 | with: 64 | node-version-file: '.nvmrc' 65 | cache: 'yarn' 66 | - run: yarn --immutable 67 | - run: yarn lint 68 | - name: Validate RC changelog 69 | if: ${{ startsWith(github.head_ref, 'release/') }} 70 | run: yarn auto-changelog validate --rc 71 | - name: Validate changelog 72 | if: ${{ !startsWith(github.head_ref, 'release/') }} 73 | run: yarn auto-changelog validate 74 | - name: Require clean working directory 75 | shell: bash 76 | run: | 77 | if ! git diff --exit-code; then 78 | echo "Working tree dirty at end of job" 79 | exit 1 80 | fi 81 | test: 82 | name: Test 83 | runs-on: ubuntu-latest 84 | needs: 85 | - prepare 86 | strategy: 87 | matrix: 88 | node-version: 89 | - 18.x 90 | - 20.x 91 | - 22.x 92 | steps: 93 | - uses: actions/checkout@v3 94 | - name: Use Node.js ${{ matrix.node-version }} 95 | uses: actions/setup-node@v4 96 | with: 97 | node-version: ${{ matrix.node-version }} 98 | cache: 'yarn' 99 | - run: yarn --immutable 100 | - run: yarn test 101 | - name: Require clean working directory 102 | shell: bash 103 | run: | 104 | if ! git diff --exit-code; then 105 | echo "Working tree dirty at end of job" 106 | exit 1 107 | fi 108 | check-workflows: 109 | name: Check workflows 110 | runs-on: ubuntu-latest 111 | steps: 112 | - uses: actions/checkout@v3 113 | - name: Download actionlint 114 | id: download-actionlint 115 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/7fdc9630cc360ea1a469eed64ac6d78caeda1234/scripts/download-actionlint.bash) 1.6.22 116 | shell: bash 117 | - name: Check workflow files 118 | run: ${{ steps.download-actionlint.outputs.executable }} -color 119 | shell: bash 120 | all-jobs-pass: 121 | name: All jobs pass 122 | runs-on: ubuntu-latest 123 | needs: 124 | - build 125 | - lint 126 | - test 127 | - check-workflows 128 | steps: 129 | - run: echo "Great success!" 130 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Pull Request 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | base-branch: 7 | description: 'The base branch for git operations and the pull request.' 8 | default: 'main' 9 | required: true 10 | release-type: 11 | description: 'A SemVer version diff, i.e. major, minor, patch, prerelease etc. Mutually exclusive with "release-version".' 12 | required: false 13 | release-version: 14 | description: 'A specific version to bump to. Mutually exclusive with "release-type".' 15 | required: false 16 | 17 | jobs: 18 | create-release-pr: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | steps: 24 | - uses: actions/checkout@v3 25 | with: 26 | # This is to guarantee that the most recent tag is fetched. 27 | # This can be configured to a more reasonable value by consumers. 28 | fetch-depth: 0 29 | # We check out the specified branch, which will be used as the base 30 | # branch for all git operations and the release PR. 31 | ref: ${{ github.event.inputs.base-branch }} 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version-file: '.nvmrc' 36 | - uses: MetaMask/action-create-release-pr@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | with: 40 | release-type: ${{ github.event.inputs.release-type }} 41 | release-version: ${{ github.event.inputs.release-version }} 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs to GitHub Pages 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | destination_dir: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | publish-docs-to-gh-pages: 12 | name: Publish docs to GitHub Pages 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: Ensure `destination_dir` is not empty 18 | if: ${{ inputs.destination_dir == '' }} 19 | run: exit 1 20 | - name: Checkout the repository 21 | uses: actions/checkout@v3 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version-file: '.nvmrc' 26 | - name: Get Yarn cache directory 27 | run: echo "YARN_CACHE_DIR=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" 28 | id: yarn-cache-dir 29 | - name: Get Yarn version 30 | run: echo "YARN_VERSION=$(yarn --version)" >> "$GITHUB_OUTPUT" 31 | id: yarn-version 32 | - name: Cache yarn dependencies 33 | uses: actions/cache@v3 34 | with: 35 | path: ${{ steps.yarn-cache-dir.outputs.YARN_CACHE_DIR }} 36 | key: yarn-cache-${{ runner.os }}-${{ steps.yarn-version.outputs.YARN_VERSION }}-${{ hashFiles('yarn.lock') }} 37 | - name: Install npm dependencies 38 | run: yarn --immutable 39 | - name: Run build script 40 | run: yarn build:docs 41 | - name: Deploy to `${{ inputs.destination_dir }}` directory of `gh-pages` branch 42 | uses: peaceiris/actions-gh-pages@068dc23d9710f1ba62e86896f84735d869951305 43 | with: 44 | github_token: ${{ secrets.GITHUB_TOKEN }} 45 | publish_dir: ./docs 46 | destination_dir: ${{ inputs.destination_dir }} 47 | -------------------------------------------------------------------------------- /.github/workflows/publish-main-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish main branch docs to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: main 6 | 7 | jobs: 8 | publish-to-gh-pages: 9 | name: Publish docs to `staging` directory of `gh-pages` branch 10 | permissions: 11 | contents: write 12 | uses: ./.github/workflows/publish-docs.yml 13 | with: 14 | destination_dir: staging 15 | -------------------------------------------------------------------------------- /.github/workflows/publish-rc-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish release candidate docs to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 'release/**' 6 | 7 | jobs: 8 | get-release-version: 9 | name: Get release version 10 | runs-on: ubuntu-latest 11 | outputs: 12 | release-version: ${{ steps.release-name.outputs.RELEASE_VERSION }} 13 | steps: 14 | - name: Extract release version from branch name 15 | id: release-name 16 | run: | 17 | BRANCH_NAME='${{ github.ref_name }}' 18 | echo "RELEASE_VERSION=v${BRANCH_NAME#release/}" >> "$GITHUB_OUTPUT" 19 | publish-to-gh-pages: 20 | name: Publish docs to `rc-${{ needs.get-release-version.outputs.release-version }}` directory of `gh-pages` branch 21 | permissions: 22 | contents: write 23 | uses: ./.github/workflows/publish-docs.yml 24 | needs: get-release-version 25 | with: 26 | destination_dir: rc-${{ needs.get-release-version.outputs.release-version }} 27 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | is-release: 9 | # release merge commits come from github-actions 10 | if: startsWith(github.event.commits[0].author.name, 'github-actions') 11 | outputs: 12 | IS_RELEASE: ${{ steps.is-release.outputs.IS_RELEASE }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: MetaMask/action-is-release@v1 16 | id: is-release 17 | 18 | publish-release: 19 | permissions: 20 | contents: write 21 | if: needs.is-release.outputs.IS_RELEASE == 'true' 22 | runs-on: ubuntu-latest 23 | needs: is-release 24 | steps: 25 | - uses: actions/checkout@v3 26 | with: 27 | ref: ${{ github.sha }} 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version-file: '.nvmrc' 32 | - uses: MetaMask/action-publish-release@v2 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Install 36 | run: | 37 | yarn install 38 | yarn build 39 | - uses: actions/cache@v3 40 | id: restore-build 41 | with: 42 | path: ./dist 43 | key: ${{ github.sha }} 44 | 45 | publish-npm-dry-run: 46 | runs-on: ubuntu-latest 47 | needs: publish-release 48 | steps: 49 | - uses: actions/checkout@v3 50 | with: 51 | ref: ${{ github.sha }} 52 | - uses: actions/cache@v3 53 | id: restore-build 54 | with: 55 | path: ./dist 56 | key: ${{ github.sha }} 57 | # Set `ignore-scripts` to skip `prepublishOnly` because the release was built already in the previous job 58 | - run: npm config set ignore-scripts true 59 | - name: Dry Run Publish 60 | # omit npm-token token to perform dry run publish 61 | uses: MetaMask/action-npm-publish@v1 62 | env: 63 | SKIP_PREPACK: true 64 | 65 | publish-npm: 66 | environment: npm-publish 67 | runs-on: ubuntu-latest 68 | needs: publish-npm-dry-run 69 | steps: 70 | - uses: actions/checkout@v3 71 | with: 72 | ref: ${{ github.sha }} 73 | - uses: actions/cache@v3 74 | id: restore-build 75 | with: 76 | path: ./dist 77 | key: ${{ github.sha }} 78 | # Set `ignore-scripts` to skip `prepublishOnly` because the release was built already in the previous job 79 | - run: npm config set ignore-scripts true 80 | - name: Publish 81 | uses: MetaMask/action-npm-publish@v1 82 | with: 83 | # This `NPM_TOKEN` needs to be manually set per-repository. 84 | # Look in the repository settings under "Environments", and set this token in the `npm-publish` environment. 85 | npm-token: ${{ secrets.NPM_TOKEN }} 86 | env: 87 | SKIP_PREPACK: true 88 | 89 | get-release-version: 90 | runs-on: ubuntu-latest 91 | needs: publish-npm 92 | outputs: 93 | RELEASE_VERSION: ${{ steps.get-release-version.outputs.RELEASE_VERSION }} 94 | steps: 95 | - uses: actions/checkout@v3 96 | with: 97 | ref: ${{ github.sha }} 98 | - id: get-release-version 99 | shell: bash 100 | run: ./scripts/get.sh ".version" "RELEASE_VERSION" 101 | 102 | publish-release-to-gh-pages: 103 | needs: get-release-version 104 | name: Publish docs to `${{ needs.get-release-version.outputs.RELEASE_VERSION }}` directory of `gh-pages` branch 105 | permissions: 106 | contents: write 107 | uses: ./.github/workflows/publish-docs.yml 108 | with: 109 | destination_dir: ${{ needs.get-release-version.outputs.RELEASE_VERSION }} 110 | 111 | publish-release-to-latest-gh-pages: 112 | needs: publish-npm 113 | name: Publish docs to `latest` directory of `gh-pages` branch 114 | permissions: 115 | contents: write 116 | uses: ./.github/workflows/publish-docs.yml 117 | with: 118 | destination_dir: latest 119 | -------------------------------------------------------------------------------- /.github/workflows/security-code-scanner.yml: -------------------------------------------------------------------------------- 1 | name: MetaMask Security Code Scanner 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | run-security-scan: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | steps: 20 | - name: MetaMask Security Code Scanner 21 | uses: MetaMask/Security-Code-Scanner@main 22 | with: 23 | repo: ${{ github.repository }} 24 | paths_ignored: | 25 | .storybook/ 26 | '**/__snapshots__/' 27 | '**/*.snap' 28 | '**/*.stories.js' 29 | '**/*.stories.tsx' 30 | '**/*.test.browser.ts*' 31 | '**/*.test.js*' 32 | '**/*.test.ts*' 33 | '**/fixtures/' 34 | '**/jest.config.js' 35 | '**/jest.environment.js' 36 | '**/mocks/' 37 | '**/test*/' 38 | docs/ 39 | e2e/ 40 | merged-packages/ 41 | node_modules 42 | storybook/ 43 | test*/ 44 | rules_excluded: example 45 | project_metrics_token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} 46 | slack_webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | package-lock.json 4 | 5 | *.tgz 6 | *.log 7 | 8 | dist 9 | test/*.js 10 | 11 | # ESLint 12 | /.eslintcache 13 | 14 | # Jest 15 | /coverage 16 | 17 | # TypeDoc 18 | /docs 19 | 20 | # yarn v3 (w/o zero-install) 21 | # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 22 | .pnp.* 23 | .yarn/* 24 | !.yarn/patches 25 | !.yarn/plugins 26 | !.yarn/releases 27 | !.yarn/sdks 28 | !.yarn/versions 29 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // All of these are defaults except singleQuote, but we specify them 2 | // for explicitness 3 | module.exports = { 4 | quoteProps: 'as-needed', 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | }; 9 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-allow-scripts", 5 | factory: function (require) { 6 | var plugin=(()=>{var a=Object.create,l=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var p=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty;var u=e=>l(e,"__esModule",{value:!0});var f=e=>{if(typeof require!="undefined")return require(e);throw new Error('Dynamic require of "'+e+'" is not supported')};var g=(e,o)=>{for(var r in o)l(e,r,{get:o[r],enumerable:!0})},m=(e,o,r)=>{if(o&&typeof o=="object"||typeof o=="function")for(let t of s(o))!c.call(e,t)&&t!=="default"&&l(e,t,{get:()=>o[t],enumerable:!(r=i(o,t))||r.enumerable});return e},x=e=>m(u(l(e!=null?a(p(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var k={};g(k,{default:()=>d});var n=x(f("@yarnpkg/shell")),y={hooks:{afterAllInstalled:async()=>{let e=await(0,n.execute)("yarn run allow-scripts");e!==0&&process.exit(e)}}},d=y;return k;})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableScripts: false 2 | 3 | enableTelemetry: 0 4 | 5 | nodeLinker: node-modules 6 | 7 | plugins: 8 | - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs 9 | spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.2.cjs 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [8.2.0] 10 | ### Added 11 | - Adds `signAuthorization` method for EIP-7702 ([#407](https://github.com/MetaMask/eth-sig-util/pull/407)) 12 | 13 | ## [8.1.2] 14 | ### Changed 15 | - Bump `@metamask/abi-utils` from `^2.0.4` to `^3.0.0` ([#405](https://github.com/MetaMask/eth-sig-util/pull/405)) 16 | - Bump `@metamask/utils` from `^9.0.0` to `^11.0.1` ([#405](https://github.com/MetaMask/eth-sig-util/pull/405)) 17 | 18 | ## [8.1.1] 19 | ### Fixed 20 | - Revert "fix: Add Permit type schema" ([#401](https://github.com/MetaMask/eth-sig-util/pull/401)) 21 | 22 | ## [8.1.0] 23 | ### Added 24 | - Add `Permit` type to `signTypedData` schema ([#399](https://github.com/MetaMask/eth-sig-util/pull/399)) 25 | 26 | ## [8.0.0] 27 | ### Changed 28 | - **BREAKING**: Values of type `number` are not accepted as address parameter anymore. Valid values are `string` and `Uint8Array`. ([#391](https://github.com/MetaMask/eth-sig-util/pull/391)) 29 | - **BREAKING**: Drop support for Node.js versions 16, 21. ([#390](https://github.com/MetaMask/eth-sig-util/pull/390)) 30 | 31 | ## [7.0.3] 32 | ### Changed 33 | - Bump `@metamask/abi-utils` to `^2.0.4` ([#381](https://github.com/MetaMask/eth-sig-util/pull/381)) 34 | - Bump `@metamask/utils` from `^9.0.0` ([#381](https://github.com/MetaMask/eth-sig-util/pull/381)) 35 | 36 | ## [7.0.2] 37 | ### Fixed 38 | - Replace dependency `tweetnacl-util` with `@scure/base` ([#358](https://github.com/MetaMask/eth-sig-util/pull/358)) 39 | 40 | ## [7.0.1] 41 | ### Changed 42 | - Remove dependency `ethjs-util` ([#349](https://github.com/MetaMask/eth-sig-util/pull/349)) 43 | 44 | ### Fixed 45 | - **BREAKING**: fix: interpret 0x as hex in bytes encodeField ([#354](https://github.com/MetaMask/eth-sig-util/pull/354)) 46 | - This fixes a regression introduced in `6.0.1` which caused inconsistent signatures when data was supplied as literal `0x`. 47 | - fix: Exclude test files from published release ([#350](https://github.com/MetaMask/eth-sig-util/pull/350)) 48 | - fix: Bump @babel/traverse from 7.21.5 to 7.23.2 ([#341](https://github.com/MetaMask/eth-sig-util/pull/341)) 49 | 50 | ## [7.0.0] 51 | ### Changed 52 | - **BREAKING**: Increase minimum Node.js version to v16 ([#332](https://github.com/MetaMask/eth-sig-util/pull/332)) 53 | - **BREAKING**: Bump `@metamask/abi-utils` from `^1.0.2` to `^2.0.2` ([#326](https://github.com/MetaMask/eth-sig-util/pull/336)) 54 | - Bump `@metamask/utils` from `^5.0.2` to `^8.1.0` ([#333](https://github.com/MetaMask/eth-sig-util/pull/333)) 55 | 56 | ## [6.0.1] 57 | ### Changed 58 | - Swap out legacy `ethereumjs-abi` for `@metamask/abi-utils` ([#319](https://github.com/MetaMask/eth-sig-util/pull/319)) 59 | 60 | ### Fixed 61 | - Bump `ethereum-cryptography` from `^2.0.0` to `^2.1.2` ([#302](https://github.com/MetaMask/eth-sig-util/pull/302)) 62 | - Bump `ethereumjs/util` from `^8.0.6` to `^8.1.0` ([#302](https://github.com/MetaMask/eth-sig-util/pull/302)) 63 | - Remove unused dependency `bn.js` (#334) ([#302](https://github.com/MetaMask/eth-sig-util/pull/302)) 64 | 65 | ## [6.0.0] 66 | ### Changed 67 | - **BREAKING**: Fix `normalize` for empty strings and `0` ([#315](https://github.com/MetaMask/eth-sig-util/pull/315)) 68 | - This is breaking as it changes the behavior of the function with an empty string or `0` as input: it will now return `0x` for an empty string and `0x00` for `0`, instead of `undefined` 69 | 70 | ## [5.1.0] 71 | ### Changed 72 | - rawEncode: fix broken BigNumber negativity check ([#307](https://github.com/MetaMask/eth-sig-util/pull/307)) 73 | - Specify type interface for multiple functions ([#307](https://github.com/MetaMask/eth-sig-util/pull/307)) 74 | - Improve `type` parameter input validation ([#307](https://github.com/MetaMask/eth-sig-util/pull/307)) 75 | - deps: bn.js@4.11.8->4.12.0 ([#309](https://github.com/MetaMask/eth-sig-util/pull/309)) 76 | - devDeps: Support TypeScript version ~4.8.4 ([#307](https://github.com/MetaMask/eth-sig-util/pull/307)) 77 | 78 | ## [5.0.3] 79 | ### Changed 80 | - Bump ethereum-cryptography, @ethereumjs/util ([#302](https://github.com/MetaMask/eth-sig-util/pull/302)) 81 | 82 | ## [5.0.2] 83 | ### Changed 84 | - allow `bn.js` to resolve any minor/patch version above 4.11.8 ([#280](https://github.com/MetaMask/eth-sig-util/pull/280)) 85 | 86 | ## [5.0.1] 87 | ### Fixed 88 | - Fix issue introduced in `v5.0.0` where the method `encodeField` encoded fields typed as `bytes` and passed as hexstrings were encoded differently than previous versions ([#271](https://github.com/MetaMask/eth-sig-util/pull/271), [#274](https://github.com/MetaMask/eth-sig-util/pull/274)) 89 | 90 | ## [5.0.0] [DEPRECATED] 91 | ### Changed 92 | - **BREAKING:** Removed support for Node v12 in favor of v14 ([#137](https://github.com/MetaMask/eth-json-rpc-middleware/pull/137)) 93 | - Replace heavy crypto packages for lighter noble implementations via upgrading `ethereumjs-util` to latest (now called `@ethereumjs/util`) ([#260](https://github.com/MetaMask/eth-sig-util/pull/260)) 94 | - Migrate to Yarn 3 ([#264](https://github.com/MetaMask/eth-sig-util/pull/264)) 95 | 96 | 97 | ## [4.0.1] 98 | ### Fixed 99 | - Fix mistake in TYPED_MESSAGE_SCHEMA ([#243](https://github.com/MetaMask/eth-sig-util/pull/243)) 100 | - The schema changed in v4 in a way that accidentally disallowed "reference types" (i.e. custom types) apart from the primary type. Reference types are now once again allowed. 101 | 102 | ## [4.0.0] 103 | ### Added 104 | - **BREAKING**: Add subpath exports ([#214](https://github.com/MetaMask/eth-sig-util/pull/214), [#211](https://github.com/MetaMask/eth-sig-util/pull/211)) 105 | - This is breaking because it prevents the import of modules that are not exposed as subpath exports. 106 | - Add `salt` to the EIP-712 `domain` type ([#176](https://github.com/MetaMask/eth-sig-util/pull/176)) 107 | - Add additional unit tests ([#146](https://github.com/MetaMask/eth-sig-util/pull/146), [#164](https://github.com/MetaMask/eth-sig-util/pull/164), [#167](https://github.com/MetaMask/eth-sig-util/pull/167), [#169](https://github.com/MetaMask/eth-sig-util/pull/169), [#172](https://github.com/MetaMask/eth-sig-util/pull/172), [#177](https://github.com/MetaMask/eth-sig-util/pull/177), [#180](https://github.com/MetaMask/eth-sig-util/pull/180), [#170](https://github.com/MetaMask/eth-sig-util/pull/170), [#171](https://github.com/MetaMask/eth-sig-util/pull/171), [#178](https://github.com/MetaMask/eth-sig-util/pull/178), [#173](https://github.com/MetaMask/eth-sig-util/pull/173), [#182](https://github.com/MetaMask/eth-sig-util/pull/182), [#184](https://github.com/MetaMask/eth-sig-util/pull/184), [#185](https://github.com/MetaMask/eth-sig-util/pull/185), [#187](https://github.com/MetaMask/eth-sig-util/pull/187)) 108 | - Improve documentation ([#157](https://github.com/MetaMask/eth-sig-util/pull/157), [#177](https://github.com/MetaMask/eth-sig-util/pull/177), [#174](https://github.com/MetaMask/eth-sig-util/pull/174), [#180](https://github.com/MetaMask/eth-sig-util/pull/180), [#178](https://github.com/MetaMask/eth-sig-util/pull/178), [#181](https://github.com/MetaMask/eth-sig-util/pull/181), [#186](https://github.com/MetaMask/eth-sig-util/pull/186), [#212](https://github.com/MetaMask/eth-sig-util/pull/212), [#207](https://github.com/MetaMask/eth-sig-util/pull/207), [#213](https://github.com/MetaMask/eth-sig-util/pull/213)) 109 | 110 | ### Changed 111 | - **BREAKING**: Consolidate `signTypedData` and `recoverTypedSignature` functions ([#156](https://github.com/MetaMask/eth-sig-util/pull/156)) 112 | - The functions `signTypedDataLegacy`, `signTypedData`, and `signTypedData_v4` have been replaced with a single `signTypedData` function with a `version` parameter. The `version` parameter determines which type of signature you get. 113 | - If you used `signTypedDataLegacy`, switch to `signTypedData` with the version `V1`. 114 | - If you used `signTypedData`, switch to `signTypedData` with the version `V3`. 115 | - If you used `signTypedData_v4`, switch to `signTypedData` with the version `V4`. 116 | - The functions `recoverTypedSignatureLegacy`, `recoverTypedSignature`, and `recoverTypedSignature_v4` have been replaced with a single `recoverTypedSignature` function. 117 | - If you used `recoverTypedSignatureLegacy`, switch to `recoverTypedMessage` with the version `V1`. 118 | - If you used `recoverTypedMessage`, switch to `recoverTypedMessage` with the version `V3`. 119 | - If you used `recoverTypedSignature_v4`, switch to `recoverTypedMessage` with the version `V4`. 120 | - **BREAKING**: Rename `TypedDataUtils.sign` to `TypedDataUtils.eip712Hash` ([#104](https://github.com/MetaMask/eth-sig-util/pull/104)) 121 | - This function never actually signed anything. It just created a hash that was later signed. The new name better reflects what the function does. 122 | - **BREAKING**: Move package under `@metamask` npm organization ([#162](https://github.com/MetaMask/eth-sig-util/pull/162)) 123 | - Update your `require` and `import` statements to import `@metamask/eth-sig-util` rather than `eth-sig-util`. 124 | - **BREAKING**: Simplify function type signatures ([#198](https://github.com/MetaMask/eth-sig-util/pull/198)) 125 | - This is only a breaking change for TypeScript projects that were importing types used by the function signatures. The types should be far simpler now. 126 | - The `TypedData` has been updated to be more restrictive (it only allows valid typed data now), and it was renamed to `TypedDataV1` 127 | - **BREAKING**: Replace `MsgParams` parameters with "options" parameters ([#204](https://github.com/MetaMask/eth-sig-util/pull/204)) 128 | - This affects the following functions: 129 | - `personalSign` 130 | - `recoverPersonalSignature` 131 | - `extractPublicKey` 132 | - `encrypt` 133 | - `encryptSafely` 134 | - `decrypt` 135 | - `decryptSafely` 136 | - `signTypedData` 137 | - `recoverTypedSignature` 138 | - All parameters are passed in as a single "options" object now, instead of the `MsgParams` type that was used for most of these functions previously. Read each function signature carefully to ensure you are correctly passing in parameters. 139 | - `personalSign` example: 140 | - Previously it was called like this: `personalSign(privateKey, { data })` 141 | - Now it is called like this: `personalSign({ privateKey, data })` 142 | - **BREAKING**: Rename `Version` type to `SignTypedDataVersion` ([#218](https://github.com/MetaMask/eth-sig-util/pull/218)) 143 | - **BREAKING**: Rename `EIP712TypedData` type to `TypedDataV1Field` ([#218](https://github.com/MetaMask/eth-sig-util/pull/218)) 144 | - Add `signTypedData` version validation ([#201](https://github.com/MetaMask/eth-sig-util/pull/201)) 145 | - Add validation to check that parameters aren't nullish ([#205](https://github.com/MetaMask/eth-sig-util/pull/205)) 146 | - Enable inline sourcemaps ([#159](https://github.com/MetaMask/eth-sig-util/pull/159)) 147 | - Update `ethereumjs-util` to v6 ([#138](https://github.com/MetaMask/eth-sig-util/pull/138), [#195](https://github.com/MetaMask/eth-sig-util/pull/195)) 148 | - Allow `TypedDataUtils` functions to be called unbound ([#152](https://github.com/MetaMask/eth-sig-util/pull/152)) 149 | - Update minimum `tweetnacl-util` version ([#155](https://github.com/MetaMask/eth-sig-util/pull/155)) 150 | - Add Solidity types to JSON schema for `signTypedData` ([#189](https://github.com/MetaMask/eth-sig-util/pull/189)) 151 | - Replace README API docs with generated docs ([#213](https://github.com/MetaMask/eth-sig-util/pull/213)) 152 | 153 | ## [3.0.1] - 2021-02-04 154 | ### Changed 155 | - Update `ethereumjs-abi` ([#96](https://github.com/MetaMask/eth-sig-util/pull/96)) 156 | - Remove unused dependencies ([#117](https://github.com/MetaMask/eth-sig-util/pull/117)) 157 | - Update minimum `tweetnacl` to latest version ([#123](https://github.com/MetaMask/eth-sig-util/pull/123)) 158 | 159 | ## [3.0.0] - 2020-11-09 160 | ### Changed 161 | - [**BREAKING**] Migrate to TypeScript ([#74](https://github.com/MetaMask/eth-sig-util/pull/74)) 162 | - Fix package metadata ([#81](https://github.com/MetaMask/eth-sig-util/pull/81) 163 | - Switch from Node.js v8 to Node.js v10 ([#76](https://github.com/MetaMask/eth-sig-util/pull/77) and [#80](https://github.com/MetaMask/eth-sig-util/pull/80)) 164 | 165 | 166 | ## [2.5.4] - 2021-02-04 167 | ### Changed 168 | - Update `ethereumjs-abi` ([#121](https://github.com/MetaMask/eth-sig-util/pull/121)) 169 | - Remove unused dependencies ([#120](https://github.com/MetaMask/eth-sig-util/pull/120)) 170 | - Update minimum `tweetnacl` to latest version ([#124](https://github.com/MetaMask/eth-sig-util/pull/124)) 171 | 172 | ## [2.5.3] - 2020-03-16 [WITHDRAWN] 173 | ### Changed 174 | - [**BREAKING**] Migrate to TypeScript ([#74](https://github.com/MetaMask/eth-sig-util/pull/74)) 175 | - Fix package metadata ([#81](https://github.com/MetaMask/eth-sig-util/pull/81) 176 | - Switch from Node.js v8 to Node.js v10 ([#76](https://github.com/MetaMask/eth-sig-util/pull/77) and [#80](https://github.com/MetaMask/eth-sig-util/pull/80)) 177 | 178 | [Unreleased]: https://github.com/MetaMask/eth-sig-util/compare/v8.2.0...HEAD 179 | [8.2.0]: https://github.com/MetaMask/eth-sig-util/compare/v8.1.2...v8.2.0 180 | [8.1.2]: https://github.com/MetaMask/eth-sig-util/compare/v8.1.1...v8.1.2 181 | [8.1.1]: https://github.com/MetaMask/eth-sig-util/compare/v8.1.0...v8.1.1 182 | [8.1.0]: https://github.com/MetaMask/eth-sig-util/compare/v8.0.0...v8.1.0 183 | [8.0.0]: https://github.com/MetaMask/eth-sig-util/compare/v7.0.3...v8.0.0 184 | [7.0.3]: https://github.com/MetaMask/eth-sig-util/compare/v7.0.2...v7.0.3 185 | [7.0.2]: https://github.com/MetaMask/eth-sig-util/compare/v7.0.1...v7.0.2 186 | [7.0.1]: https://github.com/MetaMask/eth-sig-util/compare/v7.0.0...v7.0.1 187 | [7.0.0]: https://github.com/MetaMask/eth-sig-util/compare/v6.0.1...v7.0.0 188 | [6.0.1]: https://github.com/MetaMask/eth-sig-util/compare/v6.0.0...v6.0.1 189 | [6.0.0]: https://github.com/MetaMask/eth-sig-util/compare/v5.1.0...v6.0.0 190 | [5.1.0]: https://github.com/MetaMask/eth-sig-util/compare/v5.0.3...v5.1.0 191 | [5.0.3]: https://github.com/MetaMask/eth-sig-util/compare/v5.0.2...v5.0.3 192 | [5.0.2]: https://github.com/MetaMask/eth-sig-util/compare/v5.0.1...v5.0.2 193 | [5.0.1]: https://github.com/MetaMask/eth-sig-util/compare/v5.0.0...v5.0.1 194 | [5.0.0]: https://github.com/MetaMask/eth-sig-util/compare/v4.0.1...v5.0.0 195 | [4.0.1]: https://github.com/MetaMask/eth-sig-util/compare/v4.0.0...v4.0.1 196 | [4.0.0]: https://github.com/MetaMask/eth-sig-util/compare/v3.0.1...v4.0.0 197 | [3.0.1]: https://github.com/MetaMask/eth-sig-util/compare/v3.0.0...v3.0.1 198 | [3.0.0]: https://github.com/MetaMask/eth-sig-util/compare/v2.5.4...v3.0.0 199 | [2.5.4]: https://github.com/MetaMask/eth-sig-util/compare/v2.5.3...v2.5.4 200 | [2.5.3]: https://github.com/MetaMask/eth-sig-util/releases/tag/v2.5.3 201 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020 MetaMask 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@metamask/eth-sig-util` 2 | 3 | A small collection of Ethereum signing functions. 4 | 5 | [Available on NPM](https://www.npmjs.com/package/@metamask/eth-sig-util) 6 | 7 | ## Installation 8 | 9 | `yarn add @metamask/eth-sig-util` 10 | 11 | or 12 | 13 | `npm install @metamask/eth-sig-util` 14 | 15 | ## API 16 | 17 | The full API documentation for the latest published version of this library is [available here](https://metamask.github.io/eth-sig-util/latest/index.html). 18 | 19 | ## Contributing 20 | 21 | ### Setup 22 | 23 | - Install [Node.js](https://nodejs.org) version 18 24 | - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. 25 | - Install [Yarn v3](https://yarnpkg.com/getting-started/install) 26 | - Run `yarn install` to install dependencies and run any required post-install scripts 27 | 28 | ### Testing and Linting 29 | 30 | Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. 31 | 32 | Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. 33 | 34 | ### Documentation 35 | 36 | The API documentation can be generated with the command `yarn docs`, which saves it in the `./docs` directory. Open the `./docs/index.html` file to browse the documentation. 37 | 38 | ### Release & Publishing 39 | 40 | The project follows the same release process as the other libraries in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. 41 | 42 | 1. Choose a release version. 43 | 44 | - The release version should be chosen according to SemVer. Analyze the changes to see whether they include any breaking changes, new features, or deprecations, then choose the appropriate SemVer version. See [the SemVer specification](https://semver.org/) for more information. 45 | 46 | 2. If this release is backporting changes onto a previous release, then ensure there is a major version branch for that version (e.g. `1.x` for a `v1` backport release). 47 | 48 | - The major version branch should be set to the most recent release with that major version. For example, when backporting a `v1.0.2` release, you'd want to ensure there was a `1.x` branch that was set to the `v1.0.1` tag. 49 | 50 | 3. Trigger the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event [manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) for the `Create Release Pull Request` action to create the release PR. 51 | 52 | - For a backport release, the base branch should be the major version branch that you ensured existed in step 2. For a normal release, the base branch should be the main branch for that repository (which should be the default value). 53 | - This should trigger the [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) workflow to create the release PR. 54 | 55 | 4. Update the changelog to move each change entry into the appropriate change category ([See here](https://keepachangelog.com/en/1.0.0/#types) for the full list of change categories, and the correct ordering), and edit them to be more easily understood by users of the package. 56 | 57 | - Generally any changes that don't affect consumers of the package (e.g. lockfile changes or development environment changes) are omitted. Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). 58 | - Try to explain each change in terms that users of the package would understand (e.g. avoid referencing internal variables/concepts). 59 | - Consolidate related changes into one change entry if it makes it easier to explain. 60 | - Run `yarn auto-changelog validate --rc` to check that the changelog is correctly formatted. 61 | 62 | 5. Review and QA the release. 63 | 64 | - If changes are made to the base branch, the release branch will need to be updated with these changes and review/QA will need to restart again. As such, it's probably best to avoid merging other PRs into the base branch while review is underway. 65 | 66 | 6. Squash & Merge the release. 67 | 68 | - This should trigger the [`action-publish-release`](https://github.com/MetaMask/action-publish-release) workflow to tag the final release commit and publish the release on GitHub. 69 | 70 | 7. Publish the release on npm. 71 | 72 | - Wait for the `publish-release` GitHub Action workflow to finish. This should trigger a second job (`publish-npm`), which will wait for a run approval by the [`npm publishers`](https://github.com/orgs/MetaMask/teams/npm-publishers) team. 73 | - Approve the `publish-npm` job (or ask somebody on the npm publishers team to approve it for you). 74 | - Once the `publish-npm` job has finished, check npm to verify that it has been published. 75 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | // Ensures that we collect coverage from all source files, not just tested 4 | // ones. 5 | collectCoverageFrom: ['./src/**.ts'], 6 | coverageReporters: ['text', 'html'], 7 | coverageThreshold: { 8 | global: { 9 | branches: 74, 10 | functions: 98, 11 | lines: 93.4, 12 | statements: 93.4, 13 | }, 14 | }, 15 | moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'], 16 | preset: 'ts-jest', 17 | // "resetMocks" resets all mocks, including mocked modules, to jest.fn(), 18 | // between each test case. 19 | resetMocks: true, 20 | // "restoreMocks" restores all mocks created using jest.spyOn to their 21 | // original implementations, between each test. It does not affect mocked 22 | // modules. 23 | restoreMocks: true, 24 | testEnvironment: 'node', 25 | testRegex: ['\\.test\\.(ts|js)$'], 26 | testTimeout: 2500, 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@metamask/eth-sig-util", 3 | "version": "8.2.0", 4 | "description": "A few useful functions for signing ethereum data", 5 | "keywords": [ 6 | "ethereum", 7 | "signature" 8 | ], 9 | "homepage": "https://github.com/MetaMask/eth-sig-util#readme", 10 | "bugs": { 11 | "url": "https://github.com/MetaMask/eth-sig-util/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/MetaMask/eth-sig-util.git" 16 | }, 17 | "license": "ISC", 18 | "author": "Dan Finlay", 19 | "exports": { 20 | ".": "./dist/index.js", 21 | "./encryption": "./dist/encryption.js", 22 | "./personal-sign": "./dist/personal-sign.js", 23 | "./sign-typed-data": "./dist/sign-typed-data.js" 24 | }, 25 | "main": "./dist/index.js", 26 | "files": [ 27 | "dist", 28 | "!__snapshots__", 29 | "!**/*.test.js", 30 | "!**/*.test.js.map", 31 | "!**/*.test.ts", 32 | "!**/*.test.d.ts" 33 | ], 34 | "scripts": { 35 | "build": "tsc --project .", 36 | "build:clean": "rimraf dist && yarn build", 37 | "build:docs": "typedoc", 38 | "lint": "yarn lint:eslint && yarn lint:misc --check", 39 | "lint:eslint": "eslint . --cache --ext js,ts", 40 | "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", 41 | "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", 42 | "prepack": "./scripts/prepack.sh", 43 | "test": "jest", 44 | "test:watch": "jest --watch" 45 | }, 46 | "dependencies": { 47 | "@ethereumjs/rlp": "^4.0.1", 48 | "@ethereumjs/util": "^8.1.0", 49 | "@metamask/abi-utils": "^3.0.0", 50 | "@metamask/utils": "^11.0.1", 51 | "@scure/base": "~1.1.3", 52 | "ethereum-cryptography": "^2.1.2", 53 | "tweetnacl": "^1.0.3" 54 | }, 55 | "devDependencies": { 56 | "@lavamoat/allow-scripts": "^2.3.1", 57 | "@metamask/auto-changelog": "^3.1.0", 58 | "@metamask/eslint-config": "^11.1.0", 59 | "@metamask/eslint-config-jest": "^11.1.0", 60 | "@metamask/eslint-config-nodejs": "^11.1.0", 61 | "@metamask/eslint-config-typescript": "^11.1.0", 62 | "@types/jest": "^27.0.6", 63 | "@types/node": "~18.18.14", 64 | "@typescript-eslint/eslint-plugin": "^5.59.1", 65 | "@typescript-eslint/parser": "^5.59.1", 66 | "ajv": "^8.11.0", 67 | "eslint": "^8.27.0", 68 | "eslint-config-prettier": "^8.3.0", 69 | "eslint-plugin-import": "^2.23.4", 70 | "eslint-plugin-jest": "^27.1.5", 71 | "eslint-plugin-jsdoc": "^39.6.2", 72 | "eslint-plugin-node": "^11.1.0", 73 | "eslint-plugin-prettier": "^4.2.1", 74 | "jest": "^27.0.6", 75 | "prettier": "^2.3.2", 76 | "prettier-plugin-packagejson": "^2.2.11", 77 | "rimraf": "^3.0.2", 78 | "ts-jest": "^27.0.3", 79 | "typedoc": "^0.24.6", 80 | "typescript": "~4.8.4" 81 | }, 82 | "packageManager": "yarn@3.2.2", 83 | "engines": { 84 | "node": "^18.18 || ^20.14 || >=22" 85 | }, 86 | "publishConfig": { 87 | "access": "public", 88 | "registry": "https://registry.npmjs.org/" 89 | }, 90 | "lavamoat": { 91 | "allowScripts": { 92 | "@lavamoat/preinstall-always-fail": false, 93 | "ethereumjs-util>ethereum-cryptography>keccak": true, 94 | "ethereumjs-util>ethereum-cryptography>secp256k1": true 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /scripts/get.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -u 6 | set -o pipefail 7 | 8 | KEY="${1}" 9 | OUTPUT="${2}" 10 | 11 | if [[ -z $KEY ]]; then 12 | echo "Error: KEY not specified." 13 | exit 1 14 | fi 15 | 16 | if [[ -z $OUTPUT ]]; then 17 | echo "Error: OUTPUT not specified." 18 | exit 1 19 | fi 20 | 21 | echo "$OUTPUT=$(jq --raw-output "$KEY" package.json)" >> "$GITHUB_OUTPUT" 22 | -------------------------------------------------------------------------------- /scripts/prepack.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -o pipefail 6 | 7 | if [[ -n $SKIP_PREPACK ]]; then 8 | echo "Notice: skipping prepack." 9 | exit 0 10 | fi 11 | 12 | yarn build:clean 13 | -------------------------------------------------------------------------------- /src/encryption.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decrypt, 3 | decryptSafely, 4 | encrypt, 5 | encryptSafely, 6 | getEncryptionPublicKey, 7 | } from './encryption'; 8 | 9 | describe('encryption', function () { 10 | const bob = { 11 | ethereumPrivateKey: 12 | '7e5374ec2ef0d91761a6e72fdf8f6ac665519bfdf6da0a2329cf0d804514b816', 13 | encryptionPrivateKey: 'flN07C7w2Rdhpucv349qxmVRm/322gojKc8NgEUUuBY=', 14 | encryptionPublicKey: 'C5YMNdqE4kLgxQhJO1MfuQcHP5hjVSXzamzd/TxlR0U=', 15 | }; 16 | 17 | const secretMessage = 'My name is Satoshi Buterin'; 18 | 19 | const encryptedData = { 20 | version: 'x25519-xsalsa20-poly1305', 21 | nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', 22 | ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', 23 | ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', 24 | }; 25 | 26 | it("getting bob's encryptionPublicKey", async function () { 27 | const result = getEncryptionPublicKey(bob.ethereumPrivateKey); 28 | expect(result).toBe(bob.encryptionPublicKey); 29 | }); 30 | 31 | // encryption test 32 | it("alice encrypts message with bob's encryptionPublicKey", async function () { 33 | const result = encrypt({ 34 | publicKey: bob.encryptionPublicKey, 35 | data: secretMessage, 36 | version: 'x25519-xsalsa20-poly1305', 37 | }); 38 | 39 | expect(result.ciphertext).toHaveLength(56); 40 | expect(result.ephemPublicKey).toHaveLength(44); 41 | expect(result.nonce).toHaveLength(32); 42 | expect(result.version).toBe('x25519-xsalsa20-poly1305'); 43 | }); 44 | 45 | // safe encryption test 46 | it("alice encryptsSafely message with bob's encryptionPublicKey", async function () { 47 | const version = 'x25519-xsalsa20-poly1305'; 48 | const result = encryptSafely({ 49 | publicKey: bob.encryptionPublicKey, 50 | data: secretMessage, 51 | version, 52 | }); 53 | 54 | expect(result.ciphertext).toHaveLength(2732); 55 | expect(result.ephemPublicKey).toHaveLength(44); 56 | expect(result.nonce).toHaveLength(32); 57 | expect(result.version).toBe('x25519-xsalsa20-poly1305'); 58 | }); 59 | 60 | // safe decryption test 61 | it('bob decryptSafely message that Alice encryptSafely for him', async function () { 62 | const version = 'x25519-xsalsa20-poly1305'; 63 | const result = encryptSafely({ 64 | publicKey: bob.encryptionPublicKey, 65 | data: secretMessage, 66 | version, 67 | }); 68 | 69 | const plaintext = decryptSafely({ 70 | encryptedData: result, 71 | privateKey: bob.ethereumPrivateKey, 72 | }); 73 | expect(plaintext).toBe(secretMessage); 74 | }); 75 | 76 | // decryption test 77 | it('bob decrypts message that Alice sent to him', function () { 78 | const result = decrypt({ 79 | encryptedData, 80 | privateKey: bob.ethereumPrivateKey, 81 | }); 82 | expect(result).toBe(secretMessage); 83 | }); 84 | 85 | it('decryption failed because version is wrong or missing', function () { 86 | const badVersionData = { 87 | version: 'x256k1-aes256cbc', 88 | nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', 89 | ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', 90 | ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', 91 | }; 92 | 93 | expect(() => 94 | decrypt({ 95 | encryptedData: badVersionData, 96 | privateKey: bob.ethereumPrivateKey, 97 | }), 98 | ).toThrow('Encryption type/version not supported.'); 99 | }); 100 | 101 | it('decryption failed because nonce is wrong or missing', function () { 102 | // encrypted data 103 | const badNonceData = { 104 | version: 'x25519-xsalsa20-poly1305', 105 | nonce: '', 106 | ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', 107 | ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', 108 | }; 109 | 110 | expect(() => 111 | decrypt({ 112 | encryptedData: badNonceData, 113 | privateKey: bob.ethereumPrivateKey, 114 | }), 115 | ).toThrow('bad nonce size'); 116 | }); 117 | 118 | it('decryption failed because ephemPublicKey is wrong or missing', function () { 119 | // encrypted data 120 | const badEphemData = { 121 | version: 'x25519-xsalsa20-poly1305', 122 | nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', 123 | ephemPublicKey: 'FFFF/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', 124 | ciphertext: 'f8kBcl/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', 125 | }; 126 | 127 | expect(() => 128 | decrypt({ 129 | encryptedData: badEphemData, 130 | privateKey: bob.ethereumPrivateKey, 131 | }), 132 | ).toThrow('Decryption failed.'); 133 | }); 134 | 135 | it('decryption failed because cyphertext is wrong or missing', function () { 136 | // encrypted data 137 | const badEphemData = { 138 | version: 'x25519-xsalsa20-poly1305', 139 | nonce: '1dvWO7uOnBnO7iNDJ9kO9pTasLuKNlej', 140 | ephemPublicKey: 'FBH1/pAEHOOW14Lu3FWkgV3qOEcuL78Zy+qW1RwzMXQ=', 141 | ciphertext: 'ffffff/NCyf3sybfbwAKk/np2Bzt9lRVkZejr6uh5FgnNlH/ic62DZzy', 142 | }; 143 | 144 | expect(() => 145 | decrypt({ 146 | encryptedData: badEphemData, 147 | privateKey: bob.ethereumPrivateKey, 148 | }), 149 | ).toThrow('Decryption failed.'); 150 | }); 151 | 152 | describe('validation', function () { 153 | describe('encrypt', function () { 154 | it('should throw if passed null public key', function () { 155 | expect(() => 156 | encrypt({ 157 | publicKey: null as any, 158 | data: secretMessage, 159 | version: 'x25519-xsalsa20-poly1305', 160 | }), 161 | ).toThrow('Missing publicKey parameter'); 162 | }); 163 | 164 | it('should throw if passed undefined public key', function () { 165 | expect(() => 166 | encrypt({ 167 | publicKey: undefined as any, 168 | data: secretMessage, 169 | version: 'x25519-xsalsa20-poly1305', 170 | }), 171 | ).toThrow('Missing publicKey parameter'); 172 | }); 173 | 174 | it('should throw if passed null data', function () { 175 | expect(() => 176 | encrypt({ 177 | publicKey: bob.encryptionPublicKey, 178 | data: null, 179 | version: 'x25519-xsalsa20-poly1305', 180 | }), 181 | ).toThrow('Missing data parameter'); 182 | }); 183 | 184 | it('should throw if passed undefined data', function () { 185 | expect(() => 186 | encrypt({ 187 | publicKey: bob.encryptionPublicKey, 188 | data: undefined, 189 | version: 'x25519-xsalsa20-poly1305', 190 | }), 191 | ).toThrow('Missing data parameter'); 192 | }); 193 | 194 | it('should throw if passed null version', function () { 195 | expect(() => 196 | encrypt({ 197 | publicKey: bob.encryptionPublicKey, 198 | data: secretMessage, 199 | version: null as any, 200 | }), 201 | ).toThrow('Missing version parameter'); 202 | }); 203 | 204 | it('should throw if passed undefined version', function () { 205 | expect(() => 206 | encrypt({ 207 | publicKey: bob.encryptionPublicKey, 208 | data: secretMessage, 209 | version: undefined as any, 210 | }), 211 | ).toThrow('Missing version parameter'); 212 | }); 213 | }); 214 | 215 | describe('encryptSafely', function () { 216 | it('should throw if passed null public key', function () { 217 | expect(() => 218 | encryptSafely({ 219 | publicKey: null as any, 220 | data: secretMessage, 221 | version: 'x25519-xsalsa20-poly1305', 222 | }), 223 | ).toThrow('Missing publicKey parameter'); 224 | }); 225 | 226 | it('should throw if passed undefined public key', function () { 227 | expect(() => 228 | encryptSafely({ 229 | publicKey: undefined as any, 230 | data: secretMessage, 231 | version: 'x25519-xsalsa20-poly1305', 232 | }), 233 | ).toThrow('Missing publicKey parameter'); 234 | }); 235 | 236 | it('should throw if passed null data', function () { 237 | expect(() => 238 | encryptSafely({ 239 | publicKey: bob.encryptionPublicKey, 240 | data: null, 241 | version: 'x25519-xsalsa20-poly1305', 242 | }), 243 | ).toThrow('Missing data parameter'); 244 | }); 245 | 246 | it('should throw if passed undefined data', function () { 247 | expect(() => 248 | encryptSafely({ 249 | publicKey: bob.encryptionPublicKey, 250 | data: undefined, 251 | version: 'x25519-xsalsa20-poly1305', 252 | }), 253 | ).toThrow('Missing data parameter'); 254 | }); 255 | 256 | it('should throw if passed null version', function () { 257 | expect(() => 258 | encryptSafely({ 259 | publicKey: bob.encryptionPublicKey, 260 | data: secretMessage, 261 | version: null as any, 262 | }), 263 | ).toThrow('Missing version parameter'); 264 | }); 265 | 266 | it('should throw if passed undefined version', function () { 267 | expect(() => 268 | encryptSafely({ 269 | publicKey: bob.encryptionPublicKey, 270 | data: secretMessage, 271 | version: undefined as any, 272 | }), 273 | ).toThrow('Missing version parameter'); 274 | }); 275 | }); 276 | 277 | describe('decrypt', function () { 278 | it('should throw if passed null encrypted data', function () { 279 | expect(() => 280 | decrypt({ 281 | encryptedData: null as any, 282 | privateKey: bob.ethereumPrivateKey, 283 | }), 284 | ).toThrow('Missing encryptedData parameter'); 285 | }); 286 | 287 | it('should throw if passed undefined encrypted data', function () { 288 | expect(() => 289 | decrypt({ 290 | encryptedData: undefined as any, 291 | privateKey: bob.ethereumPrivateKey, 292 | }), 293 | ).toThrow('Missing encryptedData parameter'); 294 | }); 295 | 296 | it('should throw if passed null private key', function () { 297 | expect(() => 298 | decrypt({ 299 | encryptedData, 300 | privateKey: null as any, 301 | }), 302 | ).toThrow('Missing privateKey parameter'); 303 | }); 304 | 305 | it('should throw if passed undefined private key', function () { 306 | expect(() => 307 | decrypt({ 308 | encryptedData, 309 | privateKey: undefined as any, 310 | }), 311 | ).toThrow('Missing privateKey parameter'); 312 | }); 313 | }); 314 | 315 | describe('decryptSafely', function () { 316 | it('should throw if passed null encrypted data', function () { 317 | expect(() => 318 | decryptSafely({ 319 | encryptedData: null as any, 320 | privateKey: bob.ethereumPrivateKey, 321 | }), 322 | ).toThrow('Missing encryptedData parameter'); 323 | }); 324 | 325 | it('should throw if passed undefined encrypted data', function () { 326 | expect(() => 327 | decryptSafely({ 328 | encryptedData: undefined as any, 329 | privateKey: bob.ethereumPrivateKey, 330 | }), 331 | ).toThrow('Missing encryptedData parameter'); 332 | }); 333 | 334 | it('should throw if passed null private key', function () { 335 | expect(() => 336 | decryptSafely({ 337 | encryptedData, 338 | privateKey: null as any, 339 | }), 340 | ).toThrow('Missing privateKey parameter'); 341 | }); 342 | 343 | it('should throw if passed undefined private key', function () { 344 | expect(() => 345 | decryptSafely({ 346 | encryptedData, 347 | privateKey: undefined as any, 348 | }), 349 | ).toThrow('Missing privateKey parameter'); 350 | }); 351 | }); 352 | }); 353 | }); 354 | -------------------------------------------------------------------------------- /src/encryption.ts: -------------------------------------------------------------------------------- 1 | import { base64, utf8 } from '@scure/base'; 2 | import * as nacl from 'tweetnacl'; 3 | 4 | import { isNullish } from './utils'; 5 | 6 | export type EthEncryptedData = { 7 | version: string; 8 | nonce: string; 9 | ephemPublicKey: string; 10 | ciphertext: string; 11 | }; 12 | 13 | /** 14 | * Encrypt a message. 15 | * 16 | * @param options - The encryption options. 17 | * @param options.publicKey - The public key of the message recipient. 18 | * @param options.data - The message data. 19 | * @param options.version - The type of encryption to use. 20 | * @returns The encrypted data. 21 | */ 22 | export function encrypt({ 23 | publicKey, 24 | data, 25 | version, 26 | }: { 27 | publicKey: string; 28 | data: unknown; 29 | version: string; 30 | }): EthEncryptedData { 31 | if (isNullish(publicKey)) { 32 | throw new Error('Missing publicKey parameter'); 33 | } else if (isNullish(data)) { 34 | throw new Error('Missing data parameter'); 35 | } else if (isNullish(version)) { 36 | throw new Error('Missing version parameter'); 37 | } 38 | 39 | switch (version) { 40 | case 'x25519-xsalsa20-poly1305': { 41 | if (typeof data !== 'string') { 42 | throw new Error('Message data must be given as a string'); 43 | } 44 | // generate ephemeral keypair 45 | const ephemeralKeyPair = nacl.box.keyPair(); 46 | 47 | // assemble encryption parameters - from string to UInt8 48 | let pubKeyUInt8Array: Uint8Array; 49 | try { 50 | pubKeyUInt8Array = base64.decode(publicKey); 51 | } catch (err) { 52 | throw new Error('Bad public key'); 53 | } 54 | 55 | const msgParamsUInt8Array = utf8.decode(data); 56 | const nonce = nacl.randomBytes(nacl.box.nonceLength); 57 | 58 | // encrypt 59 | const encryptedMessage = nacl.box( 60 | msgParamsUInt8Array, 61 | nonce, 62 | pubKeyUInt8Array, 63 | ephemeralKeyPair.secretKey, 64 | ); 65 | 66 | // handle encrypted data 67 | const output = { 68 | version: 'x25519-xsalsa20-poly1305', 69 | nonce: base64.encode(nonce), 70 | ephemPublicKey: base64.encode(ephemeralKeyPair.publicKey), 71 | ciphertext: base64.encode(encryptedMessage), 72 | }; 73 | // return encrypted msg data 74 | return output; 75 | } 76 | 77 | default: 78 | throw new Error('Encryption type/version not supported'); 79 | } 80 | } 81 | 82 | /** 83 | * Encrypt a message in a way that obscures the message length. 84 | * 85 | * The message is padded to a multiple of 2048 before being encrypted so that the length of the 86 | * resulting encrypted message can't be used to guess the exact length of the original message. 87 | * 88 | * @param options - The encryption options. 89 | * @param options.publicKey - The public key of the message recipient. 90 | * @param options.data - The message data. 91 | * @param options.version - The type of encryption to use. 92 | * @returns The encrypted data. 93 | */ 94 | export function encryptSafely({ 95 | publicKey, 96 | data, 97 | version, 98 | }: { 99 | publicKey: string; 100 | data: unknown; 101 | version: string; 102 | }): EthEncryptedData { 103 | if (isNullish(publicKey)) { 104 | throw new Error('Missing publicKey parameter'); 105 | } else if (isNullish(data)) { 106 | throw new Error('Missing data parameter'); 107 | } else if (isNullish(version)) { 108 | throw new Error('Missing version parameter'); 109 | } 110 | 111 | const DEFAULT_PADDING_LENGTH = 2 ** 11; 112 | const NACL_EXTRA_BYTES = 16; 113 | 114 | if (typeof data === 'object' && data && 'toJSON' in data) { 115 | // remove toJSON attack vector 116 | // TODO, check all possible children 117 | throw new Error( 118 | 'Cannot encrypt with toJSON property. Please remove toJSON property', 119 | ); 120 | } 121 | 122 | // add padding 123 | const dataWithPadding = { 124 | data, 125 | padding: '', 126 | }; 127 | 128 | // calculate padding 129 | const dataLength = Buffer.byteLength( 130 | JSON.stringify(dataWithPadding), 131 | 'utf-8', 132 | ); 133 | const modVal = dataLength % DEFAULT_PADDING_LENGTH; 134 | let padLength = 0; 135 | // Only pad if necessary 136 | if (modVal > 0) { 137 | padLength = DEFAULT_PADDING_LENGTH - modVal - NACL_EXTRA_BYTES; // nacl extra bytes 138 | } 139 | dataWithPadding.padding = '0'.repeat(padLength); 140 | 141 | const paddedMessage = JSON.stringify(dataWithPadding); 142 | return encrypt({ publicKey, data: paddedMessage, version }); 143 | } 144 | 145 | /** 146 | * Decrypt a message. 147 | * 148 | * @param options - The decryption options. 149 | * @param options.encryptedData - The encrypted data. 150 | * @param options.privateKey - The private key to decrypt with. 151 | * @returns The decrypted message. 152 | */ 153 | export function decrypt({ 154 | encryptedData, 155 | privateKey, 156 | }: { 157 | encryptedData: EthEncryptedData; 158 | privateKey: string; 159 | }): string { 160 | if (isNullish(encryptedData)) { 161 | throw new Error('Missing encryptedData parameter'); 162 | } else if (isNullish(privateKey)) { 163 | throw new Error('Missing privateKey parameter'); 164 | } 165 | 166 | switch (encryptedData.version) { 167 | case 'x25519-xsalsa20-poly1305': { 168 | const receiverPrivateKeyUint8Array = Buffer.from(privateKey, 'hex'); 169 | const receiverEncryptionPrivateKey = nacl.box.keyPair.fromSecretKey( 170 | receiverPrivateKeyUint8Array, 171 | ).secretKey; 172 | 173 | // assemble decryption parameters 174 | const nonce = base64.decode(encryptedData.nonce); 175 | const ciphertext = base64.decode(encryptedData.ciphertext); 176 | const ephemPublicKey = base64.decode(encryptedData.ephemPublicKey); 177 | 178 | // decrypt 179 | const decryptedMessage = nacl.box.open( 180 | ciphertext, 181 | nonce, 182 | ephemPublicKey, 183 | receiverEncryptionPrivateKey, 184 | ); 185 | 186 | // return decrypted msg data 187 | try { 188 | if (!decryptedMessage) { 189 | throw new Error(); 190 | } 191 | const output = utf8.encode(decryptedMessage); 192 | // TODO: This is probably extraneous but was kept to minimize changes during refactor 193 | if (!output) { 194 | throw new Error(); 195 | } 196 | return output; 197 | } catch (err) { 198 | if (err && typeof err.message === 'string' && err.message.length) { 199 | throw new Error(`Decryption failed: ${err.message as string}`); 200 | } 201 | throw new Error(`Decryption failed.`); 202 | } 203 | } 204 | 205 | default: 206 | throw new Error('Encryption type/version not supported.'); 207 | } 208 | } 209 | 210 | /** 211 | * Decrypt a message that has been encrypted using `encryptSafely`. 212 | * 213 | * @param options - The decryption options. 214 | * @param options.encryptedData - The encrypted data. 215 | * @param options.privateKey - The private key to decrypt with. 216 | * @returns The decrypted message. 217 | */ 218 | export function decryptSafely({ 219 | encryptedData, 220 | privateKey, 221 | }: { 222 | encryptedData: EthEncryptedData; 223 | privateKey: string; 224 | }): string { 225 | if (isNullish(encryptedData)) { 226 | throw new Error('Missing encryptedData parameter'); 227 | } else if (isNullish(privateKey)) { 228 | throw new Error('Missing privateKey parameter'); 229 | } 230 | 231 | const dataWithPadding = JSON.parse(decrypt({ encryptedData, privateKey })); 232 | return dataWithPadding.data; 233 | } 234 | 235 | /** 236 | * Get the encryption public key for the given key. 237 | * 238 | * @param privateKey - The private key to generate the encryption public key with. 239 | * @returns The encryption public key. 240 | */ 241 | export function getEncryptionPublicKey(privateKey: string): string { 242 | const privateKeyUint8Array = Buffer.from(privateKey, 'hex'); 243 | const encryptionPublicKey = 244 | nacl.box.keyPair.fromSecretKey(privateKeyUint8Array).publicKey; 245 | return base64.encode(encryptionPublicKey); 246 | } 247 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as sigUtil from '.'; 2 | import { concatSig, normalize } from './utils'; 3 | 4 | describe('exports', () => { 5 | it('should have all expected exports', () => { 6 | expect(Object.keys(sigUtil)).toMatchInlineSnapshot(` 7 | Array [ 8 | "concatSig", 9 | "normalize", 10 | "personalSign", 11 | "recoverPersonalSignature", 12 | "extractPublicKey", 13 | "SignTypedDataVersion", 14 | "TYPED_MESSAGE_SCHEMA", 15 | "TypedDataUtils", 16 | "typedSignatureHash", 17 | "signTypedData", 18 | "recoverTypedSignature", 19 | "encrypt", 20 | "encryptSafely", 21 | "decrypt", 22 | "decryptSafely", 23 | "getEncryptionPublicKey", 24 | "signEIP7702Authorization", 25 | "recoverEIP7702Authorization", 26 | "hashEIP7702Authorization", 27 | ] 28 | `); 29 | }); 30 | 31 | // I don't know why this is necessary. 32 | // I tried using an 'istanbul ignore next' comment but it did not work. 33 | it('should stop marking these functions as not covered', () => { 34 | expect(sigUtil.concatSig).toBe(concatSig); 35 | expect(sigUtil.normalize).toBe(normalize); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './personal-sign'; 2 | export * from './sign-typed-data'; 3 | export * from './encryption'; 4 | export * from './sign-eip7702-authorization'; 5 | export { concatSig, normalize } from './utils'; 6 | -------------------------------------------------------------------------------- /src/personal-sign.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addHexPrefix, 3 | privateToAddress, 4 | privateToPublic, 5 | } from '@ethereumjs/util'; 6 | 7 | import { 8 | extractPublicKey, 9 | personalSign, 10 | recoverPersonalSignature, 11 | } from './personal-sign'; 12 | 13 | const privateKey = Buffer.from( 14 | '4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3418bb0', 15 | 'hex', 16 | ); 17 | 18 | describe('personalSign', function () { 19 | // This is a signature of the message "Hello, world!" that was created using the private key in 20 | // the top-level `privateKey` variable. 21 | const helloWorldSignature = 22 | '0x90a938f7457df6e8f741264c32697fc52f9a8f867c52dd70713d9d2d472f2e415d9c94148991bbe1f4a1818d1dff09165782749c877f5cf1eff4ef126e55714d1c'; 23 | const helloWorldMessage = `0x${Buffer.from('Hello, world!').toString('hex')}`; 24 | 25 | it('should sign a message', function () { 26 | expect(personalSign({ privateKey, data: helloWorldMessage })).toBe( 27 | helloWorldSignature, 28 | ); 29 | }); 30 | 31 | it('should recover the address from a signature', function () { 32 | const address = addHexPrefix(privateToAddress(privateKey).toString('hex')); 33 | 34 | expect( 35 | recoverPersonalSignature({ 36 | data: helloWorldMessage, 37 | signature: helloWorldSignature, 38 | }), 39 | ).toBe(address); 40 | }); 41 | 42 | it('should recover the public key from a signature', function () { 43 | const publicKey = addHexPrefix(privateToPublic(privateKey).toString('hex')); 44 | 45 | expect( 46 | extractPublicKey({ 47 | data: helloWorldMessage, 48 | signature: helloWorldSignature, 49 | }), 50 | ).toBe(publicKey); 51 | }); 52 | 53 | it('should sign a message and recover the address of the signer', function () { 54 | const address = addHexPrefix(privateToAddress(privateKey).toString('hex')); 55 | const signature = personalSign({ 56 | privateKey, 57 | data: helloWorldMessage, 58 | }); 59 | 60 | expect( 61 | recoverPersonalSignature({ 62 | data: helloWorldMessage, 63 | signature, 64 | }), 65 | ).toBe(address); 66 | }); 67 | 68 | it('should sign a message and recover the public key of the signer', function () { 69 | const publicKey = addHexPrefix(privateToPublic(privateKey).toString('hex')); 70 | const signature = personalSign({ 71 | privateKey, 72 | data: helloWorldMessage, 73 | }); 74 | 75 | expect( 76 | extractPublicKey({ 77 | data: helloWorldMessage, 78 | signature, 79 | }), 80 | ).toBe(publicKey); 81 | }); 82 | 83 | // personal_sign was declared without an explicit set of test data 84 | // so I made a script out of geth's internals to create this test data 85 | // https://gist.github.com/kumavis/461d2c0e9a04ea0818e423bb77e3d260 86 | const testCases = [ 87 | { 88 | testLabel: 'personalSign - kumavis fml manual test I', 89 | // "hello world" 90 | message: '0x68656c6c6f20776f726c64', 91 | signature: 92 | '0xce909e8ea6851bc36c007a0072d0524b07a3ff8d4e623aca4c71ca8e57250c4d0a3fc38fa8fbaaa81ead4b9f6bd03356b6f8bf18bccad167d78891636e1d69561b', 93 | addressHex: '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', 94 | key: Buffer.from( 95 | '6969696969696969696969696969696969696969696969696969696969696969', 96 | 'hex', 97 | ), 98 | }, 99 | { 100 | testLabel: 'personalSign - kumavis fml manual test II', 101 | // some random binary message from parity's test 102 | message: 103 | '0x0cc175b9c0f1b6a831c399e26977266192eb5ffee6ae2fec3ad71c777531578f', 104 | signature: 105 | '0x9ff8350cc7354b80740a3580d0e0fd4f1f02062040bc06b893d70906f8728bb5163837fd376bf77ce03b55e9bd092b32af60e86abce48f7b8d3539988ee5a9be1c', 106 | addressHex: '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', 107 | key: Buffer.from( 108 | '6969696969696969696969696969696969696969696969696969696969696969', 109 | 'hex', 110 | ), 111 | }, 112 | { 113 | testLabel: 'personalSign - kumavis fml manual test III', 114 | // random binary message data and pk from parity's test 115 | // https://github.com/ethcore/parity/blob/5369a129ae276d38f3490abb18c5093b338246e0/rpc/src/v1/tests/mocked/eth.rs#L301-L317 116 | // note: their signature result is incorrect (last byte moved to front) due to a parity bug 117 | message: 118 | '0x0cc175b9c0f1b6a831c399e26977266192eb5ffee6ae2fec3ad71c777531578f', 119 | signature: 120 | '0xa2870db1d0c26ef93c7b72d2a0830fa6b841e0593f7186bc6c7cc317af8cf3a42fda03bd589a49949aa05db83300cdb553116274518dbe9d90c65d0213f4af491b', 121 | addressHex: '0xe0da1edcea030875cd0f199d96eb70f6ab78faf2', 122 | key: Buffer.from( 123 | '4545454545454545454545454545454545454545454545454545454545454545', 124 | 'hex', 125 | ), 126 | }, 127 | ]; 128 | 129 | for (const { testLabel, message, signature, addressHex, key } of testCases) { 130 | it(`${testLabel}`, function () { 131 | const signed = personalSign({ privateKey: key, data: message }); 132 | expect(signed).toBe(signature); 133 | 134 | const recovered = recoverPersonalSignature({ 135 | data: message, 136 | signature, 137 | }); 138 | expect(recovered).toBe(addressHex); 139 | }); 140 | } 141 | 142 | describe('validation', function () { 143 | describe('personalSign', function () { 144 | it('should throw if passed null data', function () { 145 | expect(() => 146 | personalSign({ 147 | privateKey, 148 | data: null, 149 | }), 150 | ).toThrow('Missing data parameter'); 151 | }); 152 | 153 | it('should throw if passed undefined data', function () { 154 | expect(() => 155 | personalSign({ 156 | privateKey, 157 | data: undefined, 158 | }), 159 | ).toThrow('Missing data parameter'); 160 | }); 161 | 162 | it('should throw if passed a null private key', function () { 163 | expect(() => 164 | personalSign({ 165 | privateKey: null as any, 166 | data: helloWorldMessage, 167 | }), 168 | ).toThrow('Missing privateKey parameter'); 169 | }); 170 | 171 | it('should throw if passed an undefined private key', function () { 172 | expect(() => 173 | personalSign({ 174 | privateKey: undefined as any, 175 | data: helloWorldMessage, 176 | }), 177 | ).toThrow('Missing privateKey parameter'); 178 | }); 179 | }); 180 | 181 | describe('recoverPersonalSignature', function () { 182 | it('should throw if passed null data', function () { 183 | expect(() => 184 | recoverPersonalSignature({ 185 | data: null, 186 | signature: helloWorldSignature, 187 | }), 188 | ).toThrow('Missing data parameter'); 189 | }); 190 | 191 | it('should throw if passed undefined data', function () { 192 | expect(() => 193 | recoverPersonalSignature({ 194 | data: undefined, 195 | signature: helloWorldSignature, 196 | }), 197 | ).toThrow('Missing data parameter'); 198 | }); 199 | 200 | it('should throw if passed a null signature', function () { 201 | expect(() => 202 | recoverPersonalSignature({ 203 | data: helloWorldMessage, 204 | signature: null as any, 205 | }), 206 | ).toThrow('Missing signature parameter'); 207 | }); 208 | 209 | it('should throw if passed an undefined signature', function () { 210 | expect(() => 211 | recoverPersonalSignature({ 212 | data: helloWorldMessage, 213 | signature: undefined as any, 214 | }), 215 | ).toThrow('Missing signature parameter'); 216 | }); 217 | }); 218 | 219 | describe('extractPublicKey', function () { 220 | it('should throw if passed null data', function () { 221 | expect(() => 222 | extractPublicKey({ 223 | data: null, 224 | signature: helloWorldSignature, 225 | }), 226 | ).toThrow('Missing data parameter'); 227 | }); 228 | 229 | it('should throw if passed undefined data', function () { 230 | expect(() => 231 | extractPublicKey({ 232 | data: undefined, 233 | signature: helloWorldSignature, 234 | }), 235 | ).toThrow('Missing data parameter'); 236 | }); 237 | 238 | it('should throw if passed a null signature', function () { 239 | expect(() => 240 | extractPublicKey({ 241 | data: helloWorldMessage, 242 | signature: null as any, 243 | }), 244 | ).toThrow('Missing signature parameter'); 245 | }); 246 | 247 | it('should throw if passed an undefined signature', function () { 248 | expect(() => 249 | extractPublicKey({ 250 | data: helloWorldMessage, 251 | signature: undefined as any, 252 | }), 253 | ).toThrow('Missing signature parameter'); 254 | }); 255 | }); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /src/personal-sign.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bufferToHex, 3 | ecsign, 4 | hashPersonalMessage, 5 | publicToAddress, 6 | toBuffer, 7 | ToBufferInputTypes, 8 | } from '@ethereumjs/util'; 9 | 10 | import { 11 | concatSig, 12 | isNullish, 13 | legacyToBuffer, 14 | recoverPublicKey, 15 | } from './utils'; 16 | 17 | /** 18 | * Create an Ethereum-specific signature for a message. 19 | * 20 | * This function is equivalent to the `eth_sign` Ethereum JSON-RPC method as specified in EIP-1417, 21 | * as well as the MetaMask's `personal_sign` method. 22 | * 23 | * @param options - The personal sign options. 24 | * @param options.privateKey - The key to sign with. 25 | * @param options.data - The hex data to sign. 26 | * @returns The '0x'-prefixed hex encoded signature. 27 | */ 28 | export function personalSign({ 29 | privateKey, 30 | data, 31 | }: { 32 | privateKey: Buffer; 33 | data: ToBufferInputTypes; 34 | }): string { 35 | if (isNullish(data)) { 36 | throw new Error('Missing data parameter'); 37 | } else if (isNullish(privateKey)) { 38 | throw new Error('Missing privateKey parameter'); 39 | } 40 | 41 | const message = legacyToBuffer(data); 42 | const msgHash = hashPersonalMessage(message); 43 | const sig = ecsign(msgHash, privateKey); 44 | const serialized = concatSig(toBuffer(sig.v), sig.r, sig.s); 45 | return serialized; 46 | } 47 | 48 | /** 49 | * Recover the address of the account used to create the given Ethereum signature. The message 50 | * must have been signed using the `personalSign` function, or an equivalent function. 51 | * 52 | * @param options - The signature recovery options. 53 | * @param options.data - The hex data that was signed. 54 | * @param options.signature - The '0x'-prefixed hex encoded message signature. 55 | * @returns The '0x'-prefixed hex encoded address of the message signer. 56 | */ 57 | export function recoverPersonalSignature({ 58 | data, 59 | signature, 60 | }: { 61 | data: ToBufferInputTypes; 62 | signature: string; 63 | }): string { 64 | if (isNullish(data)) { 65 | throw new Error('Missing data parameter'); 66 | } else if (isNullish(signature)) { 67 | throw new Error('Missing signature parameter'); 68 | } 69 | 70 | const publicKey = getPublicKeyFor(data, signature); 71 | const sender = publicToAddress(publicKey); 72 | const senderHex = bufferToHex(sender); 73 | return senderHex; 74 | } 75 | 76 | /** 77 | * Recover the public key of the account used to create the given Ethereum signature. The message 78 | * must have been signed using the `personalSign` function, or an equivalent function. 79 | * 80 | * @param options - The public key recovery options. 81 | * @param options.data - The hex data that was signed. 82 | * @param options.signature - The '0x'-prefixed hex encoded message signature. 83 | * @returns The '0x'-prefixed hex encoded public key of the message signer. 84 | */ 85 | export function extractPublicKey({ 86 | data, 87 | signature, 88 | }: { 89 | data: ToBufferInputTypes; 90 | signature: string; 91 | }): string { 92 | if (isNullish(data)) { 93 | throw new Error('Missing data parameter'); 94 | } else if (isNullish(signature)) { 95 | throw new Error('Missing signature parameter'); 96 | } 97 | 98 | const publicKey = getPublicKeyFor(data, signature); 99 | return `0x${publicKey.toString('hex')}`; 100 | } 101 | 102 | /** 103 | * Get the public key for the given signature and message. 104 | * 105 | * @param message - The message that was signed. 106 | * @param signature - The '0x'-prefixed hex encoded message signature. 107 | * @returns The public key of the signer. 108 | */ 109 | function getPublicKeyFor( 110 | message: ToBufferInputTypes, 111 | signature: string, 112 | ): Buffer { 113 | const messageHash = hashPersonalMessage(legacyToBuffer(message)); 114 | return recoverPublicKey(messageHash, signature); 115 | } 116 | -------------------------------------------------------------------------------- /src/sign-eip7702-authorization.test.ts: -------------------------------------------------------------------------------- 1 | import { bufferToHex, privateToAddress } from '@ethereumjs/util'; 2 | 3 | import { 4 | signEIP7702Authorization, 5 | recoverEIP7702Authorization, 6 | EIP7702Authorization, 7 | hashEIP7702Authorization, 8 | } from './sign-eip7702-authorization'; 9 | 10 | const TEST_PRIVATE_KEY = Buffer.from( 11 | '4af1bceebf7f3634ec3cff8a2c38e51178d5d4ce585c52d6043e5e2cc3418bb0', 12 | 'hex', 13 | ); 14 | 15 | const TEST_ADDRESS = bufferToHex(privateToAddress(TEST_PRIVATE_KEY)); 16 | 17 | const TEST_AUTHORIZATION: EIP7702Authorization = [ 18 | 8545, 19 | '0x1234567890123456789012345678901234567890', 20 | 1, 21 | ]; 22 | 23 | const EXPECTED_AUTHORIZATION_HASH = Buffer.from( 24 | 'b847dee5b33802280f3279d57574e1eb6bf5d628d7f63049e3cb20bad211056c', 25 | 'hex', 26 | ); 27 | 28 | const EXPECTED_SIGNATURE = 29 | '0xebea1ac12f17a56a514dfecbcbc8bbee7b089fa3fcee31680d1e2c1588f623df7973cab74e12536678995377da38c96c65c52897750b73462c6760ef2737dba41b'; 30 | 31 | describe('signAuthorization', () => { 32 | describe('signEIP7702Authorization()', () => { 33 | it('should produce the correct signature', () => { 34 | const signature = signEIP7702Authorization({ 35 | privateKey: TEST_PRIVATE_KEY, 36 | authorization: TEST_AUTHORIZATION, 37 | }); 38 | 39 | expect(signature).toBe(EXPECTED_SIGNATURE); 40 | }); 41 | 42 | it('should throw if private key is null', () => { 43 | expect(() => 44 | signEIP7702Authorization({ 45 | privateKey: null as any, 46 | authorization: TEST_AUTHORIZATION, 47 | }), 48 | ).toThrow('Missing privateKey parameter'); 49 | }); 50 | 51 | it('should throw if private key is undefined', () => { 52 | expect(() => 53 | signEIP7702Authorization({ 54 | privateKey: undefined as any, 55 | authorization: TEST_AUTHORIZATION, 56 | }), 57 | ).toThrow('Missing privateKey parameter'); 58 | }); 59 | 60 | it('should throw if authorization is null', () => { 61 | expect(() => 62 | signEIP7702Authorization({ 63 | privateKey: TEST_PRIVATE_KEY, 64 | authorization: null as any, 65 | }), 66 | ).toThrow('Missing authorization parameter'); 67 | }); 68 | 69 | it('should throw if authorization is undefined', () => { 70 | expect(() => 71 | signEIP7702Authorization({ 72 | privateKey: TEST_PRIVATE_KEY, 73 | authorization: undefined as any, 74 | }), 75 | ).toThrow('Missing authorization parameter'); 76 | }); 77 | 78 | it('should throw if chainId is null', () => { 79 | expect(() => 80 | signEIP7702Authorization({ 81 | privateKey: TEST_PRIVATE_KEY, 82 | authorization: [ 83 | null as unknown as number, 84 | TEST_AUTHORIZATION[1], 85 | TEST_AUTHORIZATION[2], 86 | ], 87 | }), 88 | ).toThrow('Missing chainId parameter'); 89 | }); 90 | 91 | it('should throw if chainId is not a number', () => { 92 | expect(() => 93 | signEIP7702Authorization({ 94 | privateKey: TEST_PRIVATE_KEY, 95 | authorization: [ 96 | '123' as any as number, 97 | TEST_AUTHORIZATION[1], 98 | TEST_AUTHORIZATION[2], 99 | ], 100 | }), 101 | ).toThrow( 102 | 'Invalid chainId: must be a non-negative number less than 2^256', 103 | ); 104 | }); 105 | 106 | it('should throw if chainId is too large', () => { 107 | expect(() => 108 | signEIP7702Authorization({ 109 | privateKey: TEST_PRIVATE_KEY, 110 | authorization: [ 111 | 2 ** 256, 112 | TEST_AUTHORIZATION[1], 113 | TEST_AUTHORIZATION[2], 114 | ], 115 | }), 116 | ).toThrow( 117 | 'Invalid chainId: must be a non-negative number less than 2^256', 118 | ); 119 | }); 120 | 121 | it('should throw if chainId is negative', () => { 122 | expect(() => 123 | signEIP7702Authorization({ 124 | privateKey: TEST_PRIVATE_KEY, 125 | authorization: [-1, TEST_AUTHORIZATION[1], TEST_AUTHORIZATION[2]], 126 | }), 127 | ).toThrow( 128 | 'Invalid chainId: must be a non-negative number less than 2^256', 129 | ); 130 | }); 131 | 132 | it('should throw if nonce is null', () => { 133 | expect(() => 134 | signEIP7702Authorization({ 135 | privateKey: TEST_PRIVATE_KEY, 136 | authorization: [ 137 | TEST_AUTHORIZATION[0], 138 | TEST_AUTHORIZATION[1], 139 | null as unknown as number, 140 | ], 141 | }), 142 | ).toThrow('Missing nonce parameter'); 143 | }); 144 | 145 | it('should throw if nonce is not a number', () => { 146 | expect(() => 147 | signEIP7702Authorization({ 148 | privateKey: TEST_PRIVATE_KEY, 149 | authorization: [ 150 | TEST_AUTHORIZATION[0], 151 | TEST_AUTHORIZATION[1], 152 | '123' as any as number, 153 | ], 154 | }), 155 | ).toThrow('Invalid nonce: must be a non-negative number less than 2^64'); 156 | }); 157 | 158 | it('should throw if nonce is negative', () => { 159 | expect(() => 160 | signEIP7702Authorization({ 161 | privateKey: TEST_PRIVATE_KEY, 162 | authorization: [TEST_AUTHORIZATION[0], TEST_AUTHORIZATION[1], -123], 163 | }), 164 | ).toThrow('Invalid nonce: must be a non-negative number less than 2^64'); 165 | }); 166 | 167 | it('should throw if nonce is too large', () => { 168 | expect(() => 169 | signEIP7702Authorization({ 170 | privateKey: TEST_PRIVATE_KEY, 171 | authorization: [ 172 | TEST_AUTHORIZATION[0], 173 | TEST_AUTHORIZATION[1], 174 | 2 ** 64, 175 | ], 176 | }), 177 | ).toThrow('Invalid nonce: must be a non-negative number less than 2^64'); 178 | }); 179 | 180 | it('should throw if contractAddress is null', () => { 181 | expect(() => 182 | signEIP7702Authorization({ 183 | privateKey: TEST_PRIVATE_KEY, 184 | authorization: [ 185 | TEST_AUTHORIZATION[0], 186 | null as unknown as string, 187 | TEST_AUTHORIZATION[2], 188 | ], 189 | }), 190 | ).toThrow('Missing contractAddress parameter'); 191 | }); 192 | 193 | it('should throw if contractAddress is not a string', () => { 194 | expect(() => 195 | signEIP7702Authorization({ 196 | privateKey: TEST_PRIVATE_KEY, 197 | authorization: [ 198 | TEST_AUTHORIZATION[0], 199 | 123 as any as string, 200 | TEST_AUTHORIZATION[2], 201 | ], 202 | }), 203 | ).toThrow('Invalid contractAddress: must be a 20 byte hex string'); 204 | }); 205 | 206 | it('should throw if contractAddress is too short', () => { 207 | expect(() => 208 | signEIP7702Authorization({ 209 | privateKey: TEST_PRIVATE_KEY, 210 | authorization: [ 211 | TEST_AUTHORIZATION[0], 212 | TEST_AUTHORIZATION[1].slice(10), 213 | TEST_AUTHORIZATION[2], 214 | ], 215 | }), 216 | ).toThrow('Invalid contractAddress: must be a 20 byte hex string'); 217 | }); 218 | 219 | it('should throw if contractAddress is too long', () => { 220 | expect(() => 221 | signEIP7702Authorization({ 222 | privateKey: TEST_PRIVATE_KEY, 223 | authorization: [ 224 | TEST_AUTHORIZATION[0], 225 | `${TEST_AUTHORIZATION[1]}00`, 226 | TEST_AUTHORIZATION[2], 227 | ], 228 | }), 229 | ).toThrow('Invalid contractAddress: must be a 20 byte hex string'); 230 | }); 231 | 232 | it('should throw if contractAddress is not valid hex', () => { 233 | expect(() => 234 | signEIP7702Authorization({ 235 | privateKey: TEST_PRIVATE_KEY, 236 | authorization: [ 237 | TEST_AUTHORIZATION[0], 238 | '0xghijklmnopqrstuvwxyghijklmnopqrstuvwxyghij', 239 | TEST_AUTHORIZATION[2], 240 | ], 241 | }), 242 | ).toThrow('Invalid contractAddress: must be a 20 byte hex string'); 243 | }); 244 | 245 | it('should throw if contractAddress is missing the 0x prefix', () => { 246 | expect(() => 247 | signEIP7702Authorization({ 248 | privateKey: TEST_PRIVATE_KEY, 249 | authorization: [ 250 | TEST_AUTHORIZATION[0], 251 | TEST_AUTHORIZATION[1].slice(2), 252 | TEST_AUTHORIZATION[2], 253 | ], 254 | }), 255 | ).toThrow('Invalid contractAddress: must be a 20 byte hex string'); 256 | }); 257 | }); 258 | 259 | describe('hashEIP7702Authorization()', () => { 260 | it('should produce the correct hash', () => { 261 | const hash = hashEIP7702Authorization(TEST_AUTHORIZATION); 262 | 263 | expect(hash).toStrictEqual(EXPECTED_AUTHORIZATION_HASH); 264 | }); 265 | 266 | it('should throw if authorization is null', () => { 267 | expect(() => 268 | hashEIP7702Authorization(null as unknown as EIP7702Authorization), 269 | ).toThrow('Missing authorization parameter'); 270 | }); 271 | 272 | it('should throw if authorization is undefined', () => { 273 | expect(() => 274 | hashEIP7702Authorization(undefined as unknown as EIP7702Authorization), 275 | ).toThrow('Missing authorization parameter'); 276 | }); 277 | 278 | it('should throw if chainId is null', () => { 279 | expect(() => 280 | hashEIP7702Authorization([ 281 | null as unknown as number, 282 | TEST_AUTHORIZATION[1], 283 | TEST_AUTHORIZATION[2], 284 | ]), 285 | ).toThrow('Missing chainId parameter'); 286 | }); 287 | 288 | it('should throw if contractAddress is null', () => { 289 | expect(() => 290 | hashEIP7702Authorization([ 291 | TEST_AUTHORIZATION[0], 292 | null as unknown as string, 293 | TEST_AUTHORIZATION[2], 294 | ]), 295 | ).toThrow('Missing contractAddress parameter'); 296 | }); 297 | 298 | it('should throw if nonce is null', () => { 299 | expect(() => 300 | hashEIP7702Authorization([ 301 | TEST_AUTHORIZATION[0], 302 | TEST_AUTHORIZATION[1], 303 | null as unknown as number, 304 | ]), 305 | ).toThrow('Missing nonce parameter'); 306 | }); 307 | }); 308 | 309 | describe('recoverEIP7702Authorization()', () => { 310 | it('should recover the address from a signature', () => { 311 | const recoveredAddress = recoverEIP7702Authorization({ 312 | authorization: TEST_AUTHORIZATION, 313 | signature: EXPECTED_SIGNATURE, 314 | }); 315 | 316 | expect(recoveredAddress).toBe(TEST_ADDRESS); 317 | }); 318 | 319 | it('should throw if signature is null', () => { 320 | expect(() => 321 | recoverEIP7702Authorization({ 322 | signature: null as unknown as string, 323 | authorization: TEST_AUTHORIZATION, 324 | }), 325 | ).toThrow('Missing signature parameter'); 326 | }); 327 | 328 | it('should throw if signature is undefined', () => { 329 | expect(() => 330 | recoverEIP7702Authorization({ 331 | signature: undefined as unknown as string, 332 | authorization: TEST_AUTHORIZATION, 333 | }), 334 | ).toThrow('Missing signature parameter'); 335 | }); 336 | 337 | it('should throw if authorization is null', () => { 338 | expect(() => 339 | recoverEIP7702Authorization({ 340 | signature: EXPECTED_SIGNATURE, 341 | authorization: null as unknown as EIP7702Authorization, 342 | }), 343 | ).toThrow('Missing authorization parameter'); 344 | }); 345 | 346 | it('should throw if authorization is undefined', () => { 347 | expect(() => 348 | recoverEIP7702Authorization({ 349 | signature: EXPECTED_SIGNATURE, 350 | authorization: undefined as unknown as EIP7702Authorization, 351 | }), 352 | ).toThrow('Missing authorization parameter'); 353 | }); 354 | }); 355 | 356 | describe('sign-and-recover', () => { 357 | const testCases = { 358 | zeroChainId: [0, '0x1234567890123456789012345678901234567890', 1], 359 | highChainId: [98765, '0x1234567890123456789012345678901234567890', 1], 360 | zeroNonce: [8545, '0x1234567890123456789012345678901234567890', 0], 361 | highNonce: [8545, '0x1234567890123456789012345678901234567890', 98765], 362 | zeroContractAddress: [1, '0x0000000000000000000000000000000000000000', 1], 363 | allZeroValues: [0, '0x0000000000000000000000000000000000000000', 0], 364 | } as { [key: string]: EIP7702Authorization }; 365 | 366 | it.each(Object.entries(testCases))( 367 | 'should sign and recover %s', 368 | (_, authorization) => { 369 | const signature = signEIP7702Authorization({ 370 | privateKey: TEST_PRIVATE_KEY, 371 | authorization, 372 | }); 373 | 374 | const recoveredAddress = recoverEIP7702Authorization({ 375 | authorization, 376 | signature, 377 | }); 378 | 379 | expect(recoveredAddress).toBe(TEST_ADDRESS); 380 | }, 381 | ); 382 | }); 383 | }); 384 | -------------------------------------------------------------------------------- /src/sign-eip7702-authorization.ts: -------------------------------------------------------------------------------- 1 | import { encode } from '@ethereumjs/rlp'; 2 | import { ecsign, publicToAddress, toBuffer } from '@ethereumjs/util'; 3 | import { bytesToHex, Hex, isValidHexAddress } from '@metamask/utils'; 4 | import { keccak256 } from 'ethereum-cryptography/keccak'; 5 | 6 | import { concatSig, isNullish, recoverPublicKey } from './utils'; 7 | 8 | const CHAIN_ID_MAX_BITLENGTH = 256; 9 | const NONCE_MAX_BITLENGTH = 64; 10 | const ADDRESS_BYTE_LENGTH = 20; 11 | 12 | /** 13 | * The authorization struct as defined in EIP-7702. 14 | * 15 | * @property chainId - The chain ID or 0 for any chain. 16 | * @property contractAddress - The address of the contract being authorized. 17 | * @property nonce - The nonce of the signing account (at the time of submission). 18 | */ 19 | export type EIP7702Authorization = [ 20 | chainId: number, 21 | contractAddress: string, 22 | nonce: number, 23 | ]; 24 | 25 | /** 26 | * Sign an authorization message with the provided private key. 27 | * 28 | * @param options - The signing options. 29 | * @param options.privateKey - The private key to sign with. 30 | * @param options.authorization - The authorization data to sign. 31 | * @returns The '0x'-prefixed hex encoded signature. 32 | */ 33 | export function signEIP7702Authorization({ 34 | privateKey, 35 | authorization, 36 | }: { 37 | privateKey: Buffer; 38 | authorization: EIP7702Authorization; 39 | }): string { 40 | validateEIP7702Authorization(authorization); 41 | 42 | if (isNullish(privateKey)) { 43 | throw new Error('Missing privateKey parameter'); 44 | } 45 | 46 | const messageHash = hashEIP7702Authorization(authorization); 47 | 48 | const { r, s, v } = ecsign(messageHash, privateKey); 49 | 50 | // v is either 27n or 28n so is guaranteed to be a single byte 51 | const vBuffer = toBuffer(v); 52 | 53 | return concatSig(vBuffer, r, s); 54 | } 55 | 56 | /** 57 | * Recover the address of the account that created the given authorization 58 | * signature. 59 | * 60 | * @param options - The signature recovery options. 61 | * @param options.signature - The '0x'-prefixed hex encoded message signature. 62 | * @param options.authorization - The authorization data that was signed. 63 | * @returns The '0x'-prefixed hex address of the signer. 64 | */ 65 | export function recoverEIP7702Authorization({ 66 | signature, 67 | authorization, 68 | }: { 69 | signature: string; 70 | authorization: EIP7702Authorization; 71 | }): string { 72 | validateEIP7702Authorization(authorization); 73 | 74 | if (isNullish(signature)) { 75 | throw new Error('Missing signature parameter'); 76 | } 77 | 78 | const messageHash = hashEIP7702Authorization(authorization); 79 | 80 | const publicKey = recoverPublicKey(messageHash, signature); 81 | 82 | const sender = publicToAddress(publicKey); 83 | 84 | return bytesToHex(sender); 85 | } 86 | 87 | /** 88 | * Hash an authorization message according to the signing scheme. 89 | * The message is encoded according to EIP-7702. 90 | * 91 | * @param authorization - The authorization data to hash. 92 | * @returns The hash of the authorization message as a Buffer. 93 | */ 94 | export function hashEIP7702Authorization( 95 | authorization: EIP7702Authorization, 96 | ): Buffer { 97 | validateEIP7702Authorization(authorization); 98 | 99 | const encodedAuthorization = encode(authorization); 100 | 101 | const message = Buffer.concat([ 102 | Buffer.from('05', 'hex'), 103 | encodedAuthorization, 104 | ]); 105 | 106 | return Buffer.from(keccak256(message)); 107 | } 108 | 109 | /** 110 | * Validates an authorization object to ensure all required parameters are present. 111 | * 112 | * @param authorization - The authorization object to validate. 113 | * @throws {Error} If the authorization object or any of its required parameters are missing. 114 | */ 115 | function validateEIP7702Authorization(authorization: EIP7702Authorization) { 116 | if (isNullish(authorization)) { 117 | throw new Error('Missing authorization parameter'); 118 | } 119 | 120 | const [chainId, contractAddress, nonce] = authorization; 121 | 122 | if (isNullish(chainId)) { 123 | throw new Error('Missing chainId parameter'); 124 | } 125 | 126 | if (isNullish(contractAddress)) { 127 | throw new Error('Missing contractAddress parameter'); 128 | } 129 | 130 | if (isNullish(nonce)) { 131 | throw new Error('Missing nonce parameter'); 132 | } 133 | 134 | if ( 135 | typeof chainId !== 'number' || 136 | chainId >= 2 ** CHAIN_ID_MAX_BITLENGTH || 137 | chainId < 0 138 | ) { 139 | throw new Error( 140 | `Invalid chainId: must be a non-negative number less than 2^${CHAIN_ID_MAX_BITLENGTH}`, 141 | ); 142 | } 143 | 144 | if ( 145 | typeof nonce !== 'number' || 146 | nonce >= 2 ** NONCE_MAX_BITLENGTH || 147 | nonce < 0 148 | ) { 149 | throw new Error( 150 | `Invalid nonce: must be a non-negative number less than 2^${NONCE_MAX_BITLENGTH}`, 151 | ); 152 | } 153 | 154 | if (!isValidHexAddress(contractAddress as Hex)) { 155 | throw new Error( 156 | `Invalid contractAddress: must be a ${ADDRESS_BYTE_LENGTH} byte hex string`, 157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/sign-typed-data.ts: -------------------------------------------------------------------------------- 1 | import { arrToBufArr, ecsign, publicToAddress } from '@ethereumjs/util'; 2 | import { encode, encodePacked } from '@metamask/abi-utils'; 3 | import { 4 | getArrayType, 5 | getByteLength, 6 | getLength, 7 | isArrayType, 8 | } from '@metamask/abi-utils/dist/parsers'; 9 | import { padStart } from '@metamask/abi-utils/dist/utils'; 10 | import { 11 | add0x, 12 | assert, 13 | bigIntToBytes, 14 | bytesToHex, 15 | concatBytes, 16 | hexToBytes, 17 | isStrictHexString, 18 | numberToBytes, 19 | signedBigIntToBytes, 20 | stringToBytes, 21 | } from '@metamask/utils'; 22 | import { keccak256 } from 'ethereum-cryptography/keccak'; 23 | 24 | import { 25 | concatSig, 26 | isNullish, 27 | legacyToBuffer, 28 | recoverPublicKey, 29 | } from './utils'; 30 | 31 | /** 32 | * This is the message format used for `V1` of `signTypedData`. 33 | */ 34 | export type TypedDataV1 = TypedDataV1Field[]; 35 | 36 | /** 37 | * This represents a single field in a `V1` `signTypedData` message. 38 | * 39 | * @property name - The name of the field. 40 | * @property type - The type of a field (must be a supported Solidity type). 41 | * @property value - The value of the field. 42 | */ 43 | export type TypedDataV1Field = { 44 | name: string; 45 | type: string; 46 | value: any; 47 | }; 48 | 49 | /** 50 | * Represents the version of `signTypedData` being used. 51 | * 52 | * V1 is based upon [an early version of 53 | * EIP-712](https://github.com/ethereum/EIPs/pull/712/commits/21abe254fe0452d8583d5b132b1d7be87c0439ca) 54 | * that lacked some later security improvements, and should generally be neglected in favor of 55 | * later versions. 56 | * 57 | * V3 is based on EIP-712, except that arrays and recursive data structures are not supported. 58 | * 59 | * V4 is based on EIP-712, and includes full support of arrays and recursive data structures. 60 | */ 61 | export enum SignTypedDataVersion { 62 | V1 = 'V1', 63 | V3 = 'V3', 64 | V4 = 'V4', 65 | } 66 | 67 | export type MessageTypeProperty = { 68 | name: string; 69 | type: string; 70 | }; 71 | 72 | export type MessageTypes = { 73 | // eslint-disable-next-line @typescript-eslint/naming-convention 74 | EIP712Domain: MessageTypeProperty[]; 75 | [additionalProperties: string]: MessageTypeProperty[]; 76 | }; 77 | 78 | /** 79 | * This is the message format used for `signTypeData`, for all versions 80 | * except `V1`. 81 | * 82 | * @template T - The custom types used by this message. 83 | * @property types - The custom types used by this message. 84 | * @property primaryType - The type of the message. 85 | * @property domain - Signing domain metadata. The signing domain is the intended context for the 86 | * signature (e.g. the dapp, protocol, etc. that it's intended for). This data is used to 87 | * construct the domain seperator of the message. 88 | * @property domain.name - The name of the signing domain. 89 | * @property domain.version - The current major version of the signing domain. 90 | * @property domain.chainId - The chain ID of the signing domain. 91 | * @property domain.verifyingContract - The address of the contract that can verify the signature. 92 | * @property domain.salt - A disambiguating salt for the protocol. 93 | * @property message - The message to be signed. 94 | */ 95 | export type TypedMessage = { 96 | types: T; 97 | primaryType: keyof T; 98 | domain: { 99 | name?: string; 100 | version?: string; 101 | chainId?: number; 102 | verifyingContract?: string; 103 | salt?: ArrayBuffer; 104 | }; 105 | message: Record; 106 | }; 107 | 108 | export const TYPED_MESSAGE_SCHEMA = { 109 | type: 'object', 110 | properties: { 111 | types: { 112 | type: 'object', 113 | additionalProperties: { 114 | type: 'array', 115 | items: { 116 | type: 'object', 117 | properties: { 118 | name: { type: 'string' }, 119 | type: { type: 'string' }, 120 | }, 121 | required: ['name', 'type'], 122 | }, 123 | }, 124 | }, 125 | primaryType: { type: 'string' }, 126 | domain: { type: 'object' }, 127 | message: { type: 'object' }, 128 | }, 129 | required: ['types', 'primaryType', 'domain', 'message'], 130 | }; 131 | 132 | /** 133 | * Validate that the given value is a valid version string. 134 | * 135 | * @param version - The version value to validate. 136 | * @param allowedVersions - A list of allowed versions. If omitted, all versions are assumed to be 137 | * allowed. 138 | */ 139 | function validateVersion( 140 | version: SignTypedDataVersion, 141 | allowedVersions?: SignTypedDataVersion[], 142 | ) { 143 | if (!Object.keys(SignTypedDataVersion).includes(version)) { 144 | throw new Error(`Invalid version: '${version}'`); 145 | } else if (allowedVersions && !allowedVersions.includes(version)) { 146 | throw new Error( 147 | `SignTypedDataVersion not allowed: '${version}'. Allowed versions are: ${allowedVersions.join( 148 | ', ', 149 | )}`, 150 | ); 151 | } 152 | } 153 | 154 | /** 155 | * Parse a string, number, or bigint value into a `Uint8Array`. 156 | * 157 | * @param type - The type of the value. 158 | * @param value - The value to parse. 159 | * @returns The parsed value. 160 | */ 161 | function parseNumber(type: string, value: string | number | bigint) { 162 | assert( 163 | value !== null, 164 | `Unable to encode value: Invalid number. Expected a valid number value, but received "${value}".`, 165 | ); 166 | 167 | const bigIntValue = BigInt(value); 168 | 169 | const length = getLength(type); 170 | const maxValue = BigInt(2) ** BigInt(length) - BigInt(1); 171 | 172 | // Note that this is not accurate, since the actual maximum value for unsigned 173 | // integers is `2 ^ (length - 1) - 1`, but this is required for backwards 174 | // compatibility with the old implementation. 175 | assert( 176 | bigIntValue >= -maxValue && bigIntValue <= maxValue, 177 | `Unable to encode value: Number "${value}" is out of range for type "${type}".`, 178 | ); 179 | 180 | return bigIntValue; 181 | } 182 | 183 | /** 184 | * Parse an address string to a `Uint8Array`. The behaviour of this is quite 185 | * strange, in that it does not parse the address as hexadecimal string, nor as 186 | * UTF-8. It does some weird stuff with the string and char codes, and then 187 | * returns the result as a `Uint8Array`. 188 | * 189 | * This is based on the old `ethereumjs-abi` implementation, which essentially 190 | * calls `new BN(address, 10)` on the address string, the equivalent of calling 191 | * `parseInt(address, 10)` in JavaScript. This is not a valid way to parse an 192 | * address and would result in `NaN` in plain JavaScript, but it is the 193 | * behaviour of the old implementation, and so we must preserve it for backwards 194 | * compatibility. 195 | * 196 | * @param address - The address to parse. 197 | * @returns The parsed address. 198 | */ 199 | function reallyStrangeAddressToBytes(address: string): Uint8Array { 200 | let addressValue = BigInt(0); 201 | 202 | for (let i = 0; i < address.length; i++) { 203 | const character = BigInt(address.charCodeAt(i) - 48); 204 | addressValue *= BigInt(10); 205 | 206 | // 'a' 207 | if (character >= 49) { 208 | addressValue += character - BigInt(49) + BigInt(0xa); 209 | 210 | // 'A' 211 | } else if (character >= 17) { 212 | addressValue += character - BigInt(17) + BigInt(0xa); 213 | 214 | // '0' - '9' 215 | } else { 216 | addressValue += character; 217 | } 218 | } 219 | 220 | return padStart(bigIntToBytes(addressValue), 20); 221 | } 222 | 223 | /** 224 | * Encode a single field. 225 | * 226 | * @param types - All type definitions. 227 | * @param name - The name of the field to encode. 228 | * @param type - The type of the field being encoded. 229 | * @param value - The value to encode. 230 | * @param version - The EIP-712 version the encoding should comply with. 231 | * @returns Encoded representation of the field. 232 | */ 233 | function encodeField( 234 | types: Record, 235 | name: string, 236 | type: string, 237 | // TODO: constrain type on `value` 238 | value: any, 239 | version: SignTypedDataVersion.V3 | SignTypedDataVersion.V4, 240 | ): [type: string, value: bigint | Buffer | boolean | string | Uint8Array] { 241 | validateVersion(version, [SignTypedDataVersion.V3, SignTypedDataVersion.V4]); 242 | 243 | if (types[type] !== undefined) { 244 | return [ 245 | 'bytes32', 246 | // TODO: return Buffer, remove string from return type 247 | version === SignTypedDataVersion.V4 && value == null // eslint-disable-line no-eq-null 248 | ? '0x0000000000000000000000000000000000000000000000000000000000000000' 249 | : arrToBufArr(keccak256(encodeData(type, value, types, version))), 250 | ]; 251 | } 252 | 253 | // `function` is supported in `@metamask/abi-utils`, but not allowed by 254 | // EIP-712, so we throw an error here. 255 | if (type === 'function') { 256 | throw new Error('Unsupported or invalid type: "function"'); 257 | } 258 | 259 | if (value === undefined) { 260 | throw new Error(`missing value for field ${name} of type ${type}`); 261 | } 262 | 263 | if (type === 'address') { 264 | if (typeof value === 'number') { 265 | return ['address', padStart(numberToBytes(value), 20)]; 266 | } else if (isStrictHexString(value)) { 267 | return ['address', add0x(value)]; 268 | } else if (typeof value === 'string') { 269 | return ['address', reallyStrangeAddressToBytes(value).subarray(0, 20)]; 270 | } 271 | } 272 | 273 | if (type === 'bool') { 274 | return ['bool', Boolean(value)]; 275 | } 276 | 277 | if (type === 'bytes') { 278 | if (typeof value === 'number') { 279 | value = numberToBytes(value); 280 | } else if (isStrictHexString(value) || value === '0x') { 281 | value = hexToBytes(value); 282 | } else if (typeof value === 'string') { 283 | value = stringToBytes(value); 284 | } 285 | return ['bytes32', arrToBufArr(keccak256(value))]; 286 | } 287 | 288 | if (type.startsWith('bytes') && type !== 'bytes' && !type.includes('[')) { 289 | if (typeof value === 'number') { 290 | if (value < 0) { 291 | return ['bytes32', new Uint8Array(32)]; 292 | } 293 | 294 | return ['bytes32', bigIntToBytes(BigInt(value))]; 295 | } else if (isStrictHexString(value)) { 296 | return ['bytes32', hexToBytes(value)]; 297 | } 298 | 299 | return ['bytes32', value]; 300 | } 301 | 302 | if (type.startsWith('int') && !type.includes('[')) { 303 | const bigIntValue = parseNumber(type, value); 304 | if (bigIntValue >= BigInt(0)) { 305 | return ['uint256', bigIntValue]; 306 | } 307 | 308 | return ['int256', bigIntValue]; 309 | } 310 | 311 | if (type === 'string') { 312 | if (typeof value === 'number') { 313 | value = numberToBytes(value); 314 | } else { 315 | value = stringToBytes(value ?? ''); 316 | } 317 | return ['bytes32', arrToBufArr(keccak256(value))]; 318 | } 319 | 320 | if (type.endsWith(']')) { 321 | if (version === SignTypedDataVersion.V3) { 322 | throw new Error( 323 | 'Arrays are unimplemented in encodeData; use V4 extension', 324 | ); 325 | } 326 | const parsedType = type.slice(0, type.lastIndexOf('[')); 327 | const typeValuePairs = value.map((item: any) => 328 | encodeField(types, name, parsedType, item, version), 329 | ); 330 | return [ 331 | 'bytes32', 332 | arrToBufArr( 333 | keccak256( 334 | encode( 335 | typeValuePairs.map(([t]) => t), 336 | typeValuePairs.map(([, v]) => v), 337 | ), 338 | ), 339 | ), 340 | ]; 341 | } 342 | 343 | return [type, value]; 344 | } 345 | 346 | /** 347 | * Encodes an object by encoding and concatenating each of its members. 348 | * 349 | * @param primaryType - The root type. 350 | * @param data - The object to encode. 351 | * @param types - Type definitions for all types included in the message. 352 | * @param version - The EIP-712 version the encoding should comply with. 353 | * @returns An encoded representation of an object. 354 | */ 355 | function encodeData( 356 | primaryType: string, 357 | data: Record, 358 | types: Record, 359 | version: SignTypedDataVersion.V3 | SignTypedDataVersion.V4, 360 | ): Buffer { 361 | validateVersion(version, [SignTypedDataVersion.V3, SignTypedDataVersion.V4]); 362 | 363 | const encodedTypes = ['bytes32']; 364 | const encodedValues: (string | bigint | boolean | Uint8Array | Buffer)[] = [ 365 | hashType(primaryType, types), 366 | ]; 367 | 368 | for (const field of types[primaryType]) { 369 | if (version === SignTypedDataVersion.V3 && data[field.name] === undefined) { 370 | continue; 371 | } 372 | const [type, value] = encodeField( 373 | types, 374 | field.name, 375 | field.type, 376 | data[field.name], 377 | version, 378 | ); 379 | encodedTypes.push(type); 380 | encodedValues.push(value); 381 | } 382 | 383 | return arrToBufArr(encode(encodedTypes, encodedValues)); 384 | } 385 | 386 | /** 387 | * Encodes the type of an object by encoding a comma delimited list of its members. 388 | * 389 | * @param primaryType - The root type to encode. 390 | * @param types - Type definitions for all types included in the message. 391 | * @returns An encoded representation of the primary type. 392 | */ 393 | function encodeType( 394 | primaryType: string, 395 | types: Record, 396 | ): string { 397 | let result = ''; 398 | const unsortedDeps = findTypeDependencies(primaryType, types); 399 | unsortedDeps.delete(primaryType); 400 | 401 | const deps = [primaryType, ...Array.from(unsortedDeps).sort()]; 402 | for (const type of deps) { 403 | const children = types[type]; 404 | if (!children) { 405 | throw new Error(`No type definition specified: ${type}`); 406 | } 407 | 408 | result += `${type}(${types[type] 409 | .map(({ name, type: t }) => `${t} ${name}`) 410 | .join(',')})`; 411 | } 412 | 413 | return result; 414 | } 415 | 416 | /** 417 | * Finds all types within a type definition object. 418 | * 419 | * @param primaryType - The root type. 420 | * @param types - Type definitions for all types included in the message. 421 | * @param results - The current set of accumulated types. 422 | * @returns The set of all types found in the type definition. 423 | */ 424 | function findTypeDependencies( 425 | primaryType: string, 426 | types: Record, 427 | results: Set = new Set(), 428 | ): Set { 429 | if (typeof primaryType !== 'string') { 430 | throw new Error( 431 | `Invalid findTypeDependencies input ${JSON.stringify(primaryType)}`, 432 | ); 433 | } 434 | const match = primaryType.match(/^\w*/u) as RegExpMatchArray; 435 | [primaryType] = match; 436 | if (results.has(primaryType) || types[primaryType] === undefined) { 437 | return results; 438 | } 439 | 440 | results.add(primaryType); 441 | 442 | for (const field of types[primaryType]) { 443 | findTypeDependencies(field.type, types, results); 444 | } 445 | return results; 446 | } 447 | 448 | /** 449 | * Hashes an object. 450 | * 451 | * @param primaryType - The root type. 452 | * @param data - The object to hash. 453 | * @param types - Type definitions for all types included in the message. 454 | * @param version - The EIP-712 version the encoding should comply with. 455 | * @returns The hash of the object. 456 | */ 457 | function hashStruct( 458 | primaryType: string, 459 | data: Record, 460 | types: Record, 461 | version: SignTypedDataVersion.V3 | SignTypedDataVersion.V4, 462 | ): Buffer { 463 | validateVersion(version, [SignTypedDataVersion.V3, SignTypedDataVersion.V4]); 464 | 465 | const encoded = encodeData(primaryType, data, types, version); 466 | const hashed = keccak256(encoded); 467 | const buf = arrToBufArr(hashed); 468 | return buf; 469 | } 470 | 471 | /** 472 | * Hashes the type of an object. 473 | * 474 | * @param primaryType - The root type to hash. 475 | * @param types - Type definitions for all types included in the message. 476 | * @returns The hash of the object type. 477 | */ 478 | function hashType( 479 | primaryType: string, 480 | types: Record, 481 | ): Buffer { 482 | const encodedHashType = stringToBytes(encodeType(primaryType, types)); 483 | return arrToBufArr(keccak256(encodedHashType)); 484 | } 485 | 486 | /** 487 | * Removes properties from a message object that are not defined per EIP-712. 488 | * 489 | * @param data - The typed message object. 490 | * @returns The typed message object with only allowed fields. 491 | */ 492 | function sanitizeData( 493 | data: TypedMessage, 494 | ): TypedMessage { 495 | const sanitizedData: Partial> = {}; 496 | for (const key in TYPED_MESSAGE_SCHEMA.properties) { 497 | if (data[key]) { 498 | sanitizedData[key] = data[key]; 499 | } 500 | } 501 | 502 | if ('types' in sanitizedData) { 503 | // TODO: Fix types 504 | sanitizedData.types = { EIP712Domain: [], ...sanitizedData.types } as any; 505 | } 506 | return sanitizedData as Required>; 507 | } 508 | 509 | /** 510 | * Create a EIP-712 Domain Hash. 511 | * This hash is used at the top of the EIP-712 encoding. 512 | * 513 | * @param typedData - The typed message to hash. 514 | * @param version - The EIP-712 version the encoding should comply with. 515 | * @returns The hash of the domain object. 516 | */ 517 | function eip712DomainHash( 518 | typedData: TypedMessage, 519 | version: SignTypedDataVersion.V3 | SignTypedDataVersion.V4, 520 | ): Buffer { 521 | validateVersion(version, [SignTypedDataVersion.V3, SignTypedDataVersion.V4]); 522 | 523 | const sanitizedData = sanitizeData(typedData); 524 | const { domain } = sanitizedData; 525 | const domainType = { EIP712Domain: sanitizedData.types.EIP712Domain }; 526 | return hashStruct('EIP712Domain', domain, domainType, version); 527 | } 528 | 529 | /** 530 | * Hash a typed message according to EIP-712. The returned message starts with the EIP-712 prefix, 531 | * which is "1901", followed by the hash of the domain separator, then the data (if any). 532 | * The result is hashed again and returned. 533 | * 534 | * This function does not sign the message. The resulting hash must still be signed to create an 535 | * EIP-712 signature. 536 | * 537 | * @param typedData - The typed message to hash. 538 | * @param version - The EIP-712 version the encoding should comply with. 539 | * @returns The hash of the typed message. 540 | */ 541 | function eip712Hash( 542 | typedData: TypedMessage, 543 | version: SignTypedDataVersion.V3 | SignTypedDataVersion.V4, 544 | ): Buffer { 545 | validateVersion(version, [SignTypedDataVersion.V3, SignTypedDataVersion.V4]); 546 | 547 | const sanitizedData = sanitizeData(typedData); 548 | const parts = [hexToBytes('1901')]; 549 | parts.push(eip712DomainHash(typedData, version)); 550 | 551 | if (sanitizedData.primaryType !== 'EIP712Domain') { 552 | parts.push( 553 | hashStruct( 554 | // TODO: Validate that this is a string, so this type cast can be removed. 555 | sanitizedData.primaryType as string, 556 | sanitizedData.message, 557 | sanitizedData.types, 558 | version, 559 | ), 560 | ); 561 | } 562 | return arrToBufArr(keccak256(concatBytes(parts))); 563 | } 564 | 565 | /** 566 | * A collection of utility functions used for signing typed data. 567 | */ 568 | export const TypedDataUtils = { 569 | encodeData, 570 | encodeType, 571 | findTypeDependencies, 572 | hashStruct, 573 | hashType, 574 | sanitizeData, 575 | eip712Hash, 576 | eip712DomainHash, 577 | }; 578 | 579 | /** 580 | * Generate the "V1" hash for the provided typed message. 581 | * 582 | * The hash will be generated in accordance with an earlier version of the EIP-712 583 | * specification. This hash is used in `signTypedData_v1`. 584 | * 585 | * @param typedData - The typed message. 586 | * @returns The '0x'-prefixed hex encoded hash representing the type of the provided message. 587 | */ 588 | export function typedSignatureHash(typedData: TypedDataV1Field[]): string { 589 | const hashBuffer = _typedSignatureHash(typedData); 590 | return bytesToHex(hashBuffer); 591 | } 592 | 593 | /** 594 | * Normalize a value, so that `@metamask/abi-utils` can handle it. This 595 | * matches the behaviour of the `ethereumjs-abi` library. 596 | * 597 | * @param type - The type of the value to normalize. 598 | * @param value - The value to normalize. 599 | * @returns The normalized value. 600 | */ 601 | function normalizeValue(type: string, value: unknown): any { 602 | if (isArrayType(type) && Array.isArray(value)) { 603 | const [innerType] = getArrayType(type); 604 | return value.map((item) => normalizeValue(innerType, item)); 605 | } 606 | 607 | if (type === 'address') { 608 | if (isStrictHexString(value)) { 609 | return padStart(hexToBytes(value).subarray(0, 20), 20); 610 | } 611 | 612 | if (value instanceof Uint8Array) { 613 | return padStart(value.subarray(0, 20), 20); 614 | } 615 | } 616 | 617 | if (type === 'bool') { 618 | return Boolean(value); 619 | } 620 | 621 | if (type.startsWith('bytes') && type !== 'bytes') { 622 | const length = getByteLength(type); 623 | if (typeof value === 'number') { 624 | if (value < 0) { 625 | // `solidityPack(['bytesN'], [-1])` returns `0x00..00`. 626 | return new Uint8Array(); 627 | } 628 | 629 | return numberToBytes(value).subarray(0, length); 630 | } 631 | 632 | if (isStrictHexString(value)) { 633 | return hexToBytes(value).subarray(0, length); 634 | } 635 | 636 | if (value instanceof Uint8Array) { 637 | return value.subarray(0, length); 638 | } 639 | } 640 | 641 | if (type.startsWith('uint')) { 642 | if (typeof value === 'number') { 643 | return Math.abs(value); 644 | } 645 | } 646 | 647 | if (type.startsWith('int')) { 648 | if (typeof value === 'number') { 649 | const length = getLength(type); 650 | return BigInt.asIntN(length, BigInt(value)); 651 | } 652 | } 653 | 654 | return value; 655 | } 656 | 657 | /** 658 | * For some reason `ethereumjs-abi` treats `address` and `address[]` differently 659 | * so we need to normalize `address[]` differently. 660 | * 661 | * @param values - The values to normalize. 662 | * @returns The normalized values. 663 | */ 664 | function normalizeAddresses(values: unknown[]) { 665 | return values.map((value) => { 666 | if (typeof value === 'number') { 667 | return padStart(numberToBytes(value), 32); 668 | } 669 | 670 | if (isStrictHexString(value)) { 671 | return padStart(hexToBytes(value).subarray(0, 32), 32); 672 | } 673 | 674 | if (value instanceof Uint8Array) { 675 | return padStart(value.subarray(0, 32), 32); 676 | } 677 | 678 | return value; 679 | }); 680 | } 681 | 682 | /** 683 | * For some reason `ethereumjs-abi` treats `intN` and `intN[]` differently 684 | * so we need to normalize `intN[]` differently. 685 | * 686 | * @param type - The type of the value to normalize. 687 | * @param values - The values to normalize. 688 | * @returns The normalized values. 689 | */ 690 | function normalizeIntegers(type: string, values: unknown[]) { 691 | return values.map((value) => { 692 | if ( 693 | typeof value === 'string' || 694 | typeof value === 'number' || 695 | typeof value === 'bigint' 696 | ) { 697 | const bigIntValue = parseNumber(type, value); 698 | if (bigIntValue >= BigInt(0)) { 699 | return padStart(bigIntToBytes(bigIntValue), 32); 700 | } 701 | 702 | const length = getLength(type); 703 | const asIntN = BigInt.asIntN(length, bigIntValue); 704 | return signedBigIntToBytes(asIntN, 32); 705 | } 706 | 707 | return value; 708 | }); 709 | } 710 | 711 | /** 712 | * Generate the "V1" hash for the provided typed message. 713 | * 714 | * The hash will be generated in accordance with an earlier version of the EIP-712 715 | * specification. This hash is used in `signTypedData_v1`. 716 | * 717 | * @param typedData - The typed message. 718 | * @returns The hash representing the type of the provided message. 719 | */ 720 | function _typedSignatureHash(typedData: TypedDataV1): Buffer { 721 | const error = new Error('Expect argument to be non-empty array'); 722 | if ( 723 | typeof typedData !== 'object' || 724 | !('length' in typedData) || 725 | !typedData.length 726 | ) { 727 | throw error; 728 | } 729 | 730 | const normalizedData = typedData.map(({ name, type, value }) => { 731 | // Handle an edge case with `address[]` types. 732 | if (type === 'address[]') { 733 | return { 734 | name, 735 | type: 'bytes32[]', 736 | value: normalizeAddresses(value), 737 | }; 738 | } 739 | 740 | // Handle an edge case with `intN[]` types. 741 | if (type.startsWith('int') && isArrayType(type)) { 742 | const [innerType, length] = getArrayType(type); 743 | return { 744 | name, 745 | type: `bytes32[${length ?? ''}]`, 746 | value: normalizeIntegers(innerType, value), 747 | }; 748 | } 749 | 750 | return { 751 | name, 752 | type, 753 | value: normalizeValue(type, value), 754 | }; 755 | }); 756 | 757 | const data = normalizedData.map((e) => { 758 | if (e.type !== 'bytes') { 759 | return e.value; 760 | } 761 | 762 | return legacyToBuffer(e.value); 763 | }); 764 | const types = normalizedData.map((e) => { 765 | if (e.type === 'function') { 766 | throw new Error('Unsupported or invalid type: "function"'); 767 | } 768 | 769 | return e.type; 770 | }); 771 | const schema = typedData.map((e) => { 772 | if (!e.name) { 773 | throw error; 774 | } 775 | return `${e.type} ${e.name}`; 776 | }); 777 | 778 | return arrToBufArr( 779 | keccak256( 780 | encodePacked( 781 | ['bytes32', 'bytes32'], 782 | [ 783 | keccak256(encodePacked(['string[]'], [schema], true)), 784 | keccak256(encodePacked(types, data, true)), 785 | ], 786 | ), 787 | ), 788 | ); 789 | } 790 | 791 | /** 792 | * Sign typed data according to EIP-712. The signing differs based upon the `version`. 793 | * 794 | * V1 is based upon [an early version of 795 | * EIP-712](https://github.com/ethereum/EIPs/pull/712/commits/21abe254fe0452d8583d5b132b1d7be87c0439ca) 796 | * that lacked some later security improvements, and should generally be neglected in favor of 797 | * later versions. 798 | * 799 | * V3 is based on [EIP-712](https://eips.ethereum.org/EIPS/eip-712), except that arrays and 800 | * recursive data structures are not supported. 801 | * 802 | * V4 is based on [EIP-712](https://eips.ethereum.org/EIPS/eip-712), and includes full support of 803 | * arrays and recursive data structures. 804 | * 805 | * @param options - The signing options. 806 | * @param options.privateKey - The private key to sign with. 807 | * @param options.data - The typed data to sign. 808 | * @param options.version - The signing version to use. 809 | * @returns The '0x'-prefixed hex encoded signature. 810 | */ 811 | export function signTypedData< 812 | V extends SignTypedDataVersion, 813 | T extends MessageTypes, 814 | >({ 815 | privateKey, 816 | data, 817 | version, 818 | }: { 819 | privateKey: Buffer; 820 | data: V extends 'V1' ? TypedDataV1 : TypedMessage; 821 | version: V; 822 | }): string { 823 | validateVersion(version); 824 | if (isNullish(data)) { 825 | throw new Error('Missing data parameter'); 826 | } else if (isNullish(privateKey)) { 827 | throw new Error('Missing private key parameter'); 828 | } 829 | 830 | const messageHash = 831 | version === SignTypedDataVersion.V1 832 | ? _typedSignatureHash(data as TypedDataV1) 833 | : TypedDataUtils.eip712Hash(data as TypedMessage, version); 834 | const sig = ecsign(messageHash, privateKey); 835 | return concatSig(arrToBufArr(bigIntToBytes(sig.v)), sig.r, sig.s); 836 | } 837 | 838 | /** 839 | * Recover the address of the account that created the given EIP-712 840 | * signature. The version provided must match the version used to 841 | * create the signature. 842 | * 843 | * @param options - The signature recovery options. 844 | * @param options.data - The typed data that was signed. 845 | * @param options.signature - The '0x-prefixed hex encoded message signature. 846 | * @param options.version - The signing version to use. 847 | * @returns The '0x'-prefixed hex address of the signer. 848 | */ 849 | export function recoverTypedSignature< 850 | V extends SignTypedDataVersion, 851 | T extends MessageTypes, 852 | >({ 853 | data, 854 | signature, 855 | version, 856 | }: { 857 | data: V extends 'V1' ? TypedDataV1 : TypedMessage; 858 | signature: string; 859 | version: V; 860 | }): string { 861 | validateVersion(version); 862 | if (isNullish(data)) { 863 | throw new Error('Missing data parameter'); 864 | } else if (isNullish(signature)) { 865 | throw new Error('Missing signature parameter'); 866 | } 867 | 868 | const messageHash = 869 | version === SignTypedDataVersion.V1 870 | ? _typedSignatureHash(data as TypedDataV1) 871 | : TypedDataUtils.eip712Hash(data as TypedMessage, version); 872 | const publicKey = recoverPublicKey(messageHash, signature); 873 | const sender = publicToAddress(publicKey); 874 | return bytesToHex(sender); 875 | } 876 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { concatSig, padWithZeroes, normalize } from './utils'; 2 | 3 | describe('padWithZeroes', function () { 4 | it('pads a string shorter than the target length with zeroes', function () { 5 | const input = 'abc'; 6 | expect(padWithZeroes(input, 5)).toBe(`00${input}`); 7 | }); 8 | 9 | it('pads an empty string', function () { 10 | const input = ''; 11 | expect(padWithZeroes(input, 4)).toBe(`0000`); 12 | }); 13 | 14 | it('returns a string equal to the target length without modifying it', function () { 15 | const input = 'abc'; 16 | expect(padWithZeroes(input, 3)).toStrictEqual(input); 17 | }); 18 | 19 | it('returns a string longer than the target length without modifying it', function () { 20 | const input = 'abcd'; 21 | expect(padWithZeroes(input, 3)).toStrictEqual(input); 22 | }); 23 | 24 | it('throws an error if passed an invalid hex string', function () { 25 | const inputs = ['0xabc', 'xyz', '-']; 26 | for (const input of inputs) { 27 | expect(() => padWithZeroes(input, 3)).toThrow( 28 | new Error(`Expected an unprefixed hex string. Received: ${input}`), 29 | ); 30 | } 31 | }); 32 | 33 | it('throws an error if passed a negative number', function () { 34 | expect(() => padWithZeroes('abc', -1)).toThrow( 35 | new Error('Expected a non-negative integer target length. Received: -1'), 36 | ); 37 | }); 38 | }); 39 | 40 | describe('concatSig', function () { 41 | it('should concatenate an extended ECDSA signature', function () { 42 | expect( 43 | concatSig( 44 | Buffer.from('1', 'hex'), 45 | Buffer.from('1', 'hex'), 46 | Buffer.from('1', 'hex'), 47 | ), 48 | ).toMatchInlineSnapshot( 49 | `"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"`, 50 | ); 51 | }); 52 | 53 | it('should concatenate an all-zero extended ECDSA signature', function () { 54 | expect( 55 | concatSig( 56 | Buffer.from('0', 'hex'), 57 | Buffer.from('0', 'hex'), 58 | Buffer.from('0', 'hex'), 59 | ), 60 | ).toMatchInlineSnapshot( 61 | `"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"`, 62 | ); 63 | }); 64 | 65 | it('should return a hex-prefixed string', function () { 66 | const signature = concatSig( 67 | Buffer.from('1', 'hex'), 68 | Buffer.from('1', 'hex'), 69 | Buffer.from('1', 'hex'), 70 | ); 71 | 72 | expect(typeof signature).toBe('string'); 73 | expect(signature.slice(0, 2)).toBe('0x'); 74 | }); 75 | 76 | it('should encode an impossibly large extended ECDSA signature', function () { 77 | const largeNumber = Number.MAX_SAFE_INTEGER.toString(16); 78 | expect( 79 | concatSig( 80 | Buffer.from(largeNumber, 'hex'), 81 | Buffer.from(largeNumber, 'hex'), 82 | Buffer.from(largeNumber, 'hex'), 83 | ), 84 | ).toMatchInlineSnapshot( 85 | `"0x000000000000000000000000000000000000000000000000001fffffffffffff000000000000000000000000000000000000000000000000001fffffffffffff1fffffffffffff"`, 86 | ); 87 | }); 88 | 89 | it('should throw if a portion of the signature is larger than the maximum safe integer', function () { 90 | const largeNumber = '20000000000000'; // This is Number.MAX_SAFE_INTEGER + 1, in hex 91 | expect(() => 92 | concatSig( 93 | Buffer.from(largeNumber, 'hex'), 94 | Buffer.from(largeNumber, 'hex'), 95 | Buffer.from(largeNumber, 'hex'), 96 | ), 97 | ).toThrow('Number exceeds 53 bits'); 98 | }); 99 | }); 100 | 101 | describe('normalize', function () { 102 | it('should normalize an address to lower case', function () { 103 | const initial = '0xA06599BD35921CfB5B71B4BE3869740385b0B306'; 104 | const result = normalize(initial); 105 | expect(result).toBe(initial.toLowerCase()); 106 | }); 107 | 108 | it('should normalize address without a 0x prefix', function () { 109 | const initial = 'A06599BD35921CfB5B71B4BE3869740385b0B306'; 110 | const result = normalize(initial); 111 | expect(result).toBe(`0x${initial.toLowerCase()}`); 112 | }); 113 | 114 | it('should normalize 0 to a byte-pair hex string', function () { 115 | const initial = 0; 116 | const result = normalize(initial); 117 | expect(result).toBe('0x00'); 118 | }); 119 | 120 | it('should normalize an integer to a byte-pair hex string', function () { 121 | const initial = 1; 122 | const result = normalize(initial); 123 | expect(result).toBe('0x01'); 124 | }); 125 | 126 | // TODO: Add validation to disallow negative integers. 127 | it('should normalize a negative integer to 0x', function () { 128 | const initial = -1; 129 | const result = normalize(initial); 130 | expect(result).toBe('0x'); 131 | }); 132 | 133 | it('should normalize an empty string to 0x', function () { 134 | const initial = ''; 135 | const result = normalize(initial); 136 | expect(result).toBe('0x'); 137 | }); 138 | 139 | // TODO: Add validation to disallow null. 140 | it('should return undefined if given null', function () { 141 | const initial = null; 142 | expect(normalize(initial as any)).toBeUndefined(); 143 | }); 144 | 145 | // TODO: Add validation to disallow undefined. 146 | it('should return undefined if given undefined', function () { 147 | const initial = undefined; 148 | expect(normalize(initial as any)).toBeUndefined(); 149 | }); 150 | 151 | it('should throw if given an object', function () { 152 | const initial = {}; 153 | expect(() => normalize(initial as any)).toThrow( 154 | 'eth-sig-util.normalize() requires hex string or integer input. received object:', 155 | ); 156 | }); 157 | 158 | it('should throw if given a boolean', function () { 159 | const initial = true; 160 | expect(() => normalize(initial as any)).toThrow( 161 | 'eth-sig-util.normalize() requires hex string or integer input. received boolean: true', 162 | ); 163 | }); 164 | 165 | it('should throw if given a bigint', function () { 166 | const initial = BigInt(Number.MAX_SAFE_INTEGER); 167 | expect(() => normalize(initial as any)).toThrow( 168 | 'eth-sig-util.normalize() requires hex string or integer input. received bigint: 9007199254740991', 169 | ); 170 | }); 171 | 172 | it('should throw if given a symbol', function () { 173 | const initial = Symbol('test'); 174 | expect(() => normalize(initial as any)).toThrow( 175 | 'Cannot convert a Symbol value to a string', 176 | ); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bufferToInt, 3 | ecrecover, 4 | fromRpcSig, 5 | fromSigned, 6 | isHexString, 7 | toBuffer, 8 | ToBufferInputTypes, 9 | toUnsigned, 10 | } from '@ethereumjs/util'; 11 | import { 12 | numberToHex, 13 | remove0x, 14 | add0x, 15 | bytesToHex, 16 | numberToBytes, 17 | } from '@metamask/utils'; 18 | 19 | /** 20 | * Pads the front of the given hex string with zeroes until it reaches the 21 | * target length. If the input string is already longer than or equal to the 22 | * target length, it is returned unmodified. 23 | * 24 | * If the input string is "0x"-prefixed or not a hex string, an error will be 25 | * thrown. 26 | * 27 | * @param hexString - The hexadecimal string to pad with zeroes. 28 | * @param targetLength - The target length of the hexadecimal string. 29 | * @returns The input string front-padded with zeroes, or the original string 30 | * if it was already greater than or equal to to the target length. 31 | */ 32 | export function padWithZeroes(hexString: string, targetLength: number): string { 33 | if (hexString !== '' && !/^[a-f0-9]+$/iu.test(hexString)) { 34 | throw new Error( 35 | `Expected an unprefixed hex string. Received: ${hexString}`, 36 | ); 37 | } 38 | 39 | if (targetLength < 0) { 40 | throw new Error( 41 | `Expected a non-negative integer target length. Received: ${targetLength}`, 42 | ); 43 | } 44 | 45 | return String.prototype.padStart.call(hexString, targetLength, '0'); 46 | } 47 | 48 | /** 49 | * Returns `true` if the given value is nullish. 50 | * 51 | * @param value - The value being checked. 52 | * @returns Whether the value is nullish. 53 | */ 54 | export function isNullish(value) { 55 | return value === null || value === undefined; 56 | } 57 | 58 | /** 59 | * Convert a value to a Buffer. This function should be equivalent to the `toBuffer` function in 60 | * `ethereumjs-util@5.2.1`. 61 | * 62 | * @param value - The value to convert to a Buffer. 63 | * @returns The given value as a Buffer. 64 | */ 65 | export function legacyToBuffer(value: ToBufferInputTypes) { 66 | return typeof value === 'string' && !isHexString(value) 67 | ? Buffer.from(value) 68 | : toBuffer(value); 69 | } 70 | 71 | /** 72 | * Concatenate an extended ECDSA signature into a single '0x'-prefixed hex string. 73 | * 74 | * @param v - The 'v' portion of the signature. 75 | * @param r - The 'r' portion of the signature. 76 | * @param s - The 's' portion of the signature. 77 | * @returns The concatenated ECDSA signature as a '0x'-prefixed string. 78 | */ 79 | export function concatSig(v: Buffer, r: Buffer, s: Buffer): string { 80 | const rSig = fromSigned(r); 81 | const sSig = fromSigned(s); 82 | const vSig = bufferToInt(v); 83 | const rStr = padWithZeroes(toUnsigned(rSig).toString('hex'), 64); 84 | const sStr = padWithZeroes(toUnsigned(sSig).toString('hex'), 64); 85 | const vStr = remove0x(numberToHex(vSig)); 86 | return add0x(rStr.concat(sStr, vStr)); 87 | } 88 | 89 | /** 90 | * Recover the public key from the given signature and message hash. 91 | * 92 | * @param messageHash - The hash of the signed message. 93 | * @param signature - The signature. 94 | * @returns The public key of the signer. 95 | */ 96 | export function recoverPublicKey( 97 | messageHash: Buffer, 98 | signature: string, 99 | ): Buffer { 100 | const sigParams = fromRpcSig(signature); 101 | return ecrecover(messageHash, sigParams.v, sigParams.r, sigParams.s); 102 | } 103 | 104 | /** 105 | * Normalize the input to a lower-cased '0x'-prefixed hex string. 106 | * 107 | * @param input - The value to normalize. 108 | * @returns The normalized value. 109 | */ 110 | export function normalize(input: number | string): string | undefined { 111 | if (isNullish(input)) { 112 | return undefined; 113 | } 114 | 115 | if (typeof input === 'number') { 116 | if (input < 0) { 117 | return '0x'; 118 | } 119 | const buffer = numberToBytes(input); 120 | input = bytesToHex(buffer); 121 | } 122 | 123 | if (typeof input !== 'string') { 124 | let msg = 'eth-sig-util.normalize() requires hex string or integer input.'; 125 | msg += ` received ${typeof input}: ${input as any as string}`; 126 | throw new Error(msg); 127 | } 128 | 129 | return add0x(input.toLowerCase()); 130 | } 131 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "inlineSources": true, 6 | "lib": ["ES2020"], 7 | "module": "CommonJS", 8 | "moduleResolution": "Node", 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | "sourceMap": true, 12 | "strictNullChecks": true, 13 | "target": "ES2017", 14 | "typeRoots": ["./node_modules/@types"] 15 | }, 16 | "include": ["./src/**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "excludePrivate": true, 4 | "hideGenerator": true, 5 | "out": "docs" 6 | } 7 | --------------------------------------------------------------------------------