Skip to content

Commit

Permalink
chore: Safe JSON RPC server and client (#9656)
Browse files Browse the repository at this point in the history
## Current situation

What was wrong with our way of exposing our APIs over JSON RPC? First of
all, the JsonRpcServer defaulted to exporting every method on the
underlying handler, including those flagged as private in ts (though
js-private like #method were safe). While we had a blacklist, it's safer
to be explicit on what to expose.

Then, argument deserialization was handled based on client inputs
exclusively, allowing to target any class registered in the RPC
interface. This means a method expecting an instance of class A as an
argument, could just be fed an instance of B by its caller.

Deserialization itself was also unchecked. Most fromJSON methods did not
validate any of their inputs, just assuming the any input object matched
the expected shape. Primitive arguments to functions were also
unchecked, so a caller could eg pass a number where a string was
expected.

Other unrelated issues included often forgetting to register a class for
(de)serialization in the rpc server, or forgetting to type all API
return types as async (if they are over an RPC interface, calling any
method should be async!).

These issues all affected the client as well. Though security is not so
much of an issue on the client, lack of validation could indeed cause
bugs by querying a server with a mismatching version.

## Proposed fix

We introduce a pair of "safe" JSON rpc client and server abstractions,
that consume a **schema** along with a handler. The schema leverages
`zod` for validating all inputs (in the case of the server) and outputs
(in the case of the client) and defining the exact set of methods that
are exposed. Schemas are type-checked against the interface, so a change
in the interface will trigger tsc to alert us to change the schemas as
well.

As a side effect of this change, since each method now knows _what_ it
should be deserializing, we can kill much of the custom logic for
(de)serialization, such as the string and class converters, and just
rely on vanilla json serialization. Each class that we intend to pass
through the wire should expose a static zod schema used for both
validation and hydration, and a `toJSON` method that is used for
serialization:

```typescript
export class TestNote {
  constructor(private data: string) {}

  static get schema() {
    return z.object({ data: z.string() }).transform(({ data }) => new TestNote(data));
  }

  toJSON() {
    return { data: this.data };
  }
}
```

Then the API is defined as a plain interface:

```typescript
export interface TestStateApi {
  getNote: (index: number) => Promise<TestNote>;
  getNotes: () => Promise<TestNote[]>;
  clear: () => Promise<void>;
  addNotes: (notes: TestNote[]) => Promise<TestNote[]>;
  fail: () => Promise<void>;
  count: () => Promise<number>;
  getStatus: () => Promise<{ status: string; count: bigint }>;
}
```

With its corresponding schema:

```typescript
export const TestStateSchema: ApiSchemaFor<TestStateApi> = {
  getNote: z.function().args(z.number()).returns(TestNote.schema),
  getNotes: z.function().returns(z.array(TestNote.schema)),
  clear: z.function().returns(z.void()),
  addNotes: z.function().args(z.array(TestNote.schema)).returns(z.array(TestNote.schema)),
  fail: z.function().returns(z.void()),
  count: z.function().returns(z.number()),
  getStatus: z.function().returns(z.object({ status: z.string(), count: schemas.BigInt })),
};
```

## Scope of this PR

This PR introduces the new safe json rpc client and server abstractions,
but does **not** yet enable them. All `start` methods still use the old
flavors. Upcoming PRs will add schemas for all our public interfaces and
make the switch.
  • Loading branch information
spalladino authored Nov 1, 2024
1 parent b70b6f1 commit e63e219
Show file tree
Hide file tree
Showing 33 changed files with 916 additions and 66 deletions.
3 changes: 2 additions & 1 deletion yarn-project/circuit-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.times": "^4.3.2",
"tslib": "^2.5.0"
"tslib": "^2.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
Expand Down
1 change: 1 addition & 0 deletions yarn-project/circuit-types/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './proving-job.js';
export * from './server_circuit_prover.js';
export * from './sync-status.js';
export * from './world_state.js';
export * from './prover-node.js';
45 changes: 45 additions & 0 deletions yarn-project/circuit-types/src/interfaces/prover-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type Signature } from '@aztec/foundation/eth-signature';
import { type ApiSchemaFor } from '@aztec/foundation/schemas';

import { z } from 'zod';

import { EpochProofQuote } from '../prover_coordination/epoch_proof_quote.js';

// Required by ts to export the schema of EpochProofQuote
export { type Signature };

const EpochProvingJobState = [
'initialized',
'processing',
'awaiting-prover',
'publishing-proof',
'completed',
'failed',
] as const;

export type EpochProvingJobState = (typeof EpochProvingJobState)[number];

/** JSON RPC public interface to a prover node. */
export interface ProverNodeApi {
getJobs(): Promise<{ uuid: string; status: EpochProvingJobState }[]>;

startProof(epochNumber: number): Promise<void>;

prove(epochNumber: number): Promise<void>;

sendEpochProofQuote(quote: EpochProofQuote): Promise<void>;
}

/** Schemas for prover node API functions. */
export class ProverNodeApiSchema implements ApiSchemaFor<ProverNodeApi> {
getJobs = z
.function()
.args()
.returns(z.array(z.object({ uuid: z.string().uuid(), status: z.enum(EpochProvingJobState) })));

startProof = z.function().args(z.number()).returns(z.void());

prove = z.function().args(z.number()).returns(z.void());

sendEpochProofQuote = z.function().args(EpochProofQuote.schema).returns(z.void());
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Buffer32 } from '@aztec/foundation/buffer';
import { type Secp256k1Signer, keccak256 } from '@aztec/foundation/crypto';
import { Signature } from '@aztec/foundation/eth-signature';
import { schemas } from '@aztec/foundation/schemas';
import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize';
import { type FieldsOf } from '@aztec/foundation/types';

import { z } from 'zod';

import { Gossipable } from '../p2p/gossipable.js';
import { TopicType, createTopicString } from '../p2p/topic_type.js';
import { EpochProofQuotePayload } from './epoch_proof_quote_payload.js';
Expand Down Expand Up @@ -40,8 +43,17 @@ export class EpochProofQuote extends Gossipable {
};
}

