Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
18296ae
refactor: improve error message for certificate signature verificatio…
ilbertt Jul 24, 2025
ff5713f
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 25, 2025
e713ce8
fix: account for clock drift in certificate freshness check
ilbertt Jul 25, 2025
c3a73a4
chore: update changelog
ilbertt Jul 25, 2025
4298f27
chore: update changelog
ilbertt Jul 25, 2025
0aaf445
fix: enable certificate freshness check in canister status request
ilbertt Jul 25, 2025
42baf52
refactor: move `mockSyncTimeResponse` to mock-replica
ilbertt Jul 25, 2025
f5a3864
fix: avoid recursive calls to syncTime
ilbertt Jul 25, 2025
7f2577c
docs: fix typos
ilbertt Jul 27, 2025
9d6941a
Merge branch 'luca/SDK-2204-clock-drift-certificate' into luca/SDK-22…
ilbertt Jul 27, 2025
33fa20e
test: remove temporary .only
ilbertt Jul 28, 2025
41c9056
refactor: clearer error and error pretty print
ilbertt Jul 28, 2025
c55ffce
test: mock sync time when needed
ilbertt Jul 28, 2025
fbadcf5
test: adapt to new error printing
ilbertt Jul 28, 2025
7380193
chore: update changelog
ilbertt Jul 28, 2025
9f8ed1f
refactor: rename certificate arg and helper function
ilbertt Jul 29, 2025
f42dc33
test: `getAdjustedCurrentTime` tests
ilbertt Jul 29, 2025
f41244a
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 29, 2025
4b754d9
Merge remote-tracking branch 'origin/luca/SDK-2204-clock-drift-certif…
ilbertt Jul 29, 2025
77f382d
refactor: remove unneeded logic from `getSubnetStatus`
ilbertt Jul 29, 2025
40f1969
fix: import new function
ilbertt Jul 29, 2025
cb04911
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 30, 2025
ee567cd
Merge remote-tracking branch 'origin/luca/SDK-2204-clock-drift-certif…
ilbertt Jul 30, 2025
0dc1643
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 31, 2025
43a183b
Merge remote-tracking branch 'origin/luca/SDK-2204-clock-drift-certif…
ilbertt Jul 31, 2025
e1454ee
fix: calculate clock drift inside error
ilbertt Aug 4, 2025
4bcd94d
Revert "fix: calculate clock drift inside error"
ilbertt Aug 4, 2025
8602840
fix: calculate clock drift inside error
ilbertt Aug 4, 2025
6f72994
Revert "refactor: rename certificate arg and helper function"
ilbertt Aug 4, 2025
57ae00d
test: update after revert
ilbertt Aug 4, 2025
4069b9d
test: remove unused variable
ilbertt Aug 4, 2025
0856683
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Aug 4, 2025
a7ead48
refactor: address PR feedback
ilbertt Aug 4, 2025
2830e41
test: refine certificate creation tests logic
ilbertt Aug 4, 2025
cc1c01c
test: add more cases for certificate freshness checks
ilbertt Aug 4, 2025
ad20d57
chore: update changelog
ilbertt Aug 4, 2025
5ff4b96
fix: address PR feedback
ilbertt Aug 4, 2025
efc9e5d
docs: timeDiffMsecs jsdocs
ilbertt Aug 4, 2025
331b454
docs: timediffmsecs jsdocs
ilbertt Aug 4, 2025
5f27310
fix: we don't need the timeDiffMsecs param
ilbertt Aug 4, 2025
de5e3af
Apply suggestion from @mraszyk
ilbertt Aug 4, 2025
5bfc26d
Apply suggestion from @mraszyk
ilbertt Aug 4, 2025
e2489d6
Merge remote-tracking branch 'origin/luca/SDK-2204-clock-drift-certif…
ilbertt Aug 4, 2025
6d65f6d
refactor: remove unneeded function
ilbertt Aug 4, 2025
ec0c7da
refactor: we don't need to check for root key again
ilbertt Aug 4, 2025
279e1dd
docs: remove comment
ilbertt Aug 4, 2025
6f9ce85
docs: improve CanisterStatus.request docs
ilbertt Aug 5, 2025
175aa08
chore: update clock drift explanation
ilbertt Aug 5, 2025
b5269a3
Merge remote-tracking branch 'origin/luca/SDK-2204-clock-drift-certif…
ilbertt Aug 5, 2025
73a4e41
test: update retry logic according to changes
ilbertt Aug 5, 2025
2c0b6ee
Merge remote-tracking branch 'origin/main' into luca/SDK-2203-enable-…
ilbertt Aug 5, 2025
c803701
refactor: remove TODO
ilbertt Aug 5, 2025
0492b59
fix: use certificate's canister id to sync time
ilbertt Aug 5, 2025
94e75ab
Merge remote-tracking branch 'origin/main' into luca/SDK-2203-enable-…
ilbertt Aug 5, 2025
29ca6a8
Merge remote-tracking branch 'origin/main' into luca/SDK-2203-enable-…
ilbertt Aug 5, 2025
9c91a3e
test: update logic after merge
ilbertt Aug 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- 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.
- fix: use the effective canister id to delete the node keys from the local cache.
- docs: add DFINITY Starlight theme to the docs
- feat: adds the `disableCertificateTimeVerification` optional field to the `CanisterStatus.request` function argument, which allows you to control the `disableTimeVerification` option for the internal `Certificate.create` call.

