Skip to content

feat(Ed25519-Key): Improve validation speeds in browsers #3100

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 3, 2025
Merged
28 changes: 24 additions & 4 deletions packages/crypto/src/keys/ed25519/ed25519.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { CID } from 'multiformats/cid'
import { identity } from 'multiformats/hashes/identity'
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
import { isPromise } from '../../util.ts'
import { publicKeyToProtobuf } from '../index.js'
import { ensureEd25519Key } from './utils.js'
import * as crypto from './index.js'
Expand Down Expand Up @@ -37,9 +38,18 @@
return uint8ArrayEquals(this.raw, key.raw)
}

verify (data: Uint8Array | Uint8ArrayList, sig: Uint8Array, options?: AbortOptions): boolean {
verify (data: Uint8Array | Uint8ArrayList, sig: Uint8Array, options?: AbortOptions): boolean | Promise<boolean> {
options?.signal?.throwIfAborted()
return crypto.hashAndVerify(this.raw, sig, data)
const result = crypto.hashAndVerify(this.raw, sig, data)

if (isPromise<boolean>(result)) {
return result.then(res => {
options?.signal?.throwIfAborted()
return res
})
}

Check warning on line 50 in packages/crypto/src/keys/ed25519/ed25519.ts

View check run for this annotation

Codecov / codecov/patch

packages/crypto/src/keys/ed25519/ed25519.ts#L46-L50

Added lines #L46 - L50 were not covered by tests

return result
}
}

Expand All @@ -63,8 +73,18 @@
return uint8ArrayEquals(this.raw, key.raw)
}

sign (message: Uint8Array | Uint8ArrayList, options?: AbortOptions): Uint8Array {
sign (message: Uint8Array | Uint8ArrayList, options?: AbortOptions): Uint8Array | Promise<Uint8Array> {
options?.signal?.throwIfAborted()
const sig = crypto.hashAndSign(this.raw, message)

if (isPromise<Uint8Array>(sig)) {
return sig.then(res => {
options?.signal?.throwIfAborted()
return res
})
}

Check warning on line 85 in packages/crypto/src/keys/ed25519/ed25519.ts

View check run for this annotation

Codecov / codecov/patch

packages/crypto/src/keys/ed25519/ed25519.ts#L81-L85

Added lines #L81 - L85 were not covered by tests

options?.signal?.throwIfAborted()
return crypto.hashAndSign(this.raw, message)
return sig
}
}
77 changes: 72 additions & 5 deletions packages/crypto/src/keys/ed25519/index.browser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ed25519 as ed } from '@noble/curves/ed25519'
import { toString as uint8arrayToString } from 'uint8arrays/to-string'
import crypto from '../../webcrypto/index.js'
import type { Uint8ArrayKeyPair } from '../interface.js'
import type { Uint8ArrayList } from 'uint8arraylist'

Expand All @@ -9,6 +11,17 @@ const KEYS_BYTE_LENGTH = 32
export { PUBLIC_KEY_BYTE_LENGTH as publicKeyLength }
export { PRIVATE_KEY_BYTE_LENGTH as privateKeyLength }

// memoize support result to skip additional awaits every time we use an ed key
let ed25519Supported: boolean | undefined
const webCryptoEd25519SupportedPromise = (async () => {
try {
await crypto.get().subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
return true
} catch {
return false
}
})()

export function generateKey (): Uint8ArrayKeyPair {
// the actual private key (32 bytes)
const privateKeyRaw = ed.utils.randomPrivateKey()
Expand All @@ -23,9 +36,6 @@ export function generateKey (): Uint8ArrayKeyPair {
}
}

/**
* Generate keypair from a 32 byte uint8array
*/
export function generateKeyFromSeed (seed: Uint8Array): Uint8ArrayKeyPair {
if (seed.length !== KEYS_BYTE_LENGTH) {
throw new TypeError('"seed" must be 32 bytes in length.')
Expand All @@ -45,16 +55,73 @@ export function generateKeyFromSeed (seed: Uint8Array): Uint8ArrayKeyPair {
}
}

export function hashAndSign (privateKey: Uint8Array, msg: Uint8Array | Uint8ArrayList): Uint8Array {
async function hashAndSignWebCrypto (privateKey: Uint8Array, msg: Uint8Array | Uint8ArrayList): Promise<Uint8Array> {
let privateKeyRaw: Uint8Array
if (privateKey.length === PRIVATE_KEY_BYTE_LENGTH) {
privateKeyRaw = privateKey.subarray(0, 32)
} else {
privateKeyRaw = privateKey
}

const jwk: JsonWebKey = {
crv: 'Ed25519',
kty: 'OKP',
x: uint8arrayToString(privateKey.subarray(32), 'base64url'),
d: uint8arrayToString(privateKeyRaw, 'base64url'),
ext: true,
key_ops: ['sign']
}

const key = await crypto.get().subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign'])
const sig = await crypto.get().subtle.sign({ name: 'Ed25519' }, key, msg instanceof Uint8Array ? msg : msg.subarray())

return new Uint8Array(sig, 0, sig.byteLength)
}

function hashAndSignNoble (privateKey: Uint8Array, msg: Uint8Array | Uint8ArrayList): Uint8Array {
const privateKeyRaw = privateKey.subarray(0, KEYS_BYTE_LENGTH)

return ed.sign(msg instanceof Uint8Array ? msg : msg.subarray(), privateKeyRaw)
}

export function hashAndVerify (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList): boolean {
export async function hashAndSign (privateKey: Uint8Array, msg: Uint8Array | Uint8ArrayList): Promise<Uint8Array> {
if (ed25519Supported == null) {
ed25519Supported = await webCryptoEd25519SupportedPromise
}

if (ed25519Supported) {
return hashAndSignWebCrypto(privateKey, msg)
}

return hashAndSignNoble(privateKey, msg)
}

async function hashAndVerifyWebCrypto (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList): Promise<boolean> {
if (publicKey.buffer instanceof ArrayBuffer) {
const key = await crypto.get().subtle.importKey('raw', publicKey.buffer, { name: 'Ed25519' }, false, ['verify'])
const isValid = await crypto.get().subtle.verify({ name: 'Ed25519' }, key, sig, msg instanceof Uint8Array ? msg : msg.subarray())
return isValid
}

throw new TypeError('WebCrypto does not support SharedArrayBuffer for Ed25519 keys')
}

function hashAndVerifyNoble (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList): boolean {
return ed.verify(sig, msg instanceof Uint8Array ? msg : msg.subarray(), publicKey)
}

export async function hashAndVerify (publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList): Promise<boolean> {
if (ed25519Supported == null) {
ed25519Supported = await webCryptoEd25519SupportedPromise
}

if (ed25519Supported) {
return hashAndVerifyWebCrypto(publicKey, sig, msg)
}

return hashAndVerifyNoble(publicKey, sig, msg)
}

function concatKeys (privateKeyRaw: Uint8Array, publicKey: Uint8Array): Uint8Array {
const privateKey = new Uint8Array(PRIVATE_KEY_BYTE_LENGTH)
for (let i = 0; i < KEYS_BYTE_LENGTH; i++) {
Expand Down
12 changes: 6 additions & 6 deletions packages/crypto/test/keys/ed25519.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('ed25519', function () {
it('signs', async () => {
const text = randomBytes(512)
const sig = await key.sign(text)
const res = key.publicKey.verify(text, sig)
const res = await key.publicKey.verify(text, sig)
expect(res).to.be.be.true()
})

Expand All @@ -68,12 +68,12 @@ describe('ed25519', function () {
)
const sig = await key.sign(text)

expect(key.sign(text.subarray()))
expect(await key.sign(text.subarray()))
.to.deep.equal(sig, 'list did not have same signature as a single buffer')

expect(key.publicKey.verify(text, sig))
expect(await key.publicKey.verify(text, sig))
.to.be.true('did not verify message as list')
expect(key.publicKey.verify(text.subarray(), sig))
expect(await key.publicKey.verify(text.subarray(), sig))
.to.be.true('did not verify message as single buffer')
})

Expand Down Expand Up @@ -143,7 +143,7 @@ describe('ed25519', function () {
it('sign and verify', async () => {
const data = uint8ArrayFromString('hello world')
const sig = await key.sign(data)
const valid = key.publicKey.verify(data, sig)
const valid = await key.publicKey.verify(data, sig)
expect(valid).to.be.true()
})

Expand All @@ -159,7 +159,7 @@ describe('ed25519', function () {
it('fails to verify for different data', async () => {
const data = uint8ArrayFromString('hello world')
const sig = await key.sign(data)
const valid = key.publicKey.verify(uint8ArrayFromString('hello'), sig)
const valid = await key.publicKey.verify(uint8ArrayFromString('hello'), sig)
expect(valid).to.be.be.false()
})

Expand Down
Loading