Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 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
7f2577c
docs: fix typos
ilbertt Jul 27, 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
cb04911
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 30, 2025
0dc1643
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 31, 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
175aa08
chore: update clock drift explanation
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- fix: do not subtract the replica permitted clock drift when calculating the ingress expiry.
- fix: pick the expiry rounding strategy based on the delta, without adding the clock drift to the delta.
- 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.
- fix: account for clock drift when verifying the certificate freshness.
- feat: adds the `timeDiffMsecs` optional field to the `CreateCertificateOptions` interface, which allows you to adjust the current time when verifying the certificate freshness.
- feat: adds the `getTimeDiffMsecs` function to the `HttpAgent` class, which returns the time difference in milliseconds between the client's clock and the IC network clock. It also adds the `getTimeDiffMsecs` function to handle the case where the agent is not an instance of `HttpAgent`.

## [3.1.0] - 2025-07-24

Expand Down
3 changes: 2 additions & 1 deletion packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { IDL } from '@dfinity/candid';
import { pollForResponse, type PollingOptions, DEFAULT_POLLING_OPTIONS } from './polling/index.ts';
import { Principal } from '@dfinity/principal';
import { Certificate, type CreateCertificateOptions, lookupResultToBuffer } from './certificate.ts';
import { HttpAgent } from './agent/http/index.ts';
import { getTimeDiffMsecs, HttpAgent } from './agent/http/index.ts';
import { utf8ToBytes } from '@noble/hashes/utils';

/**
Expand Down Expand Up @@ -427,6 +427,7 @@ function _createActorMethod(
rootKey: agent.rootKey,
canisterId: Principal.from(canisterId),
blsVerify,
timeDiffMsecs: getTimeDiffMsecs(agent),
});
const path = [utf8ToBytes('request_status'), requestId];
const status = new TextDecoder().decode(
Expand Down
25 changes: 25 additions & 0 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,17 @@ export class HttpAgent implements Agent {

return p;
}

/**
* Returns the time difference in milliseconds between the client's clock and the IC network clock,
* after the clock has been synced using the {@link HttpAgent.syncTime} method
* or during agent creation if {@link HttpAgentOptions.shouldSyncTime} was set to `true`.
*
* If the time has not been synced, returns `0`.
*/
public getTimeDiffMsecs(): number {
return this.#timeDiffMsecs;
}
}

