From 9d74f0f6e369e4fba3780d36d185da7416cd76a9 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Wed, 9 Oct 2024 12:23:46 +0100 Subject: [PATCH] clients/js: sapphire snap initial integration --- clients/js/src/cipher.ts | 6 +-- clients/js/src/provider.ts | 80 +++++++++++++++++++++++++++++++--- clients/js/test/cipher.spec.ts | 2 +- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/clients/js/src/cipher.ts b/clients/js/src/cipher.ts index bffd072d..f05d3b19 100644 --- a/clients/js/src/cipher.ts +++ b/clients/js/src/cipher.ts @@ -209,7 +209,7 @@ export class X25519DeoxysII extends Cipher { public override readonly epoch: number | undefined; private cipher: deoxysii.AEAD; - private key: Uint8Array; // Stored for curious users. + public secretKey: Uint8Array; // Stored for curious users. /** Creates a new cipher using an ephemeral keypair stored in memory. */ static ephemeral(peerPublicKey: BytesLike, epoch?: number): X25519DeoxysII { @@ -247,8 +247,8 @@ export class X25519DeoxysII extends Cipher { .update(naclScalarMult(keypair.secretKey, peerPublicKey)) .digest().buffer; - this.key = new Uint8Array(keyBytes); - this.cipher = new deoxysii.AEAD(new Uint8Array(this.key)); // deoxysii owns the input + this.secretKey = new Uint8Array(keyBytes); + this.cipher = new deoxysii.AEAD(new Uint8Array(this.secretKey)); // deoxysii owns the input } public encrypt(plaintext: Uint8Array): { diff --git a/clients/js/src/provider.ts b/clients/js/src/provider.ts index 2b7dbe0c..b4df426a 100644 --- a/clients/js/src/provider.ts +++ b/clients/js/src/provider.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -import { BytesLike } from './ethersutils.js'; +import { BytesLike, hexlify } from './ethersutils.js'; import { KeyFetcher } from './calldatapublickey.js'; import { SUBCALL_ADDR, CALLDATAPUBLICKEY_CALLDATA } from './constants.js'; +import { Cipher } from './cipher.js'; // ----------------------------------------------------------------------------- // https://eips.ethereum.org/EIPS/eip-2696#interface @@ -43,6 +44,7 @@ export function isLegacyProvider( export interface SapphireWrapOptions { fetcher: KeyFetcher; + enableSapphireSnap: boolean | undefined } export function fillOptions( @@ -80,10 +82,10 @@ export function isWrappedEthereumProvider

( * @param options (optional) Re-use parameters from other providers * @returns Sapphire wrapped provider */ -export function wrapEthereumProvider

( +export async function wrapEthereumProvider

( upstream: P, options?: SapphireWrapOptions, -): P { +): Promise

{ if (isWrappedEthereumProvider(upstream)) { return upstream; } @@ -99,7 +101,7 @@ export function wrapEthereumProvider

( // if we do this, don't then re-wrap the send() function // only wrap the send() function if there was a request() function - const request = makeSapphireRequestFn(upstream, filled_options); + const request = await makeSapphireRequestFn(upstream, filled_options); const hooks: Record = { request }; // We prefer a request() method, but a provider may expose a send() method @@ -124,6 +126,62 @@ export function wrapEthereumProvider

( ); } +// ----------------------------------------------------------------------------- +// Interact with the Sapphire MetaMask Snap to provide transaction insights +// This sends the encryption key on a per-transaction basis + +interface SnapInfoT { + version: string; + id: string; + enabled: boolean; + blocked: boolean; +} + +const SAPPHIRE_SNAP_PNPM_ID = 'npm:@oasisprotocol/sapphire-snap'; + +async function detectSapphireSnap (provider: EIP2696_EthereumProvider) { + try { + const installedSnaps = await provider.request({method: 'wallet_getSnaps'}) as Record; + for( const snap of Object.values(installedSnaps) ) { + // TODO: if it's localhost, interrogate snap + if( snap.id == SAPPHIRE_SNAP_PNPM_ID ) { + return snap.id; + } + } + } + catch( e:any ) { + return undefined; + } +} + +async function notifySapphireSnap( + snapId:string, + cipher:Cipher, + transactionData: BytesLike, + options:SapphireWrapOptions, + provider: EIP2696_EthereumProvider +) { + const secretKey = (cipher as any).secretKey as Uint8Array | undefined; + if( secretKey ) { + const peerPublicKey = await options.fetcher.fetch(provider); + await provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId: snapId, + request: { + method: 'setTransactionDecryptKeys', + params: { + id: transactionData, + ephemeralSecretKey: hexlify(secretKey), + peerPublicKey: peerPublicKey.key, + peerPublicKeyEpoch: peerPublicKey.epoch + } + } + } + }); + } +} + const SAPPHIRE_EIP1193_REQUESTFN = '#SAPPHIRE_EIP1193_REQUESTFN' as const; export function isWrappedRequestFn< @@ -148,20 +206,23 @@ export function isCallDataPublicKeyQuery(params?: object | readonly unknown[]) { * @param options * @returns */ -export function makeSapphireRequestFn( +export async function makeSapphireRequestFn( provider: EIP2696_EthereumProvider, options?: SapphireWrapOptions, -): EIP2696_EthereumProvider['request'] { +): Promise { if (isWrappedRequestFn(provider.request)) { return provider.request; } const filled_options = fillOptions(options); + const snapId = filled_options.enableSapphireSnap ? await detectSapphireSnap(provider) : undefined; + const f = async (args: EIP1193_RequestArguments) => { const cipher = await filled_options.fetcher.cipher(provider); const { method, params } = args; + let transactionData : BytesLike | undefined = undefined; // Encrypt requests which can be encrypted if ( params && @@ -169,7 +230,7 @@ export function makeSapphireRequestFn( /^eth_((send|sign)Transaction|call|estimateGas)$/.test(method) && params[0].data // Ignore balance transfers without calldata ) { - params[0].data = cipher.encryptCall(params[0].data); + transactionData = params[0].data = cipher.encryptCall(params[0].data); } const res = await provider.request({ @@ -177,6 +238,11 @@ export function makeSapphireRequestFn( params: params ?? [], }); + if( snapId !== undefined && transactionData !== undefined ) { + // Run in background so as to not delay results + notifySapphireSnap(snapId, cipher, transactionData, filled_options, provider); + } + // Decrypt responses which return encrypted data if (method === 'eth_call') { // If it's an unencrypted core.CallDataPublicKey query, don't attempt to decrypt the response diff --git a/clients/js/test/cipher.spec.ts b/clients/js/test/cipher.spec.ts index 0e00287b..3b560046 100644 --- a/clients/js/test/cipher.spec.ts +++ b/clients/js/test/cipher.spec.ts @@ -15,7 +15,7 @@ describe('X25519DeoxysII', () => { expect(hexlify(cipher.publicKey)).toEqual( '0x3046db3fa70ce605457dc47c48837ebd8bd0a26abfde5994d033e1ced68e2576', ); - expect(hexlify(cipher['key'])).toEqual( + expect(hexlify(cipher['secretKey'])).toEqual( '0xe69ac21066a8c2284e8fdc690e579af4513547b9b31dd144792c1904b45cf586', );