static get schema() {
return z
.object({
payload: EpochProofQuotePayload.schema,
signature: schemas.Signature,
})
.transform(({ payload, signature }) => new EpochProofQuote(payload, signature));
}

static fromJSON(obj: any) {
return new EpochProofQuote(EpochProofQuotePayload.fromJSON(obj.payload), Signature.from0xString(obj.signature));
return EpochProofQuote.schema.parse(obj);
}

// TODO: https://github.com/AztecProtocol/aztec-packages/issues/8911
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { EthAddress } from '@aztec/circuits.js';
import { EthAddress } from '@aztec/foundation/eth-address';
import { schemas } from '@aztec/foundation/schemas';
import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize';
import { type FieldsOf } from '@aztec/foundation/types';

import { inspect } from 'util';
import { z } from 'zod';

// Required so typescript can properly annotate the exported schema
export { type EthAddress };

export class EpochProofQuotePayload {
// Cached values
Expand All @@ -15,7 +20,15 @@ export class EpochProofQuotePayload {
public readonly bondAmount: bigint,
public readonly prover: EthAddress,
public readonly basisPointFee: number,
) {}
) {
if (basisPointFee < 0 || basisPointFee > 10000) {
throw new Error(`Invalid basisPointFee ${basisPointFee}`);
}
}

static empty() {
return new EpochProofQuotePayload(0n, 0n, 0n, EthAddress.ZERO, 0);
}