## [3.1.0] - 2025-07-24

Expand Down
154 changes: 148 additions & 6 deletions e2e/node/basic/canisterStatus.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { AgentError, CanisterStatus, HttpAgent } from '@icp-sdk/core/agent';
import {
AgentError,
CanisterStatus,
CertificateTimeErrorCode,
HttpAgent,
TrustError,
} from '@icp-sdk/core/agent';
import { Principal } from '@icp-sdk/core/principal';
import { makeAgent } from '../utils/agent.ts';
import { describe, it, afterEach, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getCanisterId } from '../utils/canisterid.ts';
import {
MockReplica,
mockSyncTimeResponse,
prepareV2ReadStateSubnetResponse,
} from '../utils/mock-replica.ts';
import { randomIdentity, randomKeyPair } from '../utils/identity.ts';

const MINUTE_TO_MSECS = 60 * 1000;

afterEach(async () => {
await Promise.resolve();
});
describe('canister status', () => {
it('should fetch successfully', async () => {
const counterCanisterId = getCanisterId('counter');
Expand All @@ -22,7 +33,7 @@ describe('canister status', () => {
});
it('should throw an error if fetchRootKey has not been called', async () => {
const counterCanisterId = getCanisterId('counter');
const agent = new HttpAgent({
const agent = HttpAgent.createSync({
host: `http://127.0.0.1:${process.env.REPLICA_PORT ?? 4943}`,
verifyQuerySignatures: false,
});
Expand Down Expand Up @@ -54,4 +65,135 @@ describe('canister status', () => {
const principal = Principal.fromText(subnet.subnetId);
expect(principal).toBeDefined();
});

describe('certificate freshness', () => {
const now = new Date('2025-07-25T12:34:56.789Z');
const canisterId = Principal.fromText('uxrrr-q7777-77774-qaaaq-cai');

const subnetKeyPair = randomKeyPair();
const nodeIdentity = randomIdentity();
const identity = randomIdentity();

let mockReplica: MockReplica;

beforeEach(async () => {
mockReplica = await MockReplica.create();

vi.setSystemTime(now);
});

it('should sync time and throw an error if the certificate is not fresh', async () => {
const timeDiffMsecs = -(6 * MINUTE_TO_MSECS);
const replicaDate = new Date(now.getTime() + timeDiffMsecs);

const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: subnetKeyPair.publicKeyDer,
identity,
});

const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
keyPair: subnetKeyPair,
date: replicaDate,
});
// first try, fails
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});
// syncs time
await mockSyncTimeResponse({
mockReplica,
keyPair: subnetKeyPair,
canisterId,
date: now, // simulate a replica time that is different from the certificate time
});

expect.assertions(4);

try {
await CanisterStatus.request({
canisterId,
agent,
paths: ['subnet'],
});
} catch (e) {
expect(e).toBeInstanceOf(TrustError);
const err = e as TrustError;
expect(err.cause.code).toBeInstanceOf(CertificateTimeErrorCode);
expect(err.message).toContain('Certificate is signed more than 5 minutes in the past');
}
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4);
});

it('should sync time and succeed if the certificate is not fresh', async () => {
const timeDiffMsecs = -(6 * MINUTE_TO_MSECS);
const replicaDate = new Date(now.getTime() + timeDiffMsecs);

const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: subnetKeyPair.publicKeyDer,
identity,
});

const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
keyPair: subnetKeyPair,
date: replicaDate,
});
// first try, fails
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});
// sync time, we return the replica date to make the agent sync time properly
await mockSyncTimeResponse({
mockReplica,
keyPair: subnetKeyPair,
canisterId,
date: new Date(now.getTime() - 4 * MINUTE_TO_MSECS),
});

