diff --git a/.env b/.env new file mode 100644 index 00000000..02300ff3 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +PRIVATE_KEY_PATH=./secp256k1-key +PUBLIC_KEY_PATH=./secp256k1-key.pub +REGISTRY_PATH=./src/registry.json +SIGNATURE_PATH=./src/signature.json diff --git a/.eslintrc.js b/.eslintrc.js index 980cfc94..11599733 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,11 +10,14 @@ module.exports = { }, { - files: ['*.js'], + files: ['*.js', 'scripts/*.ts'], parserOptions: { sourceType: 'script', }, extends: ['@metamask/eslint-config-nodejs'], + rules: { + 'node/no-process-env': 'off', + }, }, { diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c9423c2e..c90b395f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -49,6 +49,15 @@ jobs: exit 1 fi + publish-registry: + # Filtering by `push` events ensures that we only release from the `main` branch. + if: github.event_name == 'push' + needs: all-jobs-pass + uses: ./.github/workflows/publish-registry.yml + secrets: + REGISTRY_PRIVATE_KEY: ${{ secrets.REGISTRY_PRIVATE_KEY }} + METAMASKBOT_TOKEN: ${{ secrets.METAMASKBOT_TOKEN }} + is-release: # Filtering by `push` events ensures that we only release from the `main` branch, which is a # requirement for our npm publishing environment. diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml new file mode 100644 index 00000000..6603d36c --- /dev/null +++ b/.github/workflows/publish-registry.yml @@ -0,0 +1,58 @@ +name: Publish Registry + +on: + workflow_call: + secrets: + REGISTRY_PRIVATE_KEY: + required: true + METAMASKBOT_TOKEN: + required: true + +jobs: + check-updated: + name: Check if registry file was updated + runs-on: ubuntu-latest + outputs: + UPDATED: ${{ steps.updated.outputs.UPDATED }} + steps: + - uses: actions/checkout@v3 + - name: Check if registry file was updated + id: updated + run: | + git fetch --prune --unshallow + if git diff --name-only HEAD^ HEAD | grep src/registry.json + then + echo "UPDATED=true" >> "$GITHUB_OUTPUT" + else + echo "UPDATED=false" >> "$GITHUB_OUTPUT" + fi + + publish-registry: + name: Deploy registry to `gh-pages` branch + environment: registry-publish + needs: check-updated + if: ${{ needs.check-updated.outputs.UPDATED == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + - name: Install Yarn dependencies + run: yarn --immutable + - name: Sign registry + run: yarn sign + env: + PRIVATE_KEY: ${{ secrets.REGISTRY_PRIVATE_KEY }} + - run: | + mkdir -p dist + cp src/registry.json dist/registry.json + cp src/signature.json dist/signature.json + - name: Deploy registry + uses: peaceiris/actions-gh-pages@de7ea6f8efb354206b205ef54722213d99067935 + with: + personal_token: ${{ secrets.METAMASKBOT_TOKEN }} + publish_dir: ./dist + destination_dir: latest diff --git a/.gitignore b/.gitignore index d54c2ba0..3bd8e071 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ node_modules/ !.yarn/releases !.yarn/sdks !.yarn/versions + +# Signature file +src/signature.json + +secp256k1-key diff --git a/package.json b/package.json index e17332d8..e0b8a310 100644 --- a/package.json +++ b/package.json @@ -9,21 +9,26 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ - "dist/" + "dist/", + "secp256k1-key.pub" ], "scripts": { "build": "tsc --project tsconfig.build.json", "build:clean": "rimraf dist && yarn build", + "create-key": "ts-node scripts/create-key.ts", "lint": "yarn lint:eslint && yarn lint:misc --check", "lint:eslint": "eslint . --cache --ext js,ts", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", "prepack": "./scripts/prepack.sh", + "sign": "ts-node scripts/sign-registry.ts", "test": "jest && jest-it-up", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "verify": "ts-node scripts/verify-registry.ts" }, "dependencies": { "@metamask/utils": "^5.0.0", + "@noble/secp256k1": "^1.7.1", "superstruct": "^1.0.3" }, "devDependencies": { @@ -33,10 +38,13 @@ "@metamask/eslint-config-jest": "^11.1.0", "@metamask/eslint-config-nodejs": "^11.1.0", "@metamask/eslint-config-typescript": "^11.1.0", + "@noble/curves": "^0.9.0", + "@noble/hashes": "^1.3.0", "@types/jest": "^28.1.6", "@types/node": "^17.0.23", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", + "dotenv": "^16.0.3", "eslint": "^8.27.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", diff --git a/scripts/create-key.ts b/scripts/create-key.ts new file mode 100644 index 00000000..e901bee0 --- /dev/null +++ b/scripts/create-key.ts @@ -0,0 +1,65 @@ +import { bytesToHex, hasProperty } from '@metamask/utils'; +import * as secp256k1 from '@noble/secp256k1'; +import assert from 'assert'; +import * as dotenv from 'dotenv'; +import fs from 'fs/promises'; +import path from 'path'; + +dotenv.config(); + +/** + * Create a private key to be used for signing of the registry.json. + */ +async function main() { + const force = process.argv.includes('-f') || process.argv.includes('--force'); + const privateKeyPath = process.env.PRIVATE_KEY_PATH; + const publicKeyPath = process.env.PUBLIC_KEY_PATH; + + assert(privateKeyPath !== undefined, 'PRIVATE_KEY_PATH must be set.'); + assert(publicKeyPath !== undefined, 'PUBLIC_KEY_PATH must be set.'); + + const privateKeyBytes = secp256k1.utils.randomPrivateKey(); + const publicKey = bytesToHex(secp256k1.getPublicKey(privateKeyBytes, true)); + + console.log(`Key "${publicKey}" created.`); + + const privateKeyHex = bytesToHex(privateKeyBytes); + + try { + // Write to file only if it doesn't exist. + const flag = force ? 'w' : 'wx'; + + await Promise.all([ + fs.writeFile(privateKeyPath, `${privateKeyHex}\n`, { + flag, + }), + fs.writeFile(publicKeyPath, `${publicKey}\n`, { + flag, + }), + ]); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') && + error.code === 'EEXIST' + ) { + console.error( + `File ${ + hasProperty(error, 'path') ? String(error.path) : '(unknown path)' + } already exists. Use --force to overwrite.`, + ); + + // eslint-disable-next-line node/no-process-exit + process.exit(1); + } + throw error; + } + + console.log(`Successfully wrote to files. + -> Private key: ${path.resolve(privateKeyPath)} + -> Public key: ${path.resolve(publicKeyPath)}`); +} +main().catch((error) => { + throw error; +}); diff --git a/scripts/sign-registry.ts b/scripts/sign-registry.ts new file mode 100644 index 00000000..bc91014a --- /dev/null +++ b/scripts/sign-registry.ts @@ -0,0 +1,90 @@ +import { + add0x, + assert, + bytesToHex, + hexToBytes, + isHexString, +} from '@metamask/utils'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import * as dotenv from 'dotenv'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { format } from 'prettier'; + +dotenv.config(); + +/** + * Get the private key from an environment variable. Either PRIVATE_KEY_PATH or + * REGISTRY_PRIVATE_KEY must be set. + * + * @returns The private key. + * @throws If neither environment variable is set, or if the private key cannot + * be read from the file. + */ +async function getPrivateKey() { + const privateKeyPath = process.env.PRIVATE_KEY_PATH; + const privateKeyEnv = process.env.REGISTRY_PRIVATE_KEY; + + if (privateKeyPath) { + console.log('Using key from PRIVATE_KEY_PATH file.'); + return await fs.readFile(privateKeyPath, 'utf-8').then((key) => key.trim()); + } + + if (privateKeyEnv) { + console.log('Using key from REGISTRY_PRIVATE_KEY variable.'); + return privateKeyEnv; + } + + throw new Error( + 'Either PRIVATE_KEY_PATH or REGISTRY_PRIVATE_KEY environment variable must be set.', + ); +} + +/** + * Signs the registry with the given private key. + */ +async function main() { + const registryPath = process.env.REGISTRY_PATH; + const signaturePath = process.env.SIGNATURE_PATH; + + assert( + registryPath !== undefined, + 'REGISTRY_PATH environment variable must be set.', + ); + assert( + signaturePath !== undefined, + 'SIGNATURE_PATH environment variable must be set.', + ); + + const privateKey = await getPrivateKey(); + + assert(isHexString(privateKey), 'Private key must be a hex string.'); + const privateKeyBytes = hexToBytes(privateKey); + assert(privateKeyBytes.length === 32, 'Private key must be 32 bytes'); + const publicKey = bytesToHex(secp256k1.getPublicKey(privateKeyBytes)); + + const registry = await fs.readFile(registryPath); + + const signature = add0x( + secp256k1.sign(sha256(registry), privateKeyBytes).toDERHex(), + ); + + const signatureObject = format( + JSON.stringify({ + signature, + curve: 'secp256k1', + format: 'DER', + }), + { filepath: path.resolve(process.cwd(), signaturePath) }, + ); + + await fs.writeFile(signaturePath, signatureObject, 'utf-8'); + console.log( + `Signature signed using "${publicKey}" and written to "${signaturePath}".`, + ); +} + +main().catch((error) => { + throw error; +}); diff --git a/scripts/verify-registry.ts b/scripts/verify-registry.ts new file mode 100644 index 00000000..1e5066aa --- /dev/null +++ b/scripts/verify-registry.ts @@ -0,0 +1,70 @@ +import { assert, assertStruct, isHexString } from '@metamask/utils'; +import * as dotenv from 'dotenv'; +import { promises as fs } from 'fs'; + +import { SignatureStruct, verify } from '../src'; + +dotenv.config(); + +/** + * Get the private key from an environment variable. Either PRIVATE_KEY_PATH or + * REGISTRY_PRIVATE_KEY must be set. + * + * @returns The private key. + * @throws If neither environment variable is set, or if the private key cannot + * be read from the file. + */ +async function getPublicKey() { + const publicKeyPath = process.env.PUBLIC_KEY_PATH; + const registryPublicKey = process.env.REGISTRY_PUBLIC_KEY; + + if (publicKeyPath) { + console.log('Using key from PUBLIC_KEY_PATH file.'); + return await fs.readFile(publicKeyPath, 'utf-8').then((key) => key.trim()); + } + + if (registryPublicKey) { + console.log('Using key from REGISTRY_PUBLIC_KEY variable.'); + return registryPublicKey; + } + + throw new Error( + 'Either PRIVATE_KEY_PATH or REGISTRY_PRIVATE_KEY environment variable must be set.', + ); +} + +/** + * Verify the signature of the registry. + */ +async function main() { + const registryPath = process.env.REGISTRY_PATH; + const signaturePath = process.env.SIGNATURE_PATH; + + assert( + registryPath !== undefined, + 'REGISTRY_PATH environment variable must be set.', + ); + assert( + signaturePath !== undefined, + 'SIGNATURE_PATH environment variable must be set.', + ); + + const publicKey = await getPublicKey(); + assert(isHexString(publicKey), 'Public key must be a hex string.'); + + const signature = JSON.parse(await fs.readFile(signaturePath, 'utf-8')); + assertStruct(signature, SignatureStruct); + const registry = await fs.readFile(registryPath, 'utf-8'); + + const isValid = await verify({ registry, signature, publicKey }); + if (!isValid) { + console.error('Signature is invalid.'); + // eslint-disable-next-line node/no-process-exit + process.exit(1); + } + console.log('Signature is valid.'); +} + +main().catch((error) => { + throw error; +}); diff --git a/secp256k1-key.pub b/secp256k1-key.pub new file mode 100644 index 00000000..e6760a38 --- /dev/null +++ b/secp256k1-key.pub @@ -0,0 +1 @@ +0x025b65308f0f0fb8bc7f7ff87bfc296e0330eee5d3c1d1ee4a048b2fd6a86fa0a6 diff --git a/src/index.ts b/src/index.ts index 860c1783..78a69e02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,3 +47,5 @@ export const SnapsRegistryDatabaseStruct = object({ }); export type SnapsRegistryDatabase = Infer; + +export * from './verify'; diff --git a/src/verify.test.ts b/src/verify.test.ts new file mode 100644 index 00000000..7815b62c --- /dev/null +++ b/src/verify.test.ts @@ -0,0 +1,52 @@ +import { verify } from './verify'; + +const MOCK_REGISTRY = `test\n`; +const MOCK_PUBLIC_KEY = + '0x0351e33621fd89183c3db90db7db2a518a91ad0534d1345d031625d33e581e495a'; +const MOCK_SIGNATURE = { + signature: + '0x304402206d433e9172960de6717d94ae263e47eefacd3584a3274a452f8f9567b3a797db02201b2e423188fb3f9daa6ce6a8723f69df26bd3ceeee81f77250526b91e093614f', + curve: 'secp256k1' as const, + format: 'DER' as const, +}; + +describe('verify', () => { + it('verifies a valid signature', async () => { + expect( + await verify({ + registry: MOCK_REGISTRY, + signature: MOCK_SIGNATURE, + publicKey: MOCK_PUBLIC_KEY, + }), + ).toBe(true); + }); + + it('rejects an invalid signature', async () => { + expect( + await verify({ + registry: MOCK_REGISTRY, + signature: { + ...MOCK_SIGNATURE, + signature: + '0x304502210098f4864a1b13199ee1c925963ea323bead8f58d649b46a2810a630ed3ea916b902207566bb6280dbc3fa48c0eecb6978101e4a23f2f4c8ead6c73ea594ec3954900d', + }, + publicKey: MOCK_PUBLIC_KEY, + }), + ).toBe(false); + }); + + it('throws an error if the signature format is invalid', async () => { + await expect( + verify({ + registry: MOCK_REGISTRY, + signature: { + // @ts-expect-error: Invalid signature format. + foo: 'bar', + }, + publicKey: MOCK_PUBLIC_KEY, + }), + ).rejects.toThrow( + 'Invalid signature object: At path: signature -- Expected a string, but received: undefined.', + ); + }); +}); diff --git a/src/verify.ts b/src/verify.ts new file mode 100644 index 00000000..6d450974 --- /dev/null +++ b/src/verify.ts @@ -0,0 +1,53 @@ +import { + remove0x, + stringToBytes, + Hex, + assertStruct, + hexToBytes, +} from '@metamask/utils'; +import { + verify as nobleVerify, + utils, + Signature as NobleSignature, +} from '@noble/secp256k1'; +import { Infer, literal, object, pattern, string } from 'superstruct'; + +export const SignatureStruct = object({ + signature: pattern(string(), /0x[0-9a-f]{140}/u), + curve: literal('secp256k1'), + format: literal('DER'), +}); + +type Signature = Infer; + +type VerifyArgs = { + registry: string; + signature: Signature; + publicKey: Hex; +}; + +/** + * Verifies that the Snap Registry is properly signed using a cryptographic key. + * + * @param options - Parameters for signing. + * @param options.registry - Raw text of the registry.json file. + * @param options.signature - Hex-encoded encoded signature. + * @param options.publicKey - Hex-encoded or Uint8Array public key to compare + * the signature to. + * @returns Whether the signature is valid. + */ +export async function verify({ + registry, + signature, + publicKey, +}: VerifyArgs): Promise { + assertStruct(signature, SignatureStruct, 'Invalid signature object'); + + const publicKeyBytes = hexToBytes(publicKey); + + return nobleVerify( + NobleSignature.fromHex(remove0x(signature.signature)), + await utils.sha256(stringToBytes(registry)), + publicKeyBytes, + ); +} diff --git a/yarn.lock b/yarn.lock index 75251ab6..c0b0a4cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1232,10 +1232,14 @@ __metadata: "@metamask/eslint-config-nodejs": ^11.1.0 "@metamask/eslint-config-typescript": ^11.1.0 "@metamask/utils": ^5.0.0 + "@noble/curves": ^0.9.0 + "@noble/hashes": ^1.3.0 + "@noble/secp256k1": ^1.7.1 "@types/jest": ^28.1.6 "@types/node": ^17.0.23 "@typescript-eslint/eslint-plugin": ^5.43.0 "@typescript-eslint/parser": ^5.43.0 + dotenv: ^16.0.3 eslint: ^8.27.0 eslint-config-prettier: ^8.5.0 eslint-plugin-import: ^2.26.0 @@ -1268,6 +1272,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:^0.9.0": + version: 0.9.0 + resolution: "@noble/curves@npm:0.9.0" + dependencies: + "@noble/hashes": 1.3.0 + checksum: c927b3591bb604e6020efef0d0f17c844a8744a7dd22fa276e6d14cf4394fee2060597a19ad3663baed82c031a97249f6ca669723f3743a08e3946bd1015abd9 + languageName: node + linkType: hard + "@noble/hashes@npm:1.2.0, @noble/hashes@npm:~1.2.0": version: 1.2.0 resolution: "@noble/hashes@npm:1.2.0" @@ -1275,7 +1288,14 @@ __metadata: languageName: node linkType: hard -"@noble/secp256k1@npm:1.7.1, @noble/secp256k1@npm:~1.7.0": +"@noble/hashes@npm:1.3.0, @noble/hashes@npm:^1.3.0": + version: 1.3.0 + resolution: "@noble/hashes@npm:1.3.0" + checksum: d7ddb6d7c60f1ce1f87facbbef5b724cdea536fc9e7f59ae96e0fc9de96c8f1a2ae2bdedbce10f7dcc621338dfef8533daa73c873f2b5c87fa1a4e05a95c2e2e + languageName: node + linkType: hard + +"@noble/secp256k1@npm:1.7.1, @noble/secp256k1@npm:^1.7.1, @noble/secp256k1@npm:~1.7.0": version: 1.7.1 resolution: "@noble/secp256k1@npm:1.7.1" checksum: d2301f1f7690368d8409a3152450458f27e54df47e3f917292de3de82c298770890c2de7c967d237eff9c95b70af485389a9695f73eb05a43e2bd562d18b18cb @@ -2656,6 +2676,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.0.3": + version: 16.0.3 + resolution: "dotenv@npm:16.0.3" + checksum: afcf03f373d7a6d62c7e9afea6328e62851d627a4e73f2e12d0a8deae1cd375892004f3021883f8aec85932cd2834b091f568ced92b4774625b321db83b827f8 + languageName: node + linkType: hard + "ecc-jsbn@npm:~0.1.1": version: 0.1.2 resolution: "ecc-jsbn@npm:0.1.2"