diff --git a/clients/js/src/cipher.ts b/clients/js/src/cipher.ts index 31e013c5e..cc5afa3ec 100644 --- a/clients/js/src/cipher.ts +++ b/clients/js/src/cipher.ts @@ -80,6 +80,7 @@ function formatFailure(fail: CallFailure): string { export abstract class Cipher { public abstract kind: CipherKind; public abstract publicKey: Uint8Array; + public abstract ephemeralKey: Uint8Array; public abstract epoch?: number; public abstract encrypt( @@ -213,10 +214,11 @@ export abstract class Cipher { export class X25519DeoxysII extends Cipher { public override readonly kind = CipherKind.X25519DeoxysII; public override readonly publicKey: Uint8Array; + public override readonly ephemeralKey: Uint8Array; 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 { @@ -243,6 +245,7 @@ export class X25519DeoxysII extends Cipher { super(); this.publicKey = keypair.publicKey; + this.ephemeralKey = keypair.secretKey; this.epoch = epoch; // Derive a shared secret using X25519 (followed by hashing to remove ECDH bias). @@ -254,8 +257,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( diff --git a/clients/js/src/provider.ts b/clients/js/src/provider.ts index 8ed4f04e9..d08b8d37d 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,18 +44,22 @@ export function isLegacyProvider( export interface SapphireWrapOptions { fetcher: KeyFetcher; + enableSapphireSnap?: boolean; } -export function fillOptions( - options: SapphireWrapOptions | undefined, -): SapphireWrapOptions { +export interface SapphireWrapConfig + extends Omit { + fetcher?: KeyFetcher; +} + +export function fillOptions(options: SapphireWrapConfig | undefined) { if (!options) { options = {} as SapphireWrapOptions; } if (!options.fetcher) { options.fetcher = new KeyFetcher(); } - return options; + return options as SapphireWrapOptions; } // ----------------------------------------------------------------------------- @@ -124,6 +129,61 @@ 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'; + +export async function detectSapphireSnap(provider: EIP2696_EthereumProvider) { + try { + const installedSnaps = (await provider.request({ + method: 'wallet_getSnaps', + })) as Record; + for (const snap of Object.values(installedSnaps)) { + if (snap.id === SAPPHIRE_SNAP_PNPM_ID) { + return snap.id; + } + } + } catch (e: any) { + return undefined; + } +} + +export async function notifySapphireSnap( + snapId: string, + cipher: Cipher, + transactionData: BytesLike, + options: SapphireWrapOptions, + provider: EIP2696_EthereumProvider, +) { + if (cipher.ephemeralKey) { + const peerPublicKey = await options.fetcher.fetch(provider); + await provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId: snapId, + request: { + method: 'setTransactionDecryptKeys', + params: { + id: transactionData, + ephemeralSecretKey: hexlify(cipher.ephemeralKey), + peerPublicKey: hexlify(peerPublicKey.key), + peerPublicKeyEpoch: peerPublicKey.epoch, + }, + }, + }, + }); + } +} + const SAPPHIRE_EIP1193_REQUESTFN = '#SAPPHIRE_EIP1193_REQUESTFN' as const; export function isWrappedRequestFn< @@ -159,9 +219,13 @@ export function makeSapphireRequestFn( const filled_options = fillOptions(options); const f = async (args: EIP1193_RequestArguments) => { + const snapId = filled_options.enableSapphireSnap + ? await detectSapphireSnap(provider) + : undefined; 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 +233,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 +241,17 @@ 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 c1b6062c8..1f61a10bc 100644 --- a/clients/js/test/cipher.spec.ts +++ b/clients/js/test/cipher.spec.ts @@ -18,7 +18,7 @@ describe('X25519DeoxysII', () => { expect(hexlify(cipher.publicKey)).toEqual( '0x3046db3fa70ce605457dc47c48837ebd8bd0a26abfde5994d033e1ced68e2576', ); - expect(hexlify(cipher['key'])).toEqual( + expect(hexlify(cipher['secretKey'])).toEqual( '0xe69ac21066a8c2284e8fdc690e579af4513547b9b31dd144792c1904b45cf586', ); diff --git a/integrations/ethers-v6/src/index.ts b/integrations/ethers-v6/src/index.ts index 59f1a9d07..95c6ddeb5 100644 --- a/integrations/ethers-v6/src/index.ts +++ b/integrations/ethers-v6/src/index.ts @@ -16,13 +16,16 @@ import type { EIP1193_RequestArguments, EIP1193_RequestFn, EIP2696_EthereumProvider, + SapphireWrapConfig, SapphireWrapOptions, } from "@oasisprotocol/sapphire-paratime"; import { + detectSapphireSnap, fillOptions, isCalldataEnveloped, makeTaggedProxyObject, + notifySapphireSnap, } from "@oasisprotocol/sapphire-paratime"; export { NETWORKS } from "@oasisprotocol/sapphire-paratime"; @@ -89,11 +92,25 @@ export class SignerHasNoProviderError extends Error {} function hookEthersSend< C extends Signer["sendTransaction"] | Signer["signTransaction"], ->(send: C, options: SapphireWrapOptions, request: EIP1193_RequestFn): C { +>( + send: C, + options: SapphireWrapOptions, + request: EIP1193_RequestFn, + provider: EIP2696_EthereumProvider | undefined, +): C { return (async (tx: TransactionRequest) => { if (tx.data) { const cipher = await options.fetcher.cipher({ request }); tx.data = hexlify(cipher.encryptCall(tx.data)); + if (provider) { + const snapId = options.enableSapphireSnap + ? await detectSapphireSnap(provider) + : undefined; + + if (snapId !== undefined && tx.data !== undefined) { + notifySapphireSnap(snapId, cipher, tx.data, options, provider); + } + } } return send(tx); }) as C; @@ -101,7 +118,7 @@ function hookEthersSend< export function wrapEthersSigner

( upstream: P, - options?: SapphireWrapOptions, + options?: SapphireWrapConfig, ): P & EIP2696_EthereumProvider { if (isWrappedSigner(upstream)) { return upstream; @@ -110,8 +127,9 @@ export function wrapEthersSigner

( const filled_options = fillOptions(options); let signer: Signer; + let provider: (Provider & EIP2696_EthereumProvider) | undefined; if (upstream.provider) { - const provider = wrapEthersProvider(upstream.provider, filled_options); + provider = wrapEthersProvider(upstream.provider, filled_options); try { signer = upstream.connect(provider); } catch (e: unknown) { @@ -139,11 +157,13 @@ export function wrapEthersSigner

( signer.sendTransaction.bind(signer), filled_options, request, + provider, ), signTransaction: hookEthersSend( signer.signTransaction.bind(signer), filled_options, request, + provider, ), call: hookEthersCall(signer, "call", filled_options, request), estimateGas: hookEthersCall( @@ -175,7 +195,7 @@ export class ContractRunnerHasNoProviderError extends Error {} export function wrapEthersProvider

( provider: P, - options?: SapphireWrapOptions, + options?: SapphireWrapConfig, ): P & EIP2696_EthereumProvider { // Already wrapped, so don't wrap it again. if (isWrappedProvider(provider)) {