Skip to content
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

feat: add encryption capabilities #102

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ node_modules/
!.yarn/releases
!.yarn/sdks
!.yarn/versions

# ignore intellij local files
.idea/
mirceanis marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 1 addition & 1 deletion snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/message-signing-snap.git"
},
"source": {
"shasum": "0cRKmk9SSZFbxpWn4LvuTZ9HWlNR5U7x1oiBEFID6QQ=",
"shasum": "vvFMwZUglO/SifTaePzikEwZnLKZelKUBOZdZV+3SUU=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
32 changes: 31 additions & 1 deletion src/entropy-keys.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getPublicEntropyKey, signMessageWithEntropyKey } from './entropy-keys';
import {
decryptMessage,
getEncryptionPublicKey,
getPublicEntropyKey,
signMessageWithEntropyKey,
} from './entropy-keys';

const MOCK_PRIVATE_KEY =
'0xec180de430cef919666c2009b91ca3d3b7f6c471136abc9937fa40b89357bbb9';
Expand Down Expand Up @@ -26,6 +31,31 @@ describe('signMessageWithEntropyKey() tests', () => {
});
});

describe('getEncryptionPublicKey() tests', () => {
it('gets the expected encryption key', async () => {
mockSnapGetEntropy();

const publicEncryptionKey = await getEncryptionPublicKey();
const EXPECTED_KEY =
'0x50cbcf3915730e501b7476e92157307f6e9aade2a2798cf3832f73cd4990281b';
expect(publicEncryptionKey).toBe(EXPECTED_KEY);
});

it('decrypts a message intended for this public encryption key', async () => {
mockSnapGetEntropy();

const encrypted = {
version: 'x25519-xsalsa20-poly1305',
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};

const decrypted = await decryptMessage(encrypted);
expect(decrypted).toBe('hello world');
});
});

