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

Refactor callDataPublicKey handling #270

Merged
merged 14 commits into from
Feb 6, 2024
Merged
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
2 changes: 1 addition & 1 deletion clients/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 14 additions & 3 deletions clients/js/scripts/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async function getBody(request: IncomingMessage): Promise<string> {
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);
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -115,7 +120,13 @@ 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) {
Expand Down
259 changes: 259 additions & 0 deletions clients/js/src/calldatapublickey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
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;

// Which chain ID is this key for?
chainId: number;

// 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(),
} as CallDataPublicKey;
}

// TODO: remove, this is unecessary, node has `fetch` now?
async function fetchRuntimePublicKeyNode(
gwUrl: string,
): Promise<RawCallDataPublicKeyResponse> {
// 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<RawCallDataPublicKeyResponse> {
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<CallDataPublicKey> {
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<CallDataPublicKey> {
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<any>;
};
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<CallDataPublicKey>;
public abstract cipher(upstream: UpstreamProvider): Promise<Cipher>;
}

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<CallDataPublicKey> {
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<Cipher> {
const kp = await this.fetch(upstream);
return X25519DeoxysII.ephemeral(kp.key, kp.epoch);
}
}

export class MockKeyFetcher extends AbstractKeyFetcher {
#_cipher: MockCipher;

constructor(in_cipher: MockCipher) {
super();
this.#_cipher = in_cipher;
}

public async fetch(): Promise<CallDataPublicKey> {
throw new Error("MockKeyFetcher doesn't support fetch(), only cipher()");
}

public async cipher(): Promise<Cipher> {
return this.#_cipher;
}
}
Loading
Loading