From 4ad31bd4c1fcc41fee9f80f0875b509c478d8d57 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Mon, 5 Feb 2024 15:27:26 +0000 Subject: [PATCH 01/14] clients/js: split calldatapublickey code, added keyfetcher --- clients/js/src/calldatapublickey.ts | 260 ++++++++++++++++++++++++++++ clients/js/src/compat.ts | 256 ++++++--------------------- clients/js/src/interfaces.ts | 5 + clients/js/test/compat.spec.ts | 50 ++++-- 4 files changed, 353 insertions(+), 218 deletions(-) create mode 100644 clients/js/src/calldatapublickey.ts diff --git a/clients/js/src/calldatapublickey.ts b/clients/js/src/calldatapublickey.ts new file mode 100644 index 00000000..eef97cd7 --- /dev/null +++ b/clients/js/src/calldatapublickey.ts @@ -0,0 +1,260 @@ +import { getBytes } from 'ethers'; + +import { UpstreamProvider, EIP1193Provider } from './interfaces.js'; +import { CallError, OASIS_CALL_DATA_PUBLIC_KEY } from './index.js'; +import { NETWORKS } from './networks.js'; +import { Cipher, Mock as MockCipher, X25519DeoxysII } from './cipher.js'; + +const DEFAULT_PUBKEY_CACHE_EXPIRATION_MS = 60 * 5 * 1000; // 5 minutes in milliseconds + +// ----------------------------------------------------------------------------- +// Fetch calldata public key +// Well use provider when possible, and fallback to HTTP(S)? requests +// e.g. MetaMask doesn't allow the oasis_callDataPublicKey JSON-RPC method + +type RawCallDataPublicKeyResponseResult = { + key: string; + checksum: string; + signature: string; + epoch: number; +}; + +type RawCallDataPublicKeyResponse = { + result: RawCallDataPublicKeyResponseResult; +}; + +export interface CallDataPublicKey { + // PublicKey is the requested public key. + key: Uint8Array; + + // Checksum is the checksum of the key manager state. + checksum: Uint8Array; + + // Signature is the Sign(sk, (key || checksum)) from the key manager. + signature: Uint8Array; + + // Epoch is the epoch of the ephemeral runtime key. + epoch: number | undefined; + + chainId: number; + + fetched: Date; + + cipher: Cipher; +} + +function toCallDataPublicKey( + result: RawCallDataPublicKeyResponseResult, + chainId: number, +) { + const key = getBytes(result.key); + return { + key, + checksum: getBytes(result.checksum), + signature: getBytes(result.signature), + epoch: result.epoch, + chainId, + fetched: new Date(), + cipher: X25519DeoxysII.ephemeral(key), + } as CallDataPublicKey; +} + +// TODO: remove, this is unecessary, node has `fetch` now? +async function fetchRuntimePublicKeyNode( + gwUrl: string, +): Promise { + // Import http or https, depending on the URI scheme. + const https = await import(/* webpackIgnore: true */ gwUrl.split(':')[0]); + + const body = makeCallDataPublicKeyBody(); + return new Promise((resolve, reject) => { + const opts = { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': body.length, + }, + }; + const req = https.request(gwUrl, opts, (res: any) => { + const chunks: Buffer[] = []; + res.on('error', (err: any) => reject(err)); + res.on('data', (chunk: any) => chunks.push(chunk)); + res.on('end', () => { + resolve(JSON.parse(Buffer.concat(chunks).toString())); + }); + }); + req.on('error', (err: Error) => reject(err)); + req.write(body); + req.end(); + }); +} + +async function fetchRuntimePublicKeyBrowser( + gwUrl: string, + fetchImpl: typeof fetch, +): Promise { + const res = await fetchImpl(gwUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: makeCallDataPublicKeyBody(), + }); + if (!res.ok) { + throw new CallError('Failed to fetch runtime public key.', res); + } + return await res.json(); +} + +function makeCallDataPublicKeyBody(): string { + return JSON.stringify({ + jsonrpc: '2.0', + id: Math.floor(Math.random() * 1e9), + method: OASIS_CALL_DATA_PUBLIC_KEY, + params: [], + }); +} + +export async function fetchRuntimePublicKeyByChainId( + chainId: number, + opts?: { fetch?: typeof fetch }, +): Promise { + const { defaultGateway } = NETWORKS[chainId]; + if (!defaultGateway) + throw new Error( + `Unable to fetch runtime public key for network with unknown ID: ${chainId}.`, + ); + const fetchImpl = opts?.fetch ?? globalThis?.fetch; + const res = await (fetchImpl + ? fetchRuntimePublicKeyBrowser(defaultGateway, fetchImpl) + : fetchRuntimePublicKeyNode(defaultGateway)); + return toCallDataPublicKey(res.result, chainId); +} + +function fromQuantity(x: number | string): number { + if (typeof x === 'string') { + if (x.startsWith('0x')) { + return parseInt(x, 16); + } + return parseInt(x); // Assumed to be base 10 + } + return x; +} + +/** + * Picks the most user-trusted runtime calldata public key source based on what + * connections are available. + * + * NOTE: MetaMask does not support Web3 methods it doesn't know about, so we have to + * fall-back to manually querying the default gateway. + */ +export async function fetchRuntimePublicKey( + upstream: UpstreamProvider, +): Promise { + const provider = 'provider' in upstream ? upstream['provider'] : upstream; + let chainId: number | undefined; + if (provider) { + let resp; + // It's probably an EIP-1193 provider + if ('request' in provider) { + const source = provider as EIP1193Provider; + chainId = fromQuantity( + (await source.request({ method: 'eth_chainId' })) as string | number, + ); + try { + resp = await source.request({ + method: OASIS_CALL_DATA_PUBLIC_KEY, + params: [], + }); + } catch (ex) { + // don't do anything, move on to try next + } + } + // If it's a `send` provider + else if ('send' in provider) { + const source = provider as { + send: (method: string, params: any[]) => Promise; + }; + chainId = fromQuantity(await source.send('eth_chainId', [])); + try { + resp = await source.send(OASIS_CALL_DATA_PUBLIC_KEY, []); + } catch (ex) { + // don't do anything, move on to try chainId fetch + } + } + // Otherwise, we have no idea what to do with this provider! + else { + throw new Error( + 'fetchRuntimePublicKey does not support non-request non-send provier!', + ); + } + if (resp && 'key' in resp) { + return toCallDataPublicKey(resp, chainId); + } + } + + if (!chainId) { + throw new Error( + 'fetchRuntimePublicKey failed to retrieve chainId from provider', + ); + } + return fetchRuntimePublicKeyByChainId(chainId); +} + +export abstract class AbstractKeyFetcher { + public abstract fetch(upstream: UpstreamProvider): Promise; + public abstract cipher(upstream: UpstreamProvider): Promise; + constructor() {} +} + +export class KeyFetcher extends AbstractKeyFetcher { + readonly timeoutMilliseconds: number; + public pubkey?: CallDataPublicKey; + + constructor(in_timeoutMilliseconds?: number) { + super(); + if (!in_timeoutMilliseconds) { + in_timeoutMilliseconds = DEFAULT_PUBKEY_CACHE_EXPIRATION_MS; + } + this.timeoutMilliseconds = in_timeoutMilliseconds; + } + + /** + * Retrieve cached key if possible, otherwise fetch a fresh one + * + * @param upstream Upstream ETH JSON-RPC provider + * @returns calldata public key + */ + public async fetch(upstream: UpstreamProvider): Promise { + if (this.pubkey) { + const pk = this.pubkey; + const expiry = Date.now() - this.timeoutMilliseconds; + if (pk.fetched && pk.fetched.valueOf() > expiry) { + // XXX: if provider switch chain, may return cached key for wrong chain + return pk; + } + } + return (this.pubkey = await fetchRuntimePublicKey(upstream)); + } + + public async cipher(upstream: UpstreamProvider): Promise { + return (await this.fetch(upstream)).cipher; + } +} + +export class MockKeyFetcher extends AbstractKeyFetcher { + #_cipher: MockCipher; + + constructor(in_cipher: MockCipher) { + super(); + this.#_cipher = in_cipher; + } + + public async fetch(upstream: UpstreamProvider): Promise { + throw new Error("MockKeyFetcher doesn't support fetch(), only cipher()"); + } + + public async cipher(upstream: UpstreamProvider): Promise { + return this.#_cipher + } +} diff --git a/clients/js/src/compat.ts b/clients/js/src/compat.ts index 71da46a7..29a50e09 100644 --- a/clients/js/src/compat.ts +++ b/clients/js/src/compat.ts @@ -17,16 +17,11 @@ import { hexlify, } from 'ethers'; -import { - Cipher, - Kind as CipherKind, - Envelope, - X25519DeoxysII, - lazy as lazyCipher, -} from './cipher.js'; -import { CallError, OASIS_CALL_DATA_PUBLIC_KEY } from './index.js'; +import { Kind as CipherKind, Envelope } from './cipher.js'; + +import { CallError } from './index.js'; + import { EthCall, Leash, SignedCallDataPack } from './signed_calls.js'; -import { NETWORKS } from './networks.js'; import { Deferrable, @@ -34,15 +29,13 @@ import { Ethers5Provider, EIP1193Provider, Web3ReqArgs, + UpstreamProvider, } from './interfaces.js'; -export type UpstreamProvider = - | EIP1193Provider - | Ethers5Signer - | Ethers5Provider; +import { AbstractKeyFetcher, KeyFetcher } from './calldatapublickey.js'; interface SapphireWrapOptions { - cipher: Cipher; + fetcher: AbstractKeyFetcher; } const SAPPHIRE_PROP = 'sapphire'; @@ -52,13 +45,12 @@ export type SapphireAnnex = { function fillOptions( options: SapphireWrapOptions | undefined, - provider: UpstreamProvider, ): SapphireWrapOptions { if (!options) { options = {} as SapphireWrapOptions; } - if (!options.cipher) { - options.cipher = getCipher(provider); + if (!options.fetcher) { + options.fetcher = new KeyFetcher(); } return options; } @@ -106,7 +98,7 @@ export function wrap( upstream = new JsonRpcProvider(upstream) as any; } - const filled_options = fillOptions(options, upstream); + const filled_options = fillOptions(options); if (isEthersSigner(upstream)) { return wrapEthersSigner(upstream as Ethers5Signer, filled_options) as any; @@ -136,7 +128,7 @@ function wrapEIP1193Provider

