-
-
Notifications
You must be signed in to change notification settings - Fork 309
feat: Add ed25519 key derivation and signature functions #10506
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
6 commits
Select commit
Hold shift + click to select a range
5e86443
feat: Add ed25519 signature functions
FrederikBolding 1a4974c
Add public key derivation
FrederikBolding 4377264
Use subpath export
FrederikBolding bc3c5ca
Add a couple more tests
FrederikBolding 06012bb
Simplify PKCS8 header creation
FrederikBolding 3b53a55
Rename PKCS8 wrapping function
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,182 @@ | ||
| 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( | ||
| '0xc5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7', | ||
| ); | ||
| const rfcPublicKey = hexToBytes( | ||
| '0xfc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025', | ||
| ); | ||
| const rfcMessage = hexToBytes('0xaf82'); | ||
| 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)); | ||
| }); | ||
|
|
||
| it('accepts an ArrayBuffer private key', async () => { | ||
| const pubKey = await getPublicKey(rfcPrivateKey.buffer); | ||
| expect(bytesToHex(pubKey)).toBe(bytesToHex(rfcPublicKey)); | ||
| }); | ||
|
|
||
| it('accepts a DataView private key', async () => { | ||
| 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(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(getPublicKey(new Uint8Array(33))).rejects.toThrow( | ||
| 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
|
|
||
| it('accepts an ArrayBuffer private key and data', async () => { | ||
| 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 sign( | ||
| 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(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(sign(new Uint8Array(33), new Uint8Array(0))).rejects.toThrow( | ||
| 'Invalid private key length: Private key must be exactly 32 bytes for Ed25519.', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('verify', () => { | ||
| 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), | ||
| rfcMessage, | ||
| ); | ||
| expect(valid).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false when signature does not match data', async () => { | ||
| const valid = await verify( | ||
| rfcPublicKey, | ||
| hexToBytes(rfcSignature), | ||
| new Uint8Array(0), | ||
| ); | ||
| expect(valid).toBe(false); | ||
| }); | ||
|
|
||
| it('returns false when signature does not match public key', async () => { | ||
| const valid = await verify( | ||
| new Uint8Array(32), | ||
| hexToBytes(rfcSignature), | ||
| rfcMessage, | ||
| ); | ||
| expect(valid).toBe(false); | ||
| }); | ||
|
|
||
| it('accepts an ArrayBuffer public key, signature, and data', async () => { | ||
| const valid = await verify( | ||
| rfcPublicKey.buffer, | ||
| hexToBytes(rfcSignature).buffer, | ||
| rfcMessage.buffer, | ||
| ); | ||
| expect(valid).toBe(true); | ||
| }); | ||
|
|
||
| it('accepts a DataView public key, signature, and data', async () => { | ||
| const valid = await verify( | ||
| 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( | ||
| verify(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( | ||
| verify(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( | ||
| verify(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( | ||
| verify(rfcPublicKey, new Uint8Array(65), rfcMessage), | ||
| ).rejects.toThrow( | ||
| 'Invalid signature length: Signature must be exactly 64 bytes for Ed25519.', | ||
| ); | ||
| }); | ||
| }); |
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,128 @@ | ||
| import { buildPKCS8Header, toPKCS8 } 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 = buildPKCS8Header([0x2b, 0x65, 0x70]); | ||
|
|
||
| /** | ||
| * 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 getPublicKey( | ||
| privateKey: BufferSource, | ||
| ): Promise<Uint8Array> { | ||
| 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 subtlePrivateKey = await globalThis.crypto.subtle.importKey( | ||
| 'pkcs8', | ||
| toPKCS8(ED25519_PKCS8_HEADER, privateKey), | ||
| { 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. | ||
| * | ||
| * @param privateKey - The 32-byte Ed25519 private key seed. | ||
| * @param data - The data to sign. | ||
| * @returns The 64-byte Ed25519 signature. | ||
| */ | ||
| export async function sign( | ||
| privateKey: BufferSource, | ||
| data: BufferSource, | ||
| ): Promise<Uint8Array> { | ||
| 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 subtleKey = await globalThis.crypto.subtle.importKey( | ||
| 'pkcs8', | ||
| toPKCS8(ED25519_PKCS8_HEADER, privateKey), | ||
| { 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 verify( | ||
| publicKey: BufferSource, | ||
| signature: BufferSource, | ||
| data: BufferSource, | ||
| ): Promise<boolean> { | ||
| 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, | ||
| ); | ||
| } | ||
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<ArrayBuffer> { | ||||||
| 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( | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this?
Suggested change
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had it cased like this before but kinda preferred the uppercase option 🤔 |
||||||
| oid: [number, number, number], | ||||||
| ): Uint8Array<ArrayBuffer> { | ||||||
| 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 toPKCS8( | ||||||
| header: Uint8Array, | ||||||
| key: BufferSource, | ||||||
| ): Uint8Array<ArrayBuffer> { | ||||||
| const pkcs8 = new Uint8Array(header.length + key.byteLength); | ||||||
| pkcs8.set(header); | ||||||
| pkcs8.set(toUint8Array(key), header.length); | ||||||
| return pkcs8; | ||||||
| } | ||||||
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could parse
jwk.xmanually, but it seemed cleaner to let the WebCrypto API deal with it instead. Potentially faster too?