function mockSnapGetEntropy() {
const mockSnapRequest = jest
.fn()
Expand Down
46 changes: 45 additions & 1 deletion src/entropy-keys.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import type { Eip1024EncryptedData, Hex } from '@metamask/utils';
import { bytesToHex, concatBytes } from '@metamask/utils';
import { utf8ToBytes } from '@noble/ciphers/utils';
import { x25519 } from '@noble/curves/ed25519';
import { secp256k1 } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha256';

import { addressToBytes, bytesToAddress } from './utils/address-conversion';
import { ERC1024 } from './utils/ERC1024';
mirceanis marked this conversation as resolved.
Show resolved Hide resolved

/**
* Retrieve the snap entropy private key.
* @param salt - Optional salt to use for the entropy derivation. Useful for generating keys for different purposes.
* @returns Entropy Private Key Hex.
* @see https://metamask.github.io/SIPs/SIPS/sip-6
*/
async function getEntropy(): Promise<`0x${string}`> {
async function getEntropy(salt: string = ''): Promise<`0x${string}`> {
const entropy = await snap.request({
method: 'snap_getEntropy',
params: {
version: 1,
salt,
},
});

Expand All @@ -37,6 +45,42 @@ export async function getPublicEntropyKey(): Promise<string> {
return bytesToAddress(secp256k1.getPublicKey(privateKey));
}

// This is used to derive an encryption key from the entropy, to avoid key reuse.
const KEY_PURPOSE_ENCRYPTION = 'metamask:snaps:encryption';

/**
* Retrieve the secret encryption key for this snap.
* @returns Encryption Secret Key Bytes.
* @see https://metamask.github.io/SIPs/SIPS/sip-6 for more information about how the derivation works.
*/
async function getEncryptionSecretKey(): Promise<Uint8Array> {
const privateEntropy = await getEntropy(KEY_PURPOSE_ENCRYPTION);
return sha256(
concatBytes([privateEntropy, utf8ToBytes(KEY_PURPOSE_ENCRYPTION)]),
);
}

/**
* Retrieve the public encryption key for this snap.
* @returns Public Key Hex.
*/
export async function getEncryptionPublicKey(): Promise<Hex> {
const secretKeyBytes = await getEncryptionSecretKey();
return bytesToHex(x25519.getPublicKey(secretKeyBytes));
}

/**
* Decrypt an encrypted message using the snap specific encryption key.
* @param encryptedMessage - The encrypted message, encoded as a `Eip1024EncryptedData` object.
* @returns The decrypted message (string).
*/
export async function decryptMessage(
encryptedMessage: Eip1024EncryptedData,
): Promise<string> {
const secretKeyBytes = await getEncryptionSecretKey();
return ERC1024.decrypt(encryptedMessage, secretKeyBytes);
}

/**
* Signs a message and returns the signature.
* @param message - Message to sign.
Expand Down
203 changes: 203 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { installSnap } from '@metamask/snaps-jest';
import type { Hex } from '@metamask/utils';
import { hexToBytes } from '@noble/ciphers/utils';
import { secp256k1 } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha256';

import { ERC1024 } from './utils/ERC1024';

describe('onRpcRequest - getPublicKey', () => {
it('should return this snaps public key', async () => {
const snap = await installSnap();
Expand All @@ -19,6 +22,206 @@ describe('onRpcRequest - getPublicKey', () => {
});
});

describe('onRpcRequest - getEncryptionPublicKey', () => {
it('should return this snaps encryption public key', async () => {
const snap = await installSnap();
const response = await snap.request({
method: 'getEncryptionPublicKey',
});

// E.g. length = 66 chars
// E.g. starts with '0x'
const result = 'result' in response.response && response.response.result;
expect(result?.toString().length).toBe(66);
expect(result?.toString().startsWith('0x')).toBe(true);
});
});

describe('onRpcRequest - decryptMessage', () => {
it('should decrypt a message intended for the snaps public key', async () => {
const snap = await installSnap();
const pkResponse = await snap.request({
method: 'getEncryptionPublicKey',
});
const publicKey = (
'result' in pkResponse.response && pkResponse.response.result
)?.toString() as Hex;
const message = 'hello world';
const encryptedMessage = ERC1024.encrypt(publicKey, message);
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

const result = 'result' in response.response && response.response.result;
expect(result?.toString()).toBe('hello world');
});

it('should fail to decrypt a message intended for a different recipient', async () => {
const snap = await installSnap();
const encryptedMessage = {
version: 'x25519-xsalsa20-poly1305',
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32603,
message: 'invalid tag',
stack: expect.any(String),
});
});

it('should reject a message with invalid version', async () => {
const snap = await installSnap();
const encryptedMessage = {
version: '1', // invalid version
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});

it('should reject a message with missing version', async () => {
const snap = await installSnap();
const encryptedMessage = {
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});

it('should reject a message with invalid nonce', async () => {
const snap = await installSnap();
const encryptedMessage = {
version: 'x25519-xsalsa20-poly1305',
nonce: 'tooshort',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});

it('should reject a message with missing nonce', async () => {
const snap = await installSnap();
const encryptedMessage = {
version: 'x25519-xsalsa20-poly1305',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});

it('should reject a message with invalid ephemPublicKey', async () => {
const snap = await installSnap();
const encryptedMessage = {
version: 'x25519-xsalsa20-poly1305',
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ephemPublicKey: 'invalid base 64',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});

it('should reject a message with missing ephemPublicKey', async () => {
const snap = await installSnap();
const encryptedMessage = {
version: 'x25519-xsalsa20-poly1305',
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
};
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});

it('should reject a message with invalid params type', async () => {
const snap = await installSnap();
const encryptedMessage = JSON.stringify({
version: 'x25519-xsalsa20-poly1305',
nonce: 'h63LvxvCOBP3x3Oou2n5JYgCM1p4p+DF',
ephemPublicKey: 'lmIBlLKUuSBIRjlo+/hL7ngWYpMWQ7biqk7Y6pDsaXY=',
ciphertext: 'g+TpY8OlU0AS9VPvaTIIqpFnWNKvWw2COSJY',
});
const response = await snap.request({
method: 'decryptMessage',
params: { data: encryptedMessage },
});

expect(response).toRespondWithError({
code: -32602,
message:
'`decryptMessage`, must take a `data` parameter that must match the Eip1024EncryptedData schema',
stack: expect.any(String),
});
});
});

describe('onRpcRequest - signMessage', () => {
it('should return a signature that can be verified', async () => {
const snap = await installSnap();
Expand Down
Loading
Loading