static getFields(fields: FieldsOf<EpochProofQuotePayload>) {
return [
Expand Down Expand Up @@ -68,14 +81,20 @@ export class EpochProofQuotePayload {
};
}

static fromJSON(obj: any) {
return new EpochProofQuotePayload(
BigInt(obj.epochToProve),
BigInt(obj.validUntilSlot),
BigInt(obj.bondAmount),
EthAddress.fromString(obj.prover),
obj.basisPointFee,
);
static get schema() {
return z
.object({
epochToProve: z.coerce.bigint(),
validUntilSlot: z.coerce.bigint(),
bondAmount: z.coerce.bigint(),
prover: schemas.EthAddress,
basisPointFee: z.number(),
})
.transform(EpochProofQuotePayload.from);
}

static fromJSON(obj: any): EpochProofQuotePayload {
return EpochProofQuotePayload.schema.parse(obj);
}

toViemArgs(): {
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/cli/src/cmds/contracts/parse_parameter_struct.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type StructType } from '@aztec/foundation/abi';
import { JsonStringify } from '@aztec/foundation/json-rpc';
import { jsonStringify } from '@aztec/foundation/json-rpc';
import { type LogFn } from '@aztec/foundation/log';

import { getContractArtifact } from '../../utils/aztec.js';
Expand All @@ -23,5 +23,5 @@ export async function parseParameterStruct(
}

const data = parseStructString(encodedString, parameterAbitype.type as StructType);
log(`\nStruct Data: \n${JsonStringify(data, true)}\n`);
log(`\nStruct Data: \n${jsonStringify(data, true)}\n`);
}
5 changes: 3 additions & 2 deletions yarn-project/end-to-end/src/devnet/e2e_smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from '@aztec/aztec.js';
import { DefaultMultiCallEntrypoint } from '@aztec/aztec.js/entrypoint';
import { GasSettings, deriveSigningKey } from '@aztec/circuits.js';
import { startHttpRpcServer } from '@aztec/foundation/json-rpc/server';
import { createNamespacedJsonRpcServer, startHttpRpcServer } from '@aztec/foundation/json-rpc/server';
import { type DebugLogger } from '@aztec/foundation/log';
import { promiseWithResolvers } from '@aztec/foundation/promise';
import { FeeJuiceContract, TestContract } from '@aztec/noir-contracts.js';
Expand Down Expand Up @@ -109,7 +109,8 @@ describe('End-to-end tests for devnet', () => {
const localhost = await getLocalhost();
pxeUrl = `http://${localhost}:${port}`;
// start a server for the CLI to talk to
const server = startHttpRpcServer('pxe', pxe, createPXERpcServer, port);
const jsonRpcServer = createNamespacedJsonRpcServer([{ pxe: createPXERpcServer(pxe) }]);
const server = await startHttpRpcServer(jsonRpcServer, { port });

teardown = async () => {
const { promise, resolve, reject } = promiseWithResolvers<void>();
Expand Down
3 changes: 2 additions & 1 deletion yarn-project/foundation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"./worker": "./dest/worker/index.js",
"./bigint-buffer": "./dest/bigint-buffer/index.js",
"./types": "./dest/types/index.js",
"./schemas": "./dest/schemas/index.js",
"./url": "./dest/url/index.js",
"./committable": "./dest/committable/index.js",
"./noir": "./dest/noir/index.js",
Expand Down Expand Up @@ -114,7 +115,7 @@
"memdown": "^6.1.1",
"pako": "^2.1.0",
"sha3": "^2.1.4",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
Expand Down
8 changes: 2 additions & 6 deletions yarn-project/foundation/src/eth-address/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,9 @@ import { TypeRegistry } from '../serialize/type_registry.js';
* and can be serialized/deserialized from a buffer or BufferReader.
*/
export class EthAddress {
/**
* The size of an Ethereum address in bytes.
*/
/** The size of an Ethereum address in bytes. */
public static SIZE_IN_BYTES = 20;
/**
* Represents a zero Ethereum address with 20 bytes filled with zeros.
*/
/** Represents a zero Ethereum address with 20 bytes filled with zeros. */
public static ZERO = new EthAddress(Buffer.alloc(EthAddress.SIZE_IN_BYTES));

constructor(private buffer: Buffer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ describe('eth signature', () => {
expect(sender).toEqual(signer.address);
});

it('should serialize / deserialize to hex string with v=0', () => {
const signature = new Signature(Buffer32.random(), Buffer32.random(), 0, false);
const serialized = signature.to0xString();
const deserialized = Signature.from0xString(serialized);
checkEquivalence(signature, deserialized);
});

it('should serialize / deserialize to hex string with 1-digit v', () => {
const signature = new Signature(Buffer32.random(), Buffer32.random(), 1, false);
const serialized = signature.to0xString();
Expand Down
8 changes: 8 additions & 0 deletions yarn-project/foundation/src/eth-signature/eth_signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export class Signature {
return new Signature(r, s, v, isEmpty);
}

static isValid0xString(sig: `0x${string}`): boolean {
return /^0x[0-9a-f]{129,}$/i.test(sig);
}

/**
* A seperate method exists for this as when signing locally with viem, as when
* parsing from viem, we can expect the v value to be a u8, rather than our
Expand Down Expand Up @@ -106,4 +110,8 @@ export class Signature {
isEmpty: this.isEmpty,
};
}

toJSON() {
return this.to0xString();
}
}
1 change: 1 addition & 0 deletions yarn-project/foundation/src/json-rpc/client/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { createJsonRpcClient, defaultFetch, makeFetch } from './json_rpc_client.js';
export * from './safe_json_rpc_client.js';
11 changes: 7 additions & 4 deletions yarn-project/foundation/src/json-rpc/client/json_rpc_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { format } from 'util';
import { type DebugLogger, createDebugLogger } from '../../log/index.js';
import { NoRetryError, makeBackoff, retry } from '../../retry/index.js';
import { ClassConverter, type JsonClassConverterInput, type StringClassConverterInput } from '../class_converter.js';
import { JsonStringify, convertFromJsonObj, convertToJsonObj } from '../convert.js';
import { convertFromJsonObj, convertToJsonObj, jsonStringify } from '../convert.js';

export { JsonStringify } from '../convert.js';
export { jsonStringify } from '../convert.js';

const log = createDebugLogger('json-rpc:json_rpc_client');

/**
* A normal fetch function that does not retry.
* Alternatives are a fetch function with retries, or a mocked fetch.
Expand All @@ -29,19 +30,20 @@ export async function defaultFetch(
body: any,
useApiEndpoints: boolean,
noRetry = false,
stringify = jsonStringify,
) {
log.debug(format(`JsonRpcClient.fetch`, host, rpcMethod, '->', body));
let resp: Response;
if (useApiEndpoints) {
resp = await fetch(`${host}/${rpcMethod}`, {
method: 'POST',
body: JsonStringify(body),
body: stringify(body),
headers: { 'content-type': 'application/json' },
});
} else {
resp = await fetch(host, {
method: 'POST',
body: JsonStringify({ ...body, method: rpcMethod }),
body: stringify({ ...body, method: rpcMethod }),
headers: { 'content-type': 'application/json' },
});
}
Expand All @@ -55,6 +57,7 @@ export async function defaultFetch(
}
throw new Error(`Failed to parse body as JSON: ${resp.text()}`);
}

if (!resp.ok) {
const errorMessage = `(JSON-RPC PROPAGATED) (host ${host}) (method ${rpcMethod}) (code ${resp.status}) ${responseJson.error.message}`;
if (noRetry || (resp.status >= 400 && resp.status < 500)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { format } from 'util';

import { createDebugLogger } from '../../log/logger.js';
import { type ApiSchema, type ApiSchemaFor, schemaHasMethod } from '../../schemas/api.js';
import { jsonStringify2 } from '../convert.js';
import { defaultFetch } from './json_rpc_client.js';

export { jsonStringify } from '../convert.js';

/**
* Creates a Proxy object that delegates over RPC and validates outputs against a given schema.
* The server is expected to be a JsonRpcServer.
* @param host - The host URL.
* @param schema - The api schema to validate returned data against.
* @param useApiEndpoints - Whether to use the API endpoints or the default RPC endpoint.
* @param namespaceMethods - String value (or false/empty) to namespace all methods sent to the server. e.g. 'getInfo' -\> 'pxe_getInfo'
* @param fetch - The fetch implementation to use.
*/
export function createSafeJsonRpcClient<T extends object>(
host: string,
schema: ApiSchemaFor<T>,
useApiEndpoints: boolean = false,
namespaceMethods?: string | false,
fetch = defaultFetch,
log = createDebugLogger('json-rpc:client'),
): T {
let id = 0;
const request = async (methodName: string, params: any[]): Promise<any> => {
if (!schemaHasMethod(schema, methodName)) {
throw new Error(`Unspecified method ${methodName} in client schema`);
}
const method = namespaceMethods ? `${namespaceMethods}_${methodName}` : methodName;
const body = { jsonrpc: '2.0', id: id++, method, params };

log.debug(format(`request`, method, params));
const res = await fetch(host, method, body, useApiEndpoints, undefined, jsonStringify2);
log.debug(format(`result`, method, res));

if (res.error) {
throw res.error;
}
// TODO: Why check for string null and undefined?
if ([null, undefined, 'null', 'undefined'].includes(res.result)) {
return;
}

return (schema as ApiSchema)[methodName].returnType().parse(res.result);
};

// Intercept any RPC methods with a proxy
const proxy = new Proxy(
{},
{
get: (target, method: string) => {
if (['then', 'catch'].includes(method)) {
return Reflect.get(target, method);
}
return (...params: any[]) => request(method, params);
},
},
) as T;

return proxy;
}
Loading

0 comments on commit e63e219

Please sign in to comment.