( upstream: P, options?: SapphireWrapOptions, ): P & SapphireAnnex { - const filled_options = fillOptions(options, upstream); + const filled_options = fillOptions(options); const browserProvider = new BrowserProvider(upstream); const request = hookEIP1193Request(browserProvider, filled_options); const hooks: Record = { @@ -163,10 +155,16 @@ function hookEIP1193Request( ): EIP1193Provider['request'] { return async (args: Web3ReqArgs) => { const signer = await provider.getSigner(); - const { method, params } = await prepareRequest(args, signer, options); + const { method, params } = await prepareRequest( + args, + signer, + options, + provider, + ); const res = await signer.provider.send(method, params ?? []); if (method === 'eth_call') { - return options.cipher.decryptEncoded(res); + const cipher = await options.fetcher.cipher(provider); + return await cipher.decryptEncoded(res); } return res; }; @@ -174,13 +172,6 @@ function hookEIP1193Request( // ----------------------------------------------------------------------------- -function getCipher(provider: UpstreamProvider): Cipher { - return lazyCipher(async () => { - const rtPubKey = await fetchRuntimePublicKey(provider); - return X25519DeoxysII.ephemeral(rtPubKey); - }); -} - function makeProxy( upstream: U, options: SapphireWrapOptions, @@ -202,7 +193,7 @@ export function wrapEthersSigner

( upstream: P, options?: SapphireWrapOptions, ): P & SapphireAnnex { - const filled_options = fillOptions(options, upstream); + const filled_options = fillOptions(options); let signer: Ethers5Signer; if (upstream.provider) { @@ -220,10 +211,12 @@ export function wrapEthersSigner

( sendTransaction: hookEthersSend( signer.sendTransaction.bind(signer), filled_options, + signer, ), signTransaction: hookEthersSend( signer.signTransaction.bind(signer), filled_options, + signer, ), call: hookEthersCall(signer, 'call', filled_options), estimateGas: hookEthersCall(signer, 'estimateGas', filled_options), @@ -246,7 +239,7 @@ export function wrapEthersProvider

( options?: SapphireWrapOptions, signer?: Ethers5Signer | Signer, ): P & SapphireAnnex { - const filled_options = fillOptions(options, provider); + const filled_options = fillOptions(options); // Already wrapped, so don't wrap it again. if (Reflect.get(provider, SAPPHIRE_PROP) !== undefined) { @@ -268,7 +261,12 @@ export function wrapEthersProvider

( hooks['broadcastTransaction'] = (async ( raw: string, ) => { - const repacked = await repackRawTx(raw, filled_options, signer); + const repacked = await repackRawTx( + raw, + filled_options, + provider, + signer, + ); return (provider as Provider).broadcastTransaction(repacked); }); } else { @@ -276,7 +274,12 @@ export function wrapEthersProvider

( // Ethers v5 `sendTransaction` takes hex encoded byte string hooks['sendTransaction'] = ( (async (raw: string) => { - const repacked = await repackRawTx(raw, filled_options, signer); + const repacked = await repackRawTx( + raw, + filled_options, + provider, + signer, + ); return ( provider as unknown as Ethers5ProviderWithSend ).sendTransaction(repacked); @@ -335,9 +338,8 @@ function hookEthersCall( ) => { let call_data = call.data; if (!is_already_enveloped) { - call_data = await options.cipher.encryptEncode( - call.data ?? new Uint8Array(), - ); + const cipher = await options.fetcher.cipher(runner as any); + call_data = await cipher.encryptEncode(call.data ?? new Uint8Array()); } const result = await runner[method]!({ ...call, @@ -361,10 +363,11 @@ function hookEthersCall( throw new Error('signer not connected to a provider'); const provider = signer.provider; if (await callNeedsSigning(call)) { + const cipher = await options.fetcher.cipher(runner as any); const dataPack = await SignedCallDataPack.make(call, signer); res = await provider[method]({ ...call, - data: await dataPack.encryptEncode(options.cipher), + data: await dataPack.encryptEncode(cipher), }); } else { res = await sendUnsignedCall(provider, call, is_already_enveloped); @@ -374,7 +377,8 @@ function hookEthersCall( } // NOTE: if it's already enveloped, caller will decrypt it (not us) if (!is_already_enveloped && typeof res === 'string') { - return await options.cipher.decryptEncoded(res); + const cipher = await options.fetcher.cipher(runner as any); + return await cipher.decryptEncoded(res); } return res; }; @@ -382,10 +386,15 @@ function hookEthersCall( type EthersCall = (tx: EthCall | TransactionRequest) => Promise; -function hookEthersSend(send: C, options: SapphireWrapOptions): C { +function hookEthersSend( + send: C, + options: SapphireWrapOptions, + signer: Ethers5Signer, +): C { return (async (tx: EthCall | TransactionRequest, ...rest: any[]) => { if (tx.data) { - tx.data = await options.cipher.encryptEncode(tx.data); + const cipher = await options.fetcher.cipher(signer); + tx.data = await cipher.encryptEncode(tx.data); } return (send as any)(tx, ...rest); }) as C; @@ -406,13 +415,14 @@ async function prepareRequest( { method, params }: Web3ReqArgs, signer: JsonRpcSigner, options: SapphireWrapOptions, + provider: BrowserProvider, ): Promise<{ method: string; params?: Array }> { if (!Array.isArray(params)) return { method, params }; if (method === 'eth_sendRawTransaction') { return { method, - params: [await repackRawTx(params[0], options, signer)], + params: [await repackRawTx(params[0], options, provider, signer)], }; } @@ -421,9 +431,10 @@ async function prepareRequest( (await callNeedsSigning(params[0])) ) { const dataPack = await SignedCallDataPack.make(params[0], signer); + const cipher = await options.fetcher.cipher(signer); const signedCall = { ...params[0], - data: await dataPack.encryptEncode(options.cipher), + data: await dataPack.encryptEncode(cipher), }; return { method, @@ -435,7 +446,8 @@ async function prepareRequest( /^eth_((send|sign)Transaction|call|estimateGas)$/.test(method) && params[0].data // Ignore balance transfers without calldata ) { - params[0].data = await options.cipher.encryptEncode(params[0].data); + const cipher = await options.fetcher.cipher(signer); + params[0].data = await cipher.encryptEncode(params[0].data); return { method, params }; } @@ -451,6 +463,7 @@ const REPACK_ERROR = async function repackRawTx( raw: string, options: SapphireWrapOptions, + provider: UpstreamProvider, signer?: Ethers5Signer | Signer, ): Promise { const tx = Transaction.from(raw); @@ -466,7 +479,8 @@ async function repackRawTx( return raw; } - tx.data = await options.cipher.encryptEncode(tx.data); + const cipher = await options.fetcher.cipher(provider); + tx.data = await cipher.encryptEncode(tx.data); try { return signer!.signTransaction(tx); @@ -534,159 +548,3 @@ function envelopeFormatOk(envelope: Envelope): boolean { return true; } - -// ----------------------------------------------------------------------------- -// Fetch calldata public key -// Well use provider when possible, and fallback to HTTP(S)? requests -// e.g. MetaMask doesn't allow the oasis_callDataPublicKey JSON-RPC method - -type CallDataPublicKeyResponse = { - result: { - key: string; - checksum: string; - signature: string; - epoch: number; - }; -}; - -async function fetchRuntimePublicKeyNode( - gwUrl: string, -): Promise { - // Import http or https, depending on the URI scheme. - const https = await import(/* webpackIgnore: true */ gwUrl.split(':')[0]); - - const body = makeCallDataPublicKeyBody(); - return new Promise((resolve, reject) => { - const opts = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'content-length': body.length, - }, - }; - const req = https.request(gwUrl, opts, (res: any) => { - const chunks: Buffer[] = []; - res.on('error', (err: any) => reject(err)); - res.on('data', (chunk: any) => chunks.push(chunk)); - res.on('end', () => { - resolve(JSON.parse(Buffer.concat(chunks).toString())); - }); - }); - req.on('error', (err: Error) => reject(err)); - req.write(body); - req.end(); - }); -} - -async function fetchRuntimePublicKeyBrowser( - gwUrl: string, - fetchImpl: typeof fetch, -): Promise { - const res = await fetchImpl(gwUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: makeCallDataPublicKeyBody(), - }); - if (!res.ok) { - throw new CallError('Failed to fetch runtime public key.', res); - } - return await res.json(); -} - -function makeCallDataPublicKeyBody(): string { - return JSON.stringify({ - jsonrpc: '2.0', - id: Math.floor(Math.random() * 1e9), - method: OASIS_CALL_DATA_PUBLIC_KEY, - params: [], - }); -} - -export async function fetchRuntimePublicKeyByChainId( - chainId: number, - opts?: { fetch?: typeof fetch }, -): Promise { - const { defaultGateway } = NETWORKS[chainId]; - if (!defaultGateway) - throw new Error( - `Unable to fetch runtime public key for network with unknown ID: ${chainId}.`, - ); - const fetchImpl = opts?.fetch ?? globalThis?.fetch; - const res = await (fetchImpl - ? fetchRuntimePublicKeyBrowser(defaultGateway, fetchImpl) - : fetchRuntimePublicKeyNode(defaultGateway)); - return getBytes(res.result.key); -} - -function fromQuantity(x: number | string): number { - if (typeof x === 'string') { - if (x.startsWith('0x')) { - return parseInt(x, 16); - } - return parseInt(x); // Assumed to be base 10 - } - return x; -} - -/** - * Picks the most user-trusted runtime calldata public key source based on what - * connections are available. - * - * NOTE: MetaMask does not support Web3 methods it doesn't know about, so we have to - * fall-back to manually querying the default gateway. - */ -export async function fetchRuntimePublicKey( - upstream: UpstreamProvider, -): Promise { - const provider = 'provider' in upstream ? upstream['provider'] : upstream; - let chainId: number | undefined; - if (provider) { - let resp: any; - // It's probably an EIP-1193 provider - if ('request' in provider) { - const source = provider as EIP1193Provider; - try { - resp = await source.request({ - method: OASIS_CALL_DATA_PUBLIC_KEY, - params: [], - }); - } catch (ex) { - // don't do anything, move on to try next - chainId = fromQuantity( - (await source.request({ method: 'eth_chainId' })) as string | number, - ); - } - } - // If it's a `send` provider - else if ('send' in provider) { - const source = provider as { - send: (method: string, params: any[]) => Promise; - }; - try { - resp = await source.send(OASIS_CALL_DATA_PUBLIC_KEY, []); - } catch (ex) { - // don't do anything, move on to try chainId fetch - chainId = fromQuantity(await source.send('eth_chainId', [])); - } - } - // Otherwise, we have no idea what to do with this provider! - else { - throw new Error( - 'fetchRuntimePublicKey does not support non-request non-send provier!', - ); - } - if (resp && 'key' in resp) { - const key = resp.key; - return getBytes(key); - } - } - - if (!chainId) { - throw new Error( - 'fetchRuntimePublicKey failed to retrieve chainId from provider', - ); - } - return fetchRuntimePublicKeyByChainId(chainId); -} diff --git a/clients/js/src/interfaces.ts b/clients/js/src/interfaces.ts index c281168e..2d8d0d49 100644 --- a/clients/js/src/interfaces.ts +++ b/clients/js/src/interfaces.ts @@ -77,3 +77,8 @@ export async function undefer(obj: Deferrable): Promise { await Promise.all(Object.entries(obj).map(async ([k, v]) => [k, await v])), ); } + +export type UpstreamProvider = + | EIP1193Provider + | Ethers5Signer + | Ethers5Provider; diff --git a/clients/js/test/compat.spec.ts b/clients/js/test/compat.spec.ts index cb8fad21..27db1ead 100644 --- a/clients/js/test/compat.spec.ts +++ b/clients/js/test/compat.spec.ts @@ -6,21 +6,25 @@ import nacl from 'tweetnacl'; import { wrap, +} from '@oasisprotocol/sapphire-paratime/compat.js'; + +import { + MockKeyFetcher, fetchRuntimePublicKey, fetchRuntimePublicKeyByChainId, -} from '@oasisprotocol/sapphire-paratime/compat.js'; +} from '@oasisprotocol/sapphire-paratime/calldatapublickey.js'; import { Mock as MockCipher } from '@oasisprotocol/sapphire-paratime/cipher.js'; import { CHAIN_ID, verifySignedCall } from './utils'; -jest.mock('@oasisprotocol/sapphire-paratime/compat.js', () => ({ - ...jest.requireActual('@oasisprotocol/sapphire-paratime/compat.js'), +jest.mock('@oasisprotocol/sapphire-paratime/calldatapublickey.js', () => ({ + ...jest.requireActual('@oasisprotocol/sapphire-paratime/calldatapublickey.js'), fetchRuntimePublicKeyByChainId: jest .fn() .mockReturnValue(new Uint8Array(Buffer.alloc(32, 8))), })); const real_fetchRuntimePublicKeyByChainId = jest.requireActual( - '@oasisprotocol/sapphire-paratime/compat.js', + '@oasisprotocol/sapphire-paratime/calldatapublickey.js', ).fetchRuntimePublicKeyByChainId; const secretKey = @@ -28,6 +32,7 @@ const secretKey = const wallet = new ethers.Wallet(secretKey); const to = '0xb5ed90452AAC09f294a0BE877CBf2Dc4D55e096f'; const cipher = new MockCipher(); +const fetcher = new MockKeyFetcher(cipher); const data = Buffer.from([1, 2, 3, 4, 5]); class MockEIP1193Provider { @@ -94,6 +99,10 @@ class MockEIP1193Provider { if (method === 'oasis_callDataPublicKey') { return { key: `0x${Buffer.alloc(32, 42).toString('hex')}`, + checksum: '0x', + epoch: 1, + signature: '0x', + chainId: CHAIN_ID }; } throw new Error( @@ -140,7 +149,7 @@ describe('fetchRuntimePublicKey', () => { const upstream = new ethers.BrowserProvider(new MockEIP1193Provider()); const pk = await fetchRuntimePublicKey(upstream); expect(fetchRuntimePublicKeyByChainId).not.toHaveBeenCalled(); - expect(pk).toEqual(new Uint8Array(Buffer.alloc(32, 42))); + expect(pk.key).toEqual(new Uint8Array(Buffer.alloc(32, 42))); }); it('non public key provider', async () => { @@ -148,32 +157,32 @@ describe('fetchRuntimePublicKey', () => { new MockNonRuntimePublicKeyProvider(), ); // This will have retrieved the key from testnet or mainnet - expect(pk).not.toEqual(new Uint8Array(Buffer.alloc(32, 8))); + expect(pk.key).not.toEqual(new Uint8Array(Buffer.alloc(32, 8))); }); it('ethers signer', async () => { - const wrapped = wrap(wallet, { cipher }).connect( + const wrapped = wrap(wallet, { fetcher: fetcher }).connect( new ethers.BrowserProvider(new MockEIP1193Provider()), ); const pk = await fetchRuntimePublicKey(wrapped); expect(fetchRuntimePublicKeyByChainId).not.toHaveBeenCalled(); - expect(pk).toEqual(new Uint8Array(Buffer.alloc(32, 42))); + expect(pk.key).toEqual(new Uint8Array(Buffer.alloc(32, 42))); }); }); describe('ethers signer', () => { it('proxy', async () => { - const wrapped = wrap(wallet, { cipher }); + const wrapped = wrap(wallet, { fetcher }); expect(wrapped.address).toEqual( '0x11e244400Cf165ade687077984F09c3A037b868F', ); expect(await wrapped.getAddress()).toEqual(wrapped.address); - expect((wrapped as any).sapphire).toMatchObject({ cipher }); + expect((wrapped as any).sapphire).toMatchObject({ fetcher }); }); it('unsigned call/estimateGas', async () => { const upstreamProvider = new MockEIP1193Provider(); - const wrapped = wrap(wallet, { cipher }).connect( + const wrapped = wrap(wallet, { fetcher }).connect( new ethers.BrowserProvider(upstreamProvider), ); const callRequest = { @@ -206,7 +215,7 @@ describe('ethers signer', () => { runTestBattery(async () => { const provider = new MockEIP1193Provider(); - const signer = wrap(wallet, { cipher }).connect( + const signer = wrap(wallet, { fetcher }).connect( new ethers.BrowserProvider(provider), ); return [signer, provider, signer.signTransaction.bind(signer)]; @@ -220,11 +229,11 @@ describe('ethers provider', () => { beforeEach(() => { upstreamProvider = new MockEIP1193Provider(); const provider = new ethers.BrowserProvider(upstreamProvider); - wrapped = wrap(provider, { cipher }); + wrapped = wrap(provider, { fetcher }); }); it('proxy', async () => { - expect((wrapped as any).sapphire).toMatchObject({ cipher }); + expect((wrapped as any).sapphire).toMatchObject({ fetcher }); }); it('unsigned call/estimateGas', async () => { @@ -254,15 +263,15 @@ describe('ethers provider', () => { describe('window.ethereum', () => { it('proxy', async () => { - const wrapped = wrap(new MockEIP1193Provider(), { cipher }); + const wrapped = wrap(new MockEIP1193Provider(), { fetcher }); expect(wrapped.isMetaMask).toBe(false); expect(wrapped.isConnected()).toBe(false); - expect((wrapped as any).sapphire).toMatchObject({ cipher }); + expect((wrapped as any).sapphire).toMatchObject({ fetcher }); }); runTestBattery(async () => { const provider = new MockEIP1193Provider(); - const wrapped = wrap(provider, { cipher }); + const wrapped = wrap(provider, { fetcher }); const signer = await new ethers.BrowserProvider(wrapped).getSigner(); const rawSign = async (...args: unknown[]) => { const raw = await wrapped.request({ @@ -401,12 +410,15 @@ describe('fetchPublicKeyByChainId', () => { .reply(200, { result: { key: `0x${Buffer.from(publicKey).toString('hex')}`, - // TODO: checksum and signature + checksum: '0x', + epoch: 1, + signature: '0x', + chainId: CHAIN_ID }, }); const response = await real_fetchRuntimePublicKeyByChainId(chainId, opts); - expect(response).not.toHaveLength(0); + expect(response.key).not.toHaveLength(0); scope.done(); } From 7860e20c95ee37780f726a5f1011ce60931874ee Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:53:20 +0000 Subject: [PATCH 02/14] clients/js: use same cipher instance between request/response --- clients/js/src/calldatapublickey.ts | 73 ++++++++++++++--------------- clients/js/src/compat.ts | 28 +++++------ clients/js/test/compat.spec.ts | 12 ++--- 3 files changed, 56 insertions(+), 57 deletions(-) diff --git a/clients/js/src/calldatapublickey.ts b/clients/js/src/calldatapublickey.ts index eef97cd7..fa9441b5 100644 --- a/clients/js/src/calldatapublickey.ts +++ b/clients/js/src/calldatapublickey.ts @@ -24,39 +24,38 @@ type RawCallDataPublicKeyResponse = { }; export interface CallDataPublicKey { - // PublicKey is the requested public key. - key: Uint8Array; + // PublicKey is the requested public key. + key: Uint8Array; - // Checksum is the checksum of the key manager state. - checksum: Uint8Array; + // Checksum is the checksum of the key manager state. + checksum: Uint8Array; - // Signature is the Sign(sk, (key || checksum)) from the key manager. - signature: Uint8Array; + // Signature is the Sign(sk, (key || checksum)) from the key manager. + signature: Uint8Array; - // Epoch is the epoch of the ephemeral runtime key. - epoch: number | undefined; - - chainId: number; + // Epoch is the epoch of the ephemeral runtime key. + epoch: number; - fetched: Date; + // Which chain ID is this key for? + chainId: number; - cipher: Cipher; + // When was the key fetched + fetched: Date; } function toCallDataPublicKey( result: RawCallDataPublicKeyResponseResult, chainId: number, ) { - const key = getBytes(result.key); - return { - key, - checksum: getBytes(result.checksum), - signature: getBytes(result.signature), - epoch: result.epoch, - chainId, - fetched: new Date(), - cipher: X25519DeoxysII.ephemeral(key), - } as CallDataPublicKey; + const key = getBytes(result.key); + return { + key, + checksum: getBytes(result.checksum), + signature: getBytes(result.signature), + epoch: result.epoch, + chainId, + fetched: new Date(), + } as CallDataPublicKey; } // TODO: remove, this is unecessary, node has `fetch` now? @@ -202,9 +201,8 @@ export async function fetchRuntimePublicKey( } export abstract class AbstractKeyFetcher { - public abstract fetch(upstream: UpstreamProvider): Promise; - public abstract cipher(upstream: UpstreamProvider): Promise; - constructor() {} + public abstract fetch(upstream: UpstreamProvider): Promise; + public abstract cipher(upstream: UpstreamProvider): Promise; } export class KeyFetcher extends AbstractKeyFetcher { @@ -238,23 +236,24 @@ export class KeyFetcher extends AbstractKeyFetcher { } public async cipher(upstream: UpstreamProvider): Promise { - return (await this.fetch(upstream)).cipher; + const kp = await this.fetch(upstream); + return X25519DeoxysII.ephemeral(kp.key); } } export class MockKeyFetcher extends AbstractKeyFetcher { - #_cipher: MockCipher; + #_cipher: MockCipher; - constructor(in_cipher: MockCipher) { - super(); - this.#_cipher = in_cipher; - } + constructor(in_cipher: MockCipher) { + super(); + this.#_cipher = in_cipher; + } - public async fetch(upstream: UpstreamProvider): Promise { - throw new Error("MockKeyFetcher doesn't support fetch(), only cipher()"); - } + public async fetch(): Promise { + throw new Error("MockKeyFetcher doesn't support fetch(), only cipher()"); + } - public async cipher(upstream: UpstreamProvider): Promise { - return this.#_cipher - } + public async cipher(): Promise { + return this.#_cipher; + } } diff --git a/clients/js/src/compat.ts b/clients/js/src/compat.ts index 29a50e09..84cbca67 100644 --- a/clients/js/src/compat.ts +++ b/clients/js/src/compat.ts @@ -17,7 +17,7 @@ import { hexlify, } from 'ethers'; -import { Kind as CipherKind, Envelope } from './cipher.js'; +import { Kind as CipherKind, Envelope, Cipher } from './cipher.js'; import { CallError } from './index.js'; @@ -155,15 +155,15 @@ function hookEIP1193Request( ): EIP1193Provider['request'] { return async (args: Web3ReqArgs) => { const signer = await provider.getSigner(); + const cipher = await options.fetcher.cipher(provider); const { method, params } = await prepareRequest( args, signer, options, - provider, + cipher, ); const res = await signer.provider.send(method, params ?? []); if (method === 'eth_call') { - const cipher = await options.fetcher.cipher(provider); return await cipher.decryptEncoded(res); } return res; @@ -261,10 +261,11 @@ export function wrapEthersProvider

( hooks['broadcastTransaction'] = (async ( raw: string, ) => { + const cipher = await filled_options.fetcher.cipher(provider);; const repacked = await repackRawTx( raw, filled_options, - provider, + cipher, signer, ); return (provider as Provider).broadcastTransaction(repacked); @@ -274,10 +275,11 @@ export function wrapEthersProvider

( // Ethers v5 `sendTransaction` takes hex encoded byte string hooks['sendTransaction'] = ( (async (raw: string) => { + const cipher = await filled_options.fetcher.cipher(provider);; const repacked = await repackRawTx( raw, filled_options, - provider, + cipher, signer, ); return ( @@ -335,10 +337,10 @@ function hookEthersCall( runner: Ethers5Provider | Ethers5Signer | ContractRunner, call: EthCall | TransactionRequest, is_already_enveloped: boolean, + cipher: Cipher ) => { let call_data = call.data; if (!is_already_enveloped) { - const cipher = await options.fetcher.cipher(runner as any); call_data = await cipher.encryptEncode(call.data ?? new Uint8Array()); } const result = await runner[method]!({ @@ -357,27 +359,26 @@ function hookEthersCall( let res: string; const is_already_enveloped = isCalldataEnveloped(call.data!, true); + const cipher = await options.fetcher.cipher(runner as any); if (!is_already_enveloped && isEthersSigner(runner)) { const signer = runner; if (!signer.provider) throw new Error('signer not connected to a provider'); const provider = signer.provider; if (await callNeedsSigning(call)) { - const cipher = await options.fetcher.cipher(runner as any); const dataPack = await SignedCallDataPack.make(call, signer); res = await provider[method]({ ...call, data: await dataPack.encryptEncode(cipher), }); } else { - res = await sendUnsignedCall(provider, call, is_already_enveloped); + res = await sendUnsignedCall(provider, call, is_already_enveloped, cipher); } } else { - res = await sendUnsignedCall(runner, call, is_already_enveloped); + res = await sendUnsignedCall(runner, call, is_already_enveloped, cipher); } // NOTE: if it's already enveloped, caller will decrypt it (not us) if (!is_already_enveloped && typeof res === 'string') { - const cipher = await options.fetcher.cipher(runner as any); return await cipher.decryptEncoded(res); } return res; @@ -415,14 +416,14 @@ async function prepareRequest( { method, params }: Web3ReqArgs, signer: JsonRpcSigner, options: SapphireWrapOptions, - provider: BrowserProvider, + cipher: Cipher, ): Promise<{ method: string; params?: Array }> { if (!Array.isArray(params)) return { method, params }; if (method === 'eth_sendRawTransaction') { return { method, - params: [await repackRawTx(params[0], options, provider, signer)], + params: [await repackRawTx(params[0], options, cipher, signer)], }; } @@ -463,7 +464,7 @@ const REPACK_ERROR = async function repackRawTx( raw: string, options: SapphireWrapOptions, - provider: UpstreamProvider, + cipher: Cipher, signer?: Ethers5Signer | Signer, ): Promise { const tx = Transaction.from(raw); @@ -479,7 +480,6 @@ async function repackRawTx( return raw; } - const cipher = await options.fetcher.cipher(provider); tx.data = await cipher.encryptEncode(tx.data); try { diff --git a/clients/js/test/compat.spec.ts b/clients/js/test/compat.spec.ts index 27db1ead..9895fd96 100644 --- a/clients/js/test/compat.spec.ts +++ b/clients/js/test/compat.spec.ts @@ -4,9 +4,7 @@ import nock from 'nock'; import fetchImpl from 'node-fetch'; import nacl from 'tweetnacl'; -import { - wrap, -} from '@oasisprotocol/sapphire-paratime/compat.js'; +import { wrap } from '@oasisprotocol/sapphire-paratime/compat.js'; import { MockKeyFetcher, @@ -17,7 +15,9 @@ import { Mock as MockCipher } from '@oasisprotocol/sapphire-paratime/cipher.js'; import { CHAIN_ID, verifySignedCall } from './utils'; jest.mock('@oasisprotocol/sapphire-paratime/calldatapublickey.js', () => ({ - ...jest.requireActual('@oasisprotocol/sapphire-paratime/calldatapublickey.js'), + ...jest.requireActual( + '@oasisprotocol/sapphire-paratime/calldatapublickey.js', + ), fetchRuntimePublicKeyByChainId: jest .fn() .mockReturnValue(new Uint8Array(Buffer.alloc(32, 8))), @@ -102,7 +102,7 @@ class MockEIP1193Provider { checksum: '0x', epoch: 1, signature: '0x', - chainId: CHAIN_ID + chainId: CHAIN_ID, }; } throw new Error( @@ -413,7 +413,7 @@ describe('fetchPublicKeyByChainId', () => { checksum: '0x', epoch: 1, signature: '0x', - chainId: CHAIN_ID + chainId: CHAIN_ID, }, }); From eff90d83fb150de601f9c6bdac3c5db6fbb37f8f Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:58:34 +0000 Subject: [PATCH 03/14] clients/js: removed unnecessary param from repackRawTx --- clients/js/src/compat.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/clients/js/src/compat.ts b/clients/js/src/compat.ts index 84cbca67..687f781e 100644 --- a/clients/js/src/compat.ts +++ b/clients/js/src/compat.ts @@ -264,7 +264,6 @@ export function wrapEthersProvider

( const cipher = await filled_options.fetcher.cipher(provider);; const repacked = await repackRawTx( raw, - filled_options, cipher, signer, ); @@ -278,7 +277,6 @@ export function wrapEthersProvider

( const cipher = await filled_options.fetcher.cipher(provider);; const repacked = await repackRawTx( raw, - filled_options, cipher, signer, ); @@ -423,7 +421,7 @@ async function prepareRequest( if (method === 'eth_sendRawTransaction') { return { method, - params: [await repackRawTx(params[0], options, cipher, signer)], + params: [await repackRawTx(params[0], cipher, signer)], }; } @@ -463,7 +461,6 @@ const REPACK_ERROR = /** Repacks and signs a sendRawTransaction if needed and possible. */ async function repackRawTx( raw: string, - options: SapphireWrapOptions, cipher: Cipher, signer?: Ethers5Signer | Signer, ): Promise { From 899359c842a6330fb4d30800fc74672c1ac2219a Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:58:53 +0000 Subject: [PATCH 04/14] clients/js: formatting --- clients/js/src/compat.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/clients/js/src/compat.ts b/clients/js/src/compat.ts index 687f781e..2820ed67 100644 --- a/clients/js/src/compat.ts +++ b/clients/js/src/compat.ts @@ -261,12 +261,8 @@ export function wrapEthersProvider

( hooks['broadcastTransaction'] = (async ( raw: string, ) => { - const cipher = await filled_options.fetcher.cipher(provider);; - const repacked = await repackRawTx( - raw, - cipher, - signer, - ); + const cipher = await filled_options.fetcher.cipher(provider); + const repacked = await repackRawTx(raw, cipher, signer); return (provider as Provider).broadcastTransaction(repacked); }); } else { @@ -274,12 +270,8 @@ export function wrapEthersProvider

( // Ethers v5 `sendTransaction` takes hex encoded byte string hooks['sendTransaction'] = ( (async (raw: string) => { - const cipher = await filled_options.fetcher.cipher(provider);; - const repacked = await repackRawTx( - raw, - cipher, - signer, - ); + const cipher = await filled_options.fetcher.cipher(provider); + const repacked = await repackRawTx(raw, cipher, signer); return ( provider as unknown as Ethers5ProviderWithSend ).sendTransaction(repacked); @@ -335,7 +327,7 @@ function hookEthersCall( runner: Ethers5Provider | Ethers5Signer | ContractRunner, call: EthCall | TransactionRequest, is_already_enveloped: boolean, - cipher: Cipher + cipher: Cipher, ) => { let call_data = call.data; if (!is_already_enveloped) { @@ -370,7 +362,12 @@ function hookEthersCall( data: await dataPack.encryptEncode(cipher), }); } else { - res = await sendUnsignedCall(provider, call, is_already_enveloped, cipher); + res = await sendUnsignedCall( + provider, + call, + is_already_enveloped, + cipher, + ); } } else { res = await sendUnsignedCall(runner, call, is_already_enveloped, cipher); From 9609adf02a460aff1b8c7e322e585d82ba1c47be Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:52:00 +0000 Subject: [PATCH 05/14] clients/js: pass epoch param with encrypted envelopes --- clients/js/scripts/proxy.ts | 14 +++++-- clients/js/src/calldatapublickey.ts | 2 +- clients/js/src/cipher.ts | 60 +++++++++++------------------ clients/js/test/cipher.spec.ts | 12 ------ 4 files changed, 34 insertions(+), 54 deletions(-) diff --git a/clients/js/scripts/proxy.ts b/clients/js/scripts/proxy.ts index 4982d71f..8854c373 100644 --- a/clients/js/scripts/proxy.ts +++ b/clients/js/scripts/proxy.ts @@ -25,7 +25,7 @@ async function getBody(request: IncomingMessage): Promise { const LISTEN_PORT = 3000; const DIE_ON_UNENCRYPTED = true; const UPSTREAM_URL = 'http://127.0.0.1:8545'; -const SHOW_ENCRYPTED_RESULTS = true; +const SHOW_ENCRYPTED_RESULTS = false; console.log('DIE_ON_UNENCRYPTED', DIE_ON_UNENCRYPTED); console.log('UPSTREAM_URL', UPSTREAM_URL); @@ -75,6 +75,7 @@ async function onRequest(req: IncomingMessage, response: ServerResponse) { body.method === 'eth_call' ) { let isSignedQuery = false; + let epoch = false; try { const x = getBytes(body.params[0].data); const y = cborg.decode(x); @@ -84,11 +85,15 @@ async function onRequest(req: IncomingMessage, response: ServerResponse) { // {data: {body{pk:,data:,nonce:},format:},leash:{nonce:,block_hash:,block_range:,block_number:},signature:} assert(y.data.format === 1); isSignedQuery = true; + epoch = y.data.body.epoch; } else { assert(y.format === 1); + epoch = y.body.epoch; } console.log( - 'ENCRYPTED' + (isSignedQuery ? ' SIGNED QUERY' : ''), + 'ENCRYPTED' + + (isSignedQuery ? ' SIGNED QUERY' : '') + + (epoch ? ` +EPOCH(${epoch})` : ''), req.method, req.url, body.method, @@ -115,7 +120,10 @@ async function onRequest(req: IncomingMessage, response: ServerResponse) { const y = decodeRlp(x) as string[]; //console.log(pj); const z = cborg.decode(getBytes(y[5])); assert(z.format === 1); // Verify envelope format == 1 (encrypted) - console.log('ENCRYPTED', req.method, req.url, body.method); + const epoch = z.body.epoch; + console.log( + 'ENCRYPTED' + (epoch ? ` +EPOCH(${epoch})` : ''), + req.method, req.url, body.method); showResult = true; } catch (e: any) { if (DIE_ON_UNENCRYPTED) { diff --git a/clients/js/src/calldatapublickey.ts b/clients/js/src/calldatapublickey.ts index fa9441b5..20d63b87 100644 --- a/clients/js/src/calldatapublickey.ts +++ b/clients/js/src/calldatapublickey.ts @@ -237,7 +237,7 @@ export class KeyFetcher extends AbstractKeyFetcher { public async cipher(upstream: UpstreamProvider): Promise { const kp = await this.fetch(upstream); - return X25519DeoxysII.ephemeral(kp.key); + return X25519DeoxysII.ephemeral(kp.key, kp.epoch); } } diff --git a/clients/js/src/cipher.ts b/clients/js/src/cipher.ts index b4edf1b6..3ca7f437 100644 --- a/clients/js/src/cipher.ts +++ b/clients/js/src/cipher.ts @@ -4,7 +4,6 @@ import deoxysii from '@oasisprotocol/deoxysii'; import { sha512_256 } from '@noble/hashes/sha512'; import { hmac } from '@noble/hashes/hmac'; import nacl, { BoxKeyPair } from 'tweetnacl'; -import { Promisable } from 'type-fest'; import { CallError } from './index.js'; @@ -34,8 +33,9 @@ export type CallResult = { export type CallFailure = { module: string; code: number; message?: string }; export abstract class Cipher { - public abstract kind: Promisable; - public abstract publicKey: Promisable; + public abstract kind: Kind; + public abstract publicKey: Uint8Array; + public abstract epoch: number | undefined; public abstract encrypt(plaintext: Uint8Array): Promise<{ ciphertext: Uint8Array; @@ -62,10 +62,11 @@ export abstract class Cipher { } if (plaintext.length === 0) return; // Txs without data are just balance transfers, and all data in those is public. const { data, nonce } = await this.encryptCallData(getBytes(plaintext)); - const [format, pk] = await Promise.all([this.kind, this.publicKey]); - const body = pk.length && nonce.length ? { pk, nonce, data } : data; - if (format === Kind.Plain) return { body }; - return { format, body }; + const pk = this.publicKey; + const epoch = this.epoch; + const body = pk.length && nonce.length ? { pk, nonce, data, epoch } : data; + if (this.kind === Kind.Plain) return { body }; + return { format: this.kind, body }; } protected async encryptCallData( @@ -142,6 +143,7 @@ export abstract class Cipher { export class Plain extends Cipher { public override readonly kind = Kind.Plain; public override readonly publicKey = new Uint8Array(); + public override readonly epoch = undefined; public async encrypt(plaintext: Uint8Array): Promise<{ ciphertext: Uint8Array; @@ -172,29 +174,37 @@ export class Plain extends Cipher { export class X25519DeoxysII extends Cipher { public override readonly kind = Kind.X25519DeoxysII; public override readonly publicKey: Uint8Array; + public override readonly epoch: number | undefined; private cipher: deoxysii.AEAD; private key: Uint8Array; // Stored for curious users. /** Creates a new cipher using an ephemeral keypair stored in memory. */ - static ephemeral(peerPublicKey: BytesLike): X25519DeoxysII { + static ephemeral(peerPublicKey: BytesLike, epoch?: number): X25519DeoxysII { const keypair = nacl.box.keyPair(); - return new X25519DeoxysII(keypair, getBytes(peerPublicKey)); + return new X25519DeoxysII(keypair, getBytes(peerPublicKey), epoch); } static fromSecretKey( secretKey: BytesLike, peerPublicKey: BytesLike, + epoch?: number, ): X25519DeoxysII { const keypair = nacl.box.keyPair.fromSecretKey(getBytes(secretKey)); - return new X25519DeoxysII(keypair, getBytes(peerPublicKey)); + return new X25519DeoxysII(keypair, getBytes(peerPublicKey), epoch); } - public constructor(keypair: BoxKeyPair, peerPublicKey: Uint8Array) { + public constructor( + keypair: BoxKeyPair, + peerPublicKey: Uint8Array, + epoch?: number, + ) { super(); this.publicKey = keypair.publicKey; // Derive a shared secret using X25519 (followed by hashing to remove ECDH bias). + this.epoch = epoch; + const keyBytes = hmac .create( sha512_256, @@ -228,6 +238,7 @@ export class X25519DeoxysII extends Cipher { export class Mock extends Cipher { public override readonly kind = Kind.Mock; public override readonly publicKey = new Uint8Array([1, 2, 3]); + public override readonly epoch = undefined; public static readonly NONCE = new Uint8Array([10, 20, 30, 40]); @@ -247,30 +258,3 @@ export class Mock extends Cipher { return ciphertext; } } - -/** - * A Cipher that constructs itself only when needed. - * Useful for deferring async construction (e.g., fetching public keys) until in an async context. - * - * @param generator A function that yields the cipher implementation. This function must be multiply callable and without observable side effects (c.f. Rust's `impl Fn()`). - */ -export function lazy(generator: () => Promisable): Cipher { - // Note: in cases when `generate` is run concurrently, the first fulfillment will be used. - return new Proxy( - {}, - { - get(target: { inner?: Promise }, prop) { - // Props (Promiseable) - if (prop === 'kind' || prop === 'publicKey') { - if (!target.inner) target.inner = Promise.resolve(generator()); - return target.inner.then((c) => Reflect.get(c, prop)); - } - // Funcs (async) - return async (...args: unknown[]) => { - if (!target.inner) target.inner = Promise.resolve(generator()); - return target.inner.then((c) => Reflect.get(c, prop).apply(c, args)); - }; - }, - }, - ) as Cipher; -} diff --git a/clients/js/test/cipher.spec.ts b/clients/js/test/cipher.spec.ts index cfcc059c..3a4c5d96 100644 --- a/clients/js/test/cipher.spec.ts +++ b/clients/js/test/cipher.spec.ts @@ -6,7 +6,6 @@ import nacl from 'tweetnacl'; import { Plain, X25519DeoxysII, - lazy, } from '@oasisprotocol/sapphire-paratime/cipher.js'; const DATA = new Uint8Array([1, 2, 3, 4, 5]); @@ -113,14 +112,3 @@ describe('X25519DeoxysII', () => { ); }); }); - -describe('lazy', () => { - it('forwards', async () => { - const inner = X25519DeoxysII.ephemeral(nacl.box.keyPair().publicKey); - const cipher = lazy(() => inner); - expect(await cipher.publicKey).toEqual(inner.publicKey); - expect((await cipher.encrypt(DATA)).ciphertext).toHaveLength( - DATA.length + TagSize, - ); - }); -}); From 3736d8bc7431225a7a99012af37e947d4f254ef1 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Mon, 5 Feb 2024 19:56:59 +0000 Subject: [PATCH 06/14] clients/js: formatting --- clients/js/scripts/proxy.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/clients/js/scripts/proxy.ts b/clients/js/scripts/proxy.ts index 8854c373..25a905f8 100644 --- a/clients/js/scripts/proxy.ts +++ b/clients/js/scripts/proxy.ts @@ -91,9 +91,9 @@ async function onRequest(req: IncomingMessage, response: ServerResponse) { epoch = y.body.epoch; } console.log( - 'ENCRYPTED' - + (isSignedQuery ? ' SIGNED QUERY' : '') - + (epoch ? ` +EPOCH(${epoch})` : ''), + 'ENCRYPTED' + + (isSignedQuery ? ' SIGNED QUERY' : '') + + (epoch ? ` +EPOCH(${epoch})` : ''), req.method, req.url, body.method, @@ -123,7 +123,10 @@ async function onRequest(req: IncomingMessage, response: ServerResponse) { const epoch = z.body.epoch; console.log( 'ENCRYPTED' + (epoch ? ` +EPOCH(${epoch})` : ''), - req.method, req.url, body.method); + req.method, + req.url, + body.method, + ); showResult = true; } catch (e: any) { if (DIE_ON_UNENCRYPTED) { From aac5542b6c6c2fdf1fe0ff299c8449c36b9fd67f Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 07:36:47 +0000 Subject: [PATCH 07/14] clients/js: fixed prepareRequest --- clients/js/src/compat.ts | 4 ---- contracts/test/eip155.ts | 2 +- contracts/test/gas.ts | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/clients/js/src/compat.ts b/clients/js/src/compat.ts index 2820ed67..edc980c8 100644 --- a/clients/js/src/compat.ts +++ b/clients/js/src/compat.ts @@ -159,7 +159,6 @@ function hookEIP1193Request( const { method, params } = await prepareRequest( args, signer, - options, cipher, ); const res = await signer.provider.send(method, params ?? []); @@ -410,7 +409,6 @@ async function callNeedsSigning( async function prepareRequest( { method, params }: Web3ReqArgs, signer: JsonRpcSigner, - options: SapphireWrapOptions, cipher: Cipher, ): Promise<{ method: string; params?: Array }> { if (!Array.isArray(params)) return { method, params }; @@ -427,7 +425,6 @@ async function prepareRequest( (await callNeedsSigning(params[0])) ) { const dataPack = await SignedCallDataPack.make(params[0], signer); - const cipher = await options.fetcher.cipher(signer); const signedCall = { ...params[0], data: await dataPack.encryptEncode(cipher), @@ -442,7 +439,6 @@ async function prepareRequest( /^eth_((send|sign)Transaction|call|estimateGas)$/.test(method) && params[0].data // Ignore balance transfers without calldata ) { - const cipher = await options.fetcher.cipher(signer); params[0].data = await cipher.encryptEncode(params[0].data); return { method, params }; } diff --git a/contracts/test/eip155.ts b/contracts/test/eip155.ts index 1be23b56..15234b7e 100644 --- a/contracts/test/eip155.ts +++ b/contracts/test/eip155.ts @@ -70,7 +70,7 @@ describe('EIP-155', function () { const tx = await testContract.example(); expect(entropy(tx.data)).gte(EXPECTED_ENTROPY_ENCRYPTED); expect(tx.data).not.eq(calldata); - expect(tx.data.length).eq(218); + expect(tx.data.length).eq(236); }); /// Verify that contracts can sign transactions for submission with an unwrapped provider diff --git a/contracts/test/gas.ts b/contracts/test/gas.ts index b9575814..e9e3125a 100644 --- a/contracts/test/gas.ts +++ b/contracts/test/gas.ts @@ -13,7 +13,7 @@ describe('Gas Padding', function () { }); it('Gas Padding works as Expected', async () => { - const expectedGas = 122735; + const expectedGas = 122746; let tx = await contract.testConstantTime(1, 100000); let receipt = await tx.wait(); From b84bc0feca9df4e2a424df4357df618f00415f22 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 07:38:32 +0000 Subject: [PATCH 08/14] clients/js: formatting --- clients/js/src/compat.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/clients/js/src/compat.ts b/clients/js/src/compat.ts index edc980c8..32f2017b 100644 --- a/clients/js/src/compat.ts +++ b/clients/js/src/compat.ts @@ -156,11 +156,7 @@ function hookEIP1193Request( return async (args: Web3ReqArgs) => { const signer = await provider.getSigner(); const cipher = await options.fetcher.cipher(provider); - const { method, params } = await prepareRequest( - args, - signer, - cipher, - ); + const { method, params } = await prepareRequest(args, signer, cipher); const res = await signer.provider.send(method, params ?? []); if (method === 'eth_call') { return await cipher.decryptEncoded(res); From e1be958055cb4f385b590047e23256141bb28683 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 07:44:23 +0000 Subject: [PATCH 09/14] contracts: eip-155 tests don't check for exact transaction size --- contracts/test/eip155.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/eip155.ts b/contracts/test/eip155.ts index 15234b7e..5c873f57 100644 --- a/contracts/test/eip155.ts +++ b/contracts/test/eip155.ts @@ -70,7 +70,7 @@ describe('EIP-155', function () { const tx = await testContract.example(); expect(entropy(tx.data)).gte(EXPECTED_ENTROPY_ENCRYPTED); expect(tx.data).not.eq(calldata); - expect(tx.data.length).eq(236); + expect(tx.data.length).gt(230); }); /// Verify that contracts can sign transactions for submission with an unwrapped provider From 6402d891416c0b1f291cc55c6a376900be669416 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 07:51:54 +0000 Subject: [PATCH 10/14] contracts: made gas tests more flexible --- contracts/test/gas.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/contracts/test/gas.ts b/contracts/test/gas.ts index e9e3125a..67fa7321 100644 --- a/contracts/test/gas.ts +++ b/contracts/test/gas.ts @@ -4,6 +4,8 @@ import { expect } from 'chai'; import { ethers } from 'hardhat'; import { GasTests } from '../typechain-types/contracts/tests/Gas.sol/GasTests'; +const GAS_MARGIN_OF_ERROR = 5; + describe('Gas Padding', function () { let contract: GasTests; @@ -17,15 +19,24 @@ describe('Gas Padding', function () { let tx = await contract.testConstantTime(1, 100000); let receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).within(expectedGas - 1, expectedGas + 2); + expect(receipt!.cumulativeGasUsed).within( + expectedGas - GAS_MARGIN_OF_ERROR, + expectedGas + GAS_MARGIN_OF_ERROR, + ); tx = await contract.testConstantTime(2, 100000); receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).within(expectedGas - 1, expectedGas + 2); + expect(receipt!.cumulativeGasUsed).within( + expectedGas - GAS_MARGIN_OF_ERROR, + expectedGas + GAS_MARGIN_OF_ERROR, + ); tx = await contract.testConstantTime(1, 100000); receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).within(expectedGas - 2, expectedGas + 2); + expect(receipt!.cumulativeGasUsed).within( + expectedGas - GAS_MARGIN_OF_ERROR, + expectedGas + GAS_MARGIN_OF_ERROR, + ); // Note: calldata isn't included in gas padding // Thus when the value is 0 it will use 4 gas instead of 16 gas From 9537d5c0dd9787efe4a974cb4552410b14ddbbe2 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 08:05:51 +0000 Subject: [PATCH 11/14] contracts: gas tests need rewriting --- contracts/test/gas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/gas.ts b/contracts/test/gas.ts index 67fa7321..a1e412b5 100644 --- a/contracts/test/gas.ts +++ b/contracts/test/gas.ts @@ -44,7 +44,7 @@ describe('Gas Padding', function () { tx = await contract.testConstantTime(0, 100000); receipt = await tx.wait(); expect(receipt?.cumulativeGasUsed).within( - expectedGas - 13, + expectedGas - 10 - GAS_MARGIN_OF_ERROR, expectedGas - 10, ); }); From 997e7e75af202e569ec9dea5988daa5864679da4 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 08:15:18 +0000 Subject: [PATCH 12/14] contracts: made gas tests relative --- contracts/test/gas.ts | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/contracts/test/gas.ts b/contracts/test/gas.ts index a1e412b5..f91f4fee 100644 --- a/contracts/test/gas.ts +++ b/contracts/test/gas.ts @@ -4,8 +4,6 @@ import { expect } from 'chai'; import { ethers } from 'hardhat'; import { GasTests } from '../typechain-types/contracts/tests/Gas.sol/GasTests'; -const GAS_MARGIN_OF_ERROR = 5; - describe('Gas Padding', function () { let contract: GasTests; @@ -15,37 +13,22 @@ describe('Gas Padding', function () { }); it('Gas Padding works as Expected', async () => { - const expectedGas = 122746; - let tx = await contract.testConstantTime(1, 100000); let receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).within( - expectedGas - GAS_MARGIN_OF_ERROR, - expectedGas + GAS_MARGIN_OF_ERROR, - ); + const initialGasUsed = receipt!.cumulativeGasUsed; tx = await contract.testConstantTime(2, 100000); receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).within( - expectedGas - GAS_MARGIN_OF_ERROR, - expectedGas + GAS_MARGIN_OF_ERROR, - ); + expect(receipt!.cumulativeGasUsed).eq(initialGasUsed); - tx = await contract.testConstantTime(1, 100000); + tx = await contract.testConstantTime(1, 110000); receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).within( - expectedGas - GAS_MARGIN_OF_ERROR, - expectedGas + GAS_MARGIN_OF_ERROR, - ); + expect(receipt!.cumulativeGasUsed).eq(initialGasUsed+10000n); // Note: calldata isn't included in gas padding // Thus when the value is 0 it will use 4 gas instead of 16 gas - // XXX: sometimes this is off by 1 gas! tx = await contract.testConstantTime(0, 100000); receipt = await tx.wait(); - expect(receipt?.cumulativeGasUsed).within( - expectedGas - 10 - GAS_MARGIN_OF_ERROR, - expectedGas - 10, - ); + expect(receipt?.cumulativeGasUsed).eq(initialGasUsed-12n); }); }); From 406ec15b527676e03adf10d5619facb5ae7b6337 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 08:17:40 +0000 Subject: [PATCH 13/14] contracts: formatting --- contracts/test/gas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/test/gas.ts b/contracts/test/gas.ts index f91f4fee..95a83300 100644 --- a/contracts/test/gas.ts +++ b/contracts/test/gas.ts @@ -23,12 +23,12 @@ describe('Gas Padding', function () { tx = await contract.testConstantTime(1, 110000); receipt = await tx.wait(); - expect(receipt!.cumulativeGasUsed).eq(initialGasUsed+10000n); + expect(receipt!.cumulativeGasUsed).eq(initialGasUsed + 10000n); // Note: calldata isn't included in gas padding // Thus when the value is 0 it will use 4 gas instead of 16 gas tx = await contract.testConstantTime(0, 100000); receipt = await tx.wait(); - expect(receipt?.cumulativeGasUsed).eq(initialGasUsed-12n); + expect(receipt?.cumulativeGasUsed).eq(initialGasUsed - 12n); }); }); From a8803cc41bc194c128aa5ff36dece9f7156d9594 Mon Sep 17 00:00:00 2001 From: CedarMist <134699267+CedarMist@users.noreply.github.com> Date: Tue, 6 Feb 2024 10:54:33 +0000 Subject: [PATCH 14/14] clients/js: bump sapphire-paratime release to v1.3.2 --- clients/js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/js/package.json b/clients/js/package.json index 3f47ec8e..2498960c 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@oasisprotocol/sapphire-paratime", "license": "Apache-2.0", - "version": "1.3.1", + "version": "1.3.2", "description": "The Sapphire ParaTime Web3 integration library.", "homepage": "https://github.com/oasisprotocol/sapphire-paratime/tree/main/clients/js", "repository": {