From 01c66f7e89b67d9d5103366e0e026072509be203 Mon Sep 17 00:00:00 2001 From: F Date: Tue, 26 Oct 2021 16:58:26 +1100 Subject: [PATCH] Add ENR with multiaddr Imported from https://github.com/D4nte/discv5/pull/1 Replaced bcrypto backend with secp256k1. --- .cspell.json | 8 +- package-lock.json | 141 +++++++++ package.json | 6 + src/lib/enr/constants.ts | 10 + src/lib/enr/create.ts | 10 + src/lib/enr/enr.spec.ts | 363 +++++++++++++++++++++++ src/lib/enr/enr.ts | 477 ++++++++++++++++++++++++++++++ src/lib/enr/index.ts | 6 + src/lib/enr/keypair/constants.ts | 2 + src/lib/enr/keypair/index.spec.ts | 40 +++ src/lib/enr/keypair/index.ts | 68 +++++ src/lib/enr/keypair/secp256k1.ts | 79 +++++ src/lib/enr/keypair/types.ts | 61 ++++ src/lib/enr/types.ts | 11 + src/lib/enr/v4.ts | 75 +++++ 15 files changed, 1356 insertions(+), 1 deletion(-) create mode 100644 src/lib/enr/constants.ts create mode 100644 src/lib/enr/create.ts create mode 100644 src/lib/enr/enr.spec.ts create mode 100644 src/lib/enr/enr.ts create mode 100644 src/lib/enr/index.ts create mode 100644 src/lib/enr/keypair/constants.ts create mode 100644 src/lib/enr/keypair/index.spec.ts create mode 100644 src/lib/enr/keypair/index.ts create mode 100644 src/lib/enr/keypair/secp256k1.ts create mode 100644 src/lib/enr/keypair/types.ts create mode 100644 src/lib/enr/types.ts create mode 100644 src/lib/enr/v4.ts diff --git a/.cspell.json b/.cspell.json index 0018ca4d89..70f59b46ac 100644 --- a/.cspell.json +++ b/.cspell.json @@ -18,10 +18,13 @@ "dependabot", "dingpu", "Dlazy", + "dnsaddr", "Dout", "Dscore", "ecies", "editorconfig", + "ENR", + "ENRs", "ephem", "esnext", "ethersproject", @@ -53,6 +56,7 @@ "multiaddrs", "multicodecs", "mplex", + "multihashes", "muxed", "muxer", "mvps", @@ -82,6 +86,7 @@ "untracked", "upgrader", "vacp", + "varint", "waku", "wakuv", "wakunode", @@ -99,6 +104,7 @@ "node_modules/**", "build", "gen", - "proto" + "proto", + "*.spec.ts" ] } diff --git a/package-lock.json b/package-lock.json index 310d0da9f0..fe48a1bf30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,14 @@ "requires": true, "packages": { "": { + "name": "js-waku", "version": "0.14.2", "license": "MIT OR Apache-2.0", "dependencies": { "@chainsafe/libp2p-noise": "^4.1.1", "axios": "^0.21.1", + "base64url": "^3.0.1", + "bigint-buffer": "^1.1.5", "debug": "^4.3.1", "ecies-geth": "^1.5.2", "it-concat": "^2.0.0", @@ -21,7 +24,9 @@ "libp2p-mplex": "^0.10.4", "libp2p-websockets": "^0.16.1", "multiaddr": "^10.0.1", + "multihashes": "^4.0.3", "protobufjs": "^6.8.8", + "rlp": "^2.2.7", "secp256k1": "^4.0.2", "uuid": "^8.3.2" }, @@ -34,6 +39,7 @@ "@types/secp256k1": "^4.0.2", "@types/tail": "^2.0.0", "@types/uuid": "^8.3.0", + "@types/varint": "^6.0.0", "@typescript-eslint/eslint-plugin": "^4.0.1", "@typescript-eslint/parser": "^4.0.1", "app-root-path": "^3.0.0", @@ -69,6 +75,7 @@ "typedoc": "^0.22.10", "typedoc-plugin-no-inherit": "^1.3.1", "typescript": "^4.0.2", + "varint": "^6.0.0", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, @@ -3031,6 +3038,15 @@ "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", "dev": true }, + "node_modules/@types/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-2jBazyxGl4644tvu3VAez8UA/AtrcEetT9HOeAbqZ/vAcRVL/ZDFQjSS7rkWusU5cyONQVUz+nwwrNZdMva4ow==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -4118,6 +4134,14 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -4135,6 +4159,18 @@ "node": ">=0.6" } }, + "node_modules/bigint-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", + "integrity": "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.3.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/bignumber.js": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz", @@ -11331,6 +11367,33 @@ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.6.tgz", "integrity": "sha512-ngZRO82P7mPvw/3gu5NQ2QiUJGYTS0LAxvQnEAlWCJakvn7YpK2VAd9JWM5oosYUeqoVbkylH/FsqRc4fc2+ag==" }, + "node_modules/multihashes": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/multihashes/-/multihashes-4.0.3.tgz", + "integrity": "sha512-0AhMH7Iu95XjDLxIeuCOOE4t9+vQZsACyKZ9Fxw2pcsRmlX4iCn1mby0hS0bb+nQOVpdQYWPpnyusw4da5RPhA==", + "dependencies": { + "multibase": "^4.0.1", + "uint8arrays": "^3.0.0", + "varint": "^5.0.2" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/multihashes/node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/multihashes/node_modules/varint": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", + "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==" + }, "node_modules/multistream-select": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/multistream-select/-/multistream-select-2.0.0.tgz", @@ -13886,6 +13949,22 @@ "inherits": "^2.0.1" } }, + "node_modules/rlp": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/rlp/-/rlp-2.2.7.tgz", + "integrity": "sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ==", + "dependencies": { + "bn.js": "^5.2.0" + }, + "bin": { + "rlp": "bin/rlp" + } + }, + "node_modules/rlp/node_modules/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==" + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -19548,6 +19627,15 @@ "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", "dev": true }, + "@types/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-2jBazyxGl4644tvu3VAez8UA/AtrcEetT9HOeAbqZ/vAcRVL/ZDFQjSS7rkWusU5cyONQVUz+nwwrNZdMva4ow==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -20391,6 +20479,11 @@ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", "dev": true }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -20405,6 +20498,14 @@ "integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==", "peer": true }, + "bigint-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", + "integrity": "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==", + "requires": { + "bindings": "^1.3.0" + } + }, "bignumber.js": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz", @@ -26172,6 +26273,31 @@ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.6.tgz", "integrity": "sha512-ngZRO82P7mPvw/3gu5NQ2QiUJGYTS0LAxvQnEAlWCJakvn7YpK2VAd9JWM5oosYUeqoVbkylH/FsqRc4fc2+ag==" }, + "multihashes": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/multihashes/-/multihashes-4.0.3.tgz", + "integrity": "sha512-0AhMH7Iu95XjDLxIeuCOOE4t9+vQZsACyKZ9Fxw2pcsRmlX4iCn1mby0hS0bb+nQOVpdQYWPpnyusw4da5RPhA==", + "requires": { + "multibase": "^4.0.1", + "uint8arrays": "^3.0.0", + "varint": "^5.0.2" + }, + "dependencies": { + "uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "requires": { + "multiformats": "^9.4.2" + } + }, + "varint": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", + "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==" + } + } + }, "multistream-select": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/multistream-select/-/multistream-select-2.0.0.tgz", @@ -28124,6 +28250,21 @@ "inherits": "^2.0.1" } }, + "rlp": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/rlp/-/rlp-2.2.7.tgz", + "integrity": "sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ==", + "requires": { + "bn.js": "^5.2.0" + }, + "dependencies": { + "bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==" + } + } + }, "rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", diff --git a/package.json b/package.json index fec35a2e41..b10401e250 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "dependencies": { "@chainsafe/libp2p-noise": "^4.1.1", "axios": "^0.21.1", + "base64url": "^3.0.1", + "bigint-buffer": "^1.1.5", "debug": "^4.3.1", "ecies-geth": "^1.5.2", "it-concat": "^2.0.0", @@ -66,7 +68,9 @@ "libp2p-mplex": "^0.10.4", "libp2p-websockets": "^0.16.1", "multiaddr": "^10.0.1", + "multihashes": "^4.0.3", "protobufjs": "^6.8.8", + "rlp": "^2.2.7", "secp256k1": "^4.0.2", "uuid": "^8.3.2" }, @@ -79,6 +83,7 @@ "@types/secp256k1": "^4.0.2", "@types/tail": "^2.0.0", "@types/uuid": "^8.3.0", + "@types/varint": "^6.0.0", "@typescript-eslint/eslint-plugin": "^4.0.1", "@typescript-eslint/parser": "^4.0.1", "app-root-path": "^3.0.0", @@ -114,6 +119,7 @@ "typedoc": "^0.22.10", "typedoc-plugin-no-inherit": "^1.3.1", "typescript": "^4.0.2", + "varint": "^6.0.0", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, diff --git a/src/lib/enr/constants.ts b/src/lib/enr/constants.ts new file mode 100644 index 0000000000..de6970a605 --- /dev/null +++ b/src/lib/enr/constants.ts @@ -0,0 +1,10 @@ +// Maximum encoded size of an ENR +export const MAX_RECORD_SIZE = 300; + +export const ERR_INVALID_ID = 'Invalid record id'; + +export const ERR_NO_SIGNATURE = 'No valid signature found'; + +// The maximum length of byte size of a multiaddr to encode in the `multiaddr` field +// The size is a big endian 16-bit unsigned integer +export const MULTIADDR_LENGTH_SIZE = 2; diff --git a/src/lib/enr/create.ts b/src/lib/enr/create.ts new file mode 100644 index 0000000000..e0d170d5e4 --- /dev/null +++ b/src/lib/enr/create.ts @@ -0,0 +1,10 @@ +import { bufToHex } from '../utils'; + +import { NodeId } from './types'; + +export function createNodeId(buffer: Buffer): NodeId { + if (buffer.length !== 32) { + throw new Error('NodeId must be 32 bytes in length'); + } + return bufToHex(buffer); +} diff --git a/src/lib/enr/enr.spec.ts b/src/lib/enr/enr.spec.ts new file mode 100644 index 0000000000..0283f314d1 --- /dev/null +++ b/src/lib/enr/enr.spec.ts @@ -0,0 +1,363 @@ +import { assert, expect } from 'chai'; +import { Multiaddr } from 'multiaddr'; +import PeerId from 'peer-id'; + +import { bufToHex } from '../utils'; + +import { ERR_INVALID_ID } from './constants'; +import { ENR } from './enr'; +import { createKeypairFromPeerId } from './keypair'; + +import { v4 } from './index'; + +describe('ENR', function () { + describe('decodeTxt', () => { + it('should encodeTxt and decodeTxt', async () => { + const peerId = await PeerId.create({ keyType: 'secp256k1' }); + const enr = ENR.createFromPeerId(peerId); + const keypair = createKeypairFromPeerId(peerId); + enr.setLocationMultiaddr(new Multiaddr('/ip4/18.223.219.100/udp/9000')); + enr.multiaddrs = [ + new Multiaddr( + '/dns4/node-01.do-ams3.wakuv2.test.statusim.net/tcp/443/wss' + ), + new Multiaddr( + '/dns6/node-01.ac-cn-hongkong-c.wakuv2.test.statusim.net/tcp/443/wss' + ), + new Multiaddr( + '/onion3/vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd:1234/wss' + ), + ]; + const txt = enr.encodeTxt(keypair.privateKey); + expect(txt.slice(0, 4)).to.be.equal('enr:'); + const enr2 = ENR.decodeTxt(txt); + expect(bufToHex(enr2.signature as Buffer)).to.be.equal( + bufToHex(enr.signature as Buffer) + ); + const multiaddr = enr2.getLocationMultiaddr('udp')!; + expect(multiaddr.toString()).to.be.equal('/ip4/18.223.219.100/udp/9000'); + expect(enr2.multiaddrs).to.not.be.undefined; + expect(enr2.multiaddrs!.length).to.be.equal(3); + const multiaddrsAsStr = enr2.multiaddrs!.map((ma) => ma.toString()); + expect(multiaddrsAsStr).to.include( + '/dns4/node-01.do-ams3.wakuv2.test.statusim.net/tcp/443/wss' + ); + expect(multiaddrsAsStr).to.include( + '/dns6/node-01.ac-cn-hongkong-c.wakuv2.test.statusim.net/tcp/443/wss' + ); + expect(multiaddrsAsStr).to.include( + '/onion3/vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd:1234/wss' + ); + }); + + it('should decode valid enr successfully', () => { + const txt = + 'enr:-Ku4QMh15cIjmnq-co5S3tYaNXxDzKTgj0ufusA-QfZ66EWHNsULt2kb0eTHoo1Dkjvvf6CAHDS1Di-htjiPFZzaIPcLh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD2d10HAAABE________x8AgmlkgnY0gmlwhHZFkMSJc2VjcDI1NmsxoQIWSDEWdHwdEA3Lw2B_byeFQOINTZ0GdtF9DBjes6JqtIN1ZHCCIyg'; + const enr = ENR.decodeTxt(txt); + const eth2 = enr.get('eth2') as Buffer; + expect(eth2).to.not.be.undefined; + expect(bufToHex(eth2)).to.be.equal('f6775d0700000113ffffffffffff1f00'); + }); + + it('should decode valid enr with tcp successfully', async () => { + const txt = + 'enr:-IS4QAmC_o1PMi5DbR4Bh4oHVyQunZblg4bTaottPtBodAhJZvxVlWW-4rXITPNg4mwJ8cW__D9FBDc9N4mdhyMqB-EBgmlkgnY0gmlwhIbRi9KJc2VjcDI1NmsxoQOevTdO6jvv3fRruxguKR-3Ge4bcFsLeAIWEDjrfaigNoN0Y3CCdl8'; + const enr = ENR.decodeTxt(txt); + expect(enr.tcp).to.not.be.undefined; + expect(enr.tcp).to.be.equal(30303); + expect(enr.ip).to.not.be.undefined; + expect(enr.ip).to.be.equal('134.209.139.210'); + expect(enr.publicKey).to.not.be.undefined; + const peerId = await enr.peerId(); + expect(peerId.toB58String()).to.be.equal( + '16Uiu2HAmPLe7Mzm8TsYUubgCAW1aJoeFScxrLj8ppHFivPo97bUZ' + ); + }); + + it('should throw error - no id', () => { + try { + const txt = Buffer.from( + '656e723a2d435972595a62404b574342526c4179357a7a61445a584a42476b636e68344d486342465a6e75584e467264764a6a5830346a527a6a7a', + 'hex' + ).toString(); + ENR.decodeTxt(txt); + assert.fail('Expect error here'); + } catch (err) { + expect(err.message).to.be.equal(ERR_INVALID_ID); + } + }); + + it('should throw error - no public key', () => { + try { + const txt = + 'enr:-IS4QJ2d11eu6dC7E7LoXeLMgMP3kom1u3SE8esFSWvaHoo0dP1jg8O3-nx9ht-EO3CmG7L6OkHcMmoIh00IYWB92QABgmlkgnY0gmlwhH8AAAGJc2d11eu6dCsxoQIB_c-jQMOXsbjWkbN-kj99H57gfId5pfb4wa1qxwV4CIN1ZHCCIyk'; + ENR.decodeTxt(txt); + assert.fail('Expect error here'); + } catch (err) { + expect(err.message).to.be.equal('Failed to verify enr: No public key'); + } + }); + }); + + describe('verify', () => { + it('should throw error - no id', () => { + try { + const enr = new ENR({}, BigInt(0), Buffer.alloc(0)); + enr.verify(Buffer.alloc(0), Buffer.alloc(0)); + assert.fail('Expect error here'); + } catch (err) { + expect(err.message).to.be.equal(ERR_INVALID_ID); + } + }); + + it('should throw error - invalid id', () => { + try { + const enr = new ENR( + { id: Buffer.from('v3') }, + BigInt(0), + Buffer.alloc(0) + ); + enr.verify(Buffer.alloc(0), Buffer.alloc(0)); + assert.fail('Expect error here'); + } catch (err) { + expect(err.message).to.be.equal(ERR_INVALID_ID); + } + }); + + it('should throw error - no public key', () => { + try { + const enr = new ENR( + { id: Buffer.from('v4') }, + BigInt(0), + Buffer.alloc(0) + ); + enr.verify(Buffer.alloc(0), Buffer.alloc(0)); + assert.fail('Expect error here'); + } catch (err) { + expect(err.message).to.be.equal('Failed to verify enr: No public key'); + } + }); + + it('should return false', () => { + const txt = + 'enr:-Ku4QMh15cIjmnq-co5S3tYaNXxDzKTgj0ufusA-QfZ66EWHNsULt2kb0eTHoo1Dkjvvf6CAHDS1Di-htjiPFZzaIPcLh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD2d10HAAABE________x8AgmlkgnY0gmlwhHZFkMSJc2VjcDI1NmsxoQIWSDEWdHwdEA3Lw2B_byeFQOINTZ0GdtF9DBjes6JqtIN1ZHCCIyg'; + const enr = ENR.decodeTxt(txt); + // should have id and public key inside ENR + expect(enr.verify(Buffer.alloc(32), Buffer.alloc(64))).to.be.false; + }); + }); + + describe('Fuzzing testcases', () => { + it('should throw error in invalid signature', () => { + const buf = Buffer.from( + '656e723a2d4b7634514147774f54385374716d7749354c486149796d494f346f6f464b664e6b456a576130663150384f73456c67426832496a622d4772445f2d623957346b6350466377796e354845516d526371584e716470566f3168656f42683246306447356c64484f494141414141414141414143455a58526f4d704141414141414141414141505f5f5f5f5f5f5f5f5f5f676d6c6b676e5930676d6c7768424c663232534a6332566a634449314e6d73786f514a78436e4536765f7832656b67595f756f45317274777a76477934306d7139654436365866485042576749494e315a48437f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f434436410d0a', + 'hex' + ).toString(); + try { + ENR.decodeTxt(buf); + } catch (e) { + expect(e.message).to.equal( + 'Decoded ENR invalid signature: must be a byte array' + ); + } + }); + it('should throw error in invalid sequence number', () => { + const buf = Buffer.from( + '656e723a2d495334514b6b33ff583945717841337838334162436979416e537550444d764b353264433530486d31584744643574457951684d3356634a4c2d5062446b44673541507a5f706f76763022d48dcf992d5379716b306e616e636f4e572d656e7263713042676d6c6b676e5930676d6c77684838414141474a6332566a634449314e6d73786f514d31453579557370397638516a397476335a575843766146427672504e647a384b5049314e68576651577a494e315a4843434239410a', + 'hex' + ).toString(); + try { + ENR.decodeTxt(buf); + } catch (e) { + expect(e.message).to.equal( + 'Decoded ENR invalid sequence number: must be a byte array' + ); + } + }); + }); + + describe('Static tests', () => { + let privateKey: Buffer; + let record: ENR; + + beforeEach(() => { + const seq = 1n; + privateKey = Buffer.from( + 'b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291', + 'hex' + ); + record = ENR.createV4(v4.publicKey(privateKey)); + record.set('ip', Buffer.from('7f000001', 'hex')); + record.set('udp', Buffer.from((30303).toString(16), 'hex')); + record.seq = seq; + }); + + it('should properly compute the node id', () => { + expect(record.nodeId).to.equal( + 'a448f24c6d18e575453db13171562b71999873db5b286df957af199ec94617f7' + ); + }); + + it('should encode/decode to RLP encoding', () => { + const decoded = ENR.decode(record.encode(privateKey)); + expect(decoded).to.deep.equal(record); + }); + + it('should encode/decode to text encoding', () => { + // spec enr https://eips.ethereum.org/EIPS/eip-778 + const testTxt = + 'enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8'; + const decoded = ENR.decodeTxt(testTxt); + expect(decoded.udp).to.be.equal(30303); + expect(decoded.ip).to.be.equal('127.0.0.1'); + expect(decoded).to.deep.equal(record); + expect(record.encodeTxt(privateKey)).to.equal(testTxt); + }); + }); + + describe('Multiformats support', () => { + let privateKey: Buffer; + let record: ENR; + + beforeEach(() => { + privateKey = Buffer.from( + 'b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291', + 'hex' + ); + record = ENR.createV4(v4.publicKey(privateKey)); + }); + + it('should get / set UDP multiaddr', () => { + const multi0 = new Multiaddr('/ip4/127.0.0.1/udp/30303'); + const tuples0 = multi0.tuples(); + + if (!tuples0[0][1] || !tuples0[1][1]) { + throw new Error('invalid multiaddr'); + } + // set underlying records + record.set('ip', tuples0[0][1]); + record.set('udp', tuples0[1][1]); + // and get the multiaddr + expect(record.getLocationMultiaddr('udp')!.toString()).to.equal( + multi0.toString() + ); + // set the multiaddr + const multi1 = new Multiaddr('/ip4/0.0.0.0/udp/30300'); + record.setLocationMultiaddr(multi1); + // and get the multiaddr + expect(record.getLocationMultiaddr('udp')!.toString()).to.equal( + multi1.toString() + ); + // and get the underlying records + const tuples1 = multi1.tuples(); + expect(record.get('ip')).to.deep.equal(tuples1[0][1]); + expect(record.get('udp')).to.deep.equal(tuples1[1][1]); + }); + + it('should get / set TCP multiaddr', () => { + const multi0 = new Multiaddr('/ip4/127.0.0.1/tcp/30303'); + const tuples0 = multi0.tuples(); + + if (!tuples0[0][1] || !tuples0[1][1]) { + throw new Error('invalid multiaddr'); + } + + // set underlying records + record.set('ip', tuples0[0][1]); + record.set('tcp', tuples0[1][1]); + // and get the multiaddr + expect(record.getLocationMultiaddr('tcp')!.toString()).to.equal( + multi0.toString() + ); + // set the multiaddr + const multi1 = new Multiaddr('/ip4/0.0.0.0/tcp/30300'); + record.setLocationMultiaddr(multi1); + // and get the multiaddr + expect(record.getLocationMultiaddr('tcp')!.toString()).to.equal( + multi1.toString() + ); + // and get the underlying records + const tuples1 = multi1.tuples(); + expect(record.get('ip')).to.deep.equal(tuples1[0][1]); + expect(record.get('tcp')).to.deep.equal(tuples1[1][1]); + }); + + describe('location multiaddr', async () => { + const ip4 = '127.0.0.1'; + const ip6 = '::1'; + const tcp = 8080; + const udp = 8080; + + const peerId = await PeerId.create({ keyType: 'secp256k1' }); + const enr = ENR.createFromPeerId(peerId); + enr.ip = ip4; + enr.ip6 = ip6; + enr.tcp = tcp; + enr.udp = udp; + enr.tcp6 = tcp; + enr.udp6 = udp; + + it('should properly create location multiaddrs - udp4', () => { + expect(enr.getLocationMultiaddr('udp4')).to.deep.equal( + new Multiaddr(`/ip4/${ip4}/udp/${udp}`) + ); + }); + + it('should properly create location multiaddrs - tcp4', () => { + expect(enr.getLocationMultiaddr('tcp4')).to.deep.equal( + new Multiaddr(`/ip4/${ip4}/tcp/${tcp}`) + ); + }); + + it('should properly create location multiaddrs - udp6', () => { + expect(enr.getLocationMultiaddr('udp6')).to.deep.equal( + new Multiaddr(`/ip6/${ip6}/udp/${udp}`) + ); + }); + + it('should properly create location multiaddrs - tcp6', () => { + expect(enr.getLocationMultiaddr('tcp6')).to.deep.equal( + new Multiaddr(`/ip6/${ip6}/tcp/${tcp}`) + ); + }); + + it('should properly create location multiaddrs - udp', () => { + // default to ip4 + expect(enr.getLocationMultiaddr('udp')).to.deep.equal( + new Multiaddr(`/ip4/${ip4}/udp/${udp}`) + ); + // if ip6 is set, use it + enr.ip = undefined; + expect(enr.getLocationMultiaddr('udp')).to.deep.equal( + new Multiaddr(`/ip6/${ip6}/udp/${udp}`) + ); + // if ip6 does not exist, use ip4 + enr.ip6 = undefined; + enr.ip = ip4; + expect(enr.getLocationMultiaddr('udp')).to.deep.equal( + new Multiaddr(`/ip4/${ip4}/udp/${udp}`) + ); + enr.ip6 = ip6; + }); + + it('should properly create location multiaddrs - tcp', () => { + // default to ip4 + expect(enr.getLocationMultiaddr('tcp')).to.deep.equal( + new Multiaddr(`/ip4/${ip4}/tcp/${tcp}`) + ); + // if ip6 is set, use it + enr.ip = undefined; + expect(enr.getLocationMultiaddr('tcp')).to.deep.equal( + new Multiaddr(`/ip6/${ip6}/tcp/${tcp}`) + ); + // if ip6 does not exist, use ip4 + enr.ip6 = undefined; + enr.ip = ip4; + expect(enr.getLocationMultiaddr('tcp')).to.deep.equal( + new Multiaddr(`/ip4/${ip4}/tcp/${tcp}`) + ); + enr.ip6 = ip6; + }); + }); + }); +}); diff --git a/src/lib/enr/enr.ts b/src/lib/enr/enr.ts new file mode 100644 index 0000000000..b8e807fe82 --- /dev/null +++ b/src/lib/enr/enr.ts @@ -0,0 +1,477 @@ +import base64url from 'base64url'; +import { toBigIntBE } from 'bigint-buffer'; +import { Multiaddr, protocols } from 'multiaddr'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: No types available +import muConvert from 'multiaddr/src/convert'; +import PeerId from 'peer-id'; +import * as RLP from 'rlp'; +import { encode as varintEncode } from 'varint'; + +import { + ERR_INVALID_ID, + ERR_NO_SIGNATURE, + MAX_RECORD_SIZE, + MULTIADDR_LENGTH_SIZE, +} from './constants'; +import { + createKeypair, + createKeypairFromPeerId, + createPeerIdFromKeypair, + IKeypair, + KeypairType, +} from './keypair'; +import { ENRKey, ENRValue, NodeId, SequenceNumber } from './types'; +import * as v4 from './v4'; + +export class ENR extends Map { + public seq: SequenceNumber; + public signature: Buffer | null; + + constructor( + kvs: Record = {}, + seq: SequenceNumber = 1n, + signature: Buffer | null = null + ) { + super(Object.entries(kvs)); + this.seq = seq; + this.signature = signature; + } + + static createV4(publicKey: Buffer, kvs: Record = {}): ENR { + return new ENR({ + ...kvs, + id: Buffer.from('v4'), + secp256k1: publicKey, + }); + } + + static createFromPeerId( + peerId: PeerId, + kvs: Record = {} + ): ENR { + const keypair = createKeypairFromPeerId(peerId); + switch (keypair.type) { + case KeypairType.secp256k1: + return ENR.createV4(keypair.publicKey, kvs); + default: + throw new Error(); + } + } + + static decodeFromValues(decoded: Buffer[]): ENR { + if (!Array.isArray(decoded)) { + throw new Error('Decoded ENR must be an array'); + } + if (decoded.length % 2 !== 0) { + throw new Error('Decoded ENR must have an even number of elements'); + } + const [signature, seq, ...kvs] = decoded; + if (!signature || Array.isArray(signature)) { + throw new Error('Decoded ENR invalid signature: must be a byte array'); + } + if (!seq || Array.isArray(seq)) { + throw new Error( + 'Decoded ENR invalid sequence number: must be a byte array' + ); + } + const obj: Record = {}; + for (let i = 0; i < kvs.length; i += 2) { + obj[kvs[i].toString()] = Buffer.from(kvs[i + 1]); + } + const enr = new ENR(obj, toBigIntBE(seq), signature); + console.log('signature', signature.length, signature); + + if (!enr.verify(RLP.encode([seq, ...kvs]), signature)) { + throw new Error('Unable to verify ENR signature'); + } + return enr; + } + + static decode(encoded: Buffer): ENR { + const decoded = RLP.decode(encoded) as unknown as Buffer[]; + return ENR.decodeFromValues(decoded); + } + + static decodeTxt(encoded: string): ENR { + if (!encoded.startsWith('enr:')) { + throw new Error("string encoded ENR must start with 'enr:'"); + } + return ENR.decode(base64url.toBuffer(encoded.slice(4))); + } + + set(k: ENRKey, v: ENRValue): this { + this.signature = null; + this.seq++; + return super.set(k, v); + } + + get id(): string { + const id = this.get('id') as Buffer; + if (!id) throw new Error('id not found.'); + return id.toString('utf8'); + } + + get keypairType(): KeypairType { + switch (this.id) { + case 'v4': + return KeypairType.secp256k1; + default: + throw new Error(ERR_INVALID_ID); + } + } + + get publicKey(): Buffer { + switch (this.id) { + case 'v4': + return this.get('secp256k1') as Buffer; + default: + throw new Error(ERR_INVALID_ID); + } + } + + get keypair(): IKeypair { + return createKeypair(this.keypairType, undefined, this.publicKey); + } + + async peerId(): Promise { + return createPeerIdFromKeypair(this.keypair); + } + + get nodeId(): NodeId { + switch (this.id) { + case 'v4': + return v4.nodeId(this.publicKey); + default: + throw new Error(ERR_INVALID_ID); + } + } + + get ip(): string | undefined { + const raw = this.get('ip'); + if (raw) { + return muConvert.toString(protocols.names.ip4.code, raw) as string; + } else { + return undefined; + } + } + + set ip(ip: string | undefined) { + if (ip) { + this.set('ip', muConvert.toBytes(protocols.names.ip4.code, ip)); + } else { + this.delete('ip'); + } + } + + get tcp(): number | undefined { + const raw = this.get('tcp'); + if (raw) { + return Number(muConvert.toString(protocols.names.tcp.code, raw)); + } else { + return undefined; + } + } + + set tcp(port: number | undefined) { + if (port === undefined) { + this.delete('tcp'); + } else { + this.set('tcp', muConvert.toBytes(protocols.names.tcp.code, port)); + } + } + + get udp(): number | undefined { + const raw = this.get('udp'); + if (raw) { + return Number(muConvert.toString(protocols.names.udp.code, raw)); + } else { + return undefined; + } + } + + set udp(port: number | undefined) { + if (port === undefined) { + this.delete('udp'); + } else { + this.set('udp', muConvert.toBytes(protocols.names.udp.code, port)); + } + } + + get ip6(): string | undefined { + const raw = this.get('ip6'); + if (raw) { + return muConvert.toString(protocols.names.ip6.code, raw) as string; + } else { + return undefined; + } + } + + set ip6(ip: string | undefined) { + if (ip) { + this.set('ip6', muConvert.toBytes(protocols.names.ip6.code, ip)); + } else { + this.delete('ip6'); + } + } + + get tcp6(): number | undefined { + const raw = this.get('tcp6'); + if (raw) { + return Number(muConvert.toString(protocols.names.tcp.code, raw)); + } else { + return undefined; + } + } + + set tcp6(port: number | undefined) { + if (port === undefined) { + this.delete('tcp6'); + } else { + this.set('tcp6', muConvert.toBytes(protocols.names.tcp.code, port)); + } + } + + get udp6(): number | undefined { + const raw = this.get('udp6'); + if (raw) { + return Number(muConvert.toString(protocols.names.udp.code, raw)); + } else { + return undefined; + } + } + + set udp6(port: number | undefined) { + if (port === undefined) { + this.delete('udp6'); + } else { + this.set('udp6', muConvert.toBytes(protocols.names.udp.code, port)); + } + } + + /** + * Get the `multiaddrs` field from ENR. + * + * This field is used to store multiaddresses that cannot be stored with the current ENR pre-defined keys. + * These can be a multiaddresses that include encapsulation (e.g. wss) or do not use `ip4` nor `ip6` for the host + * address (e.g. `dns4`, `dnsaddr`, etc).. + * + * If the peer information only contains information that can be represented with the ENR pre-defined keys + * (ip, tcp, etc) then the usage of [[getLocationMultiaddr]] should be preferred. + * + * The multiaddresses stored in this field are expected to be location multiaddresses, ie, peer id less. + */ + get multiaddrs(): Multiaddr[] | undefined { + const raw = this.get('multiaddrs'); + + if (raw) { + const multiaddrs = []; + + try { + let index = 0; + + while (index < raw.length) { + const sizeBytes = raw.slice(index, index + 2); + const size = Buffer.from(sizeBytes).readUInt16BE(0); + + const multiaddrBytes = raw.slice( + index + MULTIADDR_LENGTH_SIZE, + index + size + MULTIADDR_LENGTH_SIZE + ); + const multiaddr = new Multiaddr(multiaddrBytes); + + multiaddrs.push(multiaddr); + index += size + MULTIADDR_LENGTH_SIZE; + } + } catch (e) { + throw new Error('Invalid value in multiaddrs field'); + } + return multiaddrs; + } else { + return undefined; + } + } + + /** + * Set the `multiaddrs` field on the ENR. + * + * This field is used to store multiaddresses that cannot be stored with the current ENR pre-defined keys. + * These can be a multiaddresses that include encapsulation (e.g. wss) or do not use `ip4` nor `ip6` for the host + * address (e.g. `dns4`, `dnsaddr`, etc).. + * + * If the peer information only contains information that can be represented with the ENR pre-defined keys + * (ip, tcp, etc) then the usage of [[setLocationMultiaddr]] should be preferred. + * + * The multiaddresses stored in this field must to be location multiaddresses, ie, peer id less. + */ + set multiaddrs(multiaddrs: Multiaddr[] | undefined) { + if (multiaddrs === undefined) { + this.delete('multiaddrs'); + } else { + let multiaddrsBuf = Buffer.from([]); + + multiaddrs.forEach((multiaddr) => { + if (multiaddr.getPeerId()) + throw new Error('`multiaddr` field MUST not contain peer id'); + + const bytes = multiaddr.bytes; + + let buf = Buffer.alloc(2); + + // Prepend the size of the next entry + const written = buf.writeUInt16BE(bytes.length, 0); + + if (written !== MULTIADDR_LENGTH_SIZE) { + throw new Error( + `Internal error: unsigned 16-bit integer was not written in ${MULTIADDR_LENGTH_SIZE} bytes` + ); + } + + buf = Buffer.concat([buf, bytes]); + + multiaddrsBuf = Buffer.concat([multiaddrsBuf, buf]); + }); + + this.set('multiaddrs', multiaddrsBuf); + } + } + + getLocationMultiaddr( + protocol: 'udp' | 'udp4' | 'udp6' | 'tcp' | 'tcp4' | 'tcp6' + ): Multiaddr | undefined { + if (protocol === 'udp') { + return ( + this.getLocationMultiaddr('udp4') || this.getLocationMultiaddr('udp6') + ); + } + if (protocol === 'tcp') { + return ( + this.getLocationMultiaddr('tcp4') || this.getLocationMultiaddr('tcp6') + ); + } + const isIpv6 = protocol.endsWith('6'); + const ipVal = this.get(isIpv6 ? 'ip6' : 'ip'); + if (!ipVal) { + return undefined; + } + + const isUdp = protocol.startsWith('udp'); + const isTcp = protocol.startsWith('tcp'); + let protoName, protoVal; + if (isUdp) { + protoName = 'udp'; + protoVal = isIpv6 ? this.get('udp6') : this.get('udp'); + } else if (isTcp) { + protoName = 'tcp'; + protoVal = isIpv6 ? this.get('tcp6') : this.get('tcp'); + } else { + return undefined; + } + if (!protoVal) { + return undefined; + } + + // Create raw multiaddr buffer + // multiaddr length is: + // 1 byte for the ip protocol (ip4 or ip6) + // N bytes for the ip address + // 1 or 2 bytes for the protocol as buffer (tcp or udp) + // 2 bytes for the port + const ipMa = protocols.names[isIpv6 ? 'ip6' : 'ip4']; + const ipByteLen = ipMa.size / 8; + const protoMa = protocols.names[protoName]; + const protoBuf = varintEncode(protoMa.code); + const maBuf = new Uint8Array(3 + ipByteLen + protoBuf.length); + maBuf[0] = ipMa.code; + maBuf.set(ipVal, 1); + maBuf.set(protoBuf, 1 + ipByteLen); + maBuf.set(protoVal, 1 + ipByteLen + protoBuf.length); + + return new Multiaddr(maBuf); + } + setLocationMultiaddr(multiaddr: Multiaddr): void { + const protoNames = multiaddr.protoNames(); + if ( + protoNames.length !== 2 && + protoNames[1] !== 'udp' && + protoNames[1] !== 'tcp' + ) { + throw new Error('Invalid multiaddr'); + } + const tuples = multiaddr.tuples(); + if (!tuples[0][1] || !tuples[1][1]) { + throw new Error('Invalid multiaddr'); + } + + // IPv4 + if (tuples[0][0] === 4) { + this.set('ip', tuples[0][1]); + this.set(protoNames[1], tuples[1][1]); + } else { + this.set('ip6', tuples[0][1]); + this.set(protoNames[1] + '6', tuples[1][1]); + } + } + + async getFullMultiaddr( + protocol: 'udp' | 'udp4' | 'udp6' | 'tcp' | 'tcp4' | 'tcp6' + ): Promise { + const locationMultiaddr = this.getLocationMultiaddr(protocol); + if (locationMultiaddr) { + const peerId = await this.peerId(); + return locationMultiaddr.encapsulate(`/p2p/${peerId.toB58String()}`); + } + return; + } + + verify(data: Buffer, signature: Buffer): boolean { + if (!this.get('id') || this.id !== 'v4') { + throw new Error(ERR_INVALID_ID); + } + if (!this.publicKey) { + throw new Error('Failed to verify enr: No public key'); + } + return v4.verify(this.publicKey, data, signature); + } + + sign(data: Buffer, privateKey: Buffer): Buffer { + switch (this.id) { + case 'v4': + this.signature = v4.sign(privateKey, data); + break; + default: + throw new Error(ERR_INVALID_ID); + } + return this.signature; + } + + encodeToValues(privateKey?: Buffer): (ENRKey | ENRValue | number)[] { + // sort keys and flatten into [k, v, k, v, ...] + const content: Array = Array.from(this.keys()) + .sort((a, b) => a.localeCompare(b)) + .map((k) => [k, this.get(k)] as [ENRKey, ENRValue]) + .flat(); + content.unshift(Number(this.seq)); + if (privateKey) { + content.unshift(this.sign(RLP.encode(content), privateKey)); + } else { + if (!this.signature) { + throw new Error(ERR_NO_SIGNATURE); + } + content.unshift(this.signature); + } + return content; + } + + encode(privateKey?: Buffer): Buffer { + const encoded = RLP.encode(this.encodeToValues(privateKey)); + if (encoded.length >= MAX_RECORD_SIZE) { + throw new Error('ENR must be less than 300 bytes'); + } + return encoded; + } + + encodeTxt(privateKey?: Buffer): string { + return 'enr:' + base64url.encode(this.encode(privateKey)); + } +} diff --git a/src/lib/enr/index.ts b/src/lib/enr/index.ts new file mode 100644 index 0000000000..79f8330afc --- /dev/null +++ b/src/lib/enr/index.ts @@ -0,0 +1,6 @@ +import * as v4Crypto from './v4'; +export const v4 = v4Crypto; +export * from './constants'; +export * from './enr'; +export * from './types'; +export * from './create'; diff --git a/src/lib/enr/keypair/constants.ts b/src/lib/enr/keypair/constants.ts new file mode 100644 index 0000000000..7c0d2fa341 --- /dev/null +++ b/src/lib/enr/keypair/constants.ts @@ -0,0 +1,2 @@ +export const ERR_TYPE_NOT_IMPLEMENTED = 'Keypair type not implemented'; +export const ERR_INVALID_KEYPAIR_TYPE = 'Invalid keypair type'; diff --git a/src/lib/enr/keypair/index.spec.ts b/src/lib/enr/keypair/index.spec.ts new file mode 100644 index 0000000000..df60cfebc9 --- /dev/null +++ b/src/lib/enr/keypair/index.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { keys } from 'libp2p-crypto'; +import PeerId from 'peer-id'; + +import { + AbstractKeypair, + createPeerIdFromKeypair, + generateKeypair, + KeypairType, +} from './index'; + +const { supportedKeys } = keys; + +describe('createPeerIdFromKeypair', function () { + it('should properly create a PeerId from a secp256k1 keypair with private key', async function () { + const keypair = await generateKeypair(KeypairType.secp256k1); + const privKey = new supportedKeys.secp256k1.Secp256k1PrivateKey( + keypair.privateKey, + keypair.publicKey + ); + + const expectedPeerId = await PeerId.createFromPrivKey(privKey.bytes); + const actualPeerId = await createPeerIdFromKeypair(keypair); + + expect(actualPeerId).to.be.deep.equal(expectedPeerId); + }); + + it('should properly create a PeerId from a secp256k1 keypair without private key', async function () { + const keypair = await generateKeypair(KeypairType.secp256k1); + delete (keypair as AbstractKeypair)._privateKey; + const pubKey = new supportedKeys.secp256k1.Secp256k1PublicKey( + keypair.publicKey + ); + + const expectedPeerId = await PeerId.createFromPubKey(pubKey.bytes); + const actualPeerId = await createPeerIdFromKeypair(keypair); + + expect(actualPeerId).to.be.deep.equal(expectedPeerId); + }); +}); diff --git a/src/lib/enr/keypair/index.ts b/src/lib/enr/keypair/index.ts new file mode 100644 index 0000000000..91b4e5a509 --- /dev/null +++ b/src/lib/enr/keypair/index.ts @@ -0,0 +1,68 @@ +import { keys } from 'libp2p-crypto'; +import mh from 'multihashes'; +import PeerId from 'peer-id'; + +const { keysPBM, supportedKeys } = keys; + +import { ERR_TYPE_NOT_IMPLEMENTED } from './constants'; +import { Secp256k1Keypair } from './secp256k1'; +import { IKeypair, KeypairType } from './types'; + +export * from './types'; +export * from './secp256k1'; + +export async function generateKeypair(type: KeypairType): Promise { + switch (type) { + case KeypairType.secp256k1: + return await Secp256k1Keypair.generate(); + default: + throw new Error(ERR_TYPE_NOT_IMPLEMENTED); + } +} + +export function createKeypair( + type: KeypairType, + privateKey?: Buffer, + publicKey?: Buffer +): IKeypair { + switch (type) { + case KeypairType.secp256k1: + return new Secp256k1Keypair(privateKey, publicKey); + default: + throw new Error(ERR_TYPE_NOT_IMPLEMENTED); + } +} + +export async function createPeerIdFromKeypair( + keypair: IKeypair +): Promise { + switch (keypair.type) { + case KeypairType.secp256k1: { + // manually create a peer id to avoid expensive ops + const privKey = keypair.hasPrivateKey() + ? new supportedKeys.secp256k1.Secp256k1PrivateKey( + keypair.privateKey, + keypair.publicKey + ) + : undefined; + + const pubKey = new supportedKeys.secp256k1.Secp256k1PublicKey( + keypair.publicKey + ); + const id = mh.encode(pubKey.bytes, 'identity'); + return new PeerId(id, privKey, pubKey); + } + default: + throw new Error(ERR_TYPE_NOT_IMPLEMENTED); + } +} + +export function createKeypairFromPeerId(peerId: PeerId): IKeypair { + // pub/private key bytes from peer-id are encoded in protobuf format + const pub = keysPBM.PublicKey.decode(peerId.pubKey.bytes); + return createKeypair( + pub.Type as KeypairType, + peerId.privKey ? Buffer.from(peerId.privKey.marshal()) : undefined, + Buffer.from(pub.Data) + ); +} diff --git a/src/lib/enr/keypair/secp256k1.ts b/src/lib/enr/keypair/secp256k1.ts new file mode 100644 index 0000000000..4de815014b --- /dev/null +++ b/src/lib/enr/keypair/secp256k1.ts @@ -0,0 +1,79 @@ +import { Buffer } from 'buffer'; +import crypto from 'crypto'; + +import * as secp256k1 from 'secp256k1'; + +import { AbstractKeypair, IKeypair, IKeypairClass, KeypairType } from './types'; + +export function secp256k1PublicKeyToCompressed(publicKey: Uint8Array): Buffer { + if (publicKey.length === 64) { + publicKey = Buffer.concat([Buffer.from([4]), publicKey]); + } + return Buffer.from(secp256k1.publicKeyConvert(publicKey, true)); +} + +export function secp256k1PublicKeyToFull(publicKey: Uint8Array): Buffer { + if (publicKey.length === 64) { + return Buffer.concat([Buffer.from([4]), publicKey]); + } + return Buffer.from(secp256k1.publicKeyConvert(publicKey, false)); +} + +export function secp256k1PublicKeyToRaw(publicKey: Uint8Array): Buffer { + return Buffer.from(secp256k1.publicKeyConvert(publicKey, false).slice(1)); +} + +export const Secp256k1Keypair: IKeypairClass = class Secp256k1Keypair + extends AbstractKeypair + implements IKeypair +{ + readonly type: KeypairType; + + constructor(privateKey?: Buffer, publicKey?: Buffer) { + let pub = publicKey; + if (pub) { + pub = secp256k1PublicKeyToCompressed(pub); + } + super(privateKey, pub); + this.type = KeypairType.secp256k1; + } + + static async generate(): Promise { + const privateKey = Buffer.from(await randomBytes(32)); + const publicKey = secp256k1.publicKeyCreate(privateKey); + return new Secp256k1Keypair(privateKey, Buffer.from(publicKey)); + } + + privateKeyVerify(key = this._privateKey): boolean { + if (key) { + return secp256k1.privateKeyVerify(key); + } + return true; + } + + publicKeyVerify(key = this._publicKey): boolean { + if (key) { + return secp256k1.publicKeyVerify(key); + } + return true; + } + + sign(msg: Buffer): Buffer { + const { signature, recid } = secp256k1.ecdsaSign(msg, this.privateKey); + return Buffer.concat([signature, Buffer.from([recid])]); + } + + verify(msg: Buffer, sig: Buffer): boolean { + return secp256k1.ecdsaVerify(sig, msg, this.publicKey); + } +}; + +function randomBytes(length: number): Uint8Array { + if (typeof window !== 'undefined' && window && window.crypto) { + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return array; + } else { + return crypto.randomBytes(length); + } +} diff --git a/src/lib/enr/keypair/types.ts b/src/lib/enr/keypair/types.ts new file mode 100644 index 0000000000..f35601fd44 --- /dev/null +++ b/src/lib/enr/keypair/types.ts @@ -0,0 +1,61 @@ +export enum KeypairType { + rsa = 0, + ed25519 = 1, + secp256k1 = 2, +} + +export interface IKeypair { + type: KeypairType; + privateKey: Buffer; + publicKey: Buffer; + privateKeyVerify(): boolean; + publicKeyVerify(): boolean; + sign(msg: Buffer): Buffer; + verify(msg: Buffer, sig: Buffer): boolean; + hasPrivateKey(): boolean; +} + +export interface IKeypairClass { + new (privateKey?: Buffer, publicKey?: Buffer): IKeypair; + generate(): Promise; +} + +export abstract class AbstractKeypair { + _privateKey?: Buffer; + readonly _publicKey?: Buffer; + + constructor(privateKey?: Buffer, publicKey?: Buffer) { + if ((this._privateKey = privateKey) && !this.privateKeyVerify()) { + throw new Error('Invalid private key'); + } + if ((this._publicKey = publicKey) && !this.publicKeyVerify()) { + throw new Error('Invalid private key'); + } + } + + get privateKey(): Buffer { + if (!this._privateKey) { + throw new Error(); + } + return this._privateKey; + } + + get publicKey(): Buffer { + if (!this._publicKey) { + throw new Error(); + } + return this._publicKey; + } + + privateKeyVerify(): boolean { + return true; + } + + publicKeyVerify(): boolean { + return true; + } + + hasPrivateKey(): boolean { + return Boolean(this._privateKey); + } +} diff --git a/src/lib/enr/types.ts b/src/lib/enr/types.ts new file mode 100644 index 0000000000..00359fb39e --- /dev/null +++ b/src/lib/enr/types.ts @@ -0,0 +1,11 @@ +// Custom and aliased types for ENRs + +/** + * We represent NodeId as a hex string, since node equality is used very heavily + * and it is convenient to index data by NodeId + */ +export type NodeId = string; +export type SequenceNumber = bigint; + +export type ENRKey = string; +export type ENRValue = Uint8Array; diff --git a/src/lib/enr/v4.ts b/src/lib/enr/v4.ts new file mode 100644 index 0000000000..eae00be7f0 --- /dev/null +++ b/src/lib/enr/v4.ts @@ -0,0 +1,75 @@ +import { Buffer } from 'buffer'; +import crypto from 'crypto'; + +import { keccak256 } from 'js-sha3'; +import * as secp256k1 from 'secp256k1'; + +import { createNodeId } from './create'; +import { NodeId } from './types'; + +export function hash(input: Uint8Array): Buffer { + return Buffer.from(keccak256.arrayBuffer(input)); +} + +export async function createPrivateKey(): Promise { + return Buffer.from(await randomBytes(32)); +} + +export function publicKey(privKey: Uint8Array): Buffer { + return Buffer.from(secp256k1.publicKeyCreate(privKey)); +} + +export function sign(privKey: Uint8Array, msg: Uint8Array): Buffer { + const { signature } = secp256k1.ecdsaSign(hash(msg), privKey); + return Buffer.from(signature); +} + +export function verify(pubKey: Buffer, msg: Buffer, sig: Buffer): boolean { + // Remove the recovery id if present (byte #65) + return secp256k1.ecdsaVerify(sig.slice(0, 64), hash(msg), pubKey); +} + +export function nodeId(pubKey: Uint8Array): NodeId { + const uncompressedPubkey = secp256k1.publicKeyConvert(pubKey, false); + + return createNodeId(hash(uncompressedPubkey.slice(1))); +} + +export class ENRKeyPair { + public constructor( + public readonly nodeId: NodeId, + public readonly privateKey: Buffer, + public readonly publicKey: Buffer + ) {} + + public static async create(privateKey?: Buffer): Promise { + if (privateKey) { + if (!secp256k1.privateKeyVerify(privateKey)) { + throw new Error('Invalid private key'); + } + } + const _privateKey = privateKey || (await createPrivateKey()); + const _publicKey = publicKey(_privateKey); + const _nodeId = nodeId(_publicKey); + + return new ENRKeyPair(_nodeId, _privateKey, _publicKey); + } + + public sign(msg: Buffer): Buffer { + return sign(this.privateKey, msg); + } + + public verify(msg: Buffer, sig: Buffer): boolean { + return verify(this.publicKey, msg, sig); + } +} + +function randomBytes(length: number): Uint8Array { + if (typeof window !== 'undefined' && window && window.crypto) { + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return array; + } else { + return crypto.randomBytes(length); + } +}