diff --git a/e2e/browser/cypress/e2e/ecdsa.cy.js b/e2e/browser/cypress/e2e/ecdsa.cy.js index d390b56c3..c4d277cbf 100644 --- a/e2e/browser/cypress/e2e/ecdsa.cy.js +++ b/e2e/browser/cypress/e2e/ecdsa.cy.js @@ -6,7 +6,9 @@ const canisterId = ids.whoami.local; const setup = async () => { const identity1 = await ECDSAKeyIdentity.generate(); - const whoami1 = createActor(ids.whoami.local, { agentOptions: { identity: identity1 } }); + const whoami1 = createActor(ids.whoami.local, { + agentOptions: { verifyQuerySignatures: false, identity: identity1 }, + }); const principal1 = await whoami1.whoami(); @@ -34,7 +36,9 @@ describe('ECDSAKeyIdentity tests with SubtleCrypto', () => { const identity2 = await ECDSAKeyIdentity.fromKeyPair(storedKeyPair); - const whoami2 = createActor(canisterId, { agentOptions: { identity: identity2 } }); + const whoami2 = createActor(canisterId, { + agentOptions: { verifyQuerySignatures: false, identity: identity2 }, + }); const principal2 = await whoami2.whoami(); diff --git a/e2e/browser/cypress/screenshots/ecdsa.cy.js/ECDSAKeyIdentity tests with SubtleCrypto -- can persist an identity in indexeddb (failed).png b/e2e/browser/cypress/screenshots/ecdsa.cy.js/ECDSAKeyIdentity tests with SubtleCrypto -- can persist an identity in indexeddb (failed).png new file mode 100644 index 000000000..f31adede6 Binary files /dev/null and b/e2e/browser/cypress/screenshots/ecdsa.cy.js/ECDSAKeyIdentity tests with SubtleCrypto -- can persist an identity in indexeddb (failed).png differ diff --git a/e2e/browser/cypress/screenshots/ecdsa.cy.js/ECDSAKeyIdentity tests with SubtleCrypto -- generates a new identity (failed).png b/e2e/browser/cypress/screenshots/ecdsa.cy.js/ECDSAKeyIdentity tests with SubtleCrypto -- generates a new identity (failed).png new file mode 100644 index 000000000..f6f42b27d Binary files /dev/null and b/e2e/browser/cypress/screenshots/ecdsa.cy.js/ECDSAKeyIdentity tests with SubtleCrypto -- generates a new identity (failed).png differ diff --git a/e2e/node/basic/canisterStatus.test.ts b/e2e/node/basic/canisterStatus.test.ts index dc58c6cee..26ba3f0a1 100644 --- a/e2e/node/basic/canisterStatus.test.ts +++ b/e2e/node/basic/canisterStatus.test.ts @@ -1,6 +1,7 @@ import { CanisterStatus, HttpAgent } from '@dfinity/agent'; import { Principal } from '@dfinity/principal'; import counter from '../canisters/counter'; +import { makeAgent } from '../utils/agent'; jest.setTimeout(30_000); afterEach(async () => { @@ -9,7 +10,7 @@ afterEach(async () => { describe('canister status', () => { it('should fetch successfully', async () => { const counterObj = await (await counter)(); - const agent = new HttpAgent({ host: `http://localhost:${process.env.REPLICA_PORT}` }); + const agent = await makeAgent(); await agent.fetchRootKey(); const request = await CanisterStatus.request({ canisterId: Principal.from(counterObj.canisterId), @@ -21,7 +22,10 @@ describe('canister status', () => { }); it('should throw an error if fetchRootKey has not been called', async () => { const counterObj = await (await counter)(); - const agent = new HttpAgent({ host: `http://localhost:${process.env.REPLICA_PORT}` }); + const agent = new HttpAgent({ + host: `http://localhost:${process.env.REPLICA_PORT ?? 4943}`, + verifyQuerySignatures: false, + }); const shouldThrow = async () => { // eslint-disable-next-line no-useless-catch try { diff --git a/e2e/node/basic/identity.test.ts b/e2e/node/basic/identity.test.ts index 964abdf29..72fda2424 100644 --- a/e2e/node/basic/identity.test.ts +++ b/e2e/node/basic/identity.test.ts @@ -12,7 +12,7 @@ import { ECDSAKeyIdentity, } from '@dfinity/identity'; import { Secp256k1KeyIdentity } from '@dfinity/identity-secp256k1'; -import agent from '../utils/agent'; +import agent, { makeAgent } from '../utils/agent'; import identityCanister from '../canisters/identity'; function createIdentity(seed: number): SignIdentity { @@ -33,7 +33,7 @@ async function createIdentityActor( idl: IDL.InterfaceFactory, ): Promise { const identity = createIdentity(seed); - const agent1 = new HttpAgent({ source: await agent, identity }); + const agent1 = await makeAgent({ identity }); return Actor.createActor(idl, { canisterId, agent: agent1, @@ -52,7 +52,7 @@ async function createSecp256k1IdentityActor( } const identity = Secp256k1KeyIdentity.generate(seed1); - const agent1 = new HttpAgent({ source: await agent, identity }); + const agent1 = await makeAgent({ identity }); return Actor.createActor(idl, { canisterId, agent: agent1, @@ -70,7 +70,9 @@ async function createEcdsaIdentityActor( } else { effectiveIdentity = await ECDSAKeyIdentity.generate(); } - const agent1 = new HttpAgent({ source: await agent, identity: effectiveIdentity }); + const agent1 = await makeAgent({ + identity: effectiveIdentity, + }); return Actor.createActor(idl, { canisterId, agent: agent1, @@ -147,15 +149,19 @@ test('delegation: principal is the same between delegated keys with secp256k1', const identityActor1 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: masterKey }), + agent: await makeAgent({ + identity: masterKey, + }), }) as any; const identityActor2 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: sessionKey }), + agent: await makeAgent({ + identity: sessionKey, + }), }) as any; const identityActor3 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: id3 }), + agent: await makeAgent({ identity: id3 }), }) as any; const principal1 = await identityActor1.whoami_query(); @@ -178,15 +184,19 @@ test('delegation: principal is the same between delegated keys', async () => { const identityActor1 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: masterKey }), + agent: await makeAgent({ + identity: masterKey, + }), }) as any; const identityActor2 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: sessionKey }), + agent: await makeAgent({ + identity: sessionKey, + }), }) as any; const identityActor3 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: id3 }), + agent: await makeAgent({ identity: id3 }), }) as any; const principal1 = await identityActor1.whoami_query(); @@ -215,19 +225,27 @@ test('delegation: works with 3 keys', async () => { const identityActorBottom = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: bottomKey }), + agent: await makeAgent({ + identity: bottomKey, + }), }) as any; const identityActorMiddle = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: middleKey }), + agent: await makeAgent({ + identity: middleKey, + }), }) as any; const identityActorRoot = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: rootKey }), + agent: await makeAgent({ + identity: rootKey, + }), }) as any; const identityActorDelegated = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: idDelegated }), + agent: await makeAgent({ + identity: idDelegated, + }), }) as any; const principalBottom = await identityActorBottom.whoami_query(); @@ -267,23 +285,33 @@ test('delegation: works with 4 keys', async () => { const identityActorBottom = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: bottomKey }), + agent: await makeAgent({ + identity: bottomKey, + }), }) as any; const identityActorMiddle = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: middleKey }), + agent: await makeAgent({ + identity: middleKey, + }), }) as any; const identityActorMiddle2 = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: middle2Key }), + agent: await makeAgent({ + identity: middle2Key, + }), }) as any; const identityActorRoot = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: rootKey }), + agent: await makeAgent({ + identity: rootKey, + }), }) as any; const identityActorDelegated = Actor.createActor(idl, { canisterId, - agent: new HttpAgent({ source: await agent, identity: idDelegated }), + agent: await makeAgent({ + identity: idDelegated, + }), }) as any; const principalBottom = await identityActorBottom.whoami_query(); diff --git a/e2e/node/basic/mainnet.test.ts b/e2e/node/basic/mainnet.test.ts new file mode 100644 index 000000000..3f1db17f4 --- /dev/null +++ b/e2e/node/basic/mainnet.test.ts @@ -0,0 +1,114 @@ +import { Actor, AnonymousIdentity, HttpAgent, Identity } from '@dfinity/agent'; +import { IDL } from '@dfinity/candid'; +import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { Principal } from '@dfinity/principal'; + +const createWhoamiActor = (identity: Identity) => { + const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; + const idlFactory = () => { + return IDL.Service({ + whoami: IDL.Func([], [IDL.Principal], ['query']), + }); + }; + jest.useFakeTimers(); + new Date(Date.now()); + + const agent = new HttpAgent({ host: 'https://icp-api.io', fetch: fetch, identity }); + + return Actor.createActor(idlFactory, { + agent, + canisterId, + }); +}; + +describe('certified query', () => { + it('should verify a query certificate', async () => { + const actor = createWhoamiActor(new AnonymousIdentity()); + + const result = await actor.whoami(); + + expect(result).toBeInstanceOf(Principal); + }); + jest.setTimeout(100_000); + it('should verify lots of query certificates', async () => { + let count = 0; + const identities = Array.from({ length: 20 }).map(() => { + const newIdentity = Ed25519KeyIdentity.generate(new Uint8Array(32).fill(count)); + count++; + return newIdentity; + }); + const actors = identities.map(createWhoamiActor); + const promises = actors.map(actor => actor.whoami()); + + const results = await Promise.all(promises); + results.forEach(result => { + expect(result).toBeInstanceOf(Principal); + }); + expect(results.length).toBe(20); + + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "__principal__": "535yc-uxytb-gfk7h-tny7p-vjkoe-i4krp-3qmcl-uqfgr-cpgej-yqtjq-rqe", + }, + Object { + "__principal__": "wf3fv-4c4nr-7ks2b-xa4u7-kf3no-32glf-lf7e4-4ng4a-wwtlu-a2vnq-nae", + }, + Object { + "__principal__": "52mr2-fw2ng-2ofst-7jekz-xbymo-3ysz7-itwdk-bgstz-r7g4g-oz5vi-pqe", + }, + Object { + "__principal__": "skpwg-42fe4-eyep5-nfyz7-66wvg-hthea-q3eek-vonbv-5wpxs-nxhmh-fqe", + }, + Object { + "__principal__": "ghaya-cncjm-ntxgt-af5pp-6hzsz-tvwlv-hrlfc-ocq3t-ai7vk-vyixr-cqe", + }, + Object { + "__principal__": "ebtho-zkeqd-ebs74-f5if3-lk6js-3a5q5-37xt4-3ylns-h7r4n-tkhly-aqe", + }, + Object { + "__principal__": "5zqn5-627q2-spx2w-mt6bm-llsnz-gipvv-5femn-3box4-vzuh6-bn723-sae", + }, + Object { + "__principal__": "tek7g-2zmny-nzjwg-ansf7-rkxv6-z32x6-3flbb-ous5d-pygjx-wkhlc-jae", + }, + Object { + "__principal__": "jjn6g-sh75l-r3cxb-wxrkl-frqld-6p6qq-d4ato-wske5-op7s5-n566f-bqe", + }, + Object { + "__principal__": "447dk-byguq-fqkfn-7h4r6-lpk74-itbnh-mkutj-m6tmy-igaff-hmfel-cqe", + }, + Object { + "__principal__": "muo3f-ines5-bxbwm-6wi5e-z663m-3zte2-r7d4x-pleey-xqvxt-scwc5-jae", + }, + Object { + "__principal__": "snzff-yj2qd-fjns7-lqhvw-rsgq7-tohk2-fjnw4-uq3d6-wtk56-pxgry-mqe", + }, + Object { + "__principal__": "pb54o-aqais-24v7j-msopl-bqeuv-paefp-vuoqc-gkezk-grujb-oetl6-sae", + }, + Object { + "__principal__": "bf37n-7ybos-wmqt6-yiov5-24q4m-ajpjk-madkr-snuj2-ngruk-tspnh-aqe", + }, + Object { + "__principal__": "tbjmy-vay5e-hqvv6-sval4-zfxmm-aii6f-b7p55-hwtz5-dejtl-qcuh2-aqe", + }, + Object { + "__principal__": "7d3pe-dh4ov-fp5xz-nctjc-5rduh-gzv3t-5ioyh-4dvx3-x4lgj-hz63t-dqe", + }, + Object { + "__principal__": "37axv-sazcg-75pi3-owhxr-kollq-xnzjz-zfxsv-nzdbp-yaelp-shcul-jae", + }, + Object { + "__principal__": "r772c-4dz5f-rpg4e-qzxgg-7bxlb-67zpu-bitgb-vsx7k-mmagd-6zk3d-4qe", + }, + Object { + "__principal__": "knkon-d3kx7-du4wt-r2fo6-uwc5a-hwhkk-m7snf-nxfhu-6bhgb-k6dn5-wae", + }, + Object { + "__principal__": "entn3-mas6a-37smu-vadtg-wgsno-zokif-vyphu-umase-lfwqe-dmcrk-kae", + }, + ] + `); + }); +}); diff --git a/e2e/node/canisters/counter.ts b/e2e/node/canisters/counter.ts index 6c92442b1..677c2b73e 100644 --- a/e2e/node/canisters/counter.ts +++ b/e2e/node/canisters/counter.ts @@ -3,7 +3,7 @@ import { IDL } from '@dfinity/candid'; import { Principal } from '@dfinity/principal'; import { readFileSync } from 'fs'; import path from 'path'; -import agent, { port, identity } from '../utils/agent'; +import agent, { port, identity, makeAgent } from '../utils/agent'; let cache: { canisterId: Principal; @@ -52,15 +52,9 @@ export async function noncelessCanister(): Promise<{ actor: any; }> { const module = readFileSync(path.join(__dirname, 'counter.wasm')); - const disableNonceAgent = await Promise.resolve( - new HttpAgent({ - host: 'http://127.0.0.1:' + port, - identity, - disableNonce: true, - }), - ).then(async agent => { - await agent.fetchRootKey(); - return agent; + const disableNonceAgent = await makeAgent({ + identity, + disableNonce: true, }); const canisterId = await Actor.createCanister({ agent: disableNonceAgent }); @@ -84,7 +78,9 @@ export async function noncelessCanister(): Promise<{ export const createActor = async (options?: HttpAgentOptions) => { const module = readFileSync(path.join(__dirname, 'counter.wasm')); - const agent = new HttpAgent({ host: `http://localhost:${process.env.REPLICA_PORT}`, ...options }); + const agent = await makeAgent({ + ...options, + }); await agent.fetchRootKey(); const canisterId = await Actor.createCanister({ agent }); diff --git a/e2e/node/utils/agent.ts b/e2e/node/utils/agent.ts index 23e7e97dc..73944576a 100644 --- a/e2e/node/utils/agent.ts +++ b/e2e/node/utils/agent.ts @@ -1,19 +1,24 @@ -import { HttpAgent } from '@dfinity/agent'; +import { HttpAgent, HttpAgentOptions } from '@dfinity/agent'; import { Ed25519KeyIdentity } from '@dfinity/identity'; export const identity = Ed25519KeyIdentity.generate(); export const principal = identity.getPrincipal(); -export const port = parseInt(process.env['REPLICA_PORT'] || '', 10); +export const port = parseInt(process.env['REPLICA_PORT'] || '4943', 10); if (Number.isNaN(port)) { throw new Error('The environment variable REPLICA_PORT is not a number.'); } -const agent = Promise.resolve(new HttpAgent({ host: 'http://127.0.0.1:' + port, identity })).then( - async agent => { - await agent.fetchRootKey(); - return agent; - }, -); +export const makeAgent = async (options?: HttpAgentOptions) => { + const agent = new HttpAgent({ + host: `http://localhost:${process.env.REPLICA_PORT ?? 4943}`, + verifyQuerySignatures: false, + ...options, + }); + await agent.fetchRootKey(); + return agent; +}; + +const agent = makeAgent(); export default agent; diff --git a/packages/agent/src/agent/http/calls.test.json b/packages/agent/src/agent/http/calls.test.json new file mode 100644 index 000000000..7312f7900 --- /dev/null +++ b/packages/agent/src/agent/http/calls.test.json @@ -0,0 +1,10 @@ +[ + [ + "https://icp-api.io/api/v2/canister/ivcos-eqaaa-aaaab-qablq-cai/query", + "{\"method\":\"POST\",\"headers\":\"{\\\"Content-Type\\\":\\\"application/cbor\\\"}\",\"body\":\"d9d9f7a167636f6e74656e74a663617267464449444c00006b63616e69737465725f69644a000000000030005701016e696e67726573735f6578706972791b178f57eae7fc46006b6d6574686f645f6e616d656677686f616d696c726571756573745f747970656571756572796673656e6465724104\"}" + ], + [ + "https://icp-api.io/api/v2/canister/ivcos-eqaaa-aaaab-qablq-cai/read_state", + "{\"method\":\"POST\",\"headers\":\"{\\\"Content-Type\\\":\\\"application/cbor\\\"}\",\"body\":\"d9d9f7a167636f6e74656e74a46e696e67726573735f6578706972791b178f57eae7fc46006570617468738181467375626e65746c726571756573745f747970656a726561645f73746174656673656e6465724104\"}" + ] +] diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 8c71f0e2d..43fde754a 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -13,12 +13,11 @@ import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; import { JSDOM } from 'jsdom'; -import { Actor, AnonymousIdentity, SignIdentity } from '../..'; +import { AnonymousIdentity, SignIdentity } from '../..'; import { Ed25519KeyIdentity } from '../../../../identity/src/identity/ed25519'; import { toHexString } from '../../../../identity/src/buffer'; import { AgentError } from '../../errors'; import { AgentHTTPResponseError } from './errors'; -import { IDL } from '@dfinity/candid'; const { window } = new JSDOM(`

Hello world

`); window.fetch = global.fetch; (global as any).window = window; @@ -830,25 +829,3 @@ test('retry requests that fail due to a network failure', async () => { expect(mockFetch.mock.calls.length).toBe(4); } }); - -describe('certified query', () => { - it('should verify a query certificate', async () => { - const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; - const idlFactory = () => { - return IDL.Service({ - whoami: IDL.Func([], [IDL.Principal], ['query']), - }); - }; - jest.useFakeTimers(); - new Date(Date.now()); - const agent = new HttpAgent({ host: 'https://icp-api.io', fetch: fetch }); - - const actor = Actor.createActor(idlFactory, { - agent, - canisterId, - }); - - const result = await actor.whoami(); - expect(result).toBeInstanceOf(Principal); - }); -}); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index c9a153e43..c73815f0d 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -4,7 +4,7 @@ import { AgentError } from '../../errors'; import { AnonymousIdentity, Identity } from '../../auth'; import * as cbor from '../../cbor'; import { hashOfMap, requestIdOf } from '../../request_id'; -import { compare, concat, fromHex } from '../../utils/buffer'; +import { concat, fromHex } from '../../utils/buffer'; import { Agent, ApiQueryResponse, @@ -121,6 +121,11 @@ export interface HttpAgentOptions { * @default 3 */ retryTimes?: number; + /** + * Whether the agent should verify signatures signed by node keys on query responses. Increases security, but adds overhead and must make a separate request to cache the node keys for the canister's subnet. + * @default true + */ + verifyQuerySignatures?: boolean; } function getDefaultFetch(): typeof fetch { @@ -182,6 +187,7 @@ export class HttpAgent implements Agent { public readonly _isAgent = true; #subnetKeys: Map = new Map(); + #verifyQuerySignatures = true; constructor(options: HttpAgentOptions = {}) { if (options.source) { @@ -235,6 +241,9 @@ export class HttpAgent implements Agent { ); } } + if (options.verifyQuerySignatures !== undefined) { + this.#verifyQuerySignatures = options.verifyQuerySignatures; + } // Default is 3, only set from option if greater or equal to 0 this._retryTimes = options.retryTimes !== undefined && options.retryTimes >= 0 ? options.retryTimes : 3; @@ -480,7 +489,10 @@ export class HttpAgent implements Agent { }); }); - const subnetStatusPromise = new Promise((resolve, reject) => { + const subnetStatusPromise = new Promise((resolve, reject) => { + if (!this.#verifyQuerySignatures) { + resolve(); + } const subnetStatus = this.#subnetKeys.get(canisterId.toString()); if (subnetStatus) { resolve(subnetStatus); @@ -495,6 +507,10 @@ export class HttpAgent implements Agent { } }); const [query, subnetStatus] = await Promise.all([queryPromise, subnetStatusPromise]); + // Skip verification if the user has disabled it + if (!this.#verifyQuerySignatures) { + return query; + } return this.#verifyQueryResponse(query, subnetStatus); } @@ -506,7 +522,7 @@ export class HttpAgent implements Agent { */ #verifyQueryResponse = ( queryResponse: ApiQueryResponse, - subnetStatus: SubnetStatus, + subnetStatus: SubnetStatus | void, ): ApiQueryResponse => { const { status, signatures, requestId } = queryResponse; @@ -541,7 +557,7 @@ export class HttpAgent implements Agent { const separatorWithHash = concat(domainSeparator, new Uint8Array(hash)); // FIX: check for match without verifying N times - const matchingKey = subnetStatus.nodeKeys.find(key => { + const matchingKey = subnetStatus?.nodeKeys.find(key => { const pubKey = new Uint8Array(fromHex(key).slice(12, 44)); try { const validity = ed25519.verify(sig.signature, new Uint8Array(separatorWithHash), pubKey);