Skip to content

Commit 84a6116

Browse files
ilberttmraszyk
andauthored
fix: account for clock drift in certificate freshness check (#1081)
Co-authored-by: mraszyk <31483726+mraszyk@users.noreply.github.com>
1 parent 8b37687 commit 84a6116

File tree

9 files changed

+454
-73
lines changed

9 files changed

+454
-73
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
- fix: do not subtract the replica permitted clock drift when calculating the ingress expiry.
66
- fix: pick the expiry rounding strategy based on the delta, without adding the clock drift to the delta.
7-
- feat: adds a `clockDriftMs` optional parameter to `Expiry.fromDeltaInMilliseconds` to add to the current time, typically used to specify the clock drift between the client's clock and the IC network clock.
8-
- fix: add declaration maps and typescript source code to published packages
7+
- feat: adds a `clockDriftMs` optional parameter to `Expiry.fromDeltaInMilliseconds` to add to the current time, typically used to specify the clock drift between the IC network clock and the client's clock.
8+
- fix: add declaration maps and typescript source code to published packages.
99
- feat: enables type inference for the arguments and return types of `FuncClass`.
1010
- feat: enables type inference for the fields of `ServiceClass`.
1111
- fix: perform the canister range checks unconditionally for delegations when constructing a `Certificate` instance.
12+
- fix: account for clock drift when verifying the certificate freshness, and syncs time with the IC network if the certificate fails the freshness check and the agent's time is not already synced.
13+
- feat: adds the `agent` optional field to the `CreateCertificateOptions` interface, which is used to sync time with the IC network if the certificate fails the freshness check, if provided.
14+
- feat: adds the `getTimeDiffMsecs` method to the `HttpAgent` class, which returns the time difference in milliseconds between the IC network clock and the client's clock.
15+
- feat: adds the `hasSyncedTime` method to the `HttpAgent` class, which returns `true` if the time has been synced at least once with the IC network, `false` otherwise.
1216

1317
## [3.1.0] - 2025-07-24
1418

e2e/node/basic/syncTime.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ describe('syncTime', () => {
9494
},
9595
'V3 call body',
9696
);
97+
expect(agent.hasSyncedTime()).toBe(false);
9798
});
9899

99100
it('should sync time when the local time does not match the subnet time', async () => {
@@ -179,6 +180,7 @@ describe('syncTime', () => {
179180
},
180181
'V3 read state body three',
181182
);
183+
expect(agent.hasSyncedTime()).toBe(true);
182184
});
183185
});
184186

@@ -197,7 +199,7 @@ describe('syncTime', () => {
197199
res.status(200).send(readStateResponse);
198200
});
199201

200-
await HttpAgent.create({
202+
const agent = await HttpAgent.create({
201203
host: mockReplica.address,
202204
rootKey: keyPair.publicKeyDer,
203205
shouldSyncTime: true,
@@ -225,6 +227,7 @@ describe('syncTime', () => {
225227
},
226228
'V3 read state body three',
227229
);
230+
expect(agent.hasSyncedTime()).toBe(true);
228231
});
229232

230233
it('should not sync time by default', async () => {
@@ -235,14 +238,15 @@ describe('syncTime', () => {
235238
res.status(200).send(readStateResponse);
236239
});
237240

238-
await HttpAgent.create({
241+
const agent = await HttpAgent.create({
239242
host: mockReplica.address,
240243
rootKey: keyPair.publicKeyDer,
241244
shouldSyncTime: false,
242245
identity: anonIdentity,
243246
});
244247

245248
expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0);
249+
expect(agent.hasSyncedTime()).toBe(false);
246250
});
247251

248252
it('should not sync time when explicitly disabled', async () => {
@@ -253,14 +257,15 @@ describe('syncTime', () => {
253257
res.status(200).send(readStateResponse);
254258
});
255259

256-
await HttpAgent.create({
260+
const agent = await HttpAgent.create({
257261
host: mockReplica.address,
258262
rootKey: keyPair.publicKeyDer,
259263
shouldSyncTime: false,
260264
identity: anonIdentity,
261265
});
262266

263267
expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0);
268+
expect(agent.hasSyncedTime()).toBe(false);
264269
});
265270
});
266271

@@ -340,6 +345,7 @@ describe('syncTime', () => {
340345
},
341346
'V3 read state body three',
342347
);
348+
expect(agent.hasSyncedTime()).toBe(true);
343349
});
344350