await expect(
CanisterStatus.request({
canisterId,
agent,
paths: ['subnet'],
}),
).resolves.not.toThrow();
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4);
});

it('should not sync time and succeed if the certificate is not fresh and disableTimeVerification is true', async () => {
const timeDiffMsecs = -(6 * MINUTE_TO_MSECS);
const replicaDate = new Date(now.getTime() + timeDiffMsecs);

const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: subnetKeyPair.publicKeyDer,
identity,
});

const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
keyPair: subnetKeyPair,
date: replicaDate,
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});

await expect(
CanisterStatus.request({
canisterId,
agent,
paths: ['subnet'],
disableCertificateTimeVerification: true,
}),
).resolves.not.toThrow();
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1);
});
});
});
103 changes: 42 additions & 61 deletions e2e/node/basic/queryExpiry.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { beforeEach, describe, it, vi, expect } from 'vitest';
import {
MockReplica,
mockSyncTimeResponse,
prepareV2QueryResponse,
prepareV2ReadStateSubnetResponse,
prepareV2ReadStateTimeResponse,
} from '../utils/mock-replica.ts';
import { IDL } from '@icp-sdk/core/candid';
import { Principal } from '@icp-sdk/core/principal';
import { KeyPair, randomIdentity, randomKeyPair } from '../utils/identity.ts';
import { randomIdentity, randomKeyPair } from '../utils/identity.ts';
import {
CertificateOutdatedErrorCode,
CertificateTimeErrorCode,
HttpAgent,
requestIdOf,
TrustError,
Expand Down Expand Up @@ -84,6 +85,12 @@ describe('queryExpiry', () => {
});

it('should fail if the timestamp is outside the max ingress expiry (no retry)', async () => {
const timeDiffMsecs = 6 * MINUTE_TO_MSECS;
const futureDate = new Date(now.getTime() + timeDiffMsecs);

// advance to go over the max ingress expiry (5 minutes)
advanceTimeByMilliseconds(timeDiffMsecs);

const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: subnetKeyPair.publicKeyDer,
Expand All @@ -105,21 +112,17 @@ describe('queryExpiry', () => {
mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(responseBody);
});

// advance to go over the max ingress expiry (5 minutes)
advanceTimeByMilliseconds(6 * MINUTE_TO_MSECS);

const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
keyPair: subnetKeyPair,
date: now,
date: futureDate, // make sure the certificate is fresh for this call
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});

expect.assertions(5);
expect.assertions(4);

try {
await actor[greetMethodName](greetReq);
Expand All @@ -128,10 +131,12 @@ describe('queryExpiry', () => {
}

expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1);
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0);
});

