Skip to content

Commit 1c623e7

Browse files
fix: use node crypto for ed25519 signing and verification (libp2p#289)
* fix: use node crypto for ed25519 signing and verification In node, use the node crypto module for ed25519 signing/verification now that it's in all LTS releases. Browsers still use the pure-js `@noble/ed25519` implementation. Before: ``` @libp2p/crypto x 484 ops/sec ±0.34% (90 runs sampled) ``` After: ``` @libp2p/crypto x 4,706 ops/sec ±0.81% (84 runs sampled) ``` * chore: pr comments Co-authored-by: Marin Petrunić <[email protected]> * chore: avoid array copy * chore: replace all .slice with .subarray Co-authored-by: Marin Petrunić <[email protected]>
1 parent d1d0f41 commit 1c623e7

12 files changed

+189
-67
lines changed

benchmark/ed25519/index.cjs renamed to benchmark/ed25519/index.js

+25-26
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
/* eslint-disable no-console */
22
// @ts-expect-error types are missing
3-
const forge = require('node-forge/lib/forge')
4-
const Benchmark = require('benchmark')
5-
const native = require('ed25519')
6-
const noble = require('@noble/ed25519')
3+
import forge from 'node-forge/lib/forge.js'
4+
import Benchmark from 'benchmark'
5+
import native from 'ed25519'
6+
import * as noble from '@noble/ed25519'
7+
import 'node-forge/lib/ed25519.js'
8+
import stable from '@stablelib/ed25519'
9+
import supercopWasm from 'supercop.wasm'
10+
import ed25519WasmPro from 'ed25519-wasm-pro'
11+
import * as libp2pCrypto from '../../dist/src/index.js'
12+
713
const { randomBytes } = noble.utils
8-
const { subtle } = require('crypto').webcrypto
9-
require('node-forge/lib/ed25519')
10-
const stable = require('@stablelib/ed25519')
11-
const supercopWasm = require('supercop.wasm')
12-
const ed25519WasmPro = require('ed25519-wasm-pro')
1314

1415
const suite = new Benchmark.Suite('ed25519 implementations')
1516

17+
suite.add('@libp2p/crypto', async (d) => {
18+
const message = Buffer.from('hello world ' + Math.random())
19+
20+
const key = await libp2pCrypto.keys.generateKeyPair('Ed25519')
21+
22+
const signature = await key.sign(message)
23+
const res = await key.public.verify(message, signature)
24+
25+
if (!res) {
26+
throw new Error('could not verify @libp2p/crypto signature')
27+
}
28+
29+
d.resolve()
30+
}, { defer: true })
31+
1632
suite.add('@noble/ed25519', async (d) => {
1733
const message = Buffer.from('hello world ' + Math.random())
1834
const privateKey = noble.utils.randomPrivateKey()
@@ -96,23 +112,6 @@ suite.add('ed25519 (native module)', async (d) => {
96112
d.resolve()
97113
}, { defer: true })
98114

99-
suite.add('node.js web-crypto', async (d) => {
100-
const message = Buffer.from('hello world ' + Math.random())
101-
102-
const key = await subtle.generateKey({
103-
name: 'NODE-ED25519',
104-
namedCurve: 'NODE-ED25519'
105-
}, true, ['sign', 'verify'])
106-
const signature = await subtle.sign('NODE-ED25519', key.privateKey, message)
107-
const res = await subtle.verify('NODE-ED25519', key.publicKey, signature, message)
108-
109-
if (!res) {
110-
throw new Error('could not verify node.js signature')
111-
}
112-
113-
d.resolve()
114-
}, { defer: true })
115-
116115
async function main () {
117116
await Promise.all([
118117
new Promise((resolve) => {

benchmark/ed25519/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "libp2p-crypto-ed25519-benchmarks",
33
"version": "0.0.0",
44
"private": true,
5+
"type": "module",
56
"scripts": {
67
"start": "node .",
78
"compat": "node compat.js"

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@
201201
"./dist/src/ciphers/aes-gcm.js": "./dist/src/ciphers/aes-gcm.browser.js",
202202
"./dist/src/hmac/index.js": "./dist/src/hmac/index-browser.js",
203203
"./dist/src/keys/ecdh.js": "./dist/src/keys/ecdh-browser.js",
204+
"./dist/src/keys/ed25519.js": "./dist/src/keys/ed25519-browser.js",
204205
"./dist/src/keys/rsa.js": "./dist/src/keys/rsa-browser.js"
205206
}
206207
}

src/ciphers/aes-gcm.browser.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ export function create (opts?: CreateOptions) {
4646
* the encryption cipher.
4747
*/
4848
async function decrypt (data: Uint8Array, password: string | Uint8Array) {
49-
const salt = data.slice(0, saltLength)
50-
const nonce = data.slice(saltLength, saltLength + nonceLength)
51-
const ciphertext = data.slice(saltLength + nonceLength)
49+
const salt = data.subarray(0, saltLength)
50+
const nonce = data.subarray(saltLength, saltLength + nonceLength)
51+
const ciphertext = data.subarray(saltLength + nonceLength)
5252
const aesGcm = { name: algorithm, iv: nonce }
5353

5454
if (typeof password === 'string') {

src/ciphers/aes-gcm.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ export function create (opts?: CreateOptions) {
5555
*/
5656
async function decryptWithKey (ciphertextAndNonce: Uint8Array, key: Uint8Array) { // eslint-disable-line require-await
5757
// Create Uint8Arrays of nonce, ciphertext and tag.
58-
const nonce = ciphertextAndNonce.slice(0, nonceLength)
59-
const ciphertext = ciphertextAndNonce.slice(nonceLength, ciphertextAndNonce.length - algorithmTagLength)
60-
const tag = ciphertextAndNonce.slice(ciphertext.length + nonceLength)
58+
const nonce = ciphertextAndNonce.subarray(0, nonceLength)
59+
const ciphertext = ciphertextAndNonce.subarray(nonceLength, ciphertextAndNonce.length - algorithmTagLength)
60+
const tag = ciphertextAndNonce.subarray(ciphertext.length + nonceLength)
6161

6262
// Create the cipher instance.
6363
const cipher = crypto.createDecipheriv(algorithm, key, nonce)
@@ -79,8 +79,8 @@ export function create (opts?: CreateOptions) {
7979
*/
8080
async function decrypt (data: Uint8Array, password: string | Uint8Array) { // eslint-disable-line require-await
8181
// Create Uint8Arrays of salt and ciphertextAndNonce.
82-
const salt = data.slice(0, saltLength)
83-
const ciphertextAndNonce = data.slice(saltLength)
82+
const salt = data.subarray(0, saltLength)
83+
const ciphertextAndNonce = data.subarray(saltLength)
8484

8585
if (typeof password === 'string') {
8686
password = uint8ArrayFromString(password)

src/keys/ecdh-browser.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,15 @@ function unmarshalPublicKey (curve: string, key: Uint8Array) {
118118

119119
const byteLen = curveLengths[curve]
120120

121-
if (!uint8ArrayEquals(key.slice(0, 1), Uint8Array.from([4]))) {
121+
if (!uint8ArrayEquals(key.subarray(0, 1), Uint8Array.from([4]))) {
122122
throw errcode(new Error('Cannot unmarshal public key - invalid key format'), 'ERR_INVALID_KEY_FORMAT')
123123
}
124124

125125
return {
126126
kty: 'EC',
127127
crv: curve,
128-
x: uint8ArrayToString(key.slice(1, byteLen + 1), 'base64url'),
129-
y: uint8ArrayToString(key.slice(1 + byteLen), 'base64url'),
128+
x: uint8ArrayToString(key.subarray(1, byteLen + 1), 'base64url'),
129+
y: uint8ArrayToString(key.subarray(1 + byteLen), 'base64url'),
130130
ext: true
131131
}
132132
}

src/keys/ed25519-browser.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as ed from '@noble/ed25519'
2+
3+
const PUBLIC_KEY_BYTE_LENGTH = 32
4+
const PRIVATE_KEY_BYTE_LENGTH = 64 // private key is actually 32 bytes but for historical reasons we concat private and public keys
5+
const KEYS_BYTE_LENGTH = 32
6+
7+
export { PUBLIC_KEY_BYTE_LENGTH as publicKeyLength }
8+
export { PRIVATE_KEY_BYTE_LENGTH as privateKeyLength }
9+
10+
export async function generateKey () {
11+
// the actual private key (32 bytes)
12+
const privateKeyRaw = ed.utils.randomPrivateKey()
13+
const publicKey = await ed.getPublicKey(privateKeyRaw)
14+
15+
// concatenated the public key to the private key
16+
const privateKey = concatKeys(privateKeyRaw, publicKey)
17+
18+
return {
19+
privateKey,
20+
publicKey
21+
}
22+
}
23+
24+
/**
25+
* Generate keypair from a 32 byte uint8array
26+
*/
27+
export async function generateKeyFromSeed (seed: Uint8Array) {
28+
if (seed.length !== KEYS_BYTE_LENGTH) {
29+
throw new TypeError('"seed" must be 32 bytes in length.')
30+
} else if (!(seed instanceof Uint8Array)) {
31+
throw new TypeError('"seed" must be a node.js Buffer, or Uint8Array.')
32+
}
33+
34+
// based on node forges algorithm, the seed is used directly as private key
35+
const privateKeyRaw = seed
36+
const publicKey = await ed.getPublicKey(privateKeyRaw)
37+
38+
const privateKey = concatKeys(privateKeyRaw, publicKey)
39+
40+
return {
41+
privateKey,
42+
publicKey
43+
}
44+
}
45+
46+
export async function hashAndSign (privateKey: Uint8Array, msg: Uint8Array) {
47+
const privateKeyRaw = privateKey.subarray(0, KEYS_BYTE_LENGTH)
48+
49+
return await ed.sign(msg, privateKeyRaw)
50+
}
51+
52+
export async function hashAndVerify (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array) {
53+
return await ed.verify(sig, msg, publicKey)
54+
}
55+
56+
function concatKeys (privateKeyRaw: Uint8Array, publicKey: Uint8Array) {
57+
const privateKey = new Uint8Array(PRIVATE_KEY_BYTE_LENGTH)
58+
for (let i = 0; i < KEYS_BYTE_LENGTH; i++) {
59+
privateKey[i] = privateKeyRaw[i]
60+
privateKey[KEYS_BYTE_LENGTH + i] = publicKey[i]
61+
}
62+
return privateKey
63+
}

src/keys/ed25519-class.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,14 @@ export function unmarshalEd25519PrivateKey (bytes: Uint8Array) {
110110
// Try the old, redundant public key version
111111
if (bytes.length > crypto.privateKeyLength) {
112112
bytes = ensureKey(bytes, crypto.privateKeyLength + crypto.publicKeyLength)
113-
const privateKeyBytes = bytes.slice(0, crypto.privateKeyLength)
114-
const publicKeyBytes = bytes.slice(crypto.privateKeyLength, bytes.length)
113+
const privateKeyBytes = bytes.subarray(0, crypto.privateKeyLength)
114+
const publicKeyBytes = bytes.subarray(crypto.privateKeyLength, bytes.length)
115115
return new Ed25519PrivateKey(privateKeyBytes, publicKeyBytes)
116116
}
117117

118118
bytes = ensureKey(bytes, crypto.privateKeyLength)
119-
const privateKeyBytes = bytes.slice(0, crypto.privateKeyLength)
120-
const publicKeyBytes = bytes.slice(crypto.publicKeyLength)
119+
const privateKeyBytes = bytes.subarray(0, crypto.privateKeyLength)
120+
const publicKeyBytes = bytes.subarray(crypto.publicKeyLength)
121121
return new Ed25519PrivateKey(privateKeyBytes, publicKeyBytes)
122122
}
123123

src/keys/ed25519.ts

+77-19
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,38 @@
1-
import * as ed from '@noble/ed25519'
1+
import crypto from 'crypto'
2+
import { promisify } from 'util'
3+
import { toString as uint8arrayToString } from 'uint8arrays/to-string'
4+
import { fromString as uint8arrayFromString } from 'uint8arrays/from-string'
5+
6+
const keypair = promisify(crypto.generateKeyPair)
27

38
const PUBLIC_KEY_BYTE_LENGTH = 32
49
const PRIVATE_KEY_BYTE_LENGTH = 64 // private key is actually 32 bytes but for historical reasons we concat private and public keys
510
const KEYS_BYTE_LENGTH = 32
11+
const SIGNATURE_BYTE_LENGTH = 64
612

713
export { PUBLIC_KEY_BYTE_LENGTH as publicKeyLength }
814
export { PRIVATE_KEY_BYTE_LENGTH as privateKeyLength }
915

16+
function derivePublicKey (privateKey: Uint8Array) {
17+
const hash = crypto.createHash('sha512')
18+
hash.update(privateKey)
19+
return hash.digest().subarray(32)
20+
}
21+
1022
export async function generateKey () {
11-
// the actual private key (32 bytes)
12-
const privateKeyRaw = ed.utils.randomPrivateKey()
13-
const publicKey = await ed.getPublicKey(privateKeyRaw)
23+
const key = await keypair('ed25519', {
24+
publicKeyEncoding: { type: 'spki', format: 'jwk' },
25+
privateKeyEncoding: { type: 'pkcs8', format: 'jwk' }
26+
})
1427

15-
// concatenated the public key to the private key
16-
const privateKey = concatKeys(privateKeyRaw, publicKey)
28+
// @ts-expect-error node types are missing jwk as a format
29+
const privateKeyRaw = uint8arrayFromString(key.privateKey.d, 'base64url')
30+
// @ts-expect-error node types are missing jwk as a format
31+
const publicKeyRaw = uint8arrayFromString(key.privateKey.x, 'base64url')
1732

1833
return {
19-
privateKey,
20-
publicKey
34+
privateKey: concatKeys(privateKeyRaw, publicKeyRaw),
35+
publicKey: publicKeyRaw
2136
}
2237
}
2338

@@ -32,25 +47,68 @@ export async function generateKeyFromSeed (seed: Uint8Array) {
3247
}
3348

3449
// based on node forges algorithm, the seed is used directly as private key
35-
const privateKeyRaw = seed
36-
const publicKey = await ed.getPublicKey(privateKeyRaw)
37-
38-
const privateKey = concatKeys(privateKeyRaw, publicKey)
50+
const publicKeyRaw = derivePublicKey(seed)
3951

4052
return {
41-
privateKey,
42-
publicKey
53+
privateKey: concatKeys(seed, publicKeyRaw),
54+
publicKey: publicKeyRaw
4355
}
4456
}
4557

46-
export async function hashAndSign (privateKey: Uint8Array, msg: Uint8Array) {
47-
const privateKeyRaw = privateKey.slice(0, KEYS_BYTE_LENGTH)
58+
export async function hashAndSign (key: Uint8Array, msg: Uint8Array) {
59+
if (!(key instanceof Uint8Array)) {
60+
throw new TypeError('"key" must be a node.js Buffer, or Uint8Array.')
61+
}
62+
63+
let privateKey: Uint8Array
64+
let publicKey: Uint8Array
65+
66+
if (key.byteLength === PRIVATE_KEY_BYTE_LENGTH) {
67+
privateKey = key.subarray(0, 32)
68+
publicKey = key.subarray(32)
69+
} else if (key.byteLength === KEYS_BYTE_LENGTH) {
70+
privateKey = key.subarray(0, 32)
71+
publicKey = derivePublicKey(privateKey)
72+
} else {
73+
throw new TypeError('"key" must be 64 or 32 bytes in length.')
74+
}
75+
76+
const obj = crypto.createPrivateKey({
77+
format: 'jwk',
78+
key: {
79+
crv: 'Ed25519',
80+
d: uint8arrayToString(privateKey, 'base64url'),
81+
x: uint8arrayToString(publicKey, 'base64url'),
82+
kty: 'OKP'
83+
}
84+
})
4885

49-
return await ed.sign(msg, privateKeyRaw)
86+
return crypto.sign(null, msg, obj)
5087
}
5188

52-
export async function hashAndVerify (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array) {
53-
return await ed.verify(sig, msg, publicKey)
89+
export async function hashAndVerify (key: Uint8Array, sig: Uint8Array, msg: Uint8Array) {
90+
if (key.byteLength !== PUBLIC_KEY_BYTE_LENGTH) {
91+
throw new TypeError('"key" must be 32 bytes in length.')
92+
} else if (!(key instanceof Uint8Array)) {
93+
throw new TypeError('"key" must be a node.js Buffer, or Uint8Array.')
94+
}
95+
96+
if (sig.byteLength !== SIGNATURE_BYTE_LENGTH) {
97+
throw new TypeError('"sig" must be 64 bytes in length.')
98+
} else if (!(sig instanceof Uint8Array)) {
99+
throw new TypeError('"sig" must be a node.js Buffer, or Uint8Array.')
100+
}
101+
102+
const obj = crypto.createPublicKey({
103+
format: 'jwk',
104+
key: {
105+
crv: 'Ed25519',
106+
x: uint8arrayToString(key, 'base64url'),
107+
kty: 'OKP'
108+
}
109+
})
110+
111+
return crypto.verify(null, msg, obj, sig)
54112
}
55113

56114
function concatKeys (privateKeyRaw: Uint8Array, publicKey: Uint8Array) {

src/keys/key-stretcher.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@ export async function keyStretcher (cipherType: 'AES-128' | 'AES-256' | 'Blowfis
6161

6262
const half = resultLength / 2
6363
const resultBuffer = uint8ArrayConcat(result)
64-
const r1 = resultBuffer.slice(0, half)
65-
const r2 = resultBuffer.slice(half, resultLength)
64+
const r1 = resultBuffer.subarray(0, half)
65+
const r2 = resultBuffer.subarray(half, resultLength)
6666

6767
const createKey = (res: Uint8Array) => ({
68-
iv: res.slice(0, ivSize),
69-
cipherKey: res.slice(ivSize, ivSize + cipherKeySize),
70-
macKey: res.slice(ivSize + cipherKeySize)
68+
iv: res.subarray(0, ivSize),
69+
cipherKey: res.subarray(ivSize, ivSize + cipherKeySize),
70+
macKey: res.subarray(ivSize + cipherKeySize)
7171
})
7272

7373
return {

src/util.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function bigIntegerToUintBase64url (num: { abs: () => any}, len?: number)
1414
// byte if the most significant bit of the number is 1:
1515
// https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-integer
1616
// Our number will always be positive so we should remove the leading padding.
17-
buf = buf[0] === 0 ? buf.slice(1) : buf
17+
buf = buf[0] === 0 ? buf.subarray(1) : buf
1818

1919
if (len != null) {
2020
if (buf.length > len) throw new Error('byte array longer than desired length')

test/keys/ed25519.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ describe('ed25519', function () {
173173
const key = await crypto.keys.unmarshalPrivateKey(fixtures.redundantPubKey.privateKey)
174174
const bytes = key.marshal()
175175
expect(bytes.length).to.equal(64)
176-
expect(bytes.slice(32)).to.eql(key.public.marshal())
176+
expect(bytes.subarray(32)).to.eql(key.public.marshal())
177177
})
178178

179179
it('verifies with data from go with redundant public key', async () => {

0 commit comments

Comments
 (0)