diff --git a/docs/generated/changelog.html b/docs/generated/changelog.html
index b24d7f30a..a100a90d0 100644
--- a/docs/generated/changelog.html
+++ b/docs/generated/changelog.html
@@ -12,6 +12,14 @@
Agent-JS Changelog
Version x.x.x
+ -
+ feat: certificate checks validate that certificate time is not more than 5 minutes ahead
+ of or behind system time.
+
+ -
+ feat: two new `leb` decoding utils added to @dfinity/agent/utils/leb to make it simpler to
+ decode leb values and time from a certificate tree
+
- chore: limit npm version to 9 in ci for compatibility with node 16
-
Adds more helpful error message for when principal is undefined during actor creation
diff --git a/packages/agent/src/canisterStatus/index.test.ts b/packages/agent/src/canisterStatus/index.test.ts
index 51e55a7a0..15b4943f3 100644
--- a/packages/agent/src/canisterStatus/index.test.ts
+++ b/packages/agent/src/canisterStatus/index.test.ts
@@ -16,6 +16,10 @@ jest.mock('../utils/bls', () => {
};
});
+jest.useFakeTimers();
+const certificateTime = Date.parse('2022-05-19T20:58:22.596Z');
+jest.setSystemTime(certificateTime);
+
// Utils
const encoder = new TextEncoder();
const encode = (arg: string): ArrayBuffer => {
diff --git a/packages/agent/src/canisterStatus/index.ts b/packages/agent/src/canisterStatus/index.ts
index 3be9b9e9e..6ff87eab9 100644
--- a/packages/agent/src/canisterStatus/index.ts
+++ b/packages/agent/src/canisterStatus/index.ts
@@ -1,12 +1,12 @@
/** @module CanisterStatus */
-import { lebDecode, PipeArrayBuffer } from '@dfinity/candid';
import { Principal } from '@dfinity/principal';
import { AgentError } from '../errors';
import { HttpAgent } from '../agent/http';
import { Certificate, CreateCertificateOptions } from '../certificate';
import { toHex } from '../utils/buffer';
import * as Cbor from '../cbor';
+import { decodeLeb128, decodeTime } from '../utils/leb';
/**
* Types of an entry on the canisterStatus map.
@@ -220,10 +220,6 @@ const decodeHex = (buf: ArrayBuffer): string => {
return toHex(buf);
};
-const decodeLeb128 = (buf: ArrayBuffer): bigint => {
- return lebDecode(new PipeArrayBuffer(buf));
-};
-
const decodeCbor = (buf: ArrayBuffer): ArrayBuffer[] => {
return Cbor.decode(buf);
};
@@ -232,12 +228,6 @@ const decodeUtf8 = (buf: ArrayBuffer): string => {
return new TextDecoder().decode(buf);
};
-// time is a LEB128-encoded Nat
-const decodeTime = (buf: ArrayBuffer): Date => {
- const decoded = decodeLeb128(buf);
- return new Date(Number(decoded / BigInt(1_000_000)));
-};
-
// Controllers are CBOR-encoded buffers, starting with a Tag we don't need
const decodeControllers = (buf: ArrayBuffer): Principal[] => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
diff --git a/packages/agent/src/certificate.test.ts b/packages/agent/src/certificate.test.ts
index 771c58739..29c7b2db2 100644
--- a/packages/agent/src/certificate.test.ts
+++ b/packages/agent/src/certificate.test.ts
@@ -1,5 +1,5 @@
/**
- * Need this to setup the proper ArrayBuffer type (otherwise in Jest ArrayBuffer isn't
+ * Need this to setup the proper ArrayBuffer type (otherwise in jest ArrayBuffer isn't
* an instance of ArrayBuffer).
* @jest-environment node
*/
@@ -7,7 +7,8 @@ import * as cbor from './cbor';
import * as Cert from './certificate';
import { fromHex, toHex } from './utils/buffer';
import { Principal } from '@dfinity/principal';
-import { NodeBuilderFlags } from 'typescript';
+import { decodeTime } from './utils/leb';
+import { lookup_path } from './certificate';
function label(str: string): ArrayBuffer {
return new TextEncoder().encode(str);
@@ -139,6 +140,30 @@ test('lookup', () => {
const SAMPLE_CERT: string =
'd9d9f7a364747265658301830182045820250f5e26868d9c1ea7ab29cbe9c15bf1c47c0d7605e803e39e375a7fe09c6ebb830183024e726571756573745f7374617475738301820458204b268227774ec77ff2b37ecb12157329d54cf376694bdd59ded7803efd82386f83025820edad510eaaa08ed2acd4781324e6446269da6753ec17760f206bbe81c465ff528301830183024b72656a6563745f636f64658203410383024e72656a6563745f6d6573736167658203584443616e69737465722069766733372d71696161612d61616161622d61616167612d63616920686173206e6f20757064617465206d6574686f64202772656769737465722783024673746174757382034872656a65637465648204582097232f31f6ab7ca4fe53eb6568fc3e02bc22fe94ab31d010e5fb3c642301f1608301820458203a48d1fc213d49307103104f7d72c2b5930edba8787b90631f343b3aa68a5f0a83024474696d65820349e2dc939091c696eb16697369676e6174757265583089a2be21b5fa8ac9fab1527e041327ce899d7da971436a1f2165393947b4d942365bfe5488710e61a619ba48388a21b16a64656c65676174696f6ea2697375626e65745f6964581dd77b2a2f7199b9a8aec93fe6fb588661358cf12223e9a3af7b4ebac4026b6365727469666963617465590231d9d9f7a26474726565830182045820ae023f28c3b9d966c8fb09f9ed755c828aadb5152e00aaf700b18c9c067294b483018302467375626e6574830182045820e83bb025f6574c8f31233dc0fe289ff546dfa1e49bd6116dd6e8896d90a4946e830182045820e782619092d69d5bebf0924138bd4116b0156b5a95e25c358ea8cf7e7161a661830183018204582062513fa926c9a9ef803ac284d620f303189588e1d3904349ab63b6470856fc4883018204582060e9a344ced2c9c4a96a0197fd585f2d259dbd193e4eada56239cac26087f9c58302581dd77b2a2f7199b9a8aec93fe6fb588661358cf12223e9a3af7b4ebac402830183024f63616e69737465725f72616e6765738203581bd9d9f781824a000000000020000001014a00000000002fffff010183024a7075626c69635f6b657982035885308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361009933e1f89e8a3c4d7fdcccdbd518089e2bd4d8180a261f18d9c247a52768ebce98dc7328a39814a8f911086a1dd50cbe015e2a53b7bf78b55288893daa15c346640e8831d72a12bdedd979d28470c34823b8d1c3f4795d9c3984a247132e94fe82045820996f17bb926be3315745dea7282005a793b58e76afeb5d43d1a28ce29d2d158583024474696d6582034995b8aac0e4eda2ea16697369676e61747572655830ace9fcdd9bc977e05d6328f889dc4e7c99114c737a494653cb27a1f55c06f4555e0f160980af5ead098acc195010b2f7';
+const parseTimeFromCert = (cert: ArrayBuffer): Date => {
+ const certObj = cbor.decode(new Uint8Array(cert)) as any;
+ if (!certObj.tree) throw new Error('Invalid certificate');
+ const lookup = lookup_path(['time'], certObj.tree);
+ if (!lookup) throw new Error('Invalid certificate');
+
+ return decodeTime(lookup);
+};
+
+test('date lookup is consistent', async () => {
+ const dateSet = new Set();
+ const nowSet = new Set();
+ for (let i = 0; i < 100; i++) {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date(Date.parse('2022-02-17T10:17:49.668Z')));
+
+ const time = parseTimeFromCert(fromHex(SAMPLE_CERT));
+ dateSet.add(time.toISOString());
+ nowSet.add(new Date().toISOString());
+ }
+ expect(dateSet.size).toEqual(1);
+ expect(nowSet.size).toEqual(1);
+});
+
test('delegation works for canisters within the subnet range', async () => {
// The certificate specifies the range from
// 0x00000000002000000101
@@ -148,6 +173,7 @@ test('delegation works for canisters within the subnet range', async () => {
const rangeInterior = Principal.fromHex('000000000020000C0101');
const rangeEnd = Principal.fromHex('00000000002FFFFF0101');
async function verifies(canisterId) {
+ jest.setSystemTime(new Date(Date.parse('2022-02-23T07:38:00.652Z')));
await expect(
Cert.Certificate.create({
certificate: fromHex(SAMPLE_CERT),
@@ -161,10 +187,6 @@ test('delegation works for canisters within the subnet range', async () => {
await verifies(rangeEnd);
});
-function fail(reason) {
- throw new Error(reason);
-}
-
test('delegation check fails for canisters outside of the subnet range', async () => {
// Use a different principal than the happy path, which isn't in the delegation ranges.
// The certificate specifies the range from
@@ -193,7 +215,7 @@ type FakeCert = {
};
test('certificate verification fails for an invalid signature', async () => {
- let badCert: FakeCert = cbor.decode(fromHex(SAMPLE_CERT));
+ const badCert: FakeCert = cbor.decode(fromHex(SAMPLE_CERT));
badCert.signature = new ArrayBuffer(badCert.signature.byteLength);
const badCertEncoded = cbor.encode(badCert);
await expect(
@@ -204,3 +226,33 @@ test('certificate verification fails for an invalid signature', async () => {
}),
).rejects.toThrow('Invalid certificate');
});
+
+test('certificate verification fails if the time of the certificate is > 5 minutes in the past', async () => {
+ const badCert: FakeCert = cbor.decode(fromHex(SAMPLE_CERT));
+ const badCertEncoded = cbor.encode(badCert);
+
+ const tenMinutesFuture = Date.parse('2022-02-23T07:48:00.652Z');
+ jest.setSystemTime(tenMinutesFuture);
+ await expect(
+ Cert.Certificate.create({
+ certificate: badCertEncoded,
+ rootKey: fromHex(IC_ROOT_KEY),
+ canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
+ }),
+ ).rejects.toThrow('Invalid certificate: Certificate is signed more than 5 minutes in the past');
+});
+
+test('certificate verification fails if the time of the certificate is > 5 minutes in the future', async () => {
+ const badCert: FakeCert = cbor.decode(fromHex(SAMPLE_CERT));
+ const badCertEncoded = cbor.encode(badCert);
+ const tenMinutesPast = Date.parse('2022-02-23T07:28:00.652Z');
+ jest.setSystemTime(tenMinutesPast);
+
+ await expect(
+ Cert.Certificate.create({
+ certificate: badCertEncoded,
+ rootKey: fromHex(IC_ROOT_KEY),
+ canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
+ }),
+ ).rejects.toThrow('Invalid certificate: Certificate is signed more than 5 minutes in the future');
+});
diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts
index 4ea0f07f9..1e8576413 100644
--- a/packages/agent/src/certificate.ts
+++ b/packages/agent/src/certificate.ts
@@ -4,6 +4,7 @@ import { hash } from './request_id';
import { concat, fromHex, toHex } from './utils/buffer';
import { Principal } from '@dfinity/principal';
import * as bls from './utils/bls';
+import { decodeTime } from './utils/leb';
/**
* A certificate may fail verification with respect to the provided public key
@@ -119,6 +120,14 @@ export interface CreateCertificateOptions {
* BLS Verification strategy. Default strategy uses wasm for performance, but that may not be available in all contexts.
*/
blsVerify?: VerifyFunc;
+
+ /**
+ * The maximum age of the certificate in minutes. Default is 5 minutes.
+ * @default 5
+ * This is used to verify the time the certificate was signed, particularly for validating Delegation certificates, which can live for longer than the default window of +/- 5 minutes. If the certificate is
+ * older than the specified age, it will fail verification.
+ */
+ maxAgeInMinutes?: number;
}
export class Certificate {
@@ -127,12 +136,12 @@ export class Certificate {
/**
* Create a new instance of a certificate, automatically verifying it. Throws a
* CertificateVerificationError if the certificate cannot be verified.
- * @constructs {@link AuthClient}
- * @param {CreateCertificateOptions} options
- * @see {@link CreateCertificateOptions}
+ * @constructs Certificate
+ * @param {CreateCertificateOptions} options {@link CreateCertificateOptions}
* @param {ArrayBuffer} options.certificate The bytes of the certificate
* @param {ArrayBuffer} options.rootKey The root key to verify against
* @param {Principal} options.canisterId The effective or signing canister ID
+ * @param {number} options.maxAgeInMinutes The maximum age of the certificate in minutes. Default is 5 minutes.
* @throws {CertificateVerificationError}
*/
public static async create(options: CreateCertificateOptions): Promise {
@@ -145,6 +154,7 @@ export class Certificate {
options.rootKey,
options.canisterId,
blsVerify,
+ options.maxAgeInMinutes,
);
await cert.verify();
return cert;
@@ -155,6 +165,8 @@ export class Certificate {
private _rootKey: ArrayBuffer,
private _canisterId: Principal,
private _blsVerify: VerifyFunc,
+ // Default to 5 minutes
+ private _maxAgeInMinutes: number = 5,
) {
this.cert = cbor.decode(new Uint8Array(certificate));
}
@@ -170,6 +182,37 @@ export class Certificate {
const key = extractDER(derKey);
const msg = concat(domain_sep('ic-state-root'), rootHash);
let sigVer = false;
+
+ const lookupTime = this.lookup(['time']);
+ if (!lookupTime) {
+ // Should never happen - time is always present in IC certificates
+ throw new CertificateVerificationError('Certificate does not contain a time');
+ }
+
+ const FIVE_MINUTES_IN_MSEC = 5 * 60 * 1000;
+ const MAX_AGE_IN_MSEC = this._maxAgeInMinutes * 60 * 1000;
+ const now = Date.now();
+ const earliestCertificateTime = now - MAX_AGE_IN_MSEC;
+ const fiveMinutesFromNow = now + FIVE_MINUTES_IN_MSEC;
+
+ const certTime = decodeTime(lookupTime);
+
+ if (certTime.getTime() < earliestCertificateTime) {
+ throw new CertificateVerificationError(
+ `Certificate is signed more than ${this._maxAgeInMinutes} minutes in the past. Certificate time: ` +
+ certTime.toISOString() +
+ ' Current time: ' +
+ new Date(now).toISOString(),
+ );
+ } else if (certTime.getTime() > fiveMinutesFromNow) {
+ throw new CertificateVerificationError(
+ 'Certificate is signed more than 5 minutes in the future. Certificate time: ' +
+ certTime.toISOString() +
+ ' Current time: ' +
+ new Date(now).toISOString(),
+ );
+ }
+
try {
sigVer = await this._blsVerify(new Uint8Array(key), new Uint8Array(sig), new Uint8Array(msg));
} catch (err) {
@@ -184,10 +227,14 @@ export class Certificate {
if (!d) {
return this._rootKey;
}
+
const cert: Certificate = await Certificate.create({
certificate: d.certificate,
rootKey: this._rootKey,
canisterId: this._canisterId,
+ blsVerify: this._blsVerify,
+ // Maximum age of 30 days for delegation certificates
+ maxAgeInMinutes: 60 * 24 * 30,
});
const rangeLookup = cert.lookup(['subnet', d.subnet_id, 'canister_ranges']);
diff --git a/packages/agent/src/utils/leb.ts b/packages/agent/src/utils/leb.ts
new file mode 100644
index 000000000..d7653f2b0
--- /dev/null
+++ b/packages/agent/src/utils/leb.ts
@@ -0,0 +1,13 @@
+import { PipeArrayBuffer, lebDecode } from '@dfinity/candid';
+
+export const decodeLeb128 = (buf: ArrayBuffer): bigint => {
+ return lebDecode(new PipeArrayBuffer(buf));
+};
+
+// time is a LEB128-encoded Nat
+export const decodeTime = (buf: ArrayBuffer): Date => {
+ const decoded = decodeLeb128(buf);
+
+ // nanoseconds to milliseconds
+ return new Date(Number(decoded) / 1_000_000);
+};
diff --git a/packages/bls-verify/src/index.test.ts b/packages/bls-verify/src/index.test.ts
index 77e9635b4..fa835156f 100644
--- a/packages/bls-verify/src/index.test.ts
+++ b/packages/bls-verify/src/index.test.ts
@@ -1,7 +1,7 @@
import { blsVerify } from './index';
import * as Cert from '../../agent/src/certificate';
import * as cbor from '../../agent/src/cbor';
-import { fromHex, toHex } from '../../agent/src/utils/buffer';
+import { fromHex } from '../../agent/src/utils/buffer';
import { Principal } from '@dfinity/principal';
// Root public key for the IC main net, encoded as hex
@@ -27,7 +27,9 @@ test('delegation works for canisters within the subnet range', async () => {
const rangeStart = Principal.fromHex('00000000002000000101');
const rangeInterior = Principal.fromHex('000000000020000C0101');
const rangeEnd = Principal.fromHex('00000000002FFFFF0101');
+ jest.useFakeTimers();
async function verifies(canisterId) {
+ jest.setSystemTime(new Date(Date.parse('2022-02-23T07:38:00.652Z')));
await expect(
Cert.Certificate.create({
certificate: fromHex(SAMPLE_CERT),