it('should retry and fail if the timestamp is outside the max ingress expiry (with retry)', async () => {
const timeDiffMsecs = 6 * MINUTE_TO_MSECS;
const futureDate = new Date(now.getTime() + timeDiffMsecs);

const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: subnetKeyPair.publicKeyDer,
Expand All @@ -153,39 +158,41 @@ describe('queryExpiry', () => {
mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(responseBody);
});
mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(responseBody);
});
mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(responseBody);
});
mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(responseBody);
});

// advance to go over the max ingress expiry (5 minutes)
advanceTimeByMilliseconds(6 * MINUTE_TO_MSECS);
advanceTimeByMilliseconds(timeDiffMsecs);

const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
keyPair: subnetKeyPair,
date: now,
});
// fetch subnet keys, fails for certificate freshness checks
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});
// sync time, keeping a date in the future to make sure the agent still has outdated time
await mockSyncTimeResponse({
mockReplica,
keyPair: subnetKeyPair,
date: futureDate,
canisterId,
});

expect.assertions(5);

try {
await actor[greetMethodName](greetReq);
} catch (e) {
expectCertificateOutdatedError(e);
expect(e).toBeInstanceOf(TrustError);
const err = e as TrustError;
expect(err.cause.code).toBeInstanceOf(CertificateTimeErrorCode);
expect(err.message).toContain('Certificate is signed more than 5 minutes in the past.');
}

expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(4);
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1);
expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1);
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4);
});

it('should not retry if the timestamp is outside the max ingress expiry (verifyQuerySignatures=false)', async () => {
Expand Down Expand Up @@ -233,7 +240,12 @@ describe('queryExpiry', () => {
'should account for local clock drift (more than 5 minutes in the %s)',
async (_, timeDiffMsecs) => {
const replicaDate = new Date(now.getTime() + timeDiffMsecs);
await mockSyncTimeResponse({ mockReplica, keyPair: subnetKeyPair, date: replicaDate });
await mockSyncTimeResponse({
mockReplica,
keyPair: subnetKeyPair,
date: replicaDate,
canisterId: ICP_LEDGER,
});

const agent = await HttpAgent.create({
host: mockReplica.address,
Expand Down Expand Up @@ -308,16 +320,6 @@ describe('queryExpiry', () => {
res.status(200).send(responseBody);
});

const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
keyPair: subnetKeyPair,
date: replicaDate,
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});

expect.assertions(4);

try {
Expand Down Expand Up @@ -366,6 +368,12 @@ describe('queryExpiry', () => {
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});
await mockSyncTimeResponse({
mockReplica,
keyPair: subnetKeyPair,
date: replicaDate,
canisterId,
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(subnetResponseBody);
});
Expand All @@ -374,7 +382,7 @@ describe('queryExpiry', () => {

expect(actorResponse).toEqual(greetRes);
expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1);
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1);
expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4);

const req = mockReplica.getV2QueryReq(canisterId.toString(), 0);
expect(requestIdOf(req.content)).toEqual(requestId);
Expand All @@ -392,30 +400,3 @@ function expectCertificateOutdatedError(e: unknown) {
expect(err.cause.code).toBeInstanceOf(CertificateOutdatedErrorCode);
expect(err.message).toContain('Certificate is stale');
}

async function mockSyncTimeResponse({
mockReplica,
keyPair,
date,
canisterId,
}: {
mockReplica: MockReplica;
keyPair: KeyPair;
date?: Date;
canisterId?: Principal | string;
}) {
canisterId = Principal.from(canisterId ?? ICP_LEDGER).toText();
const { responseBody: timeResponseBody } = await prepareV2ReadStateTimeResponse({
keyPair,
date,
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => {
res.status(200).send(timeResponseBody);
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => {
res.status(200).send(timeResponseBody);
});
mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => {
res.status(200).send(timeResponseBody);
});
}
Loading
Loading