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

fix: Make pollForResponse typesafe to avoid exceptions from unknown requests #958

Merged
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

### Changed

- chore: Removes warning that users found unhelpful, when a message originates from other sources than the identity provider in `AuthClient` during authentication.
- chore: Removes warning that users found unhelpful, when a message originates from other sources than the identity provider in `AuthClient` during authentication.
- fix: Make pollForResponse typesafe to avoid exceptions from unknown requests

## [2.1.3] - 2024-10-23

Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ describe('makeActor', () => {
},
} as UnSigned<CallRequest>;

const expectedCallRequestId = await requestIdOf(expectedCallRequest.content);
const expectedCallRequestId = requestIdOf(expectedCallRequest.content);

const httpAgent = new HttpAgent({ fetch: mockFetch });

Expand Down
9 changes: 2 additions & 7 deletions packages/agent/src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,7 @@ export interface Agent {
* `readState` uses this internally.
* Useful to avoid signing the same request multiple times.
*/
createReadStateRequest?(
options: ReadStateOptions,
identity?: Identity,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any>;
createReadStateRequest?(options: ReadStateOptions, identity?: Identity): Promise<unknown>;

/**
* Send a read state query to the replica. This includes a list of paths to return,
Expand All @@ -179,8 +175,7 @@ export interface Agent {
effectiveCanisterId: Principal | string,
options: ReadStateOptions,
identity?: Identity,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
request?: any,
request?: unknown,
): Promise<ReadStateResponse>;

call(canisterId: Principal | string, fields: CallOptions): Promise<SubmitResponse>;
Expand Down
12 changes: 6 additions & 6 deletions packages/agent/src/agent/http/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,13 @@ test('call', async () => {
ingress_expiry: new Expiry(300000),
};

const mockPartialsRequestId = await requestIdOf(mockPartialRequest);
const mockPartialsRequestId = requestIdOf(mockPartialRequest);

const expectedRequest = {
content: mockPartialRequest,
};

const expectedRequestId = await requestIdOf(expectedRequest.content);
const expectedRequestId = requestIdOf(expectedRequest.content);
expect(expectedRequestId).toEqual(mockPartialsRequestId);

const { calls } = mockFetch.mock;
Expand Down Expand Up @@ -150,7 +150,7 @@ test('queries with the same content should have the same signature', async () =>
const methodName = 'greet';
const arg = new Uint8Array([]);

const requestId = await requestIdOf({
const requestId = requestIdOf({
request_type: SubmitRequestType.Call,
nonce,
canister_id: Principal.fromText(canisterIdent).toString(),
Expand Down Expand Up @@ -213,7 +213,7 @@ test('readState should not call transformers if request is passed', async () =>
const methodName = 'greet';
const arg = new Uint8Array([]);

const requestId = await requestIdOf({
const requestId = requestIdOf({
request_type: SubmitRequestType.Call,
nonce,
canister_id: Principal.fromText(canisterIdent).toString(),
Expand Down Expand Up @@ -314,13 +314,13 @@ test('use anonymous principal if unspecified', async () => {
ingress_expiry: new Expiry(300000),
};

const mockPartialsRequestId = await requestIdOf(mockPartialRequest);
const mockPartialsRequestId = requestIdOf(mockPartialRequest);

const expectedRequest: Envelope<CallRequest> = {
content: mockPartialRequest,
};

const expectedRequestId = await requestIdOf(expectedRequest.content);
const expectedRequestId = requestIdOf(expectedRequest.content);
expect(expectedRequestId).toEqual(mockPartialsRequestId);

const { calls } = mockFetch.mock;
Expand Down
16 changes: 7 additions & 9 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ export class HttpAgent implements Agent {
): Promise<SubmitResponse> {
// TODO - restore this value
const callSync = options.callSync ?? true;
const id = await(identity !== undefined ? await identity : await this.#identity);
const id = await (identity !== undefined ? await identity : await this.#identity);
if (!id) {
throw new IdentityInvalidError(
"This identity has expired due this application's security policy. Please refresh your authentication.",
Expand Down Expand Up @@ -468,8 +468,7 @@ export class HttpAgent implements Agent {
ingress_expiry,
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let transformedRequest: any = (await this._transform({
let transformedRequest = (await this._transform({
request: {
body: null,
method: 'POST',
Expand All @@ -493,7 +492,7 @@ export class HttpAgent implements Agent {
}

// Apply transform for identity.
transformedRequest = await id.transformRequest(transformedRequest);
transformedRequest = (await id.transformRequest(transformedRequest)) as HttpAgentSubmitRequest;

const body = cbor.encode(transformedRequest.body);
const backoff = this.#backoffStrategy();
Expand Down Expand Up @@ -528,9 +527,9 @@ export class HttpAgent implements Agent {
backoff,
tries: 0,
});
const requestId = requestIdOf(submit);

const [response, requestId] = await Promise.all([request, requestIdOf(submit)]);

const response = await request;
const responseBuffer = await response.arrayBuffer();
const responseBody = (
response.status === 200 && responseBuffer.byteLength > 0
Expand Down Expand Up @@ -780,7 +779,7 @@ export class HttpAgent implements Agent {
this.log.print(`ecid ${ecid.toString()}`);
this.log.print(`canisterId ${canisterId.toString()}`);
const makeQuery = async () => {
const id = await(identity !== undefined ? identity : this.#identity);
const id = await (identity !== undefined ? identity : this.#identity);
if (!id) {
throw new IdentityInvalidError(
"This identity has expired due this application's security policy. Please refresh your authentication.",
Expand All @@ -799,7 +798,7 @@ export class HttpAgent implements Agent {
ingress_expiry: new Expiry(this.#maxIngressExpiryInMinutes * MINUTE_TO_MSECS),
};

const requestId = await requestIdOf(request);
const requestId = requestIdOf(request);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let transformedRequest: HttpAgentRequest = await this._transform({
Expand Down Expand Up @@ -1176,4 +1175,3 @@ export class HttpAgent implements Agent {
return p;
}
}

2 changes: 1 addition & 1 deletion packages/agent/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export abstract class SignIdentity implements Identity {
*/
public async transformRequest(request: HttpAgentRequest): Promise<unknown> {
const { body, ...fields } = request;
const requestId = await requestIdOf(body);
const requestId = requestIdOf(body);
return {
...fields,
body: {
Expand Down
53 changes: 48 additions & 5 deletions packages/agent/src/polling/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { toHex } from '../utils/buffer';
export * as strategy from './strategy';
import { defaultStrategy } from './strategy';
import { DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS } from '../constants';
import { ReadRequestType, ReadStateRequest } from '../agent/http/types';
export { defaultStrategy } from './strategy';
export type PollStrategy = (
canisterId: Principal,
Expand All @@ -15,23 +16,63 @@ export type PollStrategy = (
) => Promise<void>;
export type PollStrategyFactory = () => PollStrategy;

interface SignedReadStateRequestWithExpiry {
body: {
content: Pick<ReadStateRequest, 'request_type' | 'ingress_expiry'>;
};
}

/**
* Check if an object has a property
* @param value the object that might have the property
* @param property the key of property we're looking for
*/
function hasProperty<O extends object, P extends string>(
value: O,
property: P,
): value is O & Record<P, unknown> {
return Object.prototype.hasOwnProperty.call(value, property);
}

/**
* Check if value is a signed read state request with expiry
* @param value to check
*/
function isSignedReadStateRequestWithExpiry(
value: unknown,
): value is SignedReadStateRequestWithExpiry {
return (
value !== null &&
typeof value === 'object' &&
hasProperty(value, 'body') &&
value.body !== null &&
typeof value.body === 'object' &&
hasProperty(value.body, 'content') &&
value.body.content !== null &&
typeof value.body.content === 'object' &&
hasProperty(value.body.content, 'request_type') &&
value.body.content.request_type === ReadRequestType.ReadState &&
hasProperty(value.body.content, 'ingress_expiry') &&
value.body.content.ingress_expiry instanceof Expiry
krpeacock marked this conversation as resolved.
Show resolved Hide resolved
);
}

/**
* Polls the IC to check the status of the given request then
* returns the response bytes once the request has been processed.
* @param agent The agent to use to poll read_state.
* @param canisterId The effective canister ID.
* @param requestId The Request ID to poll status for.
* @param strategy A polling strategy.
* @param request Request for the readState call.
* @param request Request for the repeated readState call.
* @param blsVerify - optional replacement function that verifies the BLS signature of a certificate.
*/
export async function pollForResponse(
agent: Agent,
canisterId: Principal,
requestId: RequestId,
strategy: PollStrategy = defaultStrategy(),
// eslint-disable-next-line
request?: any,
request?: unknown,
blsVerify?: CreateCertificateOptions['blsVerify'],
): Promise<{
certificate: Certificate;
Expand All @@ -40,8 +81,10 @@ export async function pollForResponse(
const path = [new TextEncoder().encode('request_status'), requestId];
const currentRequest = request ?? (await agent.createReadStateRequest?.({ paths: [path] }));

// Use a fresh expiry for the readState call.
currentRequest.body.content.ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS);
// Use a fresh expiry for the repeated readState call
if (request && isSignedReadStateRequestWithExpiry(currentRequest)) {
currentRequest.body.content.ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS);
}

const state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest);
if (agent.rootKey == null) throw new Error('Agent root key not initialized before polling');
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/request_id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ test('requestIdOf', async () => {
arg: new Uint8Array([68, 73, 68, 76, 0, 253, 42]),
};

const requestId = await requestIdOf(request);
const requestId = requestIdOf(request);

expect(toHex(requestId)).toEqual(
'8781291c347db32a9d8c10eb62b710fce5a93be676474c42babc74c51858f94b',
Expand All @@ -90,7 +90,7 @@ test.skip('requestIdOf for sender_delegation signature', async () => {
new Uint8Array([0, 0, 0, 0, 0, 32, 0, 43, 1, 1]),
],
};
const delegation1ActualHashBytes = await requestIdOf(delegation1);
const delegation1ActualHashBytes = requestIdOf(delegation1);
expect(toHex(delegation1ActualHashBytes)).toEqual(expectedHashBytes);

// Note: this uses `bigint` and blobs, which the rest of this lib uses too.
Expand All @@ -101,15 +101,15 @@ test.skip('requestIdOf for sender_delegation signature', async () => {
targets: delegation1.targets.map(t => t),
expiration: BigInt(delegation1.expiration.toString()),
};
const delegation2ActualHashBytes = await requestIdOf(delegation2);
const delegation2ActualHashBytes = requestIdOf(delegation2);
expect(toHex(delegation2ActualHashBytes)).toEqual(toHex(delegation1ActualHashBytes));

// This one uses Principals as targets
const delegation3 = {
...delegation1,
targets: delegation1.targets.map(t => Principal.fromText(t.toString())),
};
const delegation3ActualHashBytes = await requestIdOf(delegation3);
const delegation3ActualHashBytes = requestIdOf(delegation3);
expect(toHex(delegation3ActualHashBytes)).toEqual(toHex(delegation1ActualHashBytes));
});

Expand Down
3 changes: 1 addition & 2 deletions packages/agent/src/request_id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ const hashString = (value: string): ArrayBuffer => {
* https://sdk.dfinity.org/docs/interface-spec/index.html#hash-of-map
* @param request - ic-ref request to hash into RequestId
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function requestIdOf(request: Record<string, any>): RequestId {
export function requestIdOf(request: Record<string, unknown>): RequestId {
return hashOfMap(request) as RequestId;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/identity/src/identity/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async function _createSingleDelegation(
// a user gesture if you await an async call thats not fetch, xhr, or setTimeout.
const challenge = new Uint8Array([
...domainSeparator,
...new Uint8Array(requestIdOf(delegation)),
...new Uint8Array(requestIdOf({ ...delegation })),
krpeacock marked this conversation as resolved.
Show resolved Hide resolved
]);
const signature = await from.sign(challenge);

Expand Down
Loading