From a9d84fb8d48a401b27b9fee6919d35f667dc1705 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 18 Jul 2024 15:43:59 -0700 Subject: [PATCH 01/23] wip: hack job --- packages/agent/src/actor.test.ts | 20 ++++++++- packages/agent/src/actor.ts | 49 +++++++++++++--------- packages/agent/src/agent/http/http.test.ts | 3 +- packages/agent/src/agent/http/index.ts | 38 +++++++++++------ packages/agent/src/certificate.ts | 2 +- 5 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index 8f3ef481..2e3933b0 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -6,7 +6,7 @@ import { CallRequest, SubmitRequestType, UnSigned } from './agent/http/types'; import * as cbor from './cbor'; import { requestIdOf } from './request_id'; import * as pollingImport from './polling'; -import { ActorConfig } from './actor'; +import { Actor, ActorConfig } from './actor'; const importActor = async (mockUpdatePolling?: () => void) => { jest.dontMock('./polling'); @@ -372,3 +372,21 @@ describe('makeActor', () => { }); jest.setTimeout(20000); + +test('v3 call', async () => { + jest.useRealTimers(); + const agent = await HttpAgent.create({ + host: 'http://localhost:4943', + }); + + const idlFactory = ({ IDL }) => { + return IDL.Service({ + inc_read: IDL.Func([], [IDL.Nat], []), + }); + }; + const actor = Actor.createActor(idlFactory, { + canisterId: Principal.fromText('bkyz2-fmaaa-aaaaa-qaaaq-cai'), + agent, + }); + await actor.inc_read(); //? +}); diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 88fe7f9d..e44bfb2f 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -9,12 +9,12 @@ import { SubmitResponse, } from './agent'; import { AgentError } from './errors'; -import { IDL } from '@dfinity/candid'; +import { bufFromBufLike, IDL } from '@dfinity/candid'; import { pollForResponse, PollStrategyFactory, strategy } from './polling'; import { Principal } from '@dfinity/principal'; import { RequestId } from './request_id'; import { toHex } from './utils/buffer'; -import { Certificate, CreateCertificateOptions } from './certificate'; +import { Certificate, CreateCertificateOptions, lookupResultToBuffer } from './certificate'; import managementCanisterIdl from './canisters/management_idl'; import _SERVICE, { canister_install_mode, canister_settings } from './canisters/management_service'; @@ -530,25 +530,34 @@ function _createActorMethod( arg, effectiveCanisterId: ecid, }); - - requestId; - response; - requestDetails; - - if (!response.ok || response.body /* IC-1462 */) { - throw new UpdateCallRejectedError(cid, methodName, requestId, response); + let reply: ArrayBuffer | undefined; + let certificate: Certificate | undefined; + if (response.body && response.body.certificate) { + response.body.certificate; //? + certificate = await Certificate.create({ + certificate: bufFromBufLike(response.body.certificate), + rootKey: agent.rootKey as ArrayBuffer, + canisterId: Principal.from(canisterId), + blsVerify, + }); + const path = [new TextEncoder().encode('request_status'), requestId]; + reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); + } else { + // if (!response.ok || response.body /* IC-1462 */) { + // throw new UpdateCallRejectedError(cid, methodName, requestId, response); + // } + + const pollStrategy = pollingStrategyFactory(); + // Contains the certificate and the reply from the boundary node + const { certificate, reply } = await pollForResponse( + agent, + ecid, + requestId, + pollStrategy, + blsVerify, + ); + reply; } - - const pollStrategy = pollingStrategyFactory(); - // Contains the certificate and the reply from the boundary node - const { certificate, reply } = await pollForResponse( - agent, - ecid, - requestId, - pollStrategy, - blsVerify, - ); - reply; const shouldIncludeHttpDetails = func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS); const shouldIncludeCertificate = func.annotations.includes(ACTOR_METHOD_WITH_CERTIFICATE); diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 09d9cb67..12a2028f 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -13,7 +13,7 @@ import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; import { JSDOM } from 'jsdom'; -import { AnonymousIdentity, SignIdentity, toHex } from '../..'; +import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { AgentError } from '../../errors'; import { AgentHTTPResponseError } from './errors'; @@ -809,3 +809,4 @@ test('it should log errors to console if the option is set', async () => { const agent = new HttpAgent({ host: HTTP_AGENT_HOST, fetch: jest.fn(), logToConsole: true }); await agent.syncTime(); }); + diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 8c105797..7a5cfec2 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -406,7 +406,7 @@ export class HttpAgent implements Agent { }, identity?: Identity | Promise, ): Promise { - 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.", @@ -464,26 +464,38 @@ export class HttpAgent implements Agent { const body = cbor.encode(transformedRequest.body); - this.log.print( - `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, - transformedRequest, - ); - - // Run both in parallel. The fetch is quite expensive, so we have plenty of time to - // calculate the requestId locally. - const backoff = this.#backoffStrategy(); - const request = this.#requestAndRetry({ + // Attempt v3 sync call + const requestSync = this.#requestAndRetry({ request: () => - this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + this.#fetch('' + new URL(`/api/v3/canister/${ecid.toText()}/call`, this.host), { ...this.#callOptions, ...transformedRequest.request, body, }), - backoff, + backoff: this.#backoffStrategy(), tries: 0, }); - const [response, requestId] = await Promise.all([request, requestIdOf(submit)]); + this.log.print( + `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, + transformedRequest, + ); + + // Run both in parallel. The fetch is quite expensive, so we have plenty of time to + // calculate the requestId locally. + const backoff = this.#backoffStrategy(); + // const request = this.#requestAndRetry({ + // request: () => + // this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + // ...this.#callOptions, + // ...transformedRequest.request, + // body, + // }), + // backoff, + // tries: 0, + // }); + + const [response, requestId] = await Promise.all([requestSync, requestIdOf(submit)]); const responseBuffer = await response.arrayBuffer(); const responseBody = ( diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index a8cdf551..ecdf2f8c 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -247,7 +247,7 @@ export class Certificate { sigVer = false; } if (!sigVer) { - throw new CertificateVerificationError('Signature verification failed'); + // throw new CertificateVerificationError('Signature verification failed'); } } From 0315037e703d524ec240e03ead0dcee99670bbc6 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 18 Jul 2024 16:54:07 -0700 Subject: [PATCH 02/23] v3 happy path functioning --- e2e/node/basic/watermark.test.ts | 2 +- packages/agent/src/actor.test.ts | 1 + packages/agent/src/actor.ts | 15 +++++---------- packages/agent/src/agent/api.ts | 1 + packages/agent/src/agent/http/http.test.ts | 4 ++-- packages/agent/src/certificate.ts | 2 +- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 797606db..0971fc69 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -121,7 +121,7 @@ test('replay attack', async () => { fetchProxy.replayFromHistory(queryResponseIndex); // the replayed request should throw an error - expect(fetchProxy.calls).toBe(7); + expect(fetchProxy.calls).toBe(5); await expect(actor.read()).rejects.toThrowError( 'Timestamp failed to pass the watermark after retrying the configured 3 times. We cannot guarantee the integrity of the response since it could be a replay attack.', diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index 2e3933b0..344e452c 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -376,6 +376,7 @@ jest.setTimeout(20000); test('v3 call', async () => { jest.useRealTimers(); const agent = await HttpAgent.create({ + shouldFetchRootKey: true, host: 'http://localhost:4943', }); diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index e44bfb2f..d0f4ced1 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -533,9 +533,9 @@ function _createActorMethod( let reply: ArrayBuffer | undefined; let certificate: Certificate | undefined; if (response.body && response.body.certificate) { - response.body.certificate; //? + const cert = response.body.certificate; certificate = await Certificate.create({ - certificate: bufFromBufLike(response.body.certificate), + certificate: bufFromBufLike(cert), rootKey: agent.rootKey as ArrayBuffer, canisterId: Principal.from(canisterId), blsVerify, @@ -549,14 +549,9 @@ function _createActorMethod( const pollStrategy = pollingStrategyFactory(); // Contains the certificate and the reply from the boundary node - const { certificate, reply } = await pollForResponse( - agent, - ecid, - requestId, - pollStrategy, - blsVerify, - ); - reply; + const response = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify); + certificate = response.certificate; + reply = response.reply; } const shouldIncludeHttpDetails = func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS); const shouldIncludeCertificate = func.annotations.includes(ACTOR_METHOD_WITH_CERTIFICATE); diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index 58e204d2..f81aa874 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -131,6 +131,7 @@ export interface SubmitResponse { error_code?: string; reject_code: number; reject_message: string; + certificate?: ArrayBuffer; } | null; headers: HttpHeaderField[]; }; diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 12a2028f..33a92f01 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -106,7 +106,7 @@ test('call', async () => { expect(requestId).toEqual(expectedRequestId); const call1 = calls[0][0]; const call2 = calls[0][1]; - expect(call1).toBe(`http://127.0.0.1/api/v2/canister/${canisterId.toText()}/call`); + expect(call1).toBe(`http://127.0.0.1/api/v3/canister/${canisterId.toText()}/call`); expect(call2.method).toEqual('POST'); expect(call2.body).toEqual(cbor.encode(expectedRequest)); expect(call2.headers['Content-Type']).toEqual('application/cbor'); @@ -320,7 +320,7 @@ test('use anonymous principal if unspecified', async () => { expect(calls.length).toBe(1); expect(requestId).toEqual(expectedRequestId); - expect(calls[0][0]).toBe(`http://127.0.0.1/api/v2/canister/${canisterId.toText()}/call`); + expect(calls[0][0]).toBe(`http://127.0.0.1/api/v3/canister/${canisterId.toText()}/call`); const call2 = calls[0][1]; expect(call2.method).toEqual('POST'); expect(call2.body).toEqual(cbor.encode(expectedRequest)); diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index ecdf2f8c..a8cdf551 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -247,7 +247,7 @@ export class Certificate { sigVer = false; } if (!sigVer) { - // throw new CertificateVerificationError('Signature verification failed'); + throw new CertificateVerificationError('Signature verification failed'); } } From 81ad1815a1a05ad20fe6d0e63a68b9a8f4fd2978 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Mon, 22 Jul 2024 15:06:12 -0700 Subject: [PATCH 03/23] fixing watermark / mitm vulnerability --- e2e/node/basic/watermark.test.ts | 3 ++- e2e/node/integration/actor.test.ts | 4 ++-- packages/agent/src/actor.test.ts | 9 ++++++--- packages/agent/src/agent/api.ts | 1 + packages/agent/src/agent/http/index.ts | 15 ++++++++++----- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 0971fc69..e7eb3452 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -117,6 +117,7 @@ test('replay attack', async () => { expect(startValue3).toBe(1n); const queryResponseIndex = indexOfQueryResponse(fetchProxy.history); + console.log(queryResponseIndex); fetchProxy.replayFromHistory(queryResponseIndex); @@ -128,5 +129,5 @@ test('replay attack', async () => { ); // The agent should should have made 4 additional requests (3 retries + 1 original request) - expect(fetchProxy.calls).toBe(11); + expect(fetchProxy.calls).toBe(9); }, 10_000); diff --git a/e2e/node/integration/actor.test.ts b/e2e/node/integration/actor.test.ts index 49b48cf4..855dce28 100644 --- a/e2e/node/integration/actor.test.ts +++ b/e2e/node/integration/actor.test.ts @@ -14,9 +14,9 @@ test("Legacy Agent interface should be accepted by Actor's createActor", async ( ); // Verify that update calls work - await actor.write(8n); //? + await actor.write(8n); // Verify that query calls work - const count = await actor.read(); //? + const count = await actor.read(); expect(count).toBe(8n); }, 15_000); // TODO: tests for rejected, unknown time out diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index 344e452c..d7ea5111 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -292,7 +292,7 @@ describe('makeActor', () => { expect(reply).toEqual(canisterDecodedReturnValue); expect(replyUpdate).toEqual(canisterDecodedReturnValue); expect(replyWithHttpDetails.result).toEqual(canisterDecodedReturnValue); - replyWithHttpDetails.httpDetails['requestDetails']; //? + replyWithHttpDetails.httpDetails['requestDetails']; expect(replyWithHttpDetails.httpDetails).toMatchInlineSnapshot(` { "headers": [], @@ -330,7 +330,7 @@ describe('makeActor', () => { `); expect(replyUpdateWithHttpDetails.result).toEqual(canisterDecodedReturnValue); - replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); //? + replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); expect(replyUpdateWithHttpDetails.httpDetails).toMatchSnapshot(); }); @@ -382,6 +382,7 @@ test('v3 call', async () => { const idlFactory = ({ IDL }) => { return IDL.Service({ + write: IDL.Func([IDL.Nat], [], []), inc_read: IDL.Func([], [IDL.Nat], []), }); }; @@ -389,5 +390,7 @@ test('v3 call', async () => { canisterId: Principal.fromText('bkyz2-fmaaa-aaaaa-qaaaq-cai'), agent, }); - await actor.inc_read(); //? + await actor.write(0n); + const result = await actor.inc_read(); + expect(result).toBe(1n); }); diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index f81aa874..a4425ae4 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -131,6 +131,7 @@ export interface SubmitResponse { error_code?: string; reject_code: number; reject_message: string; + // Available in a v3 call response certificate?: ArrayBuffer; } | null; headers: HttpHeaderField[]; diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 7a5cfec2..d3cd8341 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -463,7 +463,7 @@ export class HttpAgent implements Agent { transformedRequest = await id.transformRequest(transformedRequest); const body = cbor.encode(transformedRequest.body); - + const backoff = this.#backoffStrategy(); // Attempt v3 sync call const requestSync = this.#requestAndRetry({ request: () => @@ -472,18 +472,17 @@ export class HttpAgent implements Agent { ...transformedRequest.request, body, }), - backoff: this.#backoffStrategy(), + backoff, tries: 0, }); this.log.print( - `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, + `fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, transformedRequest, ); // Run both in parallel. The fetch is quite expensive, so we have plenty of time to // calculate the requestId locally. - const backoff = this.#backoffStrategy(); // const request = this.#requestAndRetry({ // request: () => // this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { @@ -502,6 +501,12 @@ export class HttpAgent implements Agent { response.status === 200 && responseBuffer.byteLength > 0 ? cbor.decode(responseBuffer) : null ) as SubmitResponse['response']['body']; + // Update the watermark with the latest time from consensus + if (responseBody?.certificate) { + const time = await this.parseTimeFromResponse({ certificate: responseBody.certificate }); + this.#waterMark = time; + } + return { requestId, response: { @@ -969,7 +974,7 @@ export class HttpAgent implements Agent { return decodedResponse; } - public async parseTimeFromResponse(response: ReadStateResponse): Promise { + public async parseTimeFromResponse(response: { certificate: ArrayBuffer }): Promise { let tree: HashTree; if (response.certificate) { const decoded: { tree: HashTree } | undefined = cbor.decode(response.certificate); From 2264d3d0ac836bc7f713f441b80cb86353c49192 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 23 Jul 2024 14:05:33 -0700 Subject: [PATCH 04/23] feat: handling for "accepted" but not replied sync call --- packages/agent/src/actor.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index d0f4ced1..b7963a9f 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -541,12 +541,24 @@ function _createActorMethod( blsVerify, }); const path = [new TextEncoder().encode('request_status'), requestId]; - reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); - } else { - // if (!response.ok || response.body /* IC-1462 */) { - // throw new UpdateCallRejectedError(cid, methodName, requestId, response); - // } - + const status = new TextDecoder().decode( + lookupResultToBuffer(certificate.lookup([...path, 'status'])), + ); + + switch (status) { + case 'replied': + reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); + break; + case 'rejected': + throw new UpdateCallRejectedError(cid, methodName, requestId, response); + case 'accepted': + // The certificate is not yet ready, so we need to poll for the response + break; + case 'default': + throw new ActorCallError(cid, methodName, 'update', { requestId: toHex(requestId) }); + } + } + if (reply === undefined) { const pollStrategy = pollingStrategyFactory(); // Contains the certificate and the reply from the boundary node const response = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify); From 0b136f1796875864360c79448042f123bf37cb5b Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 23 Jul 2024 14:17:22 -0700 Subject: [PATCH 05/23] removing mainnet unit test --- packages/agent/src/actor.test.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index d7ea5111..837eb1a7 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -372,25 +372,3 @@ describe('makeActor', () => { }); jest.setTimeout(20000); - -test('v3 call', async () => { - jest.useRealTimers(); - const agent = await HttpAgent.create({ - shouldFetchRootKey: true, - host: 'http://localhost:4943', - }); - - const idlFactory = ({ IDL }) => { - return IDL.Service({ - write: IDL.Func([IDL.Nat], [], []), - inc_read: IDL.Func([], [IDL.Nat], []), - }); - }; - const actor = Actor.createActor(idlFactory, { - canisterId: Principal.fromText('bkyz2-fmaaa-aaaaa-qaaaq-cai'), - agent, - }); - await actor.write(0n); - const result = await actor.inc_read(); - expect(result).toBe(1n); -}); From 20137753fde1994cd2068ad300ef978c67001c91 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 18 Jul 2024 15:43:59 -0700 Subject: [PATCH 06/23] wip: hack job --- packages/agent/src/actor.test.ts | 20 ++++++++- packages/agent/src/actor.ts | 49 +++++++++++++--------- packages/agent/src/agent/http/http.test.ts | 3 +- packages/agent/src/agent/http/index.ts | 38 +++++++++++------ packages/agent/src/certificate.ts | 2 +- 5 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index e848586f..95f59531 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -6,7 +6,7 @@ import { CallRequest, SubmitRequestType, UnSigned } from './agent/http/types'; import * as cbor from './cbor'; import { requestIdOf } from './request_id'; import * as pollingImport from './polling'; -import { ActorConfig } from './actor'; +import { Actor, ActorConfig } from './actor'; const importActor = async (mockUpdatePolling?: () => void) => { jest.dontMock('./polling'); @@ -371,3 +371,21 @@ describe('makeActor', () => { }); jest.setTimeout(20000); + +test('v3 call', async () => { + jest.useRealTimers(); + const agent = await HttpAgent.create({ + host: 'http://localhost:4943', + }); + + const idlFactory = ({ IDL }) => { + return IDL.Service({ + inc_read: IDL.Func([], [IDL.Nat], []), + }); + }; + const actor = Actor.createActor(idlFactory, { + canisterId: Principal.fromText('bkyz2-fmaaa-aaaaa-qaaaq-cai'), + agent, + }); + await actor.inc_read(); //? +}); diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 88fe7f9d..e44bfb2f 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -9,12 +9,12 @@ import { SubmitResponse, } from './agent'; import { AgentError } from './errors'; -import { IDL } from '@dfinity/candid'; +import { bufFromBufLike, IDL } from '@dfinity/candid'; import { pollForResponse, PollStrategyFactory, strategy } from './polling'; import { Principal } from '@dfinity/principal'; import { RequestId } from './request_id'; import { toHex } from './utils/buffer'; -import { Certificate, CreateCertificateOptions } from './certificate'; +import { Certificate, CreateCertificateOptions, lookupResultToBuffer } from './certificate'; import managementCanisterIdl from './canisters/management_idl'; import _SERVICE, { canister_install_mode, canister_settings } from './canisters/management_service'; @@ -530,25 +530,34 @@ function _createActorMethod( arg, effectiveCanisterId: ecid, }); - - requestId; - response; - requestDetails; - - if (!response.ok || response.body /* IC-1462 */) { - throw new UpdateCallRejectedError(cid, methodName, requestId, response); + let reply: ArrayBuffer | undefined; + let certificate: Certificate | undefined; + if (response.body && response.body.certificate) { + response.body.certificate; //? + certificate = await Certificate.create({ + certificate: bufFromBufLike(response.body.certificate), + rootKey: agent.rootKey as ArrayBuffer, + canisterId: Principal.from(canisterId), + blsVerify, + }); + const path = [new TextEncoder().encode('request_status'), requestId]; + reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); + } else { + // if (!response.ok || response.body /* IC-1462 */) { + // throw new UpdateCallRejectedError(cid, methodName, requestId, response); + // } + + const pollStrategy = pollingStrategyFactory(); + // Contains the certificate and the reply from the boundary node + const { certificate, reply } = await pollForResponse( + agent, + ecid, + requestId, + pollStrategy, + blsVerify, + ); + reply; } - - const pollStrategy = pollingStrategyFactory(); - // Contains the certificate and the reply from the boundary node - const { certificate, reply } = await pollForResponse( - agent, - ecid, - requestId, - pollStrategy, - blsVerify, - ); - reply; const shouldIncludeHttpDetails = func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS); const shouldIncludeCertificate = func.annotations.includes(ACTOR_METHOD_WITH_CERTIFICATE); diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index c040dae7..3861d959 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -13,7 +13,7 @@ import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; import { JSDOM } from 'jsdom'; -import { AnonymousIdentity, SignIdentity, toHex } from '../..'; +import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { AgentError } from '../../errors'; import { AgentHTTPResponseError } from './errors'; @@ -810,3 +810,4 @@ test('it should log errors to console if the option is set', async () => { const agent = new HttpAgent({ host: HTTP_AGENT_HOST, fetch: jest.fn(), logToConsole: true }); await agent.syncTime(); }); + diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index b32a9152..059e3913 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -407,7 +407,7 @@ export class HttpAgent implements Agent { }, identity?: Identity | Promise, ): Promise { - 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.", @@ -465,26 +465,38 @@ export class HttpAgent implements Agent { const body = cbor.encode(transformedRequest.body); - this.log.print( - `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, - transformedRequest, - ); - - // Run both in parallel. The fetch is quite expensive, so we have plenty of time to - // calculate the requestId locally. - const backoff = this.#backoffStrategy(); - const request = this.#requestAndRetry({ + // Attempt v3 sync call + const requestSync = this.#requestAndRetry({ request: () => - this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + this.#fetch('' + new URL(`/api/v3/canister/${ecid.toText()}/call`, this.host), { ...this.#callOptions, ...transformedRequest.request, body, }), - backoff, + backoff: this.#backoffStrategy(), tries: 0, }); - const [response, requestId] = await Promise.all([request, requestIdOf(submit)]); + this.log.print( + `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, + transformedRequest, + ); + + // Run both in parallel. The fetch is quite expensive, so we have plenty of time to + // calculate the requestId locally. + const backoff = this.#backoffStrategy(); + // const request = this.#requestAndRetry({ + // request: () => + // this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + // ...this.#callOptions, + // ...transformedRequest.request, + // body, + // }), + // backoff, + // tries: 0, + // }); + + const [response, requestId] = await Promise.all([requestSync, requestIdOf(submit)]); const responseBuffer = await response.arrayBuffer(); const responseBody = ( diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index a8cdf551..ecdf2f8c 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -247,7 +247,7 @@ export class Certificate { sigVer = false; } if (!sigVer) { - throw new CertificateVerificationError('Signature verification failed'); + // throw new CertificateVerificationError('Signature verification failed'); } } From 39d53f02e7cc2e774f3b3a605acab4691d1c9157 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Thu, 18 Jul 2024 16:54:07 -0700 Subject: [PATCH 07/23] v3 happy path functioning --- e2e/node/basic/watermark.test.ts | 2 +- packages/agent/src/actor.test.ts | 1 + packages/agent/src/actor.ts | 15 +++++---------- packages/agent/src/agent/api.ts | 1 + packages/agent/src/agent/http/http.test.ts | 4 ++-- packages/agent/src/certificate.ts | 2 +- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 22496fa0..1cd01524 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -118,7 +118,7 @@ test('replay attack', async () => { fetchProxy.replayFromHistory(queryResponseIndex); // the replayed request should throw an error - expect(fetchProxy.calls).toBe(7); + expect(fetchProxy.calls).toBe(5); await expect(actor.read()).rejects.toThrowError( 'Timestamp failed to pass the watermark after retrying the configured 3 times. We cannot guarantee the integrity of the response since it could be a replay attack.', diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index 95f59531..a684cb1c 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -375,6 +375,7 @@ jest.setTimeout(20000); test('v3 call', async () => { jest.useRealTimers(); const agent = await HttpAgent.create({ + shouldFetchRootKey: true, host: 'http://localhost:4943', }); diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index e44bfb2f..d0f4ced1 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -533,9 +533,9 @@ function _createActorMethod( let reply: ArrayBuffer | undefined; let certificate: Certificate | undefined; if (response.body && response.body.certificate) { - response.body.certificate; //? + const cert = response.body.certificate; certificate = await Certificate.create({ - certificate: bufFromBufLike(response.body.certificate), + certificate: bufFromBufLike(cert), rootKey: agent.rootKey as ArrayBuffer, canisterId: Principal.from(canisterId), blsVerify, @@ -549,14 +549,9 @@ function _createActorMethod( const pollStrategy = pollingStrategyFactory(); // Contains the certificate and the reply from the boundary node - const { certificate, reply } = await pollForResponse( - agent, - ecid, - requestId, - pollStrategy, - blsVerify, - ); - reply; + const response = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify); + certificate = response.certificate; + reply = response.reply; } const shouldIncludeHttpDetails = func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS); const shouldIncludeCertificate = func.annotations.includes(ACTOR_METHOD_WITH_CERTIFICATE); diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index 58e204d2..f81aa874 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -131,6 +131,7 @@ export interface SubmitResponse { error_code?: string; reject_code: number; reject_message: string; + certificate?: ArrayBuffer; } | null; headers: HttpHeaderField[]; }; diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 3861d959..779cc0b5 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -106,7 +106,7 @@ test('call', async () => { expect(requestId).toEqual(expectedRequestId); const call1 = calls[0][0]; const call2 = calls[0][1]; - expect(call1).toBe(`http://127.0.0.1/api/v2/canister/${canisterId.toText()}/call`); + expect(call1).toBe(`http://127.0.0.1/api/v3/canister/${canisterId.toText()}/call`); expect(call2.method).toEqual('POST'); expect(call2.body).toEqual(cbor.encode(expectedRequest)); expect(call2.headers['Content-Type']).toEqual('application/cbor'); @@ -320,7 +320,7 @@ test('use anonymous principal if unspecified', async () => { expect(calls.length).toBe(1); expect(requestId).toEqual(expectedRequestId); - expect(calls[0][0]).toBe(`http://127.0.0.1/api/v2/canister/${canisterId.toText()}/call`); + expect(calls[0][0]).toBe(`http://127.0.0.1/api/v3/canister/${canisterId.toText()}/call`); const call2 = calls[0][1]; expect(call2.method).toEqual('POST'); expect(call2.body).toEqual(cbor.encode(expectedRequest)); diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index ecdf2f8c..a8cdf551 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -247,7 +247,7 @@ export class Certificate { sigVer = false; } if (!sigVer) { - // throw new CertificateVerificationError('Signature verification failed'); + throw new CertificateVerificationError('Signature verification failed'); } } From b5043fec29b0f8e9887c1e1dcd4818c91ec5a97d Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Mon, 22 Jul 2024 15:06:12 -0700 Subject: [PATCH 08/23] fixing watermark / mitm vulnerability --- e2e/node/basic/watermark.test.ts | 3 ++- e2e/node/integration/actor.test.ts | 4 ++-- packages/agent/src/actor.test.ts | 7 +++++-- packages/agent/src/agent/api.ts | 1 + packages/agent/src/agent/http/index.ts | 15 ++++++++++----- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 1cd01524..24b49283 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -114,6 +114,7 @@ test('replay attack', async () => { expect(startValue3).toBe(1n); const queryResponseIndex = indexOfQueryResponse(fetchProxy.history); + console.log(queryResponseIndex); fetchProxy.replayFromHistory(queryResponseIndex); @@ -125,5 +126,5 @@ test('replay attack', async () => { ); // The agent should should have made 4 additional requests (3 retries + 1 original request) - expect(fetchProxy.calls).toBe(11); + expect(fetchProxy.calls).toBe(9); }, 10_000); diff --git a/e2e/node/integration/actor.test.ts b/e2e/node/integration/actor.test.ts index 49b48cf4..855dce28 100644 --- a/e2e/node/integration/actor.test.ts +++ b/e2e/node/integration/actor.test.ts @@ -14,9 +14,9 @@ test("Legacy Agent interface should be accepted by Actor's createActor", async ( ); // Verify that update calls work - await actor.write(8n); //? + await actor.write(8n); // Verify that query calls work - const count = await actor.read(); //? + const count = await actor.read(); expect(count).toBe(8n); }, 15_000); // TODO: tests for rejected, unknown time out diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index a684cb1c..d4306971 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -329,7 +329,7 @@ describe('makeActor', () => { `); expect(replyUpdateWithHttpDetails.result).toEqual(canisterDecodedReturnValue); - replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); //? + replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); expect(replyUpdateWithHttpDetails.httpDetails).toMatchSnapshot(); }); @@ -381,6 +381,7 @@ test('v3 call', async () => { const idlFactory = ({ IDL }) => { return IDL.Service({ + write: IDL.Func([IDL.Nat], [], []), inc_read: IDL.Func([], [IDL.Nat], []), }); }; @@ -388,5 +389,7 @@ test('v3 call', async () => { canisterId: Principal.fromText('bkyz2-fmaaa-aaaaa-qaaaq-cai'), agent, }); - await actor.inc_read(); //? + await actor.write(0n); + const result = await actor.inc_read(); + expect(result).toBe(1n); }); diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index f81aa874..a4425ae4 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -131,6 +131,7 @@ export interface SubmitResponse { error_code?: string; reject_code: number; reject_message: string; + // Available in a v3 call response certificate?: ArrayBuffer; } | null; headers: HttpHeaderField[]; diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 059e3913..185cf510 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -464,7 +464,7 @@ export class HttpAgent implements Agent { transformedRequest = await id.transformRequest(transformedRequest); const body = cbor.encode(transformedRequest.body); - + const backoff = this.#backoffStrategy(); // Attempt v3 sync call const requestSync = this.#requestAndRetry({ request: () => @@ -473,18 +473,17 @@ export class HttpAgent implements Agent { ...transformedRequest.request, body, }), - backoff: this.#backoffStrategy(), + backoff, tries: 0, }); this.log.print( - `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, + `fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, transformedRequest, ); // Run both in parallel. The fetch is quite expensive, so we have plenty of time to // calculate the requestId locally. - const backoff = this.#backoffStrategy(); // const request = this.#requestAndRetry({ // request: () => // this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { @@ -503,6 +502,12 @@ export class HttpAgent implements Agent { response.status === 200 && responseBuffer.byteLength > 0 ? cbor.decode(responseBuffer) : null ) as SubmitResponse['response']['body']; + // Update the watermark with the latest time from consensus + if (responseBody?.certificate) { + const time = await this.parseTimeFromResponse({ certificate: responseBody.certificate }); + this.#waterMark = time; + } + return { requestId, response: { @@ -970,7 +975,7 @@ export class HttpAgent implements Agent { return decodedResponse; } - public async parseTimeFromResponse(response: ReadStateResponse): Promise { + public async parseTimeFromResponse(response: { certificate: ArrayBuffer }): Promise { let tree: HashTree; if (response.certificate) { const decoded: { tree: HashTree } | undefined = cbor.decode(response.certificate); From e8ff517fc616194a1664e0ec90888b7f468e00fc Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 23 Jul 2024 14:05:33 -0700 Subject: [PATCH 09/23] feat: handling for "accepted" but not replied sync call --- packages/agent/src/actor.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index d0f4ced1..b7963a9f 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -541,12 +541,24 @@ function _createActorMethod( blsVerify, }); const path = [new TextEncoder().encode('request_status'), requestId]; - reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); - } else { - // if (!response.ok || response.body /* IC-1462 */) { - // throw new UpdateCallRejectedError(cid, methodName, requestId, response); - // } - + const status = new TextDecoder().decode( + lookupResultToBuffer(certificate.lookup([...path, 'status'])), + ); + + switch (status) { + case 'replied': + reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); + break; + case 'rejected': + throw new UpdateCallRejectedError(cid, methodName, requestId, response); + case 'accepted': + // The certificate is not yet ready, so we need to poll for the response + break; + case 'default': + throw new ActorCallError(cid, methodName, 'update', { requestId: toHex(requestId) }); + } + } + if (reply === undefined) { const pollStrategy = pollingStrategyFactory(); // Contains the certificate and the reply from the boundary node const response = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify); From 9b2af1a0d2b1042a7c6810ccd634b2f6fc1bbe6a Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 23 Jul 2024 14:17:22 -0700 Subject: [PATCH 10/23] removing mainnet unit test --- packages/agent/src/actor.test.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index d4306971..d15483d6 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -371,25 +371,3 @@ describe('makeActor', () => { }); jest.setTimeout(20000); - -test('v3 call', async () => { - jest.useRealTimers(); - const agent = await HttpAgent.create({ - shouldFetchRootKey: true, - host: 'http://localhost:4943', - }); - - const idlFactory = ({ IDL }) => { - return IDL.Service({ - write: IDL.Func([IDL.Nat], [], []), - inc_read: IDL.Func([], [IDL.Nat], []), - }); - }; - const actor = Actor.createActor(idlFactory, { - canisterId: Principal.fromText('bkyz2-fmaaa-aaaaa-qaaaq-cai'), - agent, - }); - await actor.write(0n); - const result = await actor.inc_read(); - expect(result).toBe(1n); -}); From f6a55e97cc3a8c7d764513481945d123ddcf0733 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Mon, 12 Aug 2024 15:18:15 -0700 Subject: [PATCH 11/23] fix: automatic fallback to v2 --- packages/agent/src/agent/http/index.ts | 182 +++++++++++++++---------- 1 file changed, 107 insertions(+), 75 deletions(-) diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 185cf510..341c659b 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -404,10 +404,12 @@ export class HttpAgent implements Agent { methodName: string; arg: ArrayBuffer; effectiveCanisterId?: Principal | string; + callSync?: boolean; }, identity?: Identity | Promise, ): Promise { - const id = await(identity !== undefined ? await identity : await this.#identity); + const callSync = options.callSync ?? true; + 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.", @@ -465,60 +467,77 @@ export class HttpAgent implements Agent { const body = cbor.encode(transformedRequest.body); const backoff = this.#backoffStrategy(); - // Attempt v3 sync call - const requestSync = this.#requestAndRetry({ - request: () => + try { + // Attempt v3 sync call + const requestSync = () => this.#fetch('' + new URL(`/api/v3/canister/${ecid.toText()}/call`, this.host), { ...this.#callOptions, ...transformedRequest.request, body, - }), - backoff, - tries: 0, - }); + }); - this.log.print( - `fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, - transformedRequest, - ); + const requestAsync = () => + this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + ...this.#callOptions, + ...transformedRequest.request, + body, + }); - // Run both in parallel. The fetch is quite expensive, so we have plenty of time to - // calculate the requestId locally. - // const request = this.#requestAndRetry({ - // request: () => - // this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { - // ...this.#callOptions, - // ...transformedRequest.request, - // body, - // }), - // backoff, - // tries: 0, - // }); - - const [response, requestId] = await Promise.all([requestSync, requestIdOf(submit)]); - - const responseBuffer = await response.arrayBuffer(); - const responseBody = ( - response.status === 200 && responseBuffer.byteLength > 0 ? cbor.decode(responseBuffer) : null - ) as SubmitResponse['response']['body']; - - // Update the watermark with the latest time from consensus - if (responseBody?.certificate) { - const time = await this.parseTimeFromResponse({ certificate: responseBody.certificate }); - this.#waterMark = time; - } + this.log.print( + `fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, + transformedRequest, + ); - return { - requestId, - response: { - ok: response.ok, - status: response.status, - statusText: response.statusText, - body: responseBody, - headers: httpHeadersTransform(response.headers), - }, - requestDetails: submit, - }; + const request = this.#requestAndRetry({ + request: callSync ? requestSync : requestAsync, + backoff, + tries: 0, + }); + + const [response, requestId] = await Promise.all([request, requestIdOf(submit)]); + + const responseBuffer = await response.arrayBuffer(); + const responseBody = ( + response.status === 200 && responseBuffer.byteLength > 0 + ? cbor.decode(responseBuffer) + : null + ) as SubmitResponse['response']['body']; + + // Update the watermark with the latest time from consensus + if (responseBody?.certificate) { + const time = await this.parseTimeFromResponse({ certificate: responseBody.certificate }); + this.#waterMark = time; + } + + return { + requestId, + response: { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: responseBody, + headers: httpHeadersTransform(response.headers), + }, + requestDetails: submit, + }; + } catch (error) { + // If the error is due to the v3 api not being supported, fall back to v2 + if ((error as AgentError).message.includes('v3 api not supported.')) { + this.log.warn('v3 api not supported. Fall back to v2'); + return this.call( + canisterId, + { + ...options, + // disable v3 api + callSync: false, + }, + identity, + ); + } + + this.log.error('Error while making call:', error as Error); + throw error; + } } async #requestAndRetryQuery(args: { @@ -691,9 +710,15 @@ export class HttpAgent implements Agent { ` Code: ${response.status} (${response.statusText})\n` + ` Body: ${responseText}\n`; - if (tries < this.#retryTimes) { - return await this.#requestAndRetry({ request, backoff, tries: tries + 1 }); + if (response.status === 404 && response.url.includes('api/v3')) { + throw new AgentHTTPResponseError('v3 api not supported. Fall back to v2', { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: httpHeadersTransform(response.headers), + }); } + throw new AgentHTTPResponseError(errorMessage, { ok: response.ok, status: response.status, @@ -944,35 +969,42 @@ export class HttpAgent implements Agent { ); // TODO - https://dfinity.atlassian.net/browse/SDK-1092 const backoff = this.#backoffStrategy(); + try { + const response = await this.#requestAndRetry({ + request: () => + this.#fetch( + '' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), + { + ...this.#fetchOptions, + ...transformedRequest.request, + body, + }, + ), + backoff, + tries: 0, + }); - const response = await this.#requestAndRetry({ - request: () => - this.#fetch('' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), { - ...this.#fetchOptions, - ...transformedRequest.request, - body, - }), - backoff, - tries: 0, - }); + if (!response.ok) { + throw new Error( + `Server returned an error:\n` + + ` Code: ${response.status} (${response.statusText})\n` + + ` Body: ${await response.text()}\n`, + ); + } + const decodedResponse: ReadStateResponse = cbor.decode(await response.arrayBuffer()); - if (!response.ok) { - throw new Error( - `Server returned an error:\n` + - ` Code: ${response.status} (${response.statusText})\n` + - ` Body: ${await response.text()}\n`, - ); - } - const decodedResponse: ReadStateResponse = cbor.decode(await response.arrayBuffer()); + this.log.print('Read state response:', decodedResponse); + const parsedTime = await this.parseTimeFromResponse(decodedResponse); + if (parsedTime > 0) { + this.log.print('Read state response time:', parsedTime); + this.#waterMark = parsedTime; + } - this.log.print('Read state response:', decodedResponse); - const parsedTime = await this.parseTimeFromResponse(decodedResponse); - if (parsedTime > 0) { - this.log.print('Read state response time:', parsedTime); - this.#waterMark = parsedTime; + return decodedResponse; + } catch (error) { + this.log.error('Caught exception while attempting to read state', error as AgentError); + throw error; } - - return decodedResponse; } public async parseTimeFromResponse(response: { certificate: ArrayBuffer }): Promise { From e7371af025a6a18f9a79e158c03f75a7154f097f Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 11:14:58 -0700 Subject: [PATCH 12/23] making e2e tests compatible with dfx 0.22.0 when the agent falls back to v2 api --- e2e/node/basic/counter.test.ts | 48 +++++++++++++++++++------- e2e/node/basic/watermark.test.ts | 37 +++++++++++++++++--- e2e/node/canisters/counter.ts | 3 +- packages/agent/src/agent/http/index.ts | 8 ++++- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/e2e/node/basic/counter.test.ts b/e2e/node/basic/counter.test.ts index 5793d008..01813ef3 100644 --- a/e2e/node/basic/counter.test.ts +++ b/e2e/node/basic/counter.test.ts @@ -1,4 +1,5 @@ -import counterCanister, { createActor } from '../canisters/counter'; +import { Actor, HttpAgent } from '@dfinity/agent'; +import counterCanister, { createActor, idl } from '../canisters/counter'; import { it, expect, describe, vi } from 'vitest'; describe('counter', () => { @@ -37,26 +38,49 @@ describe('counter', () => { describe('retrytimes', () => { it('should retry after a failure', async () => { let count = 0; + const { canisterId } = await counterCanister(); const fetchMock = vi.fn(function (...args) { - if (count <= 1) { - count += 1; + count += 1; + // let the first 3 requests pass, then throw an error on the call + if (count === 3) { return new Response('Test error - ignore', { status: 500, statusText: 'Internal Server Error', }); } + // eslint-disable-next-line prefer-spread - return fetch.apply( - null, - args as [input: string | Request, init?: RequestInit | CMRequestInit | undefined], - ); + return fetch.apply(null, args as [input: string | Request, init?: RequestInit | undefined]); }); - const counter = await createActor({ fetch: fetchMock as typeof fetch, retryTimes: 3 }); - try { - expect(await counter.greet('counter')).toEqual('Hello, counter!'); - } catch (error) { - console.error(error); + const counter = await Actor.createActor(idl, { + canisterId, + agent: await HttpAgent.create({ + fetch: fetchMock as typeof fetch, + retryTimes: 3, + host: 'http://localhost:4943', + shouldFetchRootKey: true, + }), + }); + + const result = await counter.greet('counter'); + expect(result).toEqual('Hello, counter!'); + + // The number of calls should be 4 or more, depending on whether the test environment is using v3 or v2 + if (findV2inCalls(fetchMock.mock.calls as [string, Request][]) === -1) { + // TODO - pin to 4 once dfx v0.23.0 is released + expect(fetchMock.mock.calls.length).toBe(4); + } else { + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(5); } }, 40000); }); + +const findV2inCalls = (calls: [string, Request][]) => { + for (let i = 0; i < calls.length; i++) { + if (calls[i][0].includes('v2')) { + return i; + } + } + return -1; +}; diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 24b49283..489eca48 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -79,7 +79,7 @@ test('replay queries only', async () => { expect(fetchProxy.calls).toBe(3); }, 10_000); -test('replay attack', async () => { +test.only('replay attack', async () => { const fetchProxy = new FetchProxy(); const actor = await createActor({ @@ -118,13 +118,40 @@ test('replay attack', async () => { fetchProxy.replayFromHistory(queryResponseIndex); - // the replayed request should throw an error - expect(fetchProxy.calls).toBe(5); + // The number of calls should be 4 or more, depending on whether the test environment is using v3 or v2 + const usingV2 = + findV2inCalls( + fetchProxy.history.map(response => { + return [response.url]; + }), + ) === -1; + if (usingV2) { + // TODO - pin to 5 once dfx v0.23.0 is released + // the replayed request should throw an error + expect(fetchProxy.calls).toBe(5); + } else { + expect(fetchProxy.calls).toBeGreaterThanOrEqual(6); + } await expect(actor.read()).rejects.toThrowError( 'Timestamp failed to pass the watermark after retrying the configured 3 times. We cannot guarantee the integrity of the response since it could be a replay attack.', ); - // The agent should should have made 4 additional requests (3 retries + 1 original request) - expect(fetchProxy.calls).toBe(9); + // TODO - pin to 9 once dfx v0.23.0 is released + if (usingV2) { + // the replayed request should throw an error + // The agent should should have made 4 additional requests (3 retries + 1 original request) + expect(fetchProxy.calls).toBe(9); + } else { + expect(fetchProxy.calls).toBeGreaterThanOrEqual(10); + } }, 10_000); + +const findV2inCalls = (calls: [string][]) => { + for (let i = 0; i < calls.length; i++) { + if (calls[i][0].includes('v2')) { + return i; + } + } + return -1; +}; diff --git a/e2e/node/canisters/counter.ts b/e2e/node/canisters/counter.ts index 45456bf3..47e4f2e2 100644 --- a/e2e/node/canisters/counter.ts +++ b/e2e/node/canisters/counter.ts @@ -11,7 +11,7 @@ let cache: { actor: any; } | null = null; -const idl = ({ IDL }) => { +export const idl = ({ IDL }) => { return IDL.Service({ inc: IDL.Func([], [], []), inc_read: IDL.Func([], [IDL.Nat], []), @@ -62,6 +62,7 @@ export const createActor = async (options?: HttpAgentOptions, agent?: Agent) => } const canisterId = await Actor.createCanister({ agent: effectiveAgent }); + console.log('Installing canister with ID:', canisterId.toText()); await Actor.install({ module }, { canisterId, agent: effectiveAgent }); return Actor.createActor(idl, { canisterId, agent: effectiveAgent }) as ActorSubclass<_SERVICE>; }; diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 341c659b..ec5c9e0c 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -718,6 +718,11 @@ export class HttpAgent implements Agent { headers: httpHeadersTransform(response.headers), }); } + tries; //? + this.#retryTimes; //? + if (tries < this.#retryTimes) { + return await this.#requestAndRetry({ request, backoff, tries: tries + 1 }); + } throw new AgentHTTPResponseError(errorMessage, { ok: response.ok, @@ -1083,8 +1088,9 @@ export class HttpAgent implements Agent { public async fetchRootKey(): Promise { if (!this.#rootKeyFetched) { + const status = await this.status(); //? // Hex-encoded version of the replica root key - this.rootKey = ((await this.status()) as JsonObject & { root_key: ArrayBuffer }).root_key; + this.rootKey = (status as JsonObject & { root_key: ArrayBuffer }).root_key; this.#rootKeyFetched = true; } return this.rootKey; From b5523ed026d28321eb32618610061dad6d114473 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 11:18:42 -0700 Subject: [PATCH 13/23] lint: removes unused import --- e2e/node/basic/counter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/node/basic/counter.test.ts b/e2e/node/basic/counter.test.ts index 01813ef3..05076b9d 100644 --- a/e2e/node/basic/counter.test.ts +++ b/e2e/node/basic/counter.test.ts @@ -1,5 +1,5 @@ import { Actor, HttpAgent } from '@dfinity/agent'; -import counterCanister, { createActor, idl } from '../canisters/counter'; +import counterCanister, { idl } from '../canisters/counter'; import { it, expect, describe, vi } from 'vitest'; describe('counter', () => { From 0c0dfee6e6b22a831d4ce6ae5f2badea451b8672 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 11:23:55 -0700 Subject: [PATCH 14/23] removes .only from test --- e2e/node/basic/watermark.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 489eca48..7d005fa5 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -79,7 +79,7 @@ test('replay queries only', async () => { expect(fetchProxy.calls).toBe(3); }, 10_000); -test.only('replay attack', async () => { +test('replay attack', async () => { const fetchProxy = new FetchProxy(); const actor = await createActor({ From 769953af0a4b60aea9f641a8240c2eea6688cc07 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 11:42:50 -0700 Subject: [PATCH 15/23] correct logic for usingV2 check --- e2e/node/basic/watermark.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 7d005fa5..c34420c0 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -124,7 +124,7 @@ test('replay attack', async () => { fetchProxy.history.map(response => { return [response.url]; }), - ) === -1; + ) !== -1; if (usingV2) { // TODO - pin to 5 once dfx v0.23.0 is released // the replayed request should throw an error From 26a71c7d2488d5aae8004103b9f2e2ec1882be46 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 11:50:23 -0700 Subject: [PATCH 16/23] pinning to greater than or equal to expected value for consistency --- e2e/node/basic/counter.test.ts | 2 +- e2e/node/basic/watermark.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/node/basic/counter.test.ts b/e2e/node/basic/counter.test.ts index 05076b9d..f3d333a8 100644 --- a/e2e/node/basic/counter.test.ts +++ b/e2e/node/basic/counter.test.ts @@ -71,7 +71,7 @@ describe('retrytimes', () => { // TODO - pin to 4 once dfx v0.23.0 is released expect(fetchMock.mock.calls.length).toBe(4); } else { - expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(5); + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(4); } }, 40000); }); diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index c34420c0..adad9a3a 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -130,7 +130,7 @@ test('replay attack', async () => { // the replayed request should throw an error expect(fetchProxy.calls).toBe(5); } else { - expect(fetchProxy.calls).toBeGreaterThanOrEqual(6); + expect(fetchProxy.calls).toBeGreaterThanOrEqual(5); } await expect(actor.read()).rejects.toThrowError( @@ -143,7 +143,7 @@ test('replay attack', async () => { // The agent should should have made 4 additional requests (3 retries + 1 original request) expect(fetchProxy.calls).toBe(9); } else { - expect(fetchProxy.calls).toBeGreaterThanOrEqual(10); + expect(fetchProxy.calls).toBeGreaterThanOrEqual(9); } }, 10_000); From 0fffde9c617fe59f4af31b44a853f2b2aa46aa2b Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 11:59:03 -0700 Subject: [PATCH 17/23] removing logs --- docs/CHANGELOG.md | 1 + e2e/node/canisters/counter.ts | 1 - packages/agent/src/agent/http/index.ts | 4 +--- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d4bb990e..9f6671cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- feat: sync_call support in HttpAgent and Actor - feat: management canister interface updates for schnorr signatures - feat: ensure that identity-secp256k1 seed phrase must produce a 64 byte seed - docs: documentation and metadata for use-auth-client diff --git a/e2e/node/canisters/counter.ts b/e2e/node/canisters/counter.ts index 47e4f2e2..c1439b22 100644 --- a/e2e/node/canisters/counter.ts +++ b/e2e/node/canisters/counter.ts @@ -62,7 +62,6 @@ export const createActor = async (options?: HttpAgentOptions, agent?: Agent) => } const canisterId = await Actor.createCanister({ agent: effectiveAgent }); - console.log('Installing canister with ID:', canisterId.toText()); await Actor.install({ module }, { canisterId, agent: effectiveAgent }); return Actor.createActor(idl, { canisterId, agent: effectiveAgent }) as ActorSubclass<_SERVICE>; }; diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index ec5c9e0c..0cf816f3 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -718,8 +718,6 @@ export class HttpAgent implements Agent { headers: httpHeadersTransform(response.headers), }); } - tries; //? - this.#retryTimes; //? if (tries < this.#retryTimes) { return await this.#requestAndRetry({ request, backoff, tries: tries + 1 }); } @@ -1088,7 +1086,7 @@ export class HttpAgent implements Agent { public async fetchRootKey(): Promise { if (!this.#rootKeyFetched) { - const status = await this.status(); //? + const status = await this.status(); // Hex-encoded version of the replica root key this.rootKey = (status as JsonObject & { root_key: ArrayBuffer }).root_key; this.#rootKeyFetched = true; From dd3e4c9b39732319e4e83c86ac23d1437eddd1ef Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 12:06:44 -0700 Subject: [PATCH 18/23] changelog --- docs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9f6671cc..518f7c9e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added - feat: sync_call support in HttpAgent and Actor + - Skips polling if the sync call succeeds and provides a certificate + - Falls back to v2 api if the v3 endpoint 404's + - Adds certificate to SubmitResponse endpoint + - adds callSync option to `HttpAgent.call`, which defaults to `true` - feat: management canister interface updates for schnorr signatures - feat: ensure that identity-secp256k1 seed phrase must produce a 64 byte seed - docs: documentation and metadata for use-auth-client From 445b6a975b87957bc101789e4aa9c95ff0b17413 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 13 Aug 2024 13:36:39 -0700 Subject: [PATCH 19/23] fix: corrects sync vs async log --- packages/agent/src/agent/http/index.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 0cf816f3..53928a0e 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -469,24 +469,30 @@ export class HttpAgent implements Agent { const backoff = this.#backoffStrategy(); try { // Attempt v3 sync call - const requestSync = () => - this.#fetch('' + new URL(`/api/v3/canister/${ecid.toText()}/call`, this.host), { + const requestSync = () => { + this.log.print( + `fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, + transformedRequest, + ); + return this.#fetch('' + new URL(`/api/v3/canister/${ecid.toText()}/call`, this.host), { ...this.#callOptions, ...transformedRequest.request, body, }); + }; - const requestAsync = () => - this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + const requestAsync = () => { + this.log.print( + `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, + transformedRequest, + ); + return this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { ...this.#callOptions, ...transformedRequest.request, body, }); + }; - this.log.print( - `fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, - transformedRequest, - ); const request = this.#requestAndRetry({ request: callSync ? requestSync : requestAsync, From 44b2447f7c903e51a11bc871893f45999cee083e Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Mon, 26 Aug 2024 13:57:03 -0700 Subject: [PATCH 20/23] fix: throw error in ActorMethod if agent.rootKey isn't initialized --- packages/agent/src/actor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index b7963a9f..9e6da4d6 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -525,6 +525,9 @@ function _createActorMethod( const ecid = effectiveCanisterId !== undefined ? Principal.from(effectiveCanisterId) : cid; const arg = IDL.encode(func.argTypes, args); + if (agent.rootKey == null) + throw new AgentError('Agent root key not initialized before making call'); + const { requestId, response, requestDetails } = await agent.call(cid, { methodName, arg, @@ -536,7 +539,7 @@ function _createActorMethod( const cert = response.body.certificate; certificate = await Certificate.create({ certificate: bufFromBufLike(cert), - rootKey: agent.rootKey as ArrayBuffer, + rootKey: agent.rootKey, canisterId: Principal.from(canisterId), blsVerify, }); From a123cb35994385296f40cdb97e03257da6224e35 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Mon, 26 Aug 2024 13:58:35 -0700 Subject: [PATCH 21/23] fix: remove `accepted` case from actor reply as it is not supported --- packages/agent/src/actor.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 9e6da4d6..1f9f17bd 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -554,9 +554,6 @@ function _createActorMethod( break; case 'rejected': throw new UpdateCallRejectedError(cid, methodName, requestId, response); - case 'accepted': - // The certificate is not yet ready, so we need to poll for the response - break; case 'default': throw new ActorCallError(cid, methodName, 'update', { requestId: toHex(requestId) }); } From 5242c89bc33db011049c2dd83fe86edf47fc2a24 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Mon, 26 Aug 2024 14:09:35 -0700 Subject: [PATCH 22/23] Fall back to polling if we recieve an Accepted response code --- packages/agent/src/actor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 1f9f17bd..6add0ac2 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -558,7 +558,8 @@ function _createActorMethod( throw new ActorCallError(cid, methodName, 'update', { requestId: toHex(requestId) }); } } - if (reply === undefined) { + // Fall back to polling if we recieve an Accepted response code + if (response.status === 202) { const pollStrategy = pollingStrategyFactory(); // Contains the certificate and the reply from the boundary node const response = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify); @@ -569,7 +570,6 @@ function _createActorMethod( const shouldIncludeCertificate = func.annotations.includes(ACTOR_METHOD_WITH_CERTIFICATE); const httpDetails = { ...response, requestDetails } as HttpDetailsResponse; - if (reply !== undefined) { if (shouldIncludeHttpDetails && shouldIncludeCertificate) { return { From 787241dd25b70b7cb8e4ff69b7f570d38532ce41 Mon Sep 17 00:00:00 2001 From: Kyle Peacock Date: Tue, 27 Aug 2024 10:32:54 -0700 Subject: [PATCH 23/23] removes default case --- packages/agent/src/actor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 6add0ac2..f347f6fa 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -554,8 +554,6 @@ function _createActorMethod( break; case 'rejected': throw new UpdateCallRejectedError(cid, methodName, requestId, response); - case 'default': - throw new ActorCallError(cid, methodName, 'update', { requestId: toHex(requestId) }); } } // Fall back to polling if we recieve an Accepted response code