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 5 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": "DL5B2oRkywFAXP14+bNcGTSXv5k3Axt3euy0NRsSD7c=",
"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
39 changes: 39 additions & 0 deletions src/entropy-keys.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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.
Expand Down Expand Up @@ -37,6 +42,40 @@ 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 staticSalt = 'metamask:snaps:encryption';

/**
* Retrieve the secret encryption key for this snap.
* The key is derived from the entropy key and a static salt as sha256(entropy | staticSalt).
* @returns Secret Key Bytes.
*/
async function getEncryptionSecretKey(): Promise<Uint8Array> {
const privateEntropy = await getPrivateEntropyKey();
return sha256(concatBytes([privateEntropy, utf8ToBytes(staticSalt)]));
Copy link
Member

Choose a reason for hiding this comment

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

Does EIP-1024 describe that the entropy should be concatenated with the salt and hashed? Otherwise you could use the salt property of snap_getEntropy instead.

Copy link
Author

@mirceanis mirceanis Dec 16, 2024

Choose a reason for hiding this comment

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

ERC1024 doesn't specify how the encryption key-pairs are generated from a given entropy, but rather which curve should be used (x25519), the message formats and algorithms.

I deliberately added a salt here to avoid key reuse.
The raw entropy is being used as a private key for signing but since the encryption key will be used for ECDH it's best to avoid reuse.

ethereum/EIPs#1098

Copy link
Author

@mirceanis mirceanis Dec 16, 2024

Choose a reason for hiding this comment

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

Since the entropy provided to the snap is not easily replicable to some other context, I think it's less important here to use it directly as a secret key and more important to adhere to best practices, like avoiding key reuse.

If we ever needed to document the whole key generation process for this we would include these 2 steps:

  1. SRP -> snap entropy (I don't know where to find this info yet, but I suspect this is not standard BIP44)
  2. snap entropy -> encryption secret (sha256(entropy | 'metamask:snaps:encryption'))

Copy link
Member

Choose a reason for hiding this comment

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

Sorry, maybe I'm not understanding it properly, but how does it avoid key-reuse when using a static salt? It would be the same as calling snap_getEntropy with staticSalt as salt, as that generates a completely different key too.

Copy link
Member

Choose a reason for hiding this comment

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

SRP -> snap entropy (I don't know where to find this info yet)

SIP-6: https://metamask.github.io/SIPs/SIPS/sip-6

Copy link
Author

Choose a reason for hiding this comment

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

Sorry, maybe I'm not understanding it properly, but how does it avoid key-reuse when using a static salt? It would be the same as calling snap_getEntropy with staticSalt as salt, as that generates a completely different key too.

What I meant was that from the same entropy we need to derive 2 secret keys. One for encryption and one for signing. The point of avoiding key reuse is so that by some means the encryption key gets compromised, you don't automatically leak the signing key. This should be vice-versa, but sadly we are already using the unsalted entropy directly as the signing key.

You are, of course, right when stating that we can provide the salt directly to the snap_getEntropy call. I wasn't aware it accepts a salt parameter, so thank you for insisting on this!!!

Copy link
Author

Choose a reason for hiding this comment

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

I updated the call to snap_getEntropy to achieve proper separation of concerns.
Thanks again for spotting this @Mrtenz !

}

/**
* 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