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 new file mode 100644 index 00000000000..07fb729ded1 --- /dev/null +++ b/packages/cryptography/src/ed25519.test.ts @@ -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.', + ); + }); +}); diff --git a/packages/cryptography/src/ed25519.ts b/packages/cryptography/src/ed25519.ts new file mode 100644 index 00000000000..6d985b84824 --- /dev/null +++ b/packages/cryptography/src/ed25519.ts @@ -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 { + 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 { + 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 { + 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/utils.ts b/packages/cryptography/src/utils.ts index db41cc9d0bc..ece7d7164e9 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 toPKCS8( + 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..4c1ec44f236 100644 --- a/packages/cryptography/src/x25519.ts +++ b/packages/cryptography/src/x25519.ts @@ -1,4 +1,4 @@ -import { toUint8Array } from './utils.js'; +import { buildPKCS8Header, toPKCS8 } 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, + toPKCS8(X25519_PKCS8_HEADER, privateKey), { name: 'X25519' }, false, ['deriveBits'],