From 9312756198d7cb8efb3d1f5945379efd6b0f5a04 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 12:15:39 +0200 Subject: [PATCH 01/10] feat: Add X25519 key derivation functions --- packages/cryptography/src/index.ts | 1 + packages/cryptography/src/x25519.test.ts | 101 +++++++++++++++++++++++ packages/cryptography/src/x25519.ts | 71 ++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 packages/cryptography/src/x25519.test.ts create mode 100644 packages/cryptography/src/x25519.ts diff --git a/packages/cryptography/src/index.ts b/packages/cryptography/src/index.ts index 932af223c58..a7d97925289 100644 --- a/packages/cryptography/src/index.ts +++ b/packages/cryptography/src/index.ts @@ -3,3 +3,4 @@ export * from './hmac.js'; export * from './pbkdf2.js'; export * from './sha.js'; export type * from './types.js'; +export * from './x25519.js'; diff --git a/packages/cryptography/src/x25519.test.ts b/packages/cryptography/src/x25519.test.ts new file mode 100644 index 00000000000..eb8ceaa0612 --- /dev/null +++ b/packages/cryptography/src/x25519.test.ts @@ -0,0 +1,101 @@ +import { bytesToHex, hexToBytes } from '@metamask/utils'; + +import { x25519GetPublicKey, x25519GetSharedSecret } from './x25519.js'; + +const privateKey = hexToBytes( + '0x4a78ac42b72f1232d99257d03675b6268906361f902e85ef9f407270b376b271', +); +const publicKey = hexToBytes( + '0x3131ecda5b9fb0afed66c842197b7eaf063a2e1ceebf60d206c5c11c89916d6d', +); +const publicKey2 = hexToBytes( + '0x7580f1903245d94336767cafcb781a06507b6a8f889c471c2aa348e01bc4f94b', +); +const sharedSecret = hexToBytes( + '0xdccc8b748350104639ac6bf67a1b6e7698dcd007de5cc7c6e010b0185ceade52', +); + +// RFC 7748 Section 6.1 test vectors +// https://datatracker.ietf.org/doc/html/rfc7748#section-6.1 +const rfcAlicePrivateKey = hexToBytes( + '0x77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a', +); +const rfcAlicePublicKey = hexToBytes( + '0x8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a', +); +const rfcBobPrivateKey = hexToBytes( + '0x5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb', +); +const rfcBobPublicKey = hexToBytes( + '0xde9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f', +); +const rfcSharedSecret = + '0x4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742'; + +describe('x25519GetPublicKey', () => { + it('derives a public key', async () => { + const pubKey = await x25519GetPublicKey(privateKey); + expect(bytesToHex(pubKey)).toBe(bytesToHex(publicKey)); + }); + + it('derives Alice public key from her private key', async () => { + const pubKey = await x25519GetPublicKey(rfcAlicePrivateKey); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); + }); + + it('derives Bob public key from his private key', async () => { + const pubKey = await x25519GetPublicKey(rfcBobPrivateKey); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcBobPublicKey)); + }); + + it('accepts an ArrayBuffer private key', async () => { + const pubKey = await x25519GetPublicKey(rfcAlicePrivateKey.buffer); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); + }); + + it('accepts a DataView private key', async () => { + const pubKey = await x25519GetPublicKey( + new DataView(rfcAlicePrivateKey.buffer), + ); + expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); + }); +}); + +describe('x25519GetSharedSecret', () => { + it('computes a shared secret', async () => { + const shared = await x25519GetSharedSecret(privateKey, publicKey2); + expect(bytesToHex(shared)).toBe(bytesToHex(sharedSecret)); + }); + + it('computes the shared secret from Alice private key and Bob public key', async () => { + const shared = await x25519GetSharedSecret( + rfcAlicePrivateKey, + rfcBobPublicKey, + ); + expect(bytesToHex(shared)).toBe(rfcSharedSecret); + }); + + it('computes the shared secret from Bob private key and Alice public key', async () => { + const shared = await x25519GetSharedSecret( + rfcBobPrivateKey, + rfcAlicePublicKey, + ); + expect(bytesToHex(shared)).toBe(rfcSharedSecret); + }); + + it('accepts an ArrayBuffer private and public key', async () => { + const shared = await x25519GetSharedSecret( + rfcAlicePrivateKey.buffer, + rfcBobPublicKey.buffer, + ); + expect(bytesToHex(shared)).toBe(rfcSharedSecret); + }); + + it('accepts a DataView private and public key', async () => { + const shared = await x25519GetSharedSecret( + new DataView(rfcAlicePrivateKey.buffer), + new DataView(rfcBobPublicKey.buffer), + ); + expect(bytesToHex(shared)).toBe(rfcSharedSecret); + }); +}); diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts new file mode 100644 index 00000000000..92b8b610fa2 --- /dev/null +++ b/packages/cryptography/src/x25519.ts @@ -0,0 +1,71 @@ +const X25519_KEY_SIZE = 32; + +// https://www.rfc-editor.org/rfc/rfc7748#section-4.1 +const X25519_BASE_POINT = new Uint8Array(32); +X25519_BASE_POINT[0] = 9; + +// https://www.rfc-editor.org/rfc/rfc8410#section-7 +const X25519_PKCS8_HEADER = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, + 0x22, 0x04, 0x20, +]); + +/** + * Derive the X25519 public key corresponding to a private key. + * + * @param privateKey - The 32-byte X25519 private key. + * @returns The 32-byte X25519 public key. + */ +export async function x25519GetPublicKey( + privateKey: BufferSource, +): Promise { + return x25519GetSharedSecret(privateKey, X25519_BASE_POINT); +} + +/** + * Compute the X25519 shared secret given a private key and a peer's public key. + * + * @param privateKey - The 32-byte X25519 private key. + * @param publicKey - The 32-byte X25519 public key of the peer. + * @returns The 32-byte shared secret. + */ +export async function x25519GetSharedSecret( + privateKey: BufferSource, + publicKey: BufferSource, +): Promise { + // The WebCrypto API expects private keys to be in PKCS8 format. + const pkcs8 = new Uint8Array(X25519_PKCS8_HEADER.length + X25519_KEY_SIZE); + pkcs8.set(X25519_PKCS8_HEADER); + pkcs8.set(toUint8Array(privateKey), X25519_PKCS8_HEADER.length); + + const subtlePrivateKey = await globalThis.crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: 'X25519' }, + false, + ['deriveBits'], + ); + + const subtlePublicKey = await globalThis.crypto.subtle.importKey( + 'raw', + publicKey, + { name: 'X25519' }, + false, + [], + ); + + const sharedSecret = await globalThis.crypto.subtle.deriveBits( + { name: 'X25519', public: subtlePublicKey }, + subtlePrivateKey, + X25519_KEY_SIZE * 8, + ); + + return new Uint8Array(sharedSecret); +} + +function toUint8Array(source: BufferSource): Uint8Array { + if (source instanceof ArrayBuffer) { + return new Uint8Array(source); + } + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); +} From 522ad1fa9ae07f89d5596816123a656606d77187 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 12:18:13 +0200 Subject: [PATCH 02/10] Move toUint8Array out --- packages/cryptography/src/utils.ts | 12 ++++++++++++ packages/cryptography/src/x25519.ts | 9 ++------- 2 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 packages/cryptography/src/utils.ts diff --git a/packages/cryptography/src/utils.ts b/packages/cryptography/src/utils.ts new file mode 100644 index 00000000000..db41cc9d0bc --- /dev/null +++ b/packages/cryptography/src/utils.ts @@ -0,0 +1,12 @@ +/** + * Convert a `BufferSource` to a `Uint8Array` view over the same bytes. + * + * @param source - The `ArrayBuffer`, typed array, or `DataView` to convert. + * @returns A `Uint8Array` sharing memory with the source. + */ +export function toUint8Array(source: BufferSource): Uint8Array { + if (source instanceof ArrayBuffer) { + return new Uint8Array(source); + } + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); +} diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index 92b8b610fa2..43122f53822 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -1,3 +1,5 @@ +import { toUint8Array } from './utils.js'; + const X25519_KEY_SIZE = 32; // https://www.rfc-editor.org/rfc/rfc7748#section-4.1 @@ -62,10 +64,3 @@ export async function x25519GetSharedSecret( return new Uint8Array(sharedSecret); } - -function toUint8Array(source: BufferSource): Uint8Array { - if (source instanceof ArrayBuffer) { - return new Uint8Array(source); - } - return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); -} From ea6d5eeccd479d4b8ac82604b52c8cf9bf11002c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 12:23:55 +0200 Subject: [PATCH 03/10] Add length validation --- packages/cryptography/src/x25519.test.ts | 44 ++++++++++++++++++++++++ packages/cryptography/src/x25519.ts | 12 +++++++ 2 files changed, 56 insertions(+) diff --git a/packages/cryptography/src/x25519.test.ts b/packages/cryptography/src/x25519.test.ts index eb8ceaa0612..b5212179ed4 100644 --- a/packages/cryptography/src/x25519.test.ts +++ b/packages/cryptography/src/x25519.test.ts @@ -59,6 +59,18 @@ describe('x25519GetPublicKey', () => { ); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); + + it('throws if the private key is too short', async () => { + await expect(x25519GetPublicKey(new Uint8Array(31))).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', + ); + }); + + it('throws if the private key is too long', async () => { + await expect(x25519GetPublicKey(new Uint8Array(33))).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', + ); + }); }); describe('x25519GetSharedSecret', () => { @@ -98,4 +110,36 @@ describe('x25519GetSharedSecret', () => { ); expect(bytesToHex(shared)).toBe(rfcSharedSecret); }); + + it('throws if the private key is too short', async () => { + await expect( + x25519GetSharedSecret(new Uint8Array(31), rfcBobPublicKey), + ).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', + ); + }); + + it('throws if the private key is too long', async () => { + await expect( + x25519GetSharedSecret(new Uint8Array(33), rfcBobPublicKey), + ).rejects.toThrow( + 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', + ); + }); + + it('throws if the public key is too short', async () => { + await expect( + x25519GetSharedSecret(rfcAlicePrivateKey, new Uint8Array(31)), + ).rejects.toThrow( + 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', + ); + }); + + it('throws if the public key is too long', async () => { + await expect( + x25519GetSharedSecret(rfcAlicePrivateKey, new Uint8Array(33)), + ).rejects.toThrow( + 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', + ); + }); }); diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index 43122f53822..ef94f867e68 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -35,6 +35,18 @@ export async function x25519GetSharedSecret( privateKey: BufferSource, publicKey: BufferSource, ): Promise { + if (privateKey.byteLength !== X25519_KEY_SIZE) { + throw new Error( + `Invalid private key length: Private key must be exactly ${X25519_KEY_SIZE} bytes for X25519.`, + ); + } + + if (publicKey.byteLength !== X25519_KEY_SIZE) { + throw new Error( + `Invalid public key length: Public key must be exactly ${X25519_KEY_SIZE} bytes for X25519.`, + ); + } + // The WebCrypto API expects private keys to be in PKCS8 format. const pkcs8 = new Uint8Array(X25519_PKCS8_HEADER.length + X25519_KEY_SIZE); pkcs8.set(X25519_PKCS8_HEADER); From 7e9844d8407179ea8ac7db877e065ff1324dc331 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 12:29:13 +0200 Subject: [PATCH 04/10] Add another PKCS8 header source --- packages/cryptography/src/x25519.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index ef94f867e68..6baa68e0d9e 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -7,6 +7,7 @@ const X25519_BASE_POINT = new Uint8Array(32); 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, From 9344e01f5e65cd0b38d461092dc1be0fbf11a9bd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 12:44:51 +0200 Subject: [PATCH 05/10] Update CHANGELOG --- packages/cryptography/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index a6bc460df92..da49b7477ad 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -9,10 +9,11 @@ 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)) +- 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)) - 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 `x25519GetPublicKey` and `x25519GetSharedSecret` functions for X25519 key derivation [Unreleased]: https://github.com/MetaMask/core/ From 45d1405da3196680d37318eeb5b3de026352234f Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 13:30:53 +0200 Subject: [PATCH 06/10] Use key length instead --- packages/cryptography/src/x25519.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index 6baa68e0d9e..0b5bf20c46f 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -1,6 +1,6 @@ import { toUint8Array } from './utils.js'; -const X25519_KEY_SIZE = 32; +const X25519_KEY_LENGTH = 32; // https://www.rfc-editor.org/rfc/rfc7748#section-4.1 const X25519_BASE_POINT = new Uint8Array(32); @@ -36,20 +36,20 @@ export async function x25519GetSharedSecret( privateKey: BufferSource, publicKey: BufferSource, ): Promise { - if (privateKey.byteLength !== X25519_KEY_SIZE) { + if (privateKey.byteLength !== X25519_KEY_LENGTH) { throw new Error( - `Invalid private key length: Private key must be exactly ${X25519_KEY_SIZE} bytes for X25519.`, + `Invalid private key length: Private key must be exactly ${X25519_KEY_LENGTH} bytes for X25519.`, ); } - if (publicKey.byteLength !== X25519_KEY_SIZE) { + if (publicKey.byteLength !== X25519_KEY_LENGTH) { throw new Error( - `Invalid public key length: Public key must be exactly ${X25519_KEY_SIZE} bytes for X25519.`, + `Invalid public key length: Public key must be exactly ${X25519_KEY_LENGTH} bytes for X25519.`, ); } // The WebCrypto API expects private keys to be in PKCS8 format. - const pkcs8 = new Uint8Array(X25519_PKCS8_HEADER.length + X25519_KEY_SIZE); + const pkcs8 = new Uint8Array(X25519_PKCS8_HEADER.length + X25519_KEY_LENGTH); pkcs8.set(X25519_PKCS8_HEADER); pkcs8.set(toUint8Array(privateKey), X25519_PKCS8_HEADER.length); @@ -72,7 +72,7 @@ export async function x25519GetSharedSecret( const sharedSecret = await globalThis.crypto.subtle.deriveBits( { name: 'X25519', public: subtlePublicKey }, subtlePrivateKey, - X25519_KEY_SIZE * 8, + X25519_KEY_LENGTH * 8, ); return new Uint8Array(sharedSecret); From 7439e3d0c764c8e977eff3db384f7e79b37c18c3 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 13:31:36 +0200 Subject: [PATCH 07/10] Improve naming --- packages/cryptography/CHANGELOG.md | 2 +- packages/cryptography/src/x25519.test.ts | 38 ++++++++++++------------ packages/cryptography/src/x25519.ts | 6 ++-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index da49b7477ad..fb9c1336c66 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -14,6 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 `x25519GetPublicKey` and `x25519GetSharedSecret` functions for X25519 key derivation + - Add `getX25519PublicKey` and `getX25519SharedSecret` functions for X25519 key derivation [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/cryptography/src/x25519.test.ts b/packages/cryptography/src/x25519.test.ts index b5212179ed4..4a00885e9db 100644 --- a/packages/cryptography/src/x25519.test.ts +++ b/packages/cryptography/src/x25519.test.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from '@metamask/utils'; -import { x25519GetPublicKey, x25519GetSharedSecret } from './x25519.js'; +import { getX25519PublicKey, getX25519SharedSecret } from './x25519.js'; const privateKey = hexToBytes( '0x4a78ac42b72f1232d99257d03675b6268906361f902e85ef9f407270b376b271', @@ -32,55 +32,55 @@ const rfcBobPublicKey = hexToBytes( const rfcSharedSecret = '0x4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742'; -describe('x25519GetPublicKey', () => { +describe('getX25519PublicKey', () => { it('derives a public key', async () => { - const pubKey = await x25519GetPublicKey(privateKey); + const pubKey = await getX25519PublicKey(privateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(publicKey)); }); it('derives Alice public key from her private key', async () => { - const pubKey = await x25519GetPublicKey(rfcAlicePrivateKey); + const pubKey = await getX25519PublicKey(rfcAlicePrivateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); it('derives Bob public key from his private key', async () => { - const pubKey = await x25519GetPublicKey(rfcBobPrivateKey); + const pubKey = await getX25519PublicKey(rfcBobPrivateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcBobPublicKey)); }); it('accepts an ArrayBuffer private key', async () => { - const pubKey = await x25519GetPublicKey(rfcAlicePrivateKey.buffer); + const pubKey = await getX25519PublicKey(rfcAlicePrivateKey.buffer); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); it('accepts a DataView private key', async () => { - const pubKey = await x25519GetPublicKey( + const pubKey = await getX25519PublicKey( new DataView(rfcAlicePrivateKey.buffer), ); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); it('throws if the private key is too short', async () => { - await expect(x25519GetPublicKey(new Uint8Array(31))).rejects.toThrow( + await expect(getX25519PublicKey(new Uint8Array(31))).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', ); }); it('throws if the private key is too long', async () => { - await expect(x25519GetPublicKey(new Uint8Array(33))).rejects.toThrow( + await expect(getX25519PublicKey(new Uint8Array(33))).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', ); }); }); -describe('x25519GetSharedSecret', () => { +describe('getX25519SharedSecret', () => { it('computes a shared secret', async () => { - const shared = await x25519GetSharedSecret(privateKey, publicKey2); + const shared = await getX25519SharedSecret(privateKey, publicKey2); expect(bytesToHex(shared)).toBe(bytesToHex(sharedSecret)); }); it('computes the shared secret from Alice private key and Bob public key', async () => { - const shared = await x25519GetSharedSecret( + const shared = await getX25519SharedSecret( rfcAlicePrivateKey, rfcBobPublicKey, ); @@ -88,7 +88,7 @@ describe('x25519GetSharedSecret', () => { }); it('computes the shared secret from Bob private key and Alice public key', async () => { - const shared = await x25519GetSharedSecret( + const shared = await getX25519SharedSecret( rfcBobPrivateKey, rfcAlicePublicKey, ); @@ -96,7 +96,7 @@ describe('x25519GetSharedSecret', () => { }); it('accepts an ArrayBuffer private and public key', async () => { - const shared = await x25519GetSharedSecret( + const shared = await getX25519SharedSecret( rfcAlicePrivateKey.buffer, rfcBobPublicKey.buffer, ); @@ -104,7 +104,7 @@ describe('x25519GetSharedSecret', () => { }); it('accepts a DataView private and public key', async () => { - const shared = await x25519GetSharedSecret( + const shared = await getX25519SharedSecret( new DataView(rfcAlicePrivateKey.buffer), new DataView(rfcBobPublicKey.buffer), ); @@ -113,7 +113,7 @@ describe('x25519GetSharedSecret', () => { it('throws if the private key is too short', async () => { await expect( - x25519GetSharedSecret(new Uint8Array(31), rfcBobPublicKey), + getX25519SharedSecret(new Uint8Array(31), rfcBobPublicKey), ).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', ); @@ -121,7 +121,7 @@ describe('x25519GetSharedSecret', () => { it('throws if the private key is too long', async () => { await expect( - x25519GetSharedSecret(new Uint8Array(33), rfcBobPublicKey), + getX25519SharedSecret(new Uint8Array(33), rfcBobPublicKey), ).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', ); @@ -129,7 +129,7 @@ describe('x25519GetSharedSecret', () => { it('throws if the public key is too short', async () => { await expect( - x25519GetSharedSecret(rfcAlicePrivateKey, new Uint8Array(31)), + getX25519SharedSecret(rfcAlicePrivateKey, new Uint8Array(31)), ).rejects.toThrow( 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', ); @@ -137,7 +137,7 @@ describe('x25519GetSharedSecret', () => { it('throws if the public key is too long', async () => { await expect( - x25519GetSharedSecret(rfcAlicePrivateKey, new Uint8Array(33)), + getX25519SharedSecret(rfcAlicePrivateKey, new Uint8Array(33)), ).rejects.toThrow( 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', ); diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index 0b5bf20c46f..acb93c42860 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -19,10 +19,10 @@ const X25519_PKCS8_HEADER = new Uint8Array([ * @param privateKey - The 32-byte X25519 private key. * @returns The 32-byte X25519 public key. */ -export async function x25519GetPublicKey( +export async function getX25519PublicKey( privateKey: BufferSource, ): Promise { - return x25519GetSharedSecret(privateKey, X25519_BASE_POINT); + return getX25519SharedSecret(privateKey, X25519_BASE_POINT); } /** @@ -32,7 +32,7 @@ export async function x25519GetPublicKey( * @param publicKey - The 32-byte X25519 public key of the peer. * @returns The 32-byte shared secret. */ -export async function x25519GetSharedSecret( +export async function getX25519SharedSecret( privateKey: BufferSource, publicKey: BufferSource, ): Promise { From 4849ad11a1d0c4493eab6d5f81cef982138a73cd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 15:30:44 +0200 Subject: [PATCH 08/10] Use subpath export --- packages/cryptography/CHANGELOG.md | 2 +- packages/cryptography/package.json | 4 +++ packages/cryptography/src/index.ts | 1 - packages/cryptography/src/x25519.test.ts | 38 ++++++++++++------------ packages/cryptography/src/x25519.ts | 6 ++-- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index fb9c1336c66..b6531df0d11 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -14,6 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 `getX25519PublicKey` and `getX25519SharedSecret` functions for X25519 key derivation + - Add `getPublicKey` and `getSharedSecret` functions for X25519 key derivation exported via `@metamask/cryptography/x25519` [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/cryptography/package.json b/packages/cryptography/package.json index 6e7e184f1c8..8c87f3de4d7 100644 --- a/packages/cryptography/package.json +++ b/packages/cryptography/package.json @@ -25,6 +25,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./x25519": { + "types": "./dist/x25519.d.ts", + "default": "./dist/x25519.js" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/packages/cryptography/src/index.ts b/packages/cryptography/src/index.ts index a7d97925289..932af223c58 100644 --- a/packages/cryptography/src/index.ts +++ b/packages/cryptography/src/index.ts @@ -3,4 +3,3 @@ export * from './hmac.js'; export * from './pbkdf2.js'; export * from './sha.js'; export type * from './types.js'; -export * from './x25519.js'; diff --git a/packages/cryptography/src/x25519.test.ts b/packages/cryptography/src/x25519.test.ts index 4a00885e9db..7179c2408ab 100644 --- a/packages/cryptography/src/x25519.test.ts +++ b/packages/cryptography/src/x25519.test.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from '@metamask/utils'; -import { getX25519PublicKey, getX25519SharedSecret } from './x25519.js'; +import { getPublicKey, getSharedSecret } from './x25519.js'; const privateKey = hexToBytes( '0x4a78ac42b72f1232d99257d03675b6268906361f902e85ef9f407270b376b271', @@ -32,55 +32,55 @@ const rfcBobPublicKey = hexToBytes( const rfcSharedSecret = '0x4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742'; -describe('getX25519PublicKey', () => { +describe('getPublicKey', () => { it('derives a public key', async () => { - const pubKey = await getX25519PublicKey(privateKey); + const pubKey = await getPublicKey(privateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(publicKey)); }); it('derives Alice public key from her private key', async () => { - const pubKey = await getX25519PublicKey(rfcAlicePrivateKey); + const pubKey = await getPublicKey(rfcAlicePrivateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); it('derives Bob public key from his private key', async () => { - const pubKey = await getX25519PublicKey(rfcBobPrivateKey); + const pubKey = await getPublicKey(rfcBobPrivateKey); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcBobPublicKey)); }); it('accepts an ArrayBuffer private key', async () => { - const pubKey = await getX25519PublicKey(rfcAlicePrivateKey.buffer); + const pubKey = await getPublicKey(rfcAlicePrivateKey.buffer); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); it('accepts a DataView private key', async () => { - const pubKey = await getX25519PublicKey( + const pubKey = await getPublicKey( new DataView(rfcAlicePrivateKey.buffer), ); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); it('throws if the private key is too short', async () => { - await expect(getX25519PublicKey(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 X25519.', ); }); it('throws if the private key is too long', async () => { - await expect(getX25519PublicKey(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 X25519.', ); }); }); -describe('getX25519SharedSecret', () => { +describe('getSharedSecret', () => { it('computes a shared secret', async () => { - const shared = await getX25519SharedSecret(privateKey, publicKey2); + const shared = await getSharedSecret(privateKey, publicKey2); expect(bytesToHex(shared)).toBe(bytesToHex(sharedSecret)); }); it('computes the shared secret from Alice private key and Bob public key', async () => { - const shared = await getX25519SharedSecret( + const shared = await getSharedSecret( rfcAlicePrivateKey, rfcBobPublicKey, ); @@ -88,7 +88,7 @@ describe('getX25519SharedSecret', () => { }); it('computes the shared secret from Bob private key and Alice public key', async () => { - const shared = await getX25519SharedSecret( + const shared = await getSharedSecret( rfcBobPrivateKey, rfcAlicePublicKey, ); @@ -96,7 +96,7 @@ describe('getX25519SharedSecret', () => { }); it('accepts an ArrayBuffer private and public key', async () => { - const shared = await getX25519SharedSecret( + const shared = await getSharedSecret( rfcAlicePrivateKey.buffer, rfcBobPublicKey.buffer, ); @@ -104,7 +104,7 @@ describe('getX25519SharedSecret', () => { }); it('accepts a DataView private and public key', async () => { - const shared = await getX25519SharedSecret( + const shared = await getSharedSecret( new DataView(rfcAlicePrivateKey.buffer), new DataView(rfcBobPublicKey.buffer), ); @@ -113,7 +113,7 @@ describe('getX25519SharedSecret', () => { it('throws if the private key is too short', async () => { await expect( - getX25519SharedSecret(new Uint8Array(31), rfcBobPublicKey), + getSharedSecret(new Uint8Array(31), rfcBobPublicKey), ).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', ); @@ -121,7 +121,7 @@ describe('getX25519SharedSecret', () => { it('throws if the private key is too long', async () => { await expect( - getX25519SharedSecret(new Uint8Array(33), rfcBobPublicKey), + getSharedSecret(new Uint8Array(33), rfcBobPublicKey), ).rejects.toThrow( 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', ); @@ -129,7 +129,7 @@ describe('getX25519SharedSecret', () => { it('throws if the public key is too short', async () => { await expect( - getX25519SharedSecret(rfcAlicePrivateKey, new Uint8Array(31)), + getSharedSecret(rfcAlicePrivateKey, new Uint8Array(31)), ).rejects.toThrow( 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', ); @@ -137,7 +137,7 @@ describe('getX25519SharedSecret', () => { it('throws if the public key is too long', async () => { await expect( - getX25519SharedSecret(rfcAlicePrivateKey, new Uint8Array(33)), + getSharedSecret(rfcAlicePrivateKey, new Uint8Array(33)), ).rejects.toThrow( 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', ); diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index acb93c42860..265c154bc9d 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -19,10 +19,10 @@ const X25519_PKCS8_HEADER = new Uint8Array([ * @param privateKey - The 32-byte X25519 private key. * @returns The 32-byte X25519 public key. */ -export async function getX25519PublicKey( +export async function getPublicKey( privateKey: BufferSource, ): Promise { - return getX25519SharedSecret(privateKey, X25519_BASE_POINT); + return getSharedSecret(privateKey, X25519_BASE_POINT); } /** @@ -32,7 +32,7 @@ export async function getX25519PublicKey( * @param publicKey - The 32-byte X25519 public key of the peer. * @returns The 32-byte shared secret. */ -export async function getX25519SharedSecret( +export async function getSharedSecret( privateKey: BufferSource, publicKey: BufferSource, ): Promise { From 3b51f9ce43fb6b619c41ef2ac2d1cb61b0e18db3 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 28 Sep 2026 15:44:37 +0200 Subject: [PATCH 09/10] Fix lint --- packages/cryptography/src/x25519.test.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/cryptography/src/x25519.test.ts b/packages/cryptography/src/x25519.test.ts index 7179c2408ab..08088809854 100644 --- a/packages/cryptography/src/x25519.test.ts +++ b/packages/cryptography/src/x25519.test.ts @@ -54,9 +54,7 @@ describe('getPublicKey', () => { }); it('accepts a DataView private key', async () => { - const pubKey = await getPublicKey( - new DataView(rfcAlicePrivateKey.buffer), - ); + const pubKey = await getPublicKey(new DataView(rfcAlicePrivateKey.buffer)); expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); }); @@ -80,18 +78,12 @@ describe('getSharedSecret', () => { }); it('computes the shared secret from Alice private key and Bob public key', async () => { - const shared = await getSharedSecret( - rfcAlicePrivateKey, - rfcBobPublicKey, - ); + const shared = await getSharedSecret(rfcAlicePrivateKey, rfcBobPublicKey); expect(bytesToHex(shared)).toBe(rfcSharedSecret); }); it('computes the shared secret from Bob private key and Alice public key', async () => { - const shared = await getSharedSecret( - rfcBobPrivateKey, - rfcAlicePublicKey, - ); + const shared = await getSharedSecret(rfcBobPrivateKey, rfcAlicePublicKey); expect(bytesToHex(shared)).toBe(rfcSharedSecret); }); From 935bff1092ac29342480e1d9c83f238c5b5d3cff Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 29 Sep 2026 11:15:45 +0200 Subject: [PATCH 10/10] Improve readability --- packages/cryptography/src/x25519.ts | 55 +++++++++++++++++++---------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/cryptography/src/x25519.ts b/packages/cryptography/src/x25519.ts index 265c154bc9d..bb6834d2039 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -14,25 +14,14 @@ const X25519_PKCS8_HEADER = new Uint8Array([ ]); /** - * Derive the X25519 public key corresponding to a private key. - * - * @param privateKey - The 32-byte X25519 private key. - * @returns The 32-byte X25519 public key. - */ -export async function getPublicKey( - privateKey: BufferSource, -): Promise { - return getSharedSecret(privateKey, X25519_BASE_POINT); -} - -/** - * Compute the X25519 shared secret given a private key and a peer's public key. + * Perform scalar multiplication of a point by a private key, + * specified as the X25519 function in RFC 7748, Section 5. * - * @param privateKey - The 32-byte X25519 private key. - * @param publicKey - The 32-byte X25519 public key of the peer. - * @returns The 32-byte shared secret. + * @param privateKey - The 32-byte X25519 private key (scalar). + * @param publicKey - The 32-byte X25519 public key (u-coordinate). + * @returns The 32-byte result of the scalar multiplication. */ -export async function getSharedSecret( +async function scalarMultiply( privateKey: BufferSource, publicKey: BufferSource, ): Promise { @@ -69,11 +58,39 @@ export async function getSharedSecret( [], ); - const sharedSecret = await globalThis.crypto.subtle.deriveBits( + const result = await globalThis.crypto.subtle.deriveBits( { name: 'X25519', public: subtlePublicKey }, subtlePrivateKey, X25519_KEY_LENGTH * 8, ); - return new Uint8Array(sharedSecret); + return new Uint8Array(result); +} + +/** + * Derive the X25519 public key corresponding to a private key. + * + * @param privateKey - The 32-byte X25519 private key. + * @returns The 32-byte X25519 public key. + */ +export async function getPublicKey( + privateKey: BufferSource, +): Promise { + // X25519 public keys are derived as X25519(k, 9) + return scalarMultiply(privateKey, X25519_BASE_POINT); +} + +/** + * Compute the X25519 shared secret given a private key and a peer's public key. + * + * @param privateKey - The 32-byte X25519 private key. + * @param publicKey - The 32-byte X25519 public key of the peer. + * @returns The 32-byte shared secret. + */ +export async function getSharedSecret( + privateKey: BufferSource, + publicKey: BufferSource, +): Promise { + // X25519 shared secrets are derived as X25519(a, K_b) + return scalarMultiply(privateKey, publicKey); }