Skip to content

Commit

Permalink
fix: use node crypto for ed25519 signing and verification (libp2p#289)
Browse files Browse the repository at this point in the history
* 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ć <mpetrunic@users.noreply.github.com>

* chore: avoid array copy

* chore: replace all .slice with .subarray

Co-authored-by: Marin Petrunić <mpetrunic@users.noreply.github.com>
  • Loading branch information
achingbrain and mpetrunic authored Dec 1, 2022
1 parent d1d0f41 commit 1c623e7
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 67 deletions.
51 changes: 25 additions & 26 deletions benchmark/ed25519/index.cjs → benchmark/ed25519/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
/* eslint-disable no-console */
// @ts-expect-error types are missing
const forge = require('node-forge/lib/forge')
const Benchmark = require('benchmark')
const native = require('ed25519')
const noble = require('@noble/ed25519')
import forge from 'node-forge/lib/forge.js'
import Benchmark from 'benchmark'
import native from 'ed25519'
import * as noble from '@noble/ed25519'
import 'node-forge/lib/ed25519.js'
import stable from '@stablelib/ed25519'
import supercopWasm from 'supercop.wasm'
import ed25519WasmPro from 'ed25519-wasm-pro'
import * as libp2pCrypto from '../../dist/src/index.js'

const { randomBytes } = noble.utils
const { subtle } = require('crypto').webcrypto
require('node-forge/lib/ed25519')
const stable = require('@stablelib/ed25519')
const supercopWasm = require('supercop.wasm')
const ed25519WasmPro = require('ed25519-wasm-pro')

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

suite.add('@libp2p/crypto', async (d) => {
const message = Buffer.from('hello world ' + Math.random())

const key = await libp2pCrypto.keys.generateKeyPair('Ed25519')

const signature = await key.sign(message)
const res = await key.public.verify(message, signature)

if (!res) {
throw new Error('could not verify @libp2p/crypto signature')
}

d.resolve()
}, { defer: true })

suite.add('@noble/ed25519', async (d) => {
const message = Buffer.from('hello world ' + Math.random())
const privateKey = noble.utils.randomPrivateKey()
Expand Down Expand Up @@ -96,23 +112,6 @@ suite.add('ed25519 (native module)', async (d) => {
d.resolve()
}, { defer: true })

suite.add('node.js web-crypto', async (d) => {
const message = Buffer.from('hello world ' + Math.random())

const key = await subtle.generateKey({
name: 'NODE-ED25519',
namedCurve: 'NODE-ED25519'
}, true, ['sign', 'verify'])
const signature = await subtle.sign('NODE-ED25519', key.privateKey, message)
const res = await subtle.verify('NODE-ED25519', key.publicKey, signature, message)

if (!res) {
throw new Error('could not verify node.js signature')
}

d.resolve()
}, { defer: true })

async function main () {
await Promise.all([
new Promise((resolve) => {
Expand Down
1 change: 1 addition & 0 deletions benchmark/ed25519/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "libp2p-crypto-ed25519-benchmarks",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node .",
"compat": "node compat.js"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
"./dist/src/ciphers/aes-gcm.js": "./dist/src/ciphers/aes-gcm.browser.js",
"./dist/src/hmac/index.js": "./dist/src/hmac/index-browser.js",
"./dist/src/keys/ecdh.js": "./dist/src/keys/ecdh-browser.js",
"./dist/src/keys/ed25519.js": "./dist/src/keys/ed25519-browser.js",
"./dist/src/keys/rsa.js": "./dist/src/keys/rsa-browser.js"
}
}
6 changes: 3 additions & 3 deletions src/ciphers/aes-gcm.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export function create (opts?: CreateOptions) {
* the encryption cipher.
*/
async function decrypt (data: Uint8Array, password: string | Uint8Array) {
const salt = data.slice(0, saltLength)
const nonce = data.slice(saltLength, saltLength + nonceLength)
const ciphertext = data.slice(saltLength + nonceLength)
const salt = data.subarray(0, saltLength)
const nonce = data.subarray(saltLength, saltLength + nonceLength)
const ciphertext = data.subarray(saltLength + nonceLength)
const aesGcm = { name: algorithm, iv: nonce }

if (typeof password === 'string') {
Expand Down
10 changes: 5 additions & 5 deletions src/ciphers/aes-gcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ export function create (opts?: CreateOptions) {
*/
async function decryptWithKey (ciphertextAndNonce: Uint8Array, key: Uint8Array) { // eslint-disable-line require-await
// Create Uint8Arrays of nonce, ciphertext and tag.
const nonce = ciphertextAndNonce.slice(0, nonceLength)
const ciphertext = ciphertextAndNonce.slice(nonceLength, ciphertextAndNonce.length - algorithmTagLength)
const tag = ciphertextAndNonce.slice(ciphertext.length + nonceLength)
const nonce = ciphertextAndNonce.subarray(0, nonceLength)
const ciphertext = ciphertextAndNonce.subarray(nonceLength, ciphertextAndNonce.length - algorithmTagLength)
const tag = ciphertextAndNonce.subarray(ciphertext.length + nonceLength)

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

if (typeof password === 'string') {
password = uint8ArrayFromString(password)
Expand Down
6 changes: 3 additions & 3 deletions src/keys/ecdh-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,15 @@ function unmarshalPublicKey (curve: string, key: Uint8Array) {

const byteLen = curveLengths[curve]

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

return {
kty: 'EC',
crv: curve,
x: uint8ArrayToString(key.slice(1, byteLen + 1), 'base64url'),
y: uint8ArrayToString(key.slice(1 + byteLen), 'base64url'),
x: uint8ArrayToString(key.subarray(1, byteLen + 1), 'base64url'),
y: uint8ArrayToString(key.subarray(1 + byteLen), 'base64url'),
ext: true
}
}
Expand Down
63 changes: 63 additions & 0 deletions src/keys/ed25519-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as ed from '@noble/ed25519'

const PUBLIC_KEY_BYTE_LENGTH = 32
const PRIVATE_KEY_BYTE_LENGTH = 64 // private key is actually 32 bytes but for historical reasons we concat private and public keys
const KEYS_BYTE_LENGTH = 32

export { PUBLIC_KEY_BYTE_LENGTH as publicKeyLength }
export { PRIVATE_KEY_BYTE_LENGTH as privateKeyLength }

export async function generateKey () {
// the actual private key (32 bytes)
const privateKeyRaw = ed.utils.randomPrivateKey()
const publicKey = await ed.getPublicKey(privateKeyRaw)

// concatenated the public key to the private key
const privateKey = concatKeys(privateKeyRaw, publicKey)

return {
privateKey,
publicKey
}
}

/**
* Generate keypair from a 32 byte uint8array
*/
export async function generateKeyFromSeed (seed: Uint8Array) {
if (seed.length !== KEYS_BYTE_LENGTH) {
throw new TypeError('"seed" must be 32 bytes in length.')
} else if (!(seed instanceof Uint8Array)) {
throw new TypeError('"seed" must be a node.js Buffer, or Uint8Array.')
}

// based on node forges algorithm, the seed is used directly as private key
const privateKeyRaw = seed
const publicKey = await ed.getPublicKey(privateKeyRaw)

const privateKey = concatKeys(privateKeyRaw, publicKey)

return {
privateKey,
publicKey
}
}

export async function hashAndSign (privateKey: Uint8Array, msg: Uint8Array) {
const privateKeyRaw = privateKey.subarray(0, KEYS_BYTE_LENGTH)

return await ed.sign(msg, privateKeyRaw)
}

export async function hashAndVerify (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array) {
return await ed.verify(sig, msg, publicKey)
}

function concatKeys (privateKeyRaw: Uint8Array, publicKey: Uint8Array) {
const privateKey = new Uint8Array(PRIVATE_KEY_BYTE_LENGTH)
for (let i = 0; i < KEYS_BYTE_LENGTH; i++) {
privateKey[i] = privateKeyRaw[i]
privateKey[KEYS_BYTE_LENGTH + i] = publicKey[i]
}
return privateKey
}
8 changes: 4 additions & 4 deletions src/keys/ed25519-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ export function unmarshalEd25519PrivateKey (bytes: Uint8Array) {
// Try the old, redundant public key version
if (bytes.length > crypto.privateKeyLength) {
bytes = ensureKey(bytes, crypto.privateKeyLength + crypto.publicKeyLength)
const privateKeyBytes = bytes.slice(0, crypto.privateKeyLength)
const publicKeyBytes = bytes.slice(crypto.privateKeyLength, bytes.length)
const privateKeyBytes = bytes.subarray(0, crypto.privateKeyLength)
const publicKeyBytes = bytes.subarray(crypto.privateKeyLength, bytes.length)
return new Ed25519PrivateKey(privateKeyBytes, publicKeyBytes)
}

bytes = ensureKey(bytes, crypto.privateKeyLength)
const privateKeyBytes = bytes.slice(0, crypto.privateKeyLength)
const publicKeyBytes = bytes.slice(crypto.publicKeyLength)
const privateKeyBytes = bytes.subarray(0, crypto.privateKeyLength)
const publicKeyBytes = bytes.subarray(crypto.publicKeyLength)
return new Ed25519PrivateKey(privateKeyBytes, publicKeyBytes)
}

Expand Down
96 changes: 77 additions & 19 deletions src/keys/ed25519.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import * as ed from '@noble/ed25519'
import crypto from 'crypto'
import { promisify } from 'util'
import { toString as uint8arrayToString } from 'uint8arrays/to-string'
import { fromString as uint8arrayFromString } from 'uint8arrays/from-string'

const keypair = promisify(crypto.generateKeyPair)

const PUBLIC_KEY_BYTE_LENGTH = 32
const PRIVATE_KEY_BYTE_LENGTH = 64 // private key is actually 32 bytes but for historical reasons we concat private and public keys
const KEYS_BYTE_LENGTH = 32
const SIGNATURE_BYTE_LENGTH = 64

export { PUBLIC_KEY_BYTE_LENGTH as publicKeyLength }
export { PRIVATE_KEY_BYTE_LENGTH as privateKeyLength }

function derivePublicKey (privateKey: Uint8Array) {
const hash = crypto.createHash('sha512')
hash.update(privateKey)
return hash.digest().subarray(32)
}

export async function generateKey () {
// the actual private key (32 bytes)
const privateKeyRaw = ed.utils.randomPrivateKey()
const publicKey = await ed.getPublicKey(privateKeyRaw)
const key = await keypair('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'jwk' },
privateKeyEncoding: { type: 'pkcs8', format: 'jwk' }
})

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

return {
privateKey,
publicKey
privateKey: concatKeys(privateKeyRaw, publicKeyRaw),
publicKey: publicKeyRaw
}
}

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

// based on node forges algorithm, the seed is used directly as private key
const privateKeyRaw = seed
const publicKey = await ed.getPublicKey(privateKeyRaw)

const privateKey = concatKeys(privateKeyRaw, publicKey)
const publicKeyRaw = derivePublicKey(seed)

return {
privateKey,
publicKey
privateKey: concatKeys(seed, publicKeyRaw),
publicKey: publicKeyRaw
}
}

export async function hashAndSign (privateKey: Uint8Array, msg: Uint8Array) {
const privateKeyRaw = privateKey.slice(0, KEYS_BYTE_LENGTH)
export async function hashAndSign (key: Uint8Array, msg: Uint8Array) {
if (!(key instanceof Uint8Array)) {
throw new TypeError('"key" must be a node.js Buffer, or Uint8Array.')
}

let privateKey: Uint8Array
let publicKey: Uint8Array

if (key.byteLength === PRIVATE_KEY_BYTE_LENGTH) {
privateKey = key.subarray(0, 32)
publicKey = key.subarray(32)
} else if (key.byteLength === KEYS_BYTE_LENGTH) {
privateKey = key.subarray(0, 32)
publicKey = derivePublicKey(privateKey)
} else {
throw new TypeError('"key" must be 64 or 32 bytes in length.')
}

const obj = crypto.createPrivateKey({
format: 'jwk',
key: {
crv: 'Ed25519',
d: uint8arrayToString(privateKey, 'base64url'),
x: uint8arrayToString(publicKey, 'base64url'),
kty: 'OKP'
}
})

return await ed.sign(msg, privateKeyRaw)
return crypto.sign(null, msg, obj)
}

export async function hashAndVerify (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array) {
return await ed.verify(sig, msg, publicKey)
export async function hashAndVerify (key: Uint8Array, sig: Uint8Array, msg: Uint8Array) {
if (key.byteLength !== PUBLIC_KEY_BYTE_LENGTH) {
throw new TypeError('"key" must be 32 bytes in length.')
} else if (!(key instanceof Uint8Array)) {
throw new TypeError('"key" must be a node.js Buffer, or Uint8Array.')
}

if (sig.byteLength !== SIGNATURE_BYTE_LENGTH) {
throw new TypeError('"sig" must be 64 bytes in length.')
} else if (!(sig instanceof Uint8Array)) {
throw new TypeError('"sig" must be a node.js Buffer, or Uint8Array.')
}

const obj = crypto.createPublicKey({
format: 'jwk',
key: {
crv: 'Ed25519',
x: uint8arrayToString(key, 'base64url'),
kty: 'OKP'
}
})

return crypto.verify(null, msg, obj, sig)
}

function concatKeys (privateKeyRaw: Uint8Array, publicKey: Uint8Array) {
Expand Down
10 changes: 5 additions & 5 deletions src/keys/key-stretcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ export async function keyStretcher (cipherType: 'AES-128' | 'AES-256' | 'Blowfis

const half = resultLength / 2
const resultBuffer = uint8ArrayConcat(result)
const r1 = resultBuffer.slice(0, half)
const r2 = resultBuffer.slice(half, resultLength)
const r1 = resultBuffer.subarray(0, half)
const r2 = resultBuffer.subarray(half, resultLength)

const createKey = (res: Uint8Array) => ({
iv: res.slice(0, ivSize),
cipherKey: res.slice(ivSize, ivSize + cipherKeySize),
macKey: res.slice(ivSize + cipherKeySize)
iv: res.subarray(0, ivSize),
cipherKey: res.subarray(ivSize, ivSize + cipherKeySize),
macKey: res.subarray(ivSize + cipherKeySize)
})

return {
Expand Down
2 changes: 1 addition & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function bigIntegerToUintBase64url (num: { abs: () => any}, len?: number)
// byte if the most significant bit of the number is 1:
// https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-integer
// Our number will always be positive so we should remove the leading padding.
buf = buf[0] === 0 ? buf.slice(1) : buf
buf = buf[0] === 0 ? buf.subarray(1) : buf

if (len != null) {
if (buf.length > len) throw new Error('byte array longer than desired length')
Expand Down
2 changes: 1 addition & 1 deletion test/keys/ed25519.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ describe('ed25519', function () {
const key = await crypto.keys.unmarshalPrivateKey(fixtures.redundantPubKey.privateKey)
const bytes = key.marshal()
expect(bytes.length).to.equal(64)
expect(bytes.slice(32)).to.eql(key.public.marshal())
expect(bytes.subarray(32)).to.eql(key.public.marshal())
})

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

0 comments on commit 1c623e7

Please sign in to comment.