Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions docs/generated/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ <h1>Agent-JS Changelog</h1>
<section>
<h2>Version x.x.x</h2>
<ul>
<li>
feat: certificate checks validate that certificate time is not more than 5 minutes ahead
of or behind system time.
</li>
<li>
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
</li>
<li>chore: limit npm version to 9 in ci for compatibility with node 16</li>
<li>
Adds more helpful error message for when principal is undefined during actor creation
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/canisterStatus/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
12 changes: 1 addition & 11 deletions packages/agent/src/canisterStatus/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
};
Expand All @@ -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
Expand Down
66 changes: 59 additions & 7 deletions packages/agent/src/certificate.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/**
* 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
*/
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);
Expand Down Expand Up @@ -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<string>();
const nowSet = new Set<string>();
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
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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');
});
53 changes: 50 additions & 3 deletions packages/agent/src/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Certificate> {
Expand All @@ -145,6 +154,7 @@ export class Certificate {
options.rootKey,
options.canisterId,
blsVerify,
options.maxAgeInMinutes,
);
await cert.verify();
return cert;
Expand All @@ -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));
}
Expand All @@ -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) {
Expand All @@ -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']);
Expand Down
13 changes: 13 additions & 0 deletions packages/agent/src/utils/leb.ts
Original file line number Diff line number Diff line change
@@ -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);
};
4 changes: 3 additions & 1 deletion packages/bls-verify/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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),
Expand Down