Repository navigation
feat: Add X25519 key derivation functions #10503
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
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
9312756
feat: Add X25519 key derivation functions
FrederikBolding 522ad1f
Move toUint8Array out
FrederikBolding ea6d5ee
Add length validation
FrederikBolding 7e9844d
Add another PKCS8 header source
FrederikBolding 9344e01
Update CHANGELOG
FrederikBolding 45d1405
Use key length instead
FrederikBolding 7439e3d
Improve naming
FrederikBolding 4849ad1
Use subpath export
FrederikBolding 3b51f9c
Fix lint
FrederikBolding 935bff1
Improve readability
FrederikBolding File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { bytesToHex, hexToBytes } from '@metamask/utils'; | ||
|
|
||
| import { getPublicKey, getSharedSecret } 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('getPublicKey', () => { | ||
| it('derives a public key', async () => { | ||
| const pubKey = await getPublicKey(privateKey); | ||
| expect(bytesToHex(pubKey)).toBe(bytesToHex(publicKey)); | ||
| }); | ||
|
|
||
| it('derives Alice public key from her private key', async () => { | ||
| const pubKey = await getPublicKey(rfcAlicePrivateKey); | ||
| expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); | ||
| }); | ||
|
|
||
| it('derives Bob public key from his private key', async () => { | ||
| const pubKey = await getPublicKey(rfcBobPrivateKey); | ||
| expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcBobPublicKey)); | ||
| }); | ||
|
|
||
| it('accepts an ArrayBuffer private key', async () => { | ||
| const pubKey = await getPublicKey(rfcAlicePrivateKey.buffer); | ||
| expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcAlicePublicKey)); | ||
| }); | ||
|
|
||
| it('accepts a DataView private key', async () => { | ||
| 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(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(getPublicKey(new Uint8Array(33))).rejects.toThrow( | ||
| 'Invalid private key length: Private key must be exactly 32 bytes for X25519.', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getSharedSecret', () => { | ||
| it('computes a shared secret', async () => { | ||
| 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 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); | ||
| expect(bytesToHex(shared)).toBe(rfcSharedSecret); | ||
| }); | ||
|
|
||
| it('accepts an ArrayBuffer private and public key', async () => { | ||
| const shared = await getSharedSecret( | ||
| rfcAlicePrivateKey.buffer, | ||
| rfcBobPublicKey.buffer, | ||
| ); | ||
| expect(bytesToHex(shared)).toBe(rfcSharedSecret); | ||
| }); | ||
|
|
||
| it('accepts a DataView private and public key', async () => { | ||
| const shared = await getSharedSecret( | ||
| new DataView(rfcAlicePrivateKey.buffer), | ||
| new DataView(rfcBobPublicKey.buffer), | ||
| ); | ||
| expect(bytesToHex(shared)).toBe(rfcSharedSecret); | ||
| }); | ||
|
|
||
| it('throws if the private key is too short', async () => { | ||
| await expect( | ||
| getSharedSecret(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( | ||
| getSharedSecret(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( | ||
| getSharedSecret(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( | ||
| getSharedSecret(rfcAlicePrivateKey, new Uint8Array(33)), | ||
| ).rejects.toThrow( | ||
| 'Invalid public key length: Public key must be exactly 32 bytes for X25519.', | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { toUint8Array } from './utils.js'; | ||
|
|
||
| const X25519_KEY_LENGTH = 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 | ||
| // 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, | ||
| ]); | ||
|
|
||
| /** | ||
| * 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 (scalar). | ||
| * @param publicKey - The 32-byte X25519 public key (u-coordinate). | ||
| * @returns The 32-byte result of the scalar multiplication. | ||
| */ | ||
| async function scalarMultiply( | ||
| privateKey: BufferSource, | ||
| publicKey: BufferSource, | ||
| ): Promise<Uint8Array> { | ||
| if (privateKey.byteLength !== X25519_KEY_LENGTH) { | ||
| throw new Error( | ||
| `Invalid private key length: Private key must be exactly ${X25519_KEY_LENGTH} bytes for X25519.`, | ||
| ); | ||
| } | ||
|
|
||
| if (publicKey.byteLength !== X25519_KEY_LENGTH) { | ||
| throw new Error( | ||
| `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_LENGTH); | ||
| 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 result = await globalThis.crypto.subtle.deriveBits( | ||
| { name: 'X25519', public: subtlePublicKey }, | ||
| subtlePrivateKey, | ||
| X25519_KEY_LENGTH * 8, | ||
| ); | ||
|
|
||
| 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<Uint8Array> { | ||
| // 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<Uint8Array> { | ||
| // X25519 shared secrets are derived as X25519(a, K_b) | ||
| return scalarMultiply(privateKey, publicKey); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.