Skip to content

Commit 06c274f

Browse files
authored
fix(agent): sync time before retrying if query signature is outdated (#1260)
# Description We should sync time before retrying the query (otherwise, the time check will likely fail again). Blocked by #1259. # How Has This Been Tested? Added mock replica tests.
1 parent cd085fe commit 06c274f

File tree

3 files changed

+199
-9
lines changed

3 files changed

+199
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ See the [v5 upgrading guide](https://js.icp.build/core/latest/upgrading/v5/) for
2424
- fix(agent): check if canister is in ranges for certificates without delegation
2525
- fix(agent): verify all query signatures instead of only the first one
2626
- fix(agent): sync time if ingress expiry is invalid in queries
27+
- fix(agent): sync time before retrying if query signature is outdated
2728
- fix(agent,identity/secp256k1): remove `console.*` statements
2829
- refactor(agent): only declare IC URLs once in the `HttpAgent` class
2930
- refactor(agent): split inner logic of `check_canister_ranges` into functions

e2e/node/basic/queryExpiry.test.ts

Lines changed: 197 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,13 @@ describe('queryExpiry', () => {
142142
it('should retry and fail if the timestamp is outside the max ingress expiry (with retry)', async () => {
143143
const timeDiffMsecs = 6 * MINUTE_TO_MSECS;
144144
const futureDate = new Date(now.getTime() + timeDiffMsecs);
145+
const retryTimes = 3;
145146

146147
const agent = await HttpAgent.create({
147148
host: mockReplica.address,
148149
rootKey: rootSubnetKeyPair.publicKeyDer,
149150
identity,
150-
retryTimes: 3,
151+
retryTimes,
151152
});
152153
const actor = await createActor(canisterId, { agent });
153154
const sender = identity.getPrincipal();
@@ -177,17 +178,34 @@ describe('queryExpiry', () => {
177178
// advance to go over the max ingress expiry (5 minutes)
178179
advanceTimeByMilliseconds(timeDiffMsecs);
179180

180-
// Get node key from subnet
181-
const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({
181+
// First call executed in parallel with the query
182+
await mockReadStateNodeKeysResponse({
183+
mockReplica,
182184
nodeIdentity,
183-
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
185+
canisterId,
184186
rootSubnetKeyPair,
185-
keyPair: subnetKeyPair,
187+
subnetKeyPair,
186188
date: futureDate, // we don't want this call to fail in this case, so we return the proper date
187189
});
188-
mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
189-
res.status(200).send(readStateResponseBody);
190-
});
190+
191+
for (let i = 0; i < retryTimes; i++) {
192+
await mockReadStateNodeKeysResponse({
193+
mockReplica,
194+
nodeIdentity,
195+
canisterId,
196+
rootSubnetKeyPair,
197+
subnetKeyPair,
198+
date: futureDate, // we don't want this call to fail in this case, so we return the proper date
199+
});
200+
201+
await mockSyncTimeResponse({
202+
mockReplica,
203+
rootSubnetKeyPair,
204+
keyPair: subnetKeyPair,
205+
date: futureDate,
206+
canisterId,
207+
});
208+
}
191209

192210
expect.assertions(5);
193211

@@ -198,7 +216,7 @@ describe('queryExpiry', () => {
198216
}
199217

200218
expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(4);
201-
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1);
219+
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(10);
202220
});
203221

204222
it('should not retry if the timestamp is outside the max ingress expiry (verifyQuerySignatures=false)', async () => {
@@ -388,6 +406,176 @@ describe('queryExpiry', () => {
388406
const req = mockReplica.getV3QueryReq(canisterId.toString(), 0);
389407
expect(requestIdOf(req.content)).toEqual(requestId);
390408
});
409+
410+
it('should sync time when the query response has an outdated certificate and retry succeeds', async () => {
411+
const timeDiffMsecs = -(6 * MINUTE_TO_MSECS);
412+
const replicaDate = new Date(now.getTime() + timeDiffMsecs);
413+
414+
const agent = await HttpAgent.create({
415+
host: mockReplica.address,
416+
rootKey: rootSubnetKeyPair.publicKeyDer,
417+
identity,
418+
retryTimes: 1,
419+
});
420+
const actor = await createActor(canisterId, { agent });
421+
const sender = identity.getPrincipal();
422+
423+
const { responseBody: outdatedResponse } = await prepareV3QueryResponse({
424+
canisterId,
425+
methodName: greetMethodName,
426+
arg: greetArgs,
427+
sender,
428+
reply: greetReply,
429+
nodeIdentity,
430+
date: replicaDate,
431+
});
432+
mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
433+
res.status(200).send(outdatedResponse);
434+
});
435+
436+
await mockReadStateNodeKeysResponse({
437+
mockReplica,
438+
nodeIdentity,
439+
canisterId,
440+
rootSubnetKeyPair,
441+
subnetKeyPair,
442+
date: now, // we don't want to make the certificate checks sync the time for this call
443+
});
444+
445+
await mockSyncTimeResponse({
446+
mockReplica,
447+
rootSubnetKeyPair,
448+
keyPair: subnetKeyPair,
449+
canisterId,
450+
date: now,
451+
});
452+
453+
const { responseBody: freshResponse, requestId } = await prepareV3QueryResponse({
454+
canisterId,
455+
methodName: greetMethodName,
456+
arg: greetArgs,
457+
sender,
458+
reply: greetReply,
459+
nodeIdentity,
460+
date: now,
461+
});
462+
mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
463+
res.status(200).send(freshResponse);
464+
});
465+
466+
const actorResponse = await actor[greetMethodName](greetReq);
467+
468+
expect(actorResponse).toEqual(greetRes);
469+
expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(2);
470+
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4);
471+
expect(agent.hasSyncedTime()).toBe(true);
472+
473+
const req = mockReplica.getV3QueryReq(canisterId.toString(), 1);
474+
expect(requestIdOf(req.content)).toEqual(requestId);
475+
});
476+
477+
it('should fail if the query response has an outdated certificate and retry fails', async () => {
478+
const timeDiffMsecs = -(6 * MINUTE_TO_MSECS);
479+
const replicaDate = new Date(now.getTime() + timeDiffMsecs);
480+
481+
const agent = await HttpAgent.create({
482+
host: mockReplica.address,
483+
rootKey: rootSubnetKeyPair.publicKeyDer,
484+
identity,
485+
retryTimes: 1,
486+
});
487+
const actor = await createActor(canisterId, { agent });
488+
const sender = identity.getPrincipal();
489+
490+
const { responseBody: outdatedResponse } = await prepareV3QueryResponse({
491+
canisterId,
492+
methodName: greetMethodName,
493+
arg: greetArgs,
494+
sender,
495+
reply: greetReply,
496+
nodeIdentity,
497+
date: replicaDate,
498+
});
499+
mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
500+
res.status(200).send(outdatedResponse);
501+
});
502+
mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
503+
res.status(200).send(outdatedResponse);
504+
});
505+
506+
await mockReadStateNodeKeysResponse({
507+
mockReplica,
508+
nodeIdentity,
509+
canisterId,
510+
rootSubnetKeyPair,
511+
subnetKeyPair,
512+
date: now, // we don't want to make the certificate sync the time for this call
513+
});
514+
515+
await mockSyncTimeResponse({
516+
mockReplica,
517+
rootSubnetKeyPair,
518+
keyPair: subnetKeyPair,
519+
canisterId,
520+
date: now,
521+
});
522+
523+
expect.assertions(6);
524+
525+
try {
526+
await actor[greetMethodName](greetReq);
527+
} catch (e) {
528+
expectCertificateOutdatedError(e);
529+
}
530+
531+
expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(2);
532+
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4);
533+
expect(agent.hasSyncedTime()).toBe(true);
534+
});
535+
536+
it('should not sync time if the query signature verification is disabled', async () => {
537+
const timeDiffMsecs = -(6 * MINUTE_TO_MSECS);
538+
const replicaDate = new Date(now.getTime() + timeDiffMsecs);
539+
540+
const agent = await HttpAgent.create({
541+
host: mockReplica.address,
542+
rootKey: rootSubnetKeyPair.publicKeyDer,
543+
identity,
544+
verifyQuerySignatures: false,
545+
});
546+
const actor = await createActor(canisterId, { agent });
547+
const sender = identity.getPrincipal();
548+
549+
const { responseBody } = await prepareV3QueryResponse({
550+
canisterId,
551+
methodName: greetMethodName,
552+
arg: greetArgs,
553+
sender,
554+
reply: greetReply,
555+
nodeIdentity,
556+
date: replicaDate,
557+
});
558+
mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
559+
res.status(200).send(responseBody);
560+
});
561+
562+
// Just mock the read state, but we don't expect this to be called
563+
await mockReadStateNodeKeysResponse({
564+
mockReplica,
565+
nodeIdentity,
566+
canisterId,
567+
rootSubnetKeyPair,
568+
subnetKeyPair,
569+
date: now,
570+
});
571+
572+
const actorResponse = await actor[greetMethodName](greetReq);
573+
574+
expect(actorResponse).toEqual(greetRes);
575+
expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1);
576+
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0);
577+
expect(agent.hasSyncedTime()).toBe(false);
578+
});
391579
});
392580

393581
function advanceTimeByMilliseconds(milliseconds: number) {

packages/core/src/agent/agent/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,7 @@ export class HttpAgent implements Agent {
771771
requestId,
772772
signatureTimestampMs,
773773
});
774+
await this.syncTime(ecid);
774775
return await this.#requestAndRetryQuery({ ...args, tries: tries + 1 });
775776
}
776777
throw TrustError.fromCode(

0 commit comments

Comments
 (0)