/**
Expand All @@ -1402,3 +1413,17 @@ export function calculateIngressExpiry(
const ingressExpiryMs = maxIngressExpiryInMinutes * MINUTE_TO_MSECS;
return Expiry.fromDeltaInMilliseconds(ingressExpiryMs, timeDiffMsecs);
}

/**
* Retrieves the time difference in milliseconds between the client's clock and the IC network clock.
* See {@link HttpAgent.getTimeDiffMsecs} for more details.
* @param agent The agent to retrieve the `timeDiffMsecs` property from.
* @returns The time difference in milliseconds between the client's clock and the IC network clock,
* if the agent is an {@link HttpAgent} instance. `undefined` otherwise.
*/
export function getTimeDiffMsecs(agent: Agent | HttpAgent): number | undefined {
if ('getTimeDiffMsecs' in agent) {
return agent.getTimeDiffMsecs();
}
return undefined;
}
106 changes: 80 additions & 26 deletions packages/agent/src/certificate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
import { utf8ToBytes, hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { uint8Equals } from './utils/buffer.ts';

const MINUTES_TO_MSEC = 60 * 1000;

function label(str: string): Cert.NodeLabel {
return utf8ToBytes(str) as Cert.NodeLabel;
}
Expand Down Expand Up @@ -541,12 +543,13 @@ describe('lookup_subtree', () => {
});

// The sample certificate for testing delegation is extracted from the response used in agent-rs tests, where they were taken
// from an interaction with the IC mainnet.
const SAMPLE_CERT: string =
'd9d9f7a364747265658301830182045820250f5e26868d9c1ea7ab29cbe9c15bf1c47c0d7605e803e39e375a7fe09c6ebb830183024e726571756573745f7374617475738301820458204b268227774ec77ff2b37ecb12157329d54cf376694bdd59ded7803efd82386f83025820edad510eaaa08ed2acd4781324e6446269da6753ec17760f206bbe81c465ff528301830183024b72656a6563745f636f64658203410383024e72656a6563745f6d6573736167658203584443616e69737465722069766733372d71696161612d61616161622d61616167612d63616920686173206e6f20757064617465206d6574686f64202772656769737465722783024673746174757382034872656a65637465648204582097232f31f6ab7ca4fe53eb6568fc3e02bc22fe94ab31d010e5fb3c642301f1608301820458203a48d1fc213d49307103104f7d72c2b5930edba8787b90631f343b3aa68a5f0a83024474696d65820349e2dc939091c696eb16697369676e6174757265583089a2be21b5fa8ac9fab1527e041327ce899d7da971436a1f2165393947b4d942365bfe5488710e61a619ba48388a21b16a64656c65676174696f6ea2697375626e65745f6964581dd77b2a2f7199b9a8aec93fe6fb588661358cf12223e9a3af7b4ebac4026b6365727469666963617465590231d9d9f7a26474726565830182045820ae023f28c3b9d966c8fb09f9ed755c828aadb5152e00aaf700b18c9c067294b483018302467375626e6574830182045820e83bb025f6574c8f31233dc0fe289ff546dfa1e49bd6116dd6e8896d90a4946e830182045820e782619092d69d5bebf0924138bd4116b0156b5a95e25c358ea8cf7e7161a661830183018204582062513fa926c9a9ef803ac284d620f303189588e1d3904349ab63b6470856fc4883018204582060e9a344ced2c9c4a96a0197fd585f2d259dbd193e4eada56239cac26087f9c58302581dd77b2a2f7199b9a8aec93fe6fb588661358cf12223e9a3af7b4ebac402830183024f63616e69737465725f72616e6765738203581bd9d9f781824a000000000020000001014a00000000002fffff010183024a7075626c69635f6b657982035885308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361009933e1f89e8a3c4d7fdcccdbd518089e2bd4d8180a261f18d9c247a52768ebce98dc7328a39814a8f911086a1dd50cbe015e2a53b7bf78b55288893daa15c346640e8831d72a12bdedd979d28470c34823b8d1c3f4795d9c3984a247132e94fe82045820996f17bb926be3315745dea7282005a793b58e76afeb5d43d1a28ce29d2d158583024474696d6582034995b8aac0e4eda2ea16697369676e61747572655830ace9fcdd9bc977e05d6328f889dc4e7c99114c737a494653cb27a1f55c06f4555e0f160980af5ead098acc195010b2f7';
// from an interaction with the IC mainnet. The time in the tree is 2022-02-23T07:38:00.652Z.
const SAMPLE_CERT_BYTES: Uint8Array = hexToBytes(
'd9d9f7a364747265658301830182045820250f5e26868d9c1ea7ab29cbe9c15bf1c47c0d7605e803e39e375a7fe09c6ebb830183024e726571756573745f7374617475738301820458204b268227774ec77ff2b37ecb12157329d54cf376694bdd59ded7803efd82386f83025820edad510eaaa08ed2acd4781324e6446269da6753ec17760f206bbe81c465ff528301830183024b72656a6563745f636f64658203410383024e72656a6563745f6d6573736167658203584443616e69737465722069766733372d71696161612d61616161622d61616167612d63616920686173206e6f20757064617465206d6574686f64202772656769737465722783024673746174757382034872656a65637465648204582097232f31f6ab7ca4fe53eb6568fc3e02bc22fe94ab31d010e5fb3c642301f1608301820458203a48d1fc213d49307103104f7d72c2b5930edba8787b90631f343b3aa68a5f0a83024474696d65820349e2dc939091c696eb16697369676e6174757265583089a2be21b5fa8ac9fab1527e041327ce899d7da971436a1f2165393947b4d942365bfe5488710e61a619ba48388a21b16a64656c65676174696f6ea2697375626e65745f6964581dd77b2a2f7199b9a8aec93fe6fb588661358cf12223e9a3af7b4ebac4026b6365727469666963617465590231d9d9f7a26474726565830182045820ae023f28c3b9d966c8fb09f9ed755c828aadb5152e00aaf700b18c9c067294b483018302467375626e6574830182045820e83bb025f6574c8f31233dc0fe289ff546dfa1e49bd6116dd6e8896d90a4946e830182045820e782619092d69d5bebf0924138bd4116b0156b5a95e25c358ea8cf7e7161a661830183018204582062513fa926c9a9ef803ac284d620f303189588e1d3904349ab63b6470856fc4883018204582060e9a344ced2c9c4a96a0197fd585f2d259dbd193e4eada56239cac26087f9c58302581dd77b2a2f7199b9a8aec93fe6fb588661358cf12223e9a3af7b4ebac402830183024f63616e69737465725f72616e6765738203581bd9d9f781824a000000000020000001014a00000000002fffff010183024a7075626c69635f6b657982035885308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361009933e1f89e8a3c4d7fdcccdbd518089e2bd4d8180a261f18d9c247a52768ebce98dc7328a39814a8f911086a1dd50cbe015e2a53b7bf78b55288893daa15c346640e8831d72a12bdedd979d28470c34823b8d1c3f4795d9c3984a247132e94fe82045820996f17bb926be3315745dea7282005a793b58e76afeb5d43d1a28ce29d2d158583024474696d6582034995b8aac0e4eda2ea16697369676e61747572655830ace9fcdd9bc977e05d6328f889dc4e7c99114c737a494653cb27a1f55c06f4555e0f160980af5ead098acc195010b2f7',
);

const parseTimeFromCert = (cert: Uint8Array): Date => {
const certObj = cbor.decode(cert) as { tree: Cert.HashTree };
const certObj = cbor.decode<Cert.Cert>(cert);
if (!certObj.tree) throw new Error('Invalid certificate');
const lookup = Cert.lookupResultToBuffer(Cert.lookup_path(['time'], certObj.tree));
if (!lookup) throw new Error('Invalid certificate');
Expand All @@ -561,7 +564,7 @@ test('date lookup is consistent', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date(Date.parse('2022-02-17T10:17:49.668Z')));

const time = parseTimeFromCert(hexToBytes(SAMPLE_CERT));
const time = parseTimeFromCert(SAMPLE_CERT_BYTES);
dateSet.add(time.toISOString());
nowSet.add(new Date().toISOString());
}
Expand All @@ -578,10 +581,10 @@ test('delegation works for canisters within the subnet range', async () => {
const rangeInterior = Principal.fromHex('000000000020000C0101');
const rangeEnd = Principal.fromHex('00000000002FFFFF0101');
async function verifies(canisterId: Principal) {
jest.setSystemTime(new Date(Date.parse('2022-02-23T07:38:00.652Z')));
jest.setSystemTime(parseTimeFromCert(SAMPLE_CERT_BYTES));
await expect(
Cert.Certificate.create({
certificate: hexToBytes(SAMPLE_CERT),
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId,
blsVerify: async () => true,
Expand All @@ -604,7 +607,7 @@ test('delegation check fails for canisters outside of the subnet range', async (
async function certificateFails(canisterId: Principal) {
try {
await Cert.Certificate.create({
certificate: hexToBytes(SAMPLE_CERT),
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId: canisterId,
});
Expand All @@ -618,16 +621,11 @@ test('delegation check fails for canisters outside of the subnet range', async (
await certificateFails(afterRange);
});

type FakeCert = {
tree: Cert.HashTree;
signature: ArrayBuffer;
delegation?: { subnet_id: ArrayBuffer; certificate: ArrayBuffer };
};

test('certificate verification fails for an invalid signature', async () => {
const badCert: FakeCert = cbor.decode(hexToBytes(SAMPLE_CERT));
badCert.signature = new ArrayBuffer(badCert.signature.byteLength);
const badCert = cbor.decode<Cert.Cert>(SAMPLE_CERT_BYTES);
badCert.signature = new Uint8Array(badCert.signature.byteLength); // replace the signature with an empty bytes array
const badCertEncoded = cbor.encode(badCert);

expect.assertions(2);
try {
await Cert.Certificate.create({
Expand All @@ -642,18 +640,58 @@ test('certificate verification fails for an invalid signature', async () => {
});

test('certificate verification fails if the time of the certificate is > 5 minutes in the past', async () => {
const badCert: FakeCert = cbor.decode(hexToBytes(SAMPLE_CERT));
const badCertEncoded = cbor.encode(badCert);
const tenMinutesFuture = parseTimeFromCert(SAMPLE_CERT_BYTES).getTime() + 10 * MINUTES_TO_MSEC;
jest.setSystemTime(new Date(tenMinutesFuture));
expect.assertions(2);
try {
await Cert.Certificate.create({
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
blsVerify: async () => true,
});
} catch (error) {
expect(error).toBeInstanceOf(TrustError);
expect((error as TrustError).cause.code).toBeInstanceOf(CertificateTimeErrorCode);
}
});

test('certificate verification passes if the time of the certificate is > 5 minutes in the past, but clock drift is provided', async () => {
const tenMinutesFuture = parseTimeFromCert(SAMPLE_CERT_BYTES).getTime() + 10 * MINUTES_TO_MSEC;
jest.setSystemTime(new Date(tenMinutesFuture));
// Simulate a clock drift of 6 minutes in the past, in order to test the clock drift compensation.
// As a result, we expect the current adjusted time to be only 4 minutes in the future compared to the certificate time,
// which is within the default 5 minute max certificate age window.
const timeDiffMsecs = -6 * MINUTES_TO_MSEC;

const cert = await Cert.Certificate.create({
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
blsVerify: async () => true,
timeDiffMsecs,
});
expect(cert).toBeInstanceOf(Cert.Certificate);
});

test('certificate verification fails if the time of the certificate is > max age minutes in the past (clock drift provided)', async () => {
const tenMinutesFuture = parseTimeFromCert(SAMPLE_CERT_BYTES).getTime() + 10 * MINUTES_TO_MSEC;
jest.setSystemTime(new Date(tenMinutesFuture));
const maxAgeInMinutes = 1;
// Simulate a clock drift of 6 minutes in the past, in order to test the clock drift compensation.
// As a result, we expect the current adjusted time to be only 4 minutes in the future compared to the certificate time,
// which is still outside the 1 minute max certificate age window.
const timeDiffMsecs = -6 * MINUTES_TO_MSEC;

const tenMinutesFuture = Date.parse('2022-02-23T07:48:00.652Z');
jest.setSystemTime(tenMinutesFuture);
expect.assertions(2);
try {
await Cert.Certificate.create({
certificate: badCertEncoded,
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
blsVerify: async () => true,
maxAgeInMinutes,
timeDiffMsecs,
});
} catch (error) {
expect(error).toBeInstanceOf(TrustError);
Expand All @@ -662,14 +700,12 @@ test('certificate verification fails if the time of the certificate is > 5 minut
});

test('certificate verification fails if the time of the certificate is > 5 minutes in the future', async () => {
const badCert: FakeCert = cbor.decode(hexToBytes(SAMPLE_CERT));
const badCertEncoded = cbor.encode(badCert);
const tenMinutesPast = Date.parse('2022-02-23T07:28:00.652Z');
jest.setSystemTime(tenMinutesPast);
const tenMinutesPast = parseTimeFromCert(SAMPLE_CERT_BYTES).getTime() - 10 * MINUTES_TO_MSEC;
jest.setSystemTime(new Date(tenMinutesPast));
expect.assertions(2);
try {
await Cert.Certificate.create({
certificate: badCertEncoded,
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
blsVerify: async () => true,
Expand All @@ -680,6 +716,24 @@ test('certificate verification fails if the time of the certificate is > 5 minut
}
});

test('certificate verification fails if the time of the certificate is > 5 minutes in the future, but clock drift is provided', async () => {
const tenMinutesFuture = parseTimeFromCert(SAMPLE_CERT_BYTES).getTime() - 10 * MINUTES_TO_MSEC;
jest.setSystemTime(new Date(tenMinutesFuture));
// Simulate a clock drift of 6 minutes in the future, in order to test the clock drift compensation.
// As a result, we expect the current adjusted time to be only 4 minutes in the past compared to the certificate time,
// which is within the default 5 minute max certificate age window.
const timeDiffMsecs = 6 * MINUTES_TO_MSEC;

const cert = await Cert.Certificate.create({
certificate: SAMPLE_CERT_BYTES,
rootKey: hexToBytes(IC_ROOT_KEY),
canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
blsVerify: async () => true,
timeDiffMsecs,
});
expect(cert).toBeInstanceOf(Cert.Certificate);
});

test('certificate verification fails on nested delegations', async () => {
// This is a recorded certificate from a read_state request to the II
// subnet, with the /subnet tree included. Thus, it could be used as its
Expand Down
Loading
Loading