From f6ce7bea51fbc474730c9243c84fa61c6831e74e Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Thu, 13 Jul 2023 20:32:26 +0000 Subject: [PATCH] refactor(experimental): a polyfill for `generateKey()` that implements Ed25519 key generation in userspace ## Summary For environments where Ed25519 key generation is not supported, this polyfill injects a suitable implementation that uses `@noble/curves/ed25519` behind the scenes. Here's the list of environments that do support it: https://github.com/WICG/webcrypto-secure-curves/issues/20 ## Test Plan ``` cd packages/webcrypto-ed25519-polyfill pnpm test:unit:browser pnpm test:unit:node ``` --- .../webcrypto-ed25519-polyfill/.gitignore | 1 + packages/webcrypto-ed25519-polyfill/.npmrc | 1 + .../.prettierignore | 1 + packages/webcrypto-ed25519-polyfill/LICENSE | 20 ++ packages/webcrypto-ed25519-polyfill/README.md | 35 ++++ .../webcrypto-ed25519-polyfill/package.json | 99 ++++++++++ .../src/__tests__/index-test.ts | 185 ++++++++++++++++++ .../src/__tests__/secrets-test.ts | 90 +++++++++ .../webcrypto-ed25519-polyfill/src/index.ts | 72 +++++++ .../webcrypto-ed25519-polyfill/src/secrets.ts | 82 ++++++++ .../src/types/global.d.ts | 4 + .../tsconfig.declarations.json | 10 + .../webcrypto-ed25519-polyfill/tsconfig.json | 9 + pnpm-lock.yaml | 138 +++++++++---- 14 files changed, 712 insertions(+), 35 deletions(-) create mode 100644 packages/webcrypto-ed25519-polyfill/.gitignore create mode 100644 packages/webcrypto-ed25519-polyfill/.npmrc create mode 100644 packages/webcrypto-ed25519-polyfill/.prettierignore create mode 100644 packages/webcrypto-ed25519-polyfill/LICENSE create mode 100644 packages/webcrypto-ed25519-polyfill/README.md create mode 100644 packages/webcrypto-ed25519-polyfill/package.json create mode 100644 packages/webcrypto-ed25519-polyfill/src/__tests__/index-test.ts create mode 100644 packages/webcrypto-ed25519-polyfill/src/__tests__/secrets-test.ts create mode 100644 packages/webcrypto-ed25519-polyfill/src/index.ts create mode 100644 packages/webcrypto-ed25519-polyfill/src/secrets.ts create mode 100644 packages/webcrypto-ed25519-polyfill/src/types/global.d.ts create mode 100644 packages/webcrypto-ed25519-polyfill/tsconfig.declarations.json create mode 100644 packages/webcrypto-ed25519-polyfill/tsconfig.json diff --git a/packages/webcrypto-ed25519-polyfill/.gitignore b/packages/webcrypto-ed25519-polyfill/.gitignore new file mode 100644 index 000000000000..849ddff3b7ec --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/webcrypto-ed25519-polyfill/.npmrc b/packages/webcrypto-ed25519-polyfill/.npmrc new file mode 100644 index 000000000000..b6f27f135954 --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/packages/webcrypto-ed25519-polyfill/.prettierignore b/packages/webcrypto-ed25519-polyfill/.prettierignore new file mode 100644 index 000000000000..849ddff3b7ec --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/.prettierignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/webcrypto-ed25519-polyfill/LICENSE b/packages/webcrypto-ed25519-polyfill/LICENSE new file mode 100644 index 000000000000..adfd203c6fac --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2018 Solana Labs, Inc + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/webcrypto-ed25519-polyfill/README.md b/packages/webcrypto-ed25519-polyfill/README.md new file mode 100644 index 000000000000..c977f1c469ac --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/README.md @@ -0,0 +1,35 @@ +[![npm][npm-image]][npm-url] +[![npm-downloads][npm-downloads-image]][npm-url] +[![semantic-release][semantic-release-image]][semantic-release-url] +
+[![code-style-prettier][code-style-prettier-image]][code-style-prettier-url] + +[code-style-prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square +[code-style-prettier-url]: https://github.com/prettier/prettier +[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/webcrypto-ed25519-polyfill/experimental.svg?style=flat +[npm-image]: https://img.shields.io/npm/v/@solana/webcrypto-ed25519-polyfill/experimental.svg?style=flat +[npm-url]: https://www.npmjs.com/package/@solana/webcrypto-ed25519-polyfill/v/experimental +[semantic-release-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg +[semantic-release-url]: https://github.com/semantic-release/semantic-release + +# @solana/webcrypto-ed25519-polyfill + +This package contains a polyfill that enables Ed25519 key manipulation in environments where it is not yet implemented. It does so by proxying calls to `SubtleCrypto` instance methods to an Ed25519 implementation in userspace. + +## Security warning + +Because this package's implementation of Ed25519 key generation exists in userspace, it can't guarantee that the keys you generate with it are non-exportable. Untrusted code running in your JavaScript context may still be able to gain access to and/or exfiltrate secret key material. + +## Usage + +Environments that support Ed25519 (see https://github.com/WICG/webcrypto-secure-curves/issues/20) do not require this polyfill. + +For all others, simply import this polyfill before use. + +```ts +// Importing this will shim methods on `SubtleCrypto`, adding Ed25519 support. +import '@solana/webcrypto-ed25519-polyfill'; + +// Now you can do this, in environments that do not otherwise support Ed25519. +const keyPair = await crypto.subtle.generateKey('Ed25519', false, ['sign']); +``` diff --git a/packages/webcrypto-ed25519-polyfill/package.json b/packages/webcrypto-ed25519-polyfill/package.json new file mode 100644 index 000000000000..9ca1b6bf026f --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/package.json @@ -0,0 +1,99 @@ +{ + "name": "@solana/webcrypto-ed25519-polyfill", + "version": "2.0.0-development", + "description": "A polyfill that adds Ed25519 key manipulation capabilities to `SubtleCrypto` in environments where it is not yet supported", + "exports": { + "browser": { + "import": "./dist/index.browser.js", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.js", + "require": "./dist/index.node.cjs" + }, + "react-native": "./dist/index.native.js", + "types": "./dist/types/index.d.ts" + }, + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.js": "./dist/index.browser.js" + }, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.js", + "react-native": "./dist/index.native.js", + "types": "./dist/types/index.d.ts", + "type": "module", + "files": [ + "./dist/" + ], + "sideEffects": true, + "keywords": [ + "blockchain", + "solana", + "web3" + ], + "scripts": { + "compile:js": "tsup --config build-scripts/tsup.config.library.ts", + "compile:typedefs": "tsc -p ./tsconfig.declarations.json", + "dev": "jest -c node_modules/test-config/jest-dev.config.ts --rootDir . --watch", + "prepublishOnly": "version-from-git --no-git-tag-version --template experimental.short", + "publish-packages": "pnpm publish --tag experimental --access public --no-git-checks", + "test:lint": "jest -c node_modules/test-config/jest-lint.config.ts --rootDir . --silent", + "test:prettier": "jest -c node_modules/test-config/jest-prettier.config.ts --rootDir . --silent", + "test:typecheck": "tsc --noEmit", + "test:unit:browser": "jest -c node_modules/test-config/jest-unit.config.browser.ts --rootDir . --silent", + "test:unit:node": "jest -c node_modules/test-config/jest-unit.config.node.ts --rootDir . --silent" + }, + "author": "Solana Labs Maintainers ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/solana-labs/solana-web3.js" + }, + "bugs": { + "url": "http://github.com/solana-labs/solana-web3.js/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ], + "engine": { + "node": ">=17.4" + }, + "dependencies": { + "@noble/curves": "^1.1.0" + }, + "devDependencies": { + "@solana/eslint-config-solana": "^1.0.1", + "@swc/core": "^1.3.18", + "@swc/jest": "^0.2.26", + "@types/jest": "^29.5.2", + "@typescript-eslint/eslint-plugin": "^5.57.1", + "@typescript-eslint/parser": "^5.57.1", + "build-scripts": "workspace:*", + "eslint": "^8.37.0", + "eslint-plugin-jest": "^27.1.5", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-sort-keys-fix": "^1.1.2", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.6.0", + "jest-runner-eslint": "^2.1.0", + "jest-runner-prettier": "^1.0.0", + "postcss": "^8.4.12", + "prettier": "^2.8.8", + "test-config": "workspace:*", + "ts-node": "^10.9.1", + "tsconfig": "workspace:*", + "tsup": "6.7.0", + "typescript": "^5.0.4", + "version-from-git": "^1.1.1" + }, + "bundlewatch": { + "defaultCompression": "gzip", + "files": [ + { + "path": "./dist/index*.js" + } + ] + } +} diff --git a/packages/webcrypto-ed25519-polyfill/src/__tests__/index-test.ts b/packages/webcrypto-ed25519-polyfill/src/__tests__/index-test.ts new file mode 100644 index 000000000000..0e8e1368e7ba --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/src/__tests__/index-test.ts @@ -0,0 +1,185 @@ +import { generateKeyPolyfill } from '../secrets'; + +jest.mock('../secrets'); + +describe('generateKey() polyfill', () => { + let oldIsSecureContext: boolean; + let originalGenerateKey: SubtleCrypto['generateKey']; + beforeEach(() => { + jest.spyOn(globalThis.crypto?.subtle, 'generateKey'); + originalGenerateKey = globalThis.crypto?.subtle?.generateKey; + if (__BROWSER__) { + // FIXME: JSDOM does not set `isSecureContext` or otherwise allow you to configure it. + // Some discussion: https://github.com/jsdom/jsdom/issues/2751#issuecomment-846613392 + if (globalThis.isSecureContext !== undefined) { + oldIsSecureContext = globalThis.isSecureContext; + } + } + globalThis.isSecureContext = true; + }); + afterEach(() => { + globalThis.crypto.subtle.generateKey = originalGenerateKey; + if (oldIsSecureContext !== undefined) { + globalThis.isSecureContext = oldIsSecureContext; + } + }); + describe('when required in an environment with no `generateKey` function', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globalThis.crypto.subtle.generateKey = undefined; + jest.isolateModules(() => { + require('../index'); + }); + }); + afterEach(() => { + globalThis.crypto.subtle.generateKey = originalGenerateKey; + }); + it.each([ + { __variant: 'P256', name: 'ECDSA', namedCurve: 'P-256' }, + { __variant: 'P384', name: 'ECDSA', namedCurve: 'P-384' } as EcKeyGenParams, + { __variant: 'P521', name: 'ECDSA', namedCurve: 'P-521' } as EcKeyGenParams, + ...['RSASSA-PKCS1-v1_5', 'RSA-PSS'].flatMap(rsaAlgoName => + ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'].map( + hashName => + ({ + __variant: hashName, + hash: { name: hashName }, + modulusLength: 2048, + name: rsaAlgoName, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + } as RsaHashedKeyGenParams) + ) + ), + ])('fatals when the algorithm is $name/$__variant', async algorithm => { + expect.assertions(1); + await expect(() => + globalThis.crypto.subtle.generateKey(algorithm, /* extractable */ false, ['sign', 'verify']) + ).rejects.toThrow(); + }); + it('delegates Ed25519 `generateKey` calls to the polyfill', async () => { + expect.assertions(1); + const mockKeyPair = {}; + (generateKeyPolyfill as jest.Mock).mockReturnValue(mockKeyPair); + const keyPair = await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, [ + 'sign', + 'verify', + ]); + expect(keyPair).toBe(mockKeyPair); + }); + }); + describe('when required in an environment that does not support Ed25519', () => { + beforeEach(() => { + const originalGenerateKeyImpl = originalGenerateKey; + (originalGenerateKey as jest.Mock).mockImplementation(async (...args) => { + const [algorithm] = args; + if (algorithm === 'Ed25519') { + throw new Error('Ed25519 not supported'); + } + return await originalGenerateKeyImpl.apply(globalThis.crypto.subtle, args); + }); + jest.isolateModules(() => { + require('../index'); + }); + }); + it('calls the original `generateKey` once as a test when the algorithm is "Ed25519" but never again (parallel version)', async () => { + expect.assertions(1); + await Promise.all([ + globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']), + globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']), + ]); + expect(originalGenerateKey).toHaveBeenCalledTimes(1); + }); + it('calls the original `generateKey` once as a test when the algorithm is "Ed25519" but never again (serial version)', async () => { + expect.assertions(1); + await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']); + await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']); + expect(originalGenerateKey).toHaveBeenCalledTimes(1); + }); + it('delegates Ed25519 `generateKey` calls to the polyfill', async () => { + expect.assertions(1); + const mockKeyPair = {}; + (generateKeyPolyfill as jest.Mock).mockReturnValue(mockKeyPair); + const keyPair = await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, [ + 'sign', + 'verify', + ]); + expect(keyPair).toBe(mockKeyPair); + }); + }); + describe('when required in an environment that supports Ed25519', () => { + beforeEach(() => { + jest.isolateModules(() => { + require('../index'); + }); + }); + it('overrides `generateKey`', () => { + expect(globalThis.crypto.subtle.generateKey).not.toBe(originalGenerateKey); + }); + it.each([ + { __variant: 'P256', name: 'ECDSA', namedCurve: 'P-256' }, + { __variant: 'P384', name: 'ECDSA', namedCurve: 'P-384' } as EcKeyGenParams, + { __variant: 'P521', name: 'ECDSA', namedCurve: 'P-521' } as EcKeyGenParams, + ...['RSASSA-PKCS1-v1_5', 'RSA-PSS'].flatMap(rsaAlgoName => + ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'].map( + hashName => + ({ + __variant: hashName, + hash: { name: hashName }, + modulusLength: 2048, + name: rsaAlgoName, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + } as RsaHashedKeyGenParams) + ) + ), + ])('calls the original `generateKey` when the algorithm is $name/$__variant', async algorithm => { + expect.assertions(1); + await globalThis.crypto.subtle.generateKey(algorithm, /* extractable */ false, ['sign', 'verify']); + expect(originalGenerateKey).toHaveBeenCalled(); + }); + it('delegates the call to the original `generateKey` when the algorithm is "Ed25519"', async () => { + expect.assertions(1); + const mockKeyPair = {}; + (originalGenerateKey as jest.Mock).mockResolvedValue(mockKeyPair); + await expect( + globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']) + ).resolves.toBe(mockKeyPair); + }); + it('calls the original `generateKey` once per call to `generateKey` when the algorithm is "Ed25519" but never again (parallel version)', async () => { + expect.assertions(1); + await Promise.all([ + globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']), + globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']), + ]); + expect(originalGenerateKey).toHaveBeenCalledTimes(2); + }); + it('calls the original `generateKey` once per call to `generateKey` when the algorithm is "Ed25519" but never again (serial version)', async () => { + expect.assertions(1); + await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']); + await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']); + expect(originalGenerateKey).toHaveBeenCalledTimes(2); + }); + it('does not delegate `generateKey` calls to the polyfill', async () => { + expect.assertions(1); + await globalThis.crypto.subtle.generateKey('Ed25519', /* extractable */ false, ['sign', 'verify']); + expect(generateKeyPolyfill).not.toHaveBeenCalled(); + }); + }); + describe('when required in an insecure context', () => { + beforeEach(() => { + globalThis.isSecureContext = false; + jest.isolateModules(() => { + require('../index'); + }); + }); + if (__BROWSER__) { + it('does not override `generateKey`', () => { + expect(globalThis.crypto.subtle.generateKey).toBe(originalGenerateKey); + }); + } else { + it('overrides `generateKey`', () => { + expect(globalThis.crypto.subtle.generateKey).not.toBe(originalGenerateKey); + }); + } + }); +}); diff --git a/packages/webcrypto-ed25519-polyfill/src/__tests__/secrets-test.ts b/packages/webcrypto-ed25519-polyfill/src/__tests__/secrets-test.ts new file mode 100644 index 000000000000..f2c69ff02e64 --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/src/__tests__/secrets-test.ts @@ -0,0 +1,90 @@ +import { generateKeyPolyfill } from '../secrets'; + +describe('generateKeyPolyfill', () => { + it('stores secret key bytes in an internal cache', () => { + const weakMapSetSpy = jest.spyOn(WeakMap.prototype, 'set'); + const expectedSecretKey = new Uint8Array(Array(32).fill(1)); + jest.spyOn(globalThis.crypto, 'getRandomValues').mockReturnValue(expectedSecretKey); + generateKeyPolyfill(/* extractable */ false, ['sign', 'verify']); + expect(weakMapSetSpy).toHaveBeenCalledWith(expect.anything(), expectedSecretKey); + }); + describe.each(['public', 'private'])('when generating a %s key', type => { + let keyPair: CryptoKeyPair; + beforeEach(() => { + keyPair = generateKeyPolyfill(/* extractable */ false, ['sign', 'verify']); + }); + it(`has the algorithm "Ed25519"`, () => { + expect(keyPair).toHaveProperty([`${type}Key`, 'algorithm', 'name'], 'Ed25519'); + }); + it('has the string tag "CryptoKey"', () => { + expect(keyPair).toHaveProperty([`${type}Key`, Symbol.toStringTag], 'CryptoKey'); + }); + it(`has the type "${type}"`, () => { + expect(keyPair).toHaveProperty([`${type}Key`, 'type'], type); + }); + }); + it.each([true, false])( + "sets the private key's `extractable` to `false` when generating a key pair with the extractability `%s`", + extractable => { + const { privateKey } = generateKeyPolyfill(extractable, ['sign', 'verify']); + expect(privateKey).toHaveProperty('extractable', extractable); + } + ); + it.each([true, false])( + "sets the public key's `extractable` to `true` when generating a key pair with the extractability `%s`", + extractable => { + const { publicKey } = generateKeyPolyfill(extractable, ['sign', 'verify']); + expect(publicKey).toHaveProperty('extractable', true); + } + ); + it.each(['decrypt', 'deriveBits', 'deriveKey', 'encrypt', 'unwrapKey', 'wrapKey'] as KeyUsage[])( + 'fatals when the usage `%s` is specified', + usage => { + expect(() => generateKeyPolyfill(/* extractable */ false, [usage])).toThrow(); + } + ); + it("includes `sign` among the private key's usages when the `sign` usage is specified", () => { + const { privateKey } = generateKeyPolyfill(/* extractable */ false, ['sign']); + expect(privateKey).toHaveProperty('usages', expect.arrayContaining(['sign'])); + }); + it("sets the private key's usages to an empty array when the `sign` usage is not specified", () => { + const { privateKey } = generateKeyPolyfill(/* extractable */ false, ['verify']); + expect(privateKey).toHaveProperty('usages', []); + }); + it("does not include `verify` among the private key's usages when the `verify` usage is specified", () => { + const { privateKey } = generateKeyPolyfill(/* extractable */ false, ['verify']); + expect(privateKey).toHaveProperty('usages', []); + }); + it("does not include `sign` among the public key's usages when the `sign` usage is specified", () => { + const { publicKey } = generateKeyPolyfill(/* extractable */ false, ['sign']); + expect(publicKey).toHaveProperty('usages', []); + }); + it("sets the public key's usages to an empty array when the `verify` usage is not specified", () => { + const { publicKey } = generateKeyPolyfill(/* extractable */ false, ['sign']); + expect(publicKey).toHaveProperty('usages', []); + }); + it("includes `verify` among the public key's usages when the `verify` usage is specified", () => { + const { publicKey } = generateKeyPolyfill(/* extractable */ false, ['verify']); + expect(publicKey).toHaveProperty('usages', expect.arrayContaining(['verify'])); + }); + it('fatals when no key usages are specified', () => { + expect(() => generateKeyPolyfill(/* extractable */ false, [])).toThrow(); + }); + describe('when no CSPRNG can be found', () => { + let oldGetRandomValues: Crypto['getRandomValues']; + beforeEach(() => { + oldGetRandomValues = globalThis.crypto.getRandomValues; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globalThis.crypto.getRandomValues = undefined; + }); + afterEach(() => { + globalThis.crypto.getRandomValues = oldGetRandomValues; + }); + it('fatals', () => { + expect(() => { + generateKeyPolyfill(/* extractable */ false, ['sign', 'verify']); + }).toThrow(); + }); + }); +}); diff --git a/packages/webcrypto-ed25519-polyfill/src/index.ts b/packages/webcrypto-ed25519-polyfill/src/index.ts new file mode 100644 index 000000000000..1b936f82edb3 --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/src/index.ts @@ -0,0 +1,72 @@ +import { generateKeyPolyfill } from './secrets'; + +if (!__BROWSER__ || globalThis.isSecureContext) { + /** + * Create `crypto.subtle` if it doesn't exist. + */ + const originalCryptoObject = (globalThis.crypto ||= {} as Crypto); + const originalSubtleCrypto = ((originalCryptoObject as Crypto & { subtle: SubtleCrypto }).subtle ||= + {} as SubtleCrypto); + + /** + * Override `SubtleCrypto#generateKey` + */ + const originalGenerateKey = originalSubtleCrypto.generateKey as SubtleCrypto['generateKey'] | undefined; + let originalGenerateKeySupportsEd25519: Promise | boolean | undefined; + originalSubtleCrypto.generateKey = (async (...args: Parameters) => { + const [algorithm] = args; + if (algorithm !== 'Ed25519') { + if (originalGenerateKey) { + return await originalGenerateKey.apply(originalSubtleCrypto, args); + } else { + throw new TypeError('No native `generateKey` function exists to handle this call'); + } + } + let optimisticallyGeneratedKeyPair; + if (originalGenerateKeySupportsEd25519 === undefined) { + originalGenerateKeySupportsEd25519 = new Promise(resolve => { + if (!originalGenerateKey) { + resolve((originalGenerateKeySupportsEd25519 = false)); + return; + } + originalGenerateKey + .apply(originalSubtleCrypto, args) + .then(keyPair => { + if (__DEV__) { + console.warn( + '`@solana/webcrypto-ed25519-polyfill` was included in an ' + + 'environment that supports Ed25519 key manipulation ' + + 'natively. Falling back to the native implementation. ' + + 'Consider including this polyfill only in environments where ' + + 'Ed25519 is not supported.' + ); + } + if (originalSubtleCrypto.generateKey !== originalGenerateKey) { + originalSubtleCrypto.generateKey = originalGenerateKey; + } + optimisticallyGeneratedKeyPair = keyPair; + resolve((originalGenerateKeySupportsEd25519 = true)); + }) + .catch(() => { + resolve((originalGenerateKeySupportsEd25519 = false)); + }); + }); + } + if ( + typeof originalGenerateKeySupportsEd25519 === 'boolean' + ? originalGenerateKeySupportsEd25519 + : await originalGenerateKeySupportsEd25519 + ) { + if (optimisticallyGeneratedKeyPair) { + return optimisticallyGeneratedKeyPair; + } else if (originalGenerateKey) { + return await originalGenerateKey.apply(originalSubtleCrypto, args); + } else { + throw new TypeError('No native `generateKey` function exists to handle this call'); + } + } else { + const [_, extractable, keyUsages] = args; + return generateKeyPolyfill(extractable, keyUsages); + } + }) as SubtleCrypto['generateKey']; +} diff --git a/packages/webcrypto-ed25519-polyfill/src/secrets.ts b/packages/webcrypto-ed25519-polyfill/src/secrets.ts new file mode 100644 index 000000000000..b63ad5ab0d7b --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/src/secrets.ts @@ -0,0 +1,82 @@ +/** + * HEY! <== SECRET KEY KOALA + * |/ <== WOULD LIKE YOUR + * ʕ·͡ᴥ·ʔ <== ATTENTION PLEASE + * + * Key material generated in this module must stay in this module. So long as the secrets cache and + * the methods that interact with it are not exported from `@solana/webcrypto-ed25519-polyfill`, + * accidental logging of the actual bytes of a secret key (eg. to the console, or to a remote + * server) should not be possible. + * + * WARNING: This does not imply that the secrets cache is secure against supply-chain attacks. + * Untrusted code in your JavaScript context can easily override `WeakMap.prototype.set` to steal + * private keys as they are written to the cache, without alerting you to its presence or affecting + * the regular operation of the cache. + */ +import { ed25519 } from '@noble/curves/ed25519'; + +const PROHIBITED_KEY_USAGES = new Set([ + 'decrypt', + 'deriveBits', + 'deriveKey', + 'encrypt', + 'unwrapKey', + 'wrapKey', +]); + +let storageKeyBySecretKey_INTERNAL_ONLY_DO_NOT_EXPORT: WeakMap | undefined; +function createKeyPairFromBytes( + bytes: Uint8Array, + extractable: boolean, + keyUsages: readonly KeyUsage[] +): CryptoKeyPair { + const keyPair = createKeyPair_INTERNAL_ONLY_DO_NOT_EXPORT(extractable, keyUsages); + const cache = (storageKeyBySecretKey_INTERNAL_ONLY_DO_NOT_EXPORT ||= new WeakMap()); + cache.set(keyPair.privateKey, bytes); + cache.set(keyPair.publicKey, bytes); + return keyPair; +} + +function createKeyPair_INTERNAL_ONLY_DO_NOT_EXPORT( + extractable: boolean, + keyUsages: readonly KeyUsage[] +): CryptoKeyPair { + if (keyUsages.length === 0) { + throw new DOMException('Usages cannot be empty when creating a key.', 'SyntaxError'); + } + if (keyUsages.some(usage => PROHIBITED_KEY_USAGES.has(usage))) { + throw new DOMException('Unsupported key usage for an Ed25519 key.', 'SyntaxError'); + } + const base = { + [Symbol.toStringTag]: 'CryptoKey', + algorithm: Object.freeze({ name: 'Ed25519' }), + }; + const privateKey = { + ...base, + extractable, + type: 'private', + usages: Object.freeze(keyUsages.filter(usage => usage === 'sign')) as KeyUsage[], + } as CryptoKey; + const publicKey = { + ...base, + extractable: true, + type: 'public', + usages: Object.freeze(keyUsages.filter(usage => usage === 'verify')) as KeyUsage[], + } as CryptoKey; + return Object.freeze({ + privateKey: Object.freeze(privateKey), + publicKey: Object.freeze(publicKey), + }); +} + +/** + * This function generates a key pair and stores the secret bytes associated with it in a + * module-private cache. Instead of vending the actual secret bytes, it returns a `CryptoKeyPair` + * that you can use with other methods in this package to produce signatures and derive public keys + * associated with the secret. + */ +export function generateKeyPolyfill(extractable: boolean, keyUsages: readonly KeyUsage[]): CryptoKeyPair { + const privateKeyBytes = ed25519.utils.randomPrivateKey(); + const keyPair = createKeyPairFromBytes(privateKeyBytes, extractable, keyUsages); + return keyPair; +} diff --git a/packages/webcrypto-ed25519-polyfill/src/types/global.d.ts b/packages/webcrypto-ed25519-polyfill/src/types/global.d.ts new file mode 100644 index 000000000000..2d0fcf698896 --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/src/types/global.d.ts @@ -0,0 +1,4 @@ +declare const __BROWSER__: boolean; +declare const __DEV__: boolean; +declare const __NODEJS__: boolean; +declare const __REACTNATIVE__: boolean; diff --git a/packages/webcrypto-ed25519-polyfill/tsconfig.declarations.json b/packages/webcrypto-ed25519-polyfill/tsconfig.declarations.json new file mode 100644 index 000000000000..dc2d27bb09ff --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["src/index.ts", "src/types"] +} diff --git a/packages/webcrypto-ed25519-polyfill/tsconfig.json b/packages/webcrypto-ed25519-polyfill/tsconfig.json new file mode 100644 index 000000000000..48790bda0522 --- /dev/null +++ b/packages/webcrypto-ed25519-polyfill/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@solana/keys", + "extends": "tsconfig/base.json", + "include": ["src"], + "compilerOptions": { + "lib": ["DOM", "ES2015", "ES2022.Error"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec40f948407c..823a0b8e3726 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -873,6 +873,82 @@ importers: packages/tsconfig: {} + packages/webcrypto-ed25519-polyfill: + dependencies: + '@noble/curves': + specifier: ^1.1.0 + version: 1.1.0 + devDependencies: + '@solana/eslint-config-solana': + specifier: ^1.0.1 + version: 1.0.1(@typescript-eslint/eslint-plugin@5.57.1)(@typescript-eslint/parser@5.57.1)(eslint-plugin-jest@27.1.5)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-simple-import-sort@10.0.0)(eslint-plugin-sort-keys-fix@1.1.2)(eslint@8.37.0)(typescript@5.0.4) + '@swc/core': + specifier: ^1.3.18 + version: 1.3.18 + '@swc/jest': + specifier: ^0.2.26 + version: 0.2.26(@swc/core@1.3.18) + '@types/jest': + specifier: ^29.5.2 + version: 29.5.2 + '@typescript-eslint/eslint-plugin': + specifier: ^5.57.1 + version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@5.0.4) + '@typescript-eslint/parser': + specifier: ^5.57.1 + version: 5.57.1(eslint@8.37.0)(typescript@5.0.4) + build-scripts: + specifier: workspace:* + version: link:../build-scripts + eslint: + specifier: ^8.37.0 + version: 8.37.0 + eslint-plugin-jest: + specifier: ^27.1.5 + version: 27.1.5(@typescript-eslint/eslint-plugin@5.57.1)(eslint@8.37.0)(jest@29.6.1)(typescript@5.0.4) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.0(eslint@8.37.0) + eslint-plugin-sort-keys-fix: + specifier: ^1.1.2 + version: 1.1.2 + jest: + specifier: ^29.6.1 + version: 29.6.1(@types/node@18.11.10)(ts-node@10.9.1) + jest-environment-jsdom: + specifier: ^29.6.0 + version: 29.6.0 + jest-runner-eslint: + specifier: ^2.1.0 + version: 2.1.0(eslint@8.37.0)(jest@29.6.1) + jest-runner-prettier: + specifier: ^1.0.0 + version: 1.0.0(jest@29.6.1)(prettier@2.8.8) + postcss: + specifier: ^8.4.12 + version: 8.4.12 + prettier: + specifier: ^2.8.8 + version: 2.8.8 + test-config: + specifier: workspace:* + version: link:../test-config + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@swc/core@1.3.18)(@types/node@18.11.10)(typescript@5.0.4) + tsconfig: + specifier: workspace:* + version: link:../tsconfig + tsup: + specifier: 6.7.0 + version: 6.7.0(@swc/core@1.3.18)(postcss@8.4.12)(ts-node@10.9.1)(typescript@5.0.4) + typescript: + specifier: ^5.0.4 + version: 5.0.4 + version-from-git: + specifier: ^1.1.1 + version: 1.1.1 + packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -2400,13 +2476,13 @@ packages: '@types/node': 18.11.10 chalk: 4.1.2 cosmiconfig: 8.2.0 - cosmiconfig-typescript-loader: 4.3.0(@types/node@18.11.10)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.3) + cosmiconfig-typescript-loader: 4.3.0(@types/node@18.11.10)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.6) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 resolve-from: 5.0.0 ts-node: 10.9.1(@types/node@18.11.10)(typescript@5.1.6) - typescript: 5.1.3 + typescript: 5.1.6 transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -2712,7 +2788,7 @@ packages: dependencies: ajv: 6.12.6 debug: 4.3.4(supports-color@8.1.1) - espree: 9.5.2 + espree: 9.6.0 globals: 13.20.0 ignore: 5.2.4 import-fresh: 3.3.0 @@ -3259,9 +3335,20 @@ packages: resolution: {integrity: sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==} dependencies: '@noble/hashes': 1.3.0 + dev: false + + /@noble/curves@1.1.0: + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + dependencies: + '@noble/hashes': 1.3.1 /@noble/hashes@1.3.0: resolution: {integrity: sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==} + dev: false + + /@noble/hashes@1.3.1: + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -3875,8 +3962,8 @@ packages: resolution: {integrity: sha512-PHaO0BdoiQRPpieC1p31wJsBaxwIOWLh8j2ocXNKX8boCQVldt26Jqm2tZE4KlrvnCIV78owPLv1pEUgqhxZ3w==} dependencies: '@babel/runtime': 7.22.3 - '@noble/curves': 1.0.0 - '@noble/hashes': 1.3.0 + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.1 '@solana/buffer-layout': 4.0.0 agentkeepalive: 4.2.1 bigint-buffer: 1.1.5 @@ -4536,7 +4623,7 @@ packages: debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.1 + semver: 7.5.3 tsutils: 3.21.0(typescript@5.0.3) typescript: 5.0.3 transitivePeerDependencies: @@ -4557,7 +4644,7 @@ packages: debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.1 + semver: 7.5.3 tsutils: 3.21.0(typescript@5.0.4) typescript: 5.0.4 transitivePeerDependencies: @@ -4662,7 +4749,7 @@ packages: '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.0.3) eslint: 8.37.0 eslint-scope: 5.1.1 - semver: 7.5.1 + semver: 7.5.3 transitivePeerDependencies: - supports-color - typescript @@ -4682,7 +4769,7 @@ packages: '@typescript-eslint/typescript-estree': 5.57.1(typescript@5.0.4) eslint: 8.37.0 eslint-scope: 5.1.1 - semver: 7.5.1 + semver: 7.5.3 transitivePeerDependencies: - supports-color - typescript @@ -4702,7 +4789,7 @@ packages: '@typescript-eslint/typescript-estree': 5.59.11(typescript@5.0.4) eslint: 8.37.0 eslint-scope: 5.1.1 - semver: 7.5.1 + semver: 7.5.3 transitivePeerDependencies: - supports-color - typescript @@ -4837,14 +4924,6 @@ packages: dependencies: acorn: 8.10.0 - /acorn-jsx@5.3.2(acorn@8.8.2): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.8.2 - dev: true - /acorn-walk@7.2.0: resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} engines: {node: '>=0.4.0'} @@ -4863,17 +4942,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - /acorn@8.8.2: - resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} - engines: {node: '>=0.4.0'} - hasBin: true - /agadoo@3.0.0: resolution: {integrity: sha512-gq+fjT3Ilrhb88Jf+vYMjdO/+3znYfa7vJ4IMLPFsBPUxglnr40Ed3yCLrW6IABdJAedB94b2BkqR6I04lh3dg==} hasBin: true dependencies: '@rollup/plugin-virtual': 3.0.1(rollup@3.25.1) - acorn: 8.8.2 + acorn: 8.10.0 rollup: 3.25.1 dev: true @@ -5751,7 +5825,7 @@ packages: vary: 1.1.2 dev: true - /cosmiconfig-typescript-loader@4.3.0(@types/node@18.11.10)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.3): + /cosmiconfig-typescript-loader@4.3.0(@types/node@18.11.10)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@5.1.6): resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} engines: {node: '>=12', npm: '>=6'} peerDependencies: @@ -5763,7 +5837,7 @@ packages: '@types/node': 18.11.10 cosmiconfig: 8.2.0 ts-node: 10.9.1(@types/node@18.11.10)(typescript@5.1.6) - typescript: 5.1.3 + typescript: 5.1.6 dev: true /cosmiconfig@7.1.0: @@ -6760,8 +6834,8 @@ packages: resolution: {integrity: sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - acorn: 8.8.2 - acorn-jsx: 5.3.2(acorn@8.8.2) + acorn: 8.10.0 + acorn-jsx: 5.3.2(acorn@8.10.0) eslint-visitor-keys: 3.4.1 dev: true @@ -11720,7 +11794,7 @@ packages: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 18.11.10 - acorn: 8.8.2 + acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 @@ -12095,12 +12169,6 @@ packages: engines: {node: '>=12.20'} hasBin: true - /typescript@5.1.3: - resolution: {integrity: sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - /typescript@5.1.6: resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} engines: {node: '>=14.17'}