Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cryptography/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 4 additions & 0 deletions packages/cryptography/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
182 changes: 182 additions & 0 deletions packages/cryptography/src/ed25519.test.ts
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.',
);
});
});
128 changes: 128 additions & 0 deletions packages/cryptography/src/ed25519.ts
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 },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could parse jwk.x manually, but it seemed cleaner to let the WebCrypto API deal with it instead. Potentially faster too?

{ 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,
);
}
47 changes: 46 additions & 1 deletion packages/cryptography/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this?

Suggested change
export function buildPKCS8Header(
export function buildPkcs8Header(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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;
}
Loading
Loading