Skip to content

Commit

Permalink
refactor(experimental): a polyfill for generateKey() that implement…
Browse files Browse the repository at this point in the history
…s Ed25519 key generation in userspace (#1395)

## 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: WICG/webcrypto-secure-curves#20

## Test Plan

```
cd packages/webcrypto-ed25519-polyfill
pnpm test:unit:browser
pnpm test:unit:node
```
  • Loading branch information
steveluscher committed Jul 14, 2023
1 parent 91c8b54 commit f0d60d9
Show file tree
Hide file tree
Showing 14 changed files with 712 additions and 35 deletions.
1 change: 1 addition & 0 deletions packages/webcrypto-ed25519-polyfill/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
1 change: 1 addition & 0 deletions packages/webcrypto-ed25519-polyfill/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
1 change: 1 addition & 0 deletions packages/webcrypto-ed25519-polyfill/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
20 changes: 20 additions & 0 deletions packages/webcrypto-ed25519-polyfill/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions packages/webcrypto-ed25519-polyfill/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[![npm][npm-image]][npm-url]
[![npm-downloads][npm-downloads-image]][npm-url]
[![semantic-release][semantic-release-image]][semantic-release-url]
<br />
[![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']);
```
99 changes: 99 additions & 0 deletions packages/webcrypto-ed25519-polyfill/package.json
Original file line number Diff line number Diff line change
@@ -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 <maintainers@solanalabs.com>",
"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"
}
]
}
}
185 changes: 185 additions & 0 deletions packages/webcrypto-ed25519-polyfill/src/__tests__/index-test.ts
Original file line number Diff line number Diff line change
@@ -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" (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" (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);
});
}
});
});
Loading

0 comments on commit f0d60d9

Please sign in to comment.