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

Initial work to implement Sapphire snap connection #431

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 3 additions & 3 deletions clients/js/src/cipher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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): {
Expand Down
89 changes: 82 additions & 7 deletions clients/js/src/provider.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -43,6 +44,7 @@ export function isLegacyProvider<T extends object>(

export interface SapphireWrapOptions {
fetcher: KeyFetcher;
Copy link
Contributor

Choose a reason for hiding this comment

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

Options object is optional. When provided, TS requires fetcher, but we should be able just to set enableSapphireSnap flag. As fetcher always defaults to new KeyFetcher() should it now be fetcher: KeyFetcher | undefined; ?

enableSapphireSnap: boolean | undefined;
}

export function fillOptions(
Expand Down Expand Up @@ -80,10 +82,10 @@ export function isWrappedEthereumProvider<P extends EIP2696_EthereumProvider>(
* @param options (optional) Re-use parameters from other providers
* @returns Sapphire wrapped provider
*/
export function wrapEthereumProvider<P extends EIP2696_EthereumProvider>(
export async function wrapEthereumProvider<P extends EIP2696_EthereumProvider>(
Copy link
Contributor

@buberdds buberdds Dec 10, 2024

Choose a reason for hiding this comment

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

Does it affect all integrations? For example I don't see ether-v6 is relying on wrapEthereumProvider. It has it's own logic. Do we plan to apply the same code in a few places, or share some snap utils across packages?
I guess for ether-v6 we should call notifySapphireSnap right after https://github.com/oasisprotocol/sapphire-paratime/blob/main/integrations/ethers-v6/src/index.ts#L96

upstream: P,
options?: SapphireWrapOptions,
): P {
): Promise<P> {
if (isWrappedEthereumProvider(upstream)) {
return upstream;
}
Expand All @@ -99,7 +101,7 @@ export function wrapEthereumProvider<P extends EIP2696_EthereumProvider>(
// 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<string, unknown> = { request };

// We prefer a request() method, but a provider may expose a send() method
Expand All @@ -124,6 +126,63 @@ export function wrapEthereumProvider<P extends EIP2696_EthereumProvider>(
);
}

// -----------------------------------------------------------------------------
// 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<string, SnapInfoT>;
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,
Copy link
Contributor

Choose a reason for hiding this comment

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

hexlify

peerPublicKeyEpoch: peerPublicKey.epoch,
},
},
},
});
}
}

const SAPPHIRE_EIP1193_REQUESTFN = '#SAPPHIRE_EIP1193_REQUESTFN' as const;

export function isWrappedRequestFn<
Expand All @@ -148,35 +207,51 @@ 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<EIP2696_EthereumProvider['request']> {
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 &&
Array.isArray(params) &&
/^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({
method,
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
Expand Down
2 changes: 1 addition & 1 deletion clients/js/test/cipher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('X25519DeoxysII', () => {
expect(hexlify(cipher.publicKey)).toEqual(
'0x3046db3fa70ce605457dc47c48837ebd8bd0a26abfde5994d033e1ced68e2576',
);
expect(hexlify(cipher['key'])).toEqual(
expect(hexlify(cipher['secretKey'])).toEqual(
'0xe69ac21066a8c2284e8fdc690e579af4513547b9b31dd144792c1904b45cf586',
);

Expand Down
Loading
Loading