345351
it('should not sync time by default', async () => {
@@ -389,6 +395,7 @@ describe('syncTime', () => {
389395
);
390396

391397
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0);
398+
expect(agent.hasSyncedTime()).toBe(false);
392399
});
393400

394401
it('should not sync time when explicitly disabled', async () => {
@@ -440,6 +447,7 @@ describe('syncTime', () => {
440447
);
441448

442449
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0);
450+
expect(agent.hasSyncedTime()).toBe(false);
443451
});
444452
});
445453
});

packages/agent/src/actor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ function _createActorMethod(
427427
rootKey: agent.rootKey,
428428
canisterId: Principal.from(canisterId),
429429
blsVerify,
430+
agent,
430431
});
431432
const path = [utf8ToBytes('request_status'), requestId];
432433
const status = new TextDecoder().decode(

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

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export enum RequestStatusResponseStatus {
8585
const MINUTE_TO_MSECS = 60 * 1_000;
8686
const MSECS_TO_NANOSECONDS = 1_000_000;
8787

88+
const DEFAULT_TIME_DIFF_MSECS = 0;
89+
8890
// Root public key for the IC, encoded as hex
8991
export const IC_ROOT_KEY =
9092
'308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814' +
@@ -286,7 +288,8 @@ export class HttpAgent implements Agent {
286288
#rootKeyPromise: Promise<Uint8Array> | null = null;
287289
readonly #shouldFetchRootKey: boolean = false;
288290

289-
#timeDiffMsecs = 0;
291+
#timeDiffMsecs = DEFAULT_TIME_DIFF_MSECS;
292+
#hasSyncedTime = false;
290293
#syncTimePromise: Promise<void> | null = null;
291294
readonly #shouldSyncTime: boolean = false;
292295

@@ -313,7 +316,7 @@ export class HttpAgent implements Agent {
313316
#updatePipeline: HttpAgentRequestTransformFn[] = [];
314317

315318
#subnetKeys: ExpirableMap<string, SubnetStatus> = new ExpirableMap({
316-
expirationTime: 5 * 60 * 1000, // 5 minutes
319+
expirationTime: 5 * MINUTE_TO_MSECS,
317320
});
318321
#verifyQuerySignatures = true;
319322

@@ -949,7 +952,13 @@ export class HttpAgent implements Agent {
949952
// Attempt to make the query i=retryTimes times
950953
// Make query and fetch subnet keys in parallel
951954
try {
952-
const [queryResult, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]);
955+
const [queryResult, subnetStatus] = await Promise.all([
956+
makeQuery(),
957+
getSubnetStatus().catch(err => {
958+
this.log.warn('Failed to fetch subnet keys. Error:', err);
959+
return undefined;
960+
}),
961+
]);
953962
const { requestDetails, query } = queryResult;
954963

955964
const queryWithDetails = {
@@ -968,10 +977,8 @@ export class HttpAgent implements Agent {
968977
} catch {
969978
// In case the node signatures have changed, refresh the subnet keys and try again
970979
this.log.warn('Query response verification failed. Retrying with fresh subnet keys.');
971-
this.#subnetKeys.delete(canisterId.toString());
972-
await this.fetchSubnetKeys(ecid.toString());
973-
974-
const updatedSubnetStatus = this.#subnetKeys.get(canisterId.toString());
980+
this.#subnetKeys.delete(ecid.toString());
981+
const updatedSubnetStatus = await getSubnetStatus();
975982
if (!updatedSubnetStatus) {
976983
throw TrustError.fromCode(new MissingSignatureErrorCode());
977984
}
@@ -1208,8 +1215,8 @@ export class HttpAgent implements Agent {
12081215
}
12091216
const date = decodeTime(timeLookup.value);
12101217
this.log.print('Time from response:', date);
1211-
this.log.print('Time from response in milliseconds:', Number(date));
1212-
return Number(date);
1218+
this.log.print('Time from response in milliseconds:', date.getTime());
1219+
return date.getTime();
12131220
} else {
12141221
this.log.warn('No certificate found in response');
12151222
}
@@ -1241,12 +1248,14 @@ export class HttpAgent implements Agent {
12411248
fetch: this.#fetch,
12421249
retryTimes: 0,
12431250
rootKey: this.rootKey ?? undefined,
1251+
shouldSyncTime: false,
12441252
});
12451253

12461254
const replicaTimes = await Promise.all(
12471255
Array(3)
12481256
.fill(null)
12491257
.map(async () => {
1258+
// TODO: disable certificate freshness check for this request
12501259
const status = await canisterStatusRequest({
12511260
canisterId,
12521261
agent: anonymousAgent,
@@ -1264,8 +1273,9 @@ export class HttpAgent implements Agent {
12641273
return typeof current === 'number' && current > max ? current : max;
12651274
}, 0);
12661275

1267-
if (maxReplicaTime > BigInt(0)) {
1268-
this.#timeDiffMsecs = Number(maxReplicaTime) - Number(callTime);
1276+
if (maxReplicaTime > 0) {
1277+
this.#timeDiffMsecs = maxReplicaTime - callTime;
1278+
this.#hasSyncedTime = true;
12691279
this.log.notify({
12701280
message: `Syncing time: offset of ${this.#timeDiffMsecs}`,
12711281
level: 'info',
@@ -1341,7 +1351,7 @@ export class HttpAgent implements Agent {
13411351
}
13421352

13431353
async #syncTimeGuard(canisterIdOverride?: Principal): Promise<void> {
1344-
if (this.#shouldSyncTime && this.#timeDiffMsecs === 0) {
1354+
if (this.#shouldSyncTime && !this.hasSyncedTime()) {
13451355
await this.syncTime(canisterIdOverride);
13461356
}
13471357
}
@@ -1386,6 +1396,23 @@ export class HttpAgent implements Agent {
13861396

13871397
return p;
13881398
}
1399+
1400+
/**
1401+
* Returns the time difference in milliseconds between the IC network clock and the client's clock,
1402+
* after the clock has been synced.
1403+
*
1404+
* If the time has not been synced, returns `0`.
1405+
*/
1406+
public getTimeDiffMsecs(): number {
1407+
return this.#timeDiffMsecs;
1408+
}
1409+
1410+
/**
1411+
* Returns `true` if the time has been synced at least once with the IC network, `false` otherwise.
1412+
*/
1413+
public hasSyncedTime(): boolean {
1414+
return this.#hasSyncedTime;
1415+
}
13891416
}
13901417

13911418
/**

packages/agent/src/agent/http/transforms.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class Expiry {
3838
* If the delta is less than 90 seconds, the expiry is rounded down to the nearest second.
3939
* Otherwise, the expiry is rounded down to the nearest minute.
4040
* @param deltaInMs The milliseconds to add to the current time.
41-
* @param clockDriftMs The milliseconds to add to the current time, typically the clock drift between the client and the IC network clock. Defaults to `0` if not provided.
41+
* @param clockDriftMs The milliseconds to add to the current time, typically the clock drift between IC network clock and the client's clock. Defaults to `0` if not provided.
4242
* @returns {Expiry} The constructed Expiry object.
4343
*/
4444
public static fromDeltaInMilliseconds(deltaInMs: number, clockDriftMs: number = 0): Expiry {

0 commit comments

Comments
 (0)