From 5e864436094b59e69b66e643c9236290ef195b14 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 13:44:30 +0200 Subject: [PATCH 1/6] feat: Add ed25519 signature functions --- packages/cryptography/src/ed25519.test.ts | 133 ++++++++++++++++++++++ packages/cryptography/src/ed25519.ts | 94 +++++++++++++++ packages/cryptography/src/index.ts | 1 + 3 files changed, 228 insertions(+) create mode 100644 packages/cryptography/src/ed25519.test.ts create mode 100644 packages/cryptography/src/ed25519.ts diff --git a/packages/cryptography/src/ed25519.test.ts b/packages/cryptography/src/ed25519.test.ts new file mode 100644 index 00000000000..2b39b675d89 --- /dev/null +++ b/packages/cryptography/src/ed25519.test.ts @@ -0,0 +1,133 @@ +import { bytesToHex, hexToBytes } from '@metamask/utils'; + +import { ed25519Sign, ed25519Verify } from './ed25519.js'; + +// RFC 8032 Section 6 Ed25519 test vector 3 (2-byte message) +// https://www.rfc-editor.org/rfc/rfc8032#section-6 +const rfcPrivateKey = hexToBytes( + '0xc5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7', +); +const rfcPublicKey = hexToBytes( + '0xfc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025', +); +const rfcMessage = hexToBytes('0xaf82'); +const rfcSignature = + '0x6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a'; + +describe('ed25519Sign', () => { + it('matches RFC 8032 test vector 3', async () => { + const signature = await ed25519Sign(rfcPrivateKey, rfcMessage); + expect(bytesToHex(signature)).toBe(rfcSignature); + }); + + it('accepts an ArrayBuffer private key and data', async () => { + const signature = await ed25519Sign( + rfcPrivateKey.buffer, + rfcMessage.buffer, + ); + expect(bytesToHex(signature)).toBe(rfcSignature); + }); + + it('accepts a DataView private key and data', async () => { + const signature = await ed25519Sign( + new DataView(rfcPrivateKey.buffer), + new DataView(rfcMessage.buffer), + ); + expect(bytesToHex(signature)).toBe(rfcSignature); + }); + + it('throws if the private key is too short', async () => { + await expect( + ed25519Sign(new Uint8Array(31), new Uint8Array(0)), + ).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', + ); + }); + + it('throws if the private key is too long', async () => { + await expect( + ed25519Sign(new Uint8Array(33), new Uint8Array(0)), + ).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', + ); + }); +}); + +describe('ed25519Verify', () => { + it('verifies a valid signature', async () => { + const valid = await ed25519Verify( + rfcPublicKey, + hexToBytes(rfcSignature), + rfcMessage, + ); + expect(valid).toBe(true); + }); + + it('returns false when signature does not match data', async () => { + const valid = await ed25519Verify( + rfcPublicKey, + hexToBytes(rfcSignature), + new Uint8Array(0), + ); + expect(valid).toBe(false); + }); + + it('returns false when signature does not match public key', async () => { + const valid = await ed25519Verify( + new Uint8Array(32), + hexToBytes(rfcSignature), + rfcMessage, + ); + expect(valid).toBe(false); + }); + + it('accepts an ArrayBuffer public key, signature, and data', async () => { + const valid = await ed25519Verify( + rfcPublicKey.buffer, + hexToBytes(rfcSignature).buffer, + rfcMessage.buffer, + ); + expect(valid).toBe(true); + }); + + it('accepts a DataView public key, signature, and data', async () => { + const valid = await ed25519Verify( + new DataView(rfcPublicKey.buffer), + new DataView(hexToBytes(rfcSignature).buffer), + new DataView(rfcMessage.buffer), + ); + expect(valid).toBe(true); + }); + + it('throws if the public key is too short', async () => { + await expect( + ed25519Verify(new Uint8Array(31), hexToBytes(rfcSignature), rfcMessage), + ).rejects.toThrow( + 'Invalid public key length: Public key must be exactly 32 bytes for Ed25519.', + ); + }); + + it('throws if the public key is too long', async () => { + await expect( + ed25519Verify(new Uint8Array(33), hexToBytes(rfcSignature), rfcMessage), + ).rejects.toThrow( + 'Invalid public key length: Public key must be exactly 32 bytes for Ed25519.', + ); + }); + + it('throws if the signature is too short', async () => { + await expect( + ed25519Verify(rfcPublicKey, new Uint8Array(63), rfcMessage), + ).rejects.toThrow( + 'Invalid signature length: Signature must be exactly 64 bytes for Ed25519.', + ); + }); + + it('throws if the signature is too long', async () => { + await expect( + ed25519Verify(rfcPublicKey, new Uint8Array(65), rfcMessage), + ).rejects.toThrow( + 'Invalid signature length: Signature must be exactly 64 bytes for Ed25519.', + ); + }); +}); diff --git a/packages/cryptography/src/ed25519.ts b/packages/cryptography/src/ed25519.ts new file mode 100644 index 00000000000..7246dcfdac1 --- /dev/null +++ b/packages/cryptography/src/ed25519.ts @@ -0,0 +1,94 @@ +import { toUint8Array } from './utils.js'; + +// https://www.rfc-editor.org/rfc/rfc8032 +const ED25519_KEY_LENGTH = 32; +const ED25519_SIGNATURE_LENGTH = 64; + +// https://www.rfc-editor.org/rfc/rfc8410#section-7 +// https://github.com/nodejs/node/blob/main/test/parallel/test-webcrypto-export-import-cfrg.js +const ED25519_PKCS8_HEADER = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, + 0x22, 0x04, 0x20, +]); + +/** + * Sign the given data using the given Ed25519 private key. + * + * @param privateKey - The 32-byte Ed25519 private key seed. + * @param data - The data to sign. + * @returns The 64-byte Ed25519 signature. + */ +export async function ed25519Sign( + privateKey: BufferSource, + data: BufferSource, +): Promise { + if (privateKey.byteLength !== ED25519_KEY_LENGTH) { + throw new Error( + `Invalid private key length: Private key must be exactly ${ED25519_KEY_LENGTH} bytes for Ed25519.`, + ); + } + + // The WebCrypto API expects private keys to be in PKCS8 format. + const pkcs8 = new Uint8Array( + ED25519_PKCS8_HEADER.length + ED25519_KEY_LENGTH, + ); + pkcs8.set(ED25519_PKCS8_HEADER); + pkcs8.set(toUint8Array(privateKey), ED25519_PKCS8_HEADER.length); + + const subtleKey = await globalThis.crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: 'Ed25519' }, + false, + ['sign'], + ); + + const signature = await globalThis.crypto.subtle.sign( + { name: 'Ed25519' }, + subtleKey, + data, + ); + + return new Uint8Array(signature); +} + +/** + * Verify an Ed25519 signature. + * + * @param publicKey - The 32-byte Ed25519 public key. + * @param signature - The 64-byte signature to verify. + * @param data - The signed data. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export async function ed25519Verify( + publicKey: BufferSource, + signature: BufferSource, + data: BufferSource, +): Promise { + if (publicKey.byteLength !== ED25519_KEY_LENGTH) { + throw new Error( + `Invalid public key length: Public key must be exactly ${ED25519_KEY_LENGTH} bytes for Ed25519.`, + ); + } + + if (signature.byteLength !== ED25519_SIGNATURE_LENGTH) { + throw new Error( + `Invalid signature length: Signature must be exactly ${ED25519_SIGNATURE_LENGTH} bytes for Ed25519.`, + ); + } + + const subtleKey = await globalThis.crypto.subtle.importKey( + 'raw', + publicKey, + { name: 'Ed25519' }, + false, + ['verify'], + ); + + return globalThis.crypto.subtle.verify( + { name: 'Ed25519' }, + subtleKey, + signature, + data, + ); +} diff --git a/packages/cryptography/src/index.ts b/packages/cryptography/src/index.ts index 753e73d9843..af17e79b27b 100644 --- a/packages/cryptography/src/index.ts +++ b/packages/cryptography/src/index.ts @@ -1,3 +1,4 @@ +export * from './ed25519.js'; export * from './hkdf.js'; export * from './hmac.js'; export * from './pbkdf2.js'; From 1a4974cf9cbe8e0afe52bc9c1668617bdc0065ae Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 13:53:29 +0200 Subject: [PATCH 2/6] Add public key derivation --- packages/cryptography/src/ed25519.test.ts | 33 +++++++++++++++- packages/cryptography/src/ed25519.ts | 48 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/cryptography/src/ed25519.test.ts b/packages/cryptography/src/ed25519.test.ts index 2b39b675d89..09a7df3d9f7 100644 --- a/packages/cryptography/src/ed25519.test.ts +++ b/packages/cryptography/src/ed25519.test.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from '@metamask/utils'; -import { ed25519Sign, ed25519Verify } from './ed25519.js'; +import { ed25519Sign, ed25519Verify, getEd25519PublicKey } from './ed25519.js'; // RFC 8032 Section 6 Ed25519 test vector 3 (2-byte message) // https://www.rfc-editor.org/rfc/rfc8032#section-6 @@ -14,6 +14,37 @@ const rfcMessage = hexToBytes('0xaf82'); const rfcSignature = '0x6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a'; +describe('getEd25519PublicKey', () => { + it('derives the public key from RFC 8032 test vector 3', async () => { + const pubKey = await getEd25519PublicKey(rfcPrivateKey); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); + }); + + it('accepts an ArrayBuffer private key', async () => { + const pubKey = await getEd25519PublicKey(rfcPrivateKey.buffer); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); + }); + + it('accepts a DataView private key', async () => { + const pubKey = await getEd25519PublicKey( + new DataView(rfcPrivateKey.buffer), + ); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); + }); + + it('throws if the private key is too short', async () => { + await expect(getEd25519PublicKey(new Uint8Array(31))).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', + ); + }); + + it('throws if the private key is too long', async () => { + await expect(getEd25519PublicKey(new Uint8Array(33))).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', + ); + }); +}); + describe('ed25519Sign', () => { it('matches RFC 8032 test vector 3', async () => { const signature = await ed25519Sign(rfcPrivateKey, rfcMessage); diff --git a/packages/cryptography/src/ed25519.ts b/packages/cryptography/src/ed25519.ts index 7246dcfdac1..e7d970db508 100644 --- a/packages/cryptography/src/ed25519.ts +++ b/packages/cryptography/src/ed25519.ts @@ -11,6 +11,54 @@ const ED25519_PKCS8_HEADER = new Uint8Array([ 0x22, 0x04, 0x20, ]); +/** + * Derive the Ed25519 public key corresponding to the given private key. + * + * @param privateKey - The 32-byte Ed25519 private key. + * @returns The 32-byte Ed25519 public key. + */ +export async function getEd25519PublicKey( + privateKey: BufferSource, +): Promise { + if (privateKey.byteLength !== ED25519_KEY_LENGTH) { + throw new Error( + `Invalid private key length: Private key must be exactly ${ED25519_KEY_LENGTH} bytes for Ed25519.`, + ); + } + + const pkcs8 = new Uint8Array( + ED25519_PKCS8_HEADER.length + ED25519_KEY_LENGTH, + ); + pkcs8.set(ED25519_PKCS8_HEADER); + pkcs8.set(toUint8Array(privateKey), ED25519_PKCS8_HEADER.length); + + const subtlePrivateKey = await globalThis.crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: 'Ed25519' }, + true, + ['sign'], + ); + + const jwk = await globalThis.crypto.subtle.exportKey('jwk', subtlePrivateKey); + + // Intentionally discarding private key from JWK (`d`). + const subtlePublicKey = await globalThis.crypto.subtle.importKey( + 'jwk', + { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, + { name: 'Ed25519' }, + true, + ['verify'], + ); + + const publicKey = await globalThis.crypto.subtle.exportKey( + 'raw', + subtlePublicKey, + ); + + return new Uint8Array(publicKey); +} + /** * Sign the given data using the given Ed25519 private key. * From 4377264dd504989fbe25eab0f512d13ffee2f090 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 15:36:57 +0200 Subject: [PATCH 3/6] Use subpath export --- packages/cryptography/CHANGELOG.md | 3 +- packages/cryptography/package.json | 4 ++ packages/cryptography/src/ed25519.test.ts | 55 ++++++++++------------- packages/cryptography/src/ed25519.ts | 6 +-- packages/cryptography/src/index.ts | 1 - 5 files changed, 32 insertions(+), 37 deletions(-) diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index c6a0fc94ff0..c5154b15b51 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -9,12 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release ([#10282](https://github.com/MetaMask/core/pull/10282), [#10431](https://github.com/MetaMask/core/pull/10431), [#10403](https://github.com/MetaMask/core/pull/10403), [#10468](https://github.com/MetaMask/core/pull/10468), [#10503](https://github.com/MetaMask/core/pull/10503), [#10563](https://github.com/MetaMask/core/pull/10563)) +- Initial release ([#10282](https://github.com/MetaMask/core/pull/10282), [#10431](https://github.com/MetaMask/core/pull/10431), [#10403](https://github.com/MetaMask/core/pull/10403), [#10468](https://github.com/MetaMask/core/pull/10468), [#10503](https://github.com/MetaMask/core/pull/10503), [#10563](https://github.com/MetaMask/core/pull/10563), [#10506](https://github.com/MetaMask/core/pull/10506)) - Add `sha256`, `sha384`, and `sha512` functions for computing SHA digests - Add `hmacSha256`, `hmacSha384`, and `hmacSha512` functions for computing HMAC digests - Add `pbkdf2Sha256`, `pbkdf2Sha384`, and `pbkdf2Sha512` functions for key derivation - Add `hkdfSha256`, `hkdfSha384`, and `hkdfSha512` functions for key derivation - Add `getPublicKey` and `getSharedSecret` functions for X25519 key derivation exported via `@metamask/cryptography/x25519` - Add `getRandomBytes` function for generating cryptographically secure random bytes + - Add `getPublicKey`, `sign`, and `verify` functions for Ed25519 exported via `@metamask/cryptography/ed25519` [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/cryptography/package.json b/packages/cryptography/package.json index 24b7b07db44..fd2e7dfb13c 100644 --- a/packages/cryptography/package.json +++ b/packages/cryptography/package.json @@ -25,6 +25,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./ed25519": { + "types": "./dist/ed25519.d.ts", + "default": "./dist/ed25519.js" + }, "./x25519": { "types": "./dist/x25519.d.ts", "default": "./dist/x25519.js" diff --git a/packages/cryptography/src/ed25519.test.ts b/packages/cryptography/src/ed25519.test.ts index 09a7df3d9f7..c74b6faaf5f 100644 --- a/packages/cryptography/src/ed25519.test.ts +++ b/packages/cryptography/src/ed25519.test.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from '@metamask/utils'; -import { ed25519Sign, ed25519Verify, getEd25519PublicKey } from './ed25519.js'; +import { getPublicKey, sign, verify } from './ed25519.js'; // RFC 8032 Section 6 Ed25519 test vector 3 (2-byte message) // https://www.rfc-editor.org/rfc/rfc8032#section-6 @@ -14,53 +14,48 @@ const rfcMessage = hexToBytes('0xaf82'); const rfcSignature = '0x6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a'; -describe('getEd25519PublicKey', () => { +describe('getPublicKey', () => { it('derives the public key from RFC 8032 test vector 3', async () => { - const pubKey = await getEd25519PublicKey(rfcPrivateKey); + const pubKey = await getPublicKey(rfcPrivateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); }); it('accepts an ArrayBuffer private key', async () => { - const pubKey = await getEd25519PublicKey(rfcPrivateKey.buffer); + const pubKey = await getPublicKey(rfcPrivateKey.buffer); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); }); it('accepts a DataView private key', async () => { - const pubKey = await getEd25519PublicKey( - new DataView(rfcPrivateKey.buffer), - ); + const pubKey = await getPublicKey(new DataView(rfcPrivateKey.buffer)); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); }); it('throws if the private key is too short', async () => { - await expect(getEd25519PublicKey(new Uint8Array(31))).rejects.toThrow( + await expect(getPublicKey(new Uint8Array(31))).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', ); }); it('throws if the private key is too long', async () => { - await expect(getEd25519PublicKey(new Uint8Array(33))).rejects.toThrow( + await expect(getPublicKey(new Uint8Array(33))).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', ); }); }); -describe('ed25519Sign', () => { +describe('sign', () => { it('matches RFC 8032 test vector 3', async () => { - const signature = await ed25519Sign(rfcPrivateKey, rfcMessage); + const signature = await sign(rfcPrivateKey, rfcMessage); expect(bytesToHex(signature)).toBe(rfcSignature); }); it('accepts an ArrayBuffer private key and data', async () => { - const signature = await ed25519Sign( - rfcPrivateKey.buffer, - rfcMessage.buffer, - ); + const signature = await sign(rfcPrivateKey.buffer, rfcMessage.buffer); expect(bytesToHex(signature)).toBe(rfcSignature); }); it('accepts a DataView private key and data', async () => { - const signature = await ed25519Sign( + const signature = await sign( new DataView(rfcPrivateKey.buffer), new DataView(rfcMessage.buffer), ); @@ -68,25 +63,21 @@ describe('ed25519Sign', () => { }); it('throws if the private key is too short', async () => { - await expect( - ed25519Sign(new Uint8Array(31), new Uint8Array(0)), - ).rejects.toThrow( + await expect(sign(new Uint8Array(31), new Uint8Array(0))).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', ); }); it('throws if the private key is too long', async () => { - await expect( - ed25519Sign(new Uint8Array(33), new Uint8Array(0)), - ).rejects.toThrow( + await expect(sign(new Uint8Array(33), new Uint8Array(0))).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', ); }); }); -describe('ed25519Verify', () => { +describe('verify', () => { it('verifies a valid signature', async () => { - const valid = await ed25519Verify( + const valid = await verify( rfcPublicKey, hexToBytes(rfcSignature), rfcMessage, @@ -95,7 +86,7 @@ describe('ed25519Verify', () => { }); it('returns false when signature does not match data', async () => { - const valid = await ed25519Verify( + const valid = await verify( rfcPublicKey, hexToBytes(rfcSignature), new Uint8Array(0), @@ -104,7 +95,7 @@ describe('ed25519Verify', () => { }); it('returns false when signature does not match public key', async () => { - const valid = await ed25519Verify( + const valid = await verify( new Uint8Array(32), hexToBytes(rfcSignature), rfcMessage, @@ -113,7 +104,7 @@ describe('ed25519Verify', () => { }); it('accepts an ArrayBuffer public key, signature, and data', async () => { - const valid = await ed25519Verify( + const valid = await verify( rfcPublicKey.buffer, hexToBytes(rfcSignature).buffer, rfcMessage.buffer, @@ -122,7 +113,7 @@ describe('ed25519Verify', () => { }); it('accepts a DataView public key, signature, and data', async () => { - const valid = await ed25519Verify( + const valid = await verify( new DataView(rfcPublicKey.buffer), new DataView(hexToBytes(rfcSignature).buffer), new DataView(rfcMessage.buffer), @@ -132,7 +123,7 @@ describe('ed25519Verify', () => { it('throws if the public key is too short', async () => { await expect( - ed25519Verify(new Uint8Array(31), hexToBytes(rfcSignature), rfcMessage), + verify(new Uint8Array(31), hexToBytes(rfcSignature), rfcMessage), ).rejects.toThrow( 'Invalid public key length: Public key must be exactly 32 bytes for Ed25519.', ); @@ -140,7 +131,7 @@ describe('ed25519Verify', () => { it('throws if the public key is too long', async () => { await expect( - ed25519Verify(new Uint8Array(33), hexToBytes(rfcSignature), rfcMessage), + verify(new Uint8Array(33), hexToBytes(rfcSignature), rfcMessage), ).rejects.toThrow( 'Invalid public key length: Public key must be exactly 32 bytes for Ed25519.', ); @@ -148,7 +139,7 @@ describe('ed25519Verify', () => { it('throws if the signature is too short', async () => { await expect( - ed25519Verify(rfcPublicKey, new Uint8Array(63), rfcMessage), + verify(rfcPublicKey, new Uint8Array(63), rfcMessage), ).rejects.toThrow( 'Invalid signature length: Signature must be exactly 64 bytes for Ed25519.', ); @@ -156,7 +147,7 @@ describe('ed25519Verify', () => { it('throws if the signature is too long', async () => { await expect( - ed25519Verify(rfcPublicKey, new Uint8Array(65), rfcMessage), + verify(rfcPublicKey, new Uint8Array(65), rfcMessage), ).rejects.toThrow( 'Invalid signature length: Signature must be exactly 64 bytes for Ed25519.', ); diff --git a/packages/cryptography/src/ed25519.ts b/packages/cryptography/src/ed25519.ts index e7d970db508..fc66741fd2d 100644 --- a/packages/cryptography/src/ed25519.ts +++ b/packages/cryptography/src/ed25519.ts @@ -17,7 +17,7 @@ const ED25519_PKCS8_HEADER = new Uint8Array([ * @param privateKey - The 32-byte Ed25519 private key. * @returns The 32-byte Ed25519 public key. */ -export async function getEd25519PublicKey( +export async function getPublicKey( privateKey: BufferSource, ): Promise { if (privateKey.byteLength !== ED25519_KEY_LENGTH) { @@ -66,7 +66,7 @@ export async function getEd25519PublicKey( * @param data - The data to sign. * @returns The 64-byte Ed25519 signature. */ -export async function ed25519Sign( +export async function sign( privateKey: BufferSource, data: BufferSource, ): Promise { @@ -108,7 +108,7 @@ export async function ed25519Sign( * @param data - The signed data. * @returns `true` if the signature is valid, `false` otherwise. */ -export async function ed25519Verify( +export async function verify( publicKey: BufferSource, signature: BufferSource, data: BufferSource, diff --git a/packages/cryptography/src/index.ts b/packages/cryptography/src/index.ts index af17e79b27b..753e73d9843 100644 --- a/packages/cryptography/src/index.ts +++ b/packages/cryptography/src/index.ts @@ -1,4 +1,3 @@ -export * from './ed25519.js'; export * from './hkdf.js'; export * from './hmac.js'; export * from './pbkdf2.js'; From bc3c5ca7ed69bb3c5970539f850b51ddc748fc5e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 15:58:09 +0200 Subject: [PATCH 4/6] Add a couple more tests --- packages/cryptography/src/ed25519.test.ts | 31 +++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cryptography/src/ed25519.test.ts b/packages/cryptography/src/ed25519.test.ts index c74b6faaf5f..07fb729ded1 100644 --- a/packages/cryptography/src/ed25519.test.ts +++ b/packages/cryptography/src/ed25519.test.ts @@ -1,7 +1,14 @@ -import { bytesToHex, hexToBytes } from '@metamask/utils'; +import { bytesToHex, hexToBytes, stringToBytes } from '@metamask/utils'; import { getPublicKey, sign, verify } from './ed25519.js'; +const privateKey = hexToBytes( + '0xf05665c0091fc75a5a558eddb88acd3ce2a789e15c0e10ceb334849357394ac1', +); +const publicKey = hexToBytes( + '0x2d0eba7e02a698405c3e3ce6b35acd00def24ffb7c10c2127f58393e2c44f935', +); + // RFC 8032 Section 6 Ed25519 test vector 3 (2-byte message) // https://www.rfc-editor.org/rfc/rfc8032#section-6 const rfcPrivateKey = hexToBytes( @@ -15,6 +22,11 @@ const rfcSignature = '0x6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a'; describe('getPublicKey', () => { + it('derives the public key from a provided private key', async () => { + const pubKey = await getPublicKey(privateKey); + expect(bytesToHex(pubKey)).toBe(bytesToHex(publicKey)); + }); + it('derives the public key from RFC 8032 test vector 3', async () => { const pubKey = await getPublicKey(rfcPrivateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); @@ -44,6 +56,13 @@ describe('getPublicKey', () => { }); describe('sign', () => { + it('signs the provided data with the private key', async () => { + const signature = await sign(privateKey, stringToBytes('foo')); + expect(bytesToHex(signature)).toBe( + '0x0062c22e7ff3c86a9af932d2641b5c532e6b8d7c05c489467cc875c3b27bebd2463010fc816e65b520e60f40ef192ee79e85a9cea918bd2a41d566ee6aeba50b', + ); + }); + it('matches RFC 8032 test vector 3', async () => { const signature = await sign(rfcPrivateKey, rfcMessage); expect(bytesToHex(signature)).toBe(rfcSignature); @@ -76,7 +95,15 @@ describe('sign', () => { }); describe('verify', () => { - it('verifies a valid signature', async () => { + it('verifies the provided data, public key and signature', async () => { + const signature = hexToBytes( + '0x0062c22e7ff3c86a9af932d2641b5c532e6b8d7c05c489467cc875c3b27bebd2463010fc816e65b520e60f40ef192ee79e85a9cea918bd2a41d566ee6aeba50b', + ); + const verified = await verify(publicKey, signature, stringToBytes('foo')); + expect(verified).toBe(true); + }); + + it('verifies the RFC 8032 test vector 3 signature', async () => { const valid = await verify( rfcPublicKey, hexToBytes(rfcSignature), From 06012bbe9568dbf84da49a813872abc76f54f4ff Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 29 Sep 2026 12:19:55 +0200 Subject: [PATCH 5/6] Simplify PKCS8 header creation --- packages/cryptography/src/ed25519.ts | 24 +++----------- packages/cryptography/src/utils.ts | 47 +++++++++++++++++++++++++++- packages/cryptography/src/x25519.ts | 13 ++------ 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/packages/cryptography/src/ed25519.ts b/packages/cryptography/src/ed25519.ts index fc66741fd2d..24f31fa4eee 100644 --- a/packages/cryptography/src/ed25519.ts +++ b/packages/cryptography/src/ed25519.ts @@ -1,4 +1,4 @@ -import { toUint8Array } from './utils.js'; +import { buildPKCS8Header, wrapInPKCS8 } from './utils.js'; // https://www.rfc-editor.org/rfc/rfc8032 const ED25519_KEY_LENGTH = 32; @@ -6,10 +6,7 @@ const ED25519_SIGNATURE_LENGTH = 64; // https://www.rfc-editor.org/rfc/rfc8410#section-7 // https://github.com/nodejs/node/blob/main/test/parallel/test-webcrypto-export-import-cfrg.js -const ED25519_PKCS8_HEADER = new Uint8Array([ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, - 0x22, 0x04, 0x20, -]); +const ED25519_PKCS8_HEADER = buildPKCS8Header([0x2b, 0x65, 0x70]); /** * Derive the Ed25519 public key corresponding to the given private key. @@ -26,15 +23,10 @@ export async function getPublicKey( ); } - const pkcs8 = new Uint8Array( - ED25519_PKCS8_HEADER.length + ED25519_KEY_LENGTH, - ); - pkcs8.set(ED25519_PKCS8_HEADER); - pkcs8.set(toUint8Array(privateKey), ED25519_PKCS8_HEADER.length); - + // The WebCrypto API expects private keys to be in PKCS8 format. const subtlePrivateKey = await globalThis.crypto.subtle.importKey( 'pkcs8', - pkcs8, + wrapInPKCS8(ED25519_PKCS8_HEADER, privateKey), { name: 'Ed25519' }, true, ['sign'], @@ -77,15 +69,9 @@ export async function sign( } // The WebCrypto API expects private keys to be in PKCS8 format. - const pkcs8 = new Uint8Array( - ED25519_PKCS8_HEADER.length + ED25519_KEY_LENGTH, - ); - pkcs8.set(ED25519_PKCS8_HEADER); - pkcs8.set(toUint8Array(privateKey), ED25519_PKCS8_HEADER.length); - const subtleKey = await globalThis.crypto.subtle.importKey( 'pkcs8', - pkcs8, + wrapInPKCS8(ED25519_PKCS8_HEADER, privateKey), { name: 'Ed25519' }, false, ['sign'], diff --git a/packages/cryptography/src/utils.ts b/packages/cryptography/src/utils.ts index db41cc9d0bc..ed6eda0e8fd 100644 --- a/packages/cryptography/src/utils.ts +++ b/packages/cryptography/src/utils.ts @@ -4,9 +4,54 @@ * @param source - The `ArrayBuffer`, typed array, or `DataView` to convert. * @returns A `Uint8Array` sharing memory with the source. */ -export function toUint8Array(source: BufferSource): Uint8Array { +export function toUint8Array(source: BufferSource): Uint8Array { if (source instanceof ArrayBuffer) { return new Uint8Array(source); } return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); } + +/** + * Build the 16-byte PKCS8 header for a private key. + * https://www.rfc-editor.org/rfc/rfc8410#section-7 + * + * @param oid - The 3-byte curve OID. + * @returns The PKCS8 header. + */ +export function buildPKCS8Header( + oid: [number, number, number], +): Uint8Array { + return new Uint8Array([ + 0x30, + 0x2e, + 0x02, + 0x01, + 0x00, + 0x30, + 0x05, + 0x06, + 0x03, + ...oid, + 0x04, + 0x22, + 0x04, + 0x20, + ]); +} + +/** + * Wrap a raw private key in a PKCS8 envelope. + * + * @param header - The algorithm-specific PKCS8 header. + * @param key - The raw key bytes to wrap. + * @returns The complete PKCS8 envelope. + */ +export function wrapInPKCS8( + header: Uint8Array, + key: BufferSource, +): Uint8Array { + const pkcs8 = new Uint8Array(header.length + key.byteLength); + pkcs8.set(header); + pkcs8.set(toUint8Array(key), header.length); + return pkcs8; +} diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index bb6834d2039..c0cc74bf3f9 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -1,4 +1,4 @@ -import { toUint8Array } from './utils.js'; +import { buildPKCS8Header, wrapInPKCS8 } from './utils.js'; const X25519_KEY_LENGTH = 32; @@ -8,10 +8,7 @@ X25519_BASE_POINT[0] = 9; // https://www.rfc-editor.org/rfc/rfc8410#section-7 // https://github.com/nodejs/node/blob/main/test/parallel/test-webcrypto-export-import-cfrg.js -const X25519_PKCS8_HEADER = new Uint8Array([ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, - 0x22, 0x04, 0x20, -]); +const X25519_PKCS8_HEADER = buildPKCS8Header([0x2b, 0x65, 0x6e]); /** * Perform scalar multiplication of a point by a private key, @@ -38,13 +35,9 @@ async function scalarMultiply( } // The WebCrypto API expects private keys to be in PKCS8 format. - const pkcs8 = new Uint8Array(X25519_PKCS8_HEADER.length + X25519_KEY_LENGTH); - pkcs8.set(X25519_PKCS8_HEADER); - pkcs8.set(toUint8Array(privateKey), X25519_PKCS8_HEADER.length); - const subtlePrivateKey = await globalThis.crypto.subtle.importKey( 'pkcs8', - pkcs8, + wrapInPKCS8(X25519_PKCS8_HEADER, privateKey), { name: 'X25519' }, false, ['deriveBits'], From 3b53a555dc6af32cc1be91db06dee11a315696b7 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 29 Sep 2026 12:36:29 +0200 Subject: [PATCH 6/6] Rename PKCS8 wrapping function --- packages/cryptography/src/ed25519.ts | 6 +++--- packages/cryptography/src/utils.ts | 2 +- packages/cryptography/src/x25519.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cryptography/src/ed25519.ts b/packages/cryptography/src/ed25519.ts index 24f31fa4eee..6d985b84824 100644 --- a/packages/cryptography/src/ed25519.ts +++ b/packages/cryptography/src/ed25519.ts @@ -1,4 +1,4 @@ -import { buildPKCS8Header, wrapInPKCS8 } from './utils.js'; +import { buildPKCS8Header, toPKCS8 } from './utils.js'; // https://www.rfc-editor.org/rfc/rfc8032 const ED25519_KEY_LENGTH = 32; @@ -26,7 +26,7 @@ export async function getPublicKey( // The WebCrypto API expects private keys to be in PKCS8 format. const subtlePrivateKey = await globalThis.crypto.subtle.importKey( 'pkcs8', - wrapInPKCS8(ED25519_PKCS8_HEADER, privateKey), + toPKCS8(ED25519_PKCS8_HEADER, privateKey), { name: 'Ed25519' }, true, ['sign'], @@ -71,7 +71,7 @@ export async function sign( // The WebCrypto API expects private keys to be in PKCS8 format. const subtleKey = await globalThis.crypto.subtle.importKey( 'pkcs8', - wrapInPKCS8(ED25519_PKCS8_HEADER, privateKey), + toPKCS8(ED25519_PKCS8_HEADER, privateKey), { name: 'Ed25519' }, false, ['sign'], diff --git a/packages/cryptography/src/utils.ts b/packages/cryptography/src/utils.ts index ed6eda0e8fd..ece7d7164e9 100644 --- a/packages/cryptography/src/utils.ts +++ b/packages/cryptography/src/utils.ts @@ -46,7 +46,7 @@ export function buildPKCS8Header( * @param key - The raw key bytes to wrap. * @returns The complete PKCS8 envelope. */ -export function wrapInPKCS8( +export function toPKCS8( header: Uint8Array, key: BufferSource, ): Uint8Array { diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index c0cc74bf3f9..4c1ec44f236 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -1,4 +1,4 @@ -import { buildPKCS8Header, wrapInPKCS8 } from './utils.js'; +import { buildPKCS8Header, toPKCS8 } from './utils.js'; const X25519_KEY_LENGTH = 32; @@ -37,7 +37,7 @@ async function scalarMultiply( // The WebCrypto API expects private keys to be in PKCS8 format. const subtlePrivateKey = await globalThis.crypto.subtle.importKey( 'pkcs8', - wrapInPKCS8(X25519_PKCS8_HEADER, privateKey), + toPKCS8(X25519_PKCS8_HEADER, privateKey), { name: 'X25519' }, false, ['deriveBits'],