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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- feat(agent): introduce the `getSubnetIdFromCertificate` method in the `Certificate` namespace to retrieve the subnet ID from a certificate
- feat(agent): introduce the `SubnetStatus` utility namespace to request subnet information directly from the IC public API
- feat(agent): export `IC_STATE_ROOT_DOMAIN_SEPARATOR` constant
- fix(agent): check if canister is in ranges for certificates without delegation
- refactor(agent): only declare IC URLs once in the `HttpAgent` class
- refactor(agent): split inner logic of `check_canister_ranges` into functions
- test(principal): remove unneeded dependency
Expand Down
152 changes: 152 additions & 0 deletions e2e/node/basic/fetchSubnetKeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { vi, expect, it, beforeEach, describe } from 'vitest';
import { CertificateNotAuthorizedErrorCode, HttpAgent, TrustError } from '@icp-sdk/core/agent';
import { Principal } from '@icp-sdk/core/principal';
import {
MockReplica,
prepareV3ReadStateResponse,
prepareV3ReadStateRootSubnetResponse,
} from '../utils/mock-replica.ts';
import { randomIdentity, randomKeyPair } from '../utils/identity.ts';

describe('fetchSubnetKeys (root key, no delegation)', () => {
const now = new Date('2025-12-16T06:34:56.789Z');
const canisterId = Principal.fromText('v2nog-2aaaa-aaaab-p777q-cai');

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

let mockReplica: MockReplica;

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

vi.setSystemTime(now);
});

it('should verify that the canister is in the allowed canister ranges', async () => {
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: rootSubnetKeyPair.publicKeyDer,
identity,
});

const { responseBody: readStateResponseBody } = await prepareV3ReadStateRootSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
rootSubnetKeyPair,
date: now,
});
mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(readStateResponseBody);
});

const nodeKeys = await agent.fetchSubnetKeys(canisterId);

const expectedNodeKey = nodeIdentity.getPublicKey().toDer();
expect(nodeKeys.get(nodeIdentity.getPrincipal().toText())).toEqual(expectedNodeKey);
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1);
});

it('should throw if the canister is not in the allowed canister ranges', async () => {
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: rootSubnetKeyPair.publicKeyDer,
identity,
});
const anotherCanisterId = Principal.fromText('jrlun-jiaaa-aaaab-aaaaa-cai');

const { responseBody: readStateResponseBody } = await prepareV3ReadStateRootSubnetResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
rootSubnetKeyPair,
date: now,
});
mockReplica.setV3ReadStateSpyImplOnce(anotherCanisterId.toString(), (_req, res) => {
res.status(200).send(readStateResponseBody);
});

await expect(agent.fetchSubnetKeys(anotherCanisterId)).rejects.toThrow(
TrustError.fromCode(
new CertificateNotAuthorizedErrorCode(
anotherCanisterId,
Principal.selfAuthenticating(rootSubnetKeyPair.publicKeyDer),
),
),
);
expect(mockReplica.getV3ReadStateSpy(anotherCanisterId.toString())).toHaveBeenCalledTimes(1);
});
});

describe('fetchSubnetKeys (delegated subnet)', () => {
const now = new Date('2025-12-16T06:34:56.789Z');
const canisterId = Principal.fromText('v2nog-2aaaa-aaaab-p777q-cai');

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

let mockReplica: MockReplica;

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

vi.setSystemTime(now);
});

it('should verify that the canister is in the allowed canister ranges', async () => {
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: rootSubnetKeyPair.publicKeyDer,
identity,
});

const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
rootSubnetKeyPair,
keyPair: subnetKeyPair,
date: now,
});
mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => {
res.status(200).send(readStateResponseBody);
});

const nodeKeys = await agent.fetchSubnetKeys(canisterId);

const expectedNodeKey = nodeIdentity.getPublicKey().toDer();
expect(nodeKeys.get(nodeIdentity.getPrincipal().toText())).toEqual(expectedNodeKey);
expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1);
});

it('should throw if the canister is not in the allowed canister ranges', async () => {
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: rootSubnetKeyPair.publicKeyDer,
identity,
});
const anotherCanisterId = Principal.fromText('jrlun-jiaaa-aaaab-aaaaa-cai');

const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({
nodeIdentity,
canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]],
rootSubnetKeyPair,
keyPair: subnetKeyPair,
date: now,
});
mockReplica.setV3ReadStateSpyImplOnce(anotherCanisterId.toString(), (_req, res) => {
res.status(200).send(readStateResponseBody);
});

await expect(agent.fetchSubnetKeys(anotherCanisterId)).rejects.toThrow(
TrustError.fromCode(
new CertificateNotAuthorizedErrorCode(
anotherCanisterId,
Principal.selfAuthenticating(subnetKeyPair.publicKeyDer),
),
),
);
expect(mockReplica.getV3ReadStateSpy(anotherCanisterId.toString())).toHaveBeenCalledTimes(1);
});
});
43 changes: 42 additions & 1 deletion e2e/node/utils/mock-replica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
import { Principal } from '@icp-sdk/core/principal';
import { Ed25519KeyIdentity } from '@icp-sdk/core/identity';
import { Mock, vi } from 'vitest';
import { createReplyTree, createSubnetTree } from './tree.ts';
import { createReplyTree, createRootSubnetTree, createSubnetTree } from './tree.ts';
import { randomKeyPair, signBls, KeyPair, randomIdentity } from './identity.ts';
import { concatBytes, toBytes } from '@noble/hashes/utils';

Expand Down Expand Up @@ -404,6 +404,47 @@ export async function prepareV3ReadStateResponse({
};
}

/**
* Prepares a version 3 read state response for the root subnet.
* The difference is that the certificate does not have a delegation.
* @param {V3ReadStateOptions} options - The options for preparing the response.
* @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node.
* @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the root subnet.
* @param {KeyPair} options.rootSubnetKeyPair - The key pair for signing the root subnet.
* @param {Date} options.date - The date for the response.
* @returns {Promise<V3ReadStateResponse>} A promise that resolves to the prepared response.
*/
export async function prepareV3ReadStateRootSubnetResponse({
nodeIdentity,
canisterRanges,
rootSubnetKeyPair,
date,
}: Omit<V3ReadStateOptions, 'keyPair'>): Promise<V3ReadStateResponse> {
date = date ?? new Date();
const subnetId = Principal.selfAuthenticating(rootSubnetKeyPair.publicKeyDer).toUint8Array();

const tree = createRootSubnetTree({
subnetId,
subnetPublicKey: rootSubnetKeyPair.publicKeyDer,
nodeIdentity,
canisterRanges,
date,
});
const signature = await signTree(tree, rootSubnetKeyPair);

const cert: Cert = {
tree,
signature,
};
const responseBody: ReadStateResponse = {
certificate: Cbor.encode(cert),
};

return {
responseBody: Cbor.encode(responseBody),
};
}

interface V3QueryResponseOptions {
canisterId: Principal | string;
methodName: string;
Expand Down
78 changes: 65 additions & 13 deletions e2e/node/utils/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ interface SubnetTreeOptions {

/**
* Creates a subnet hash tree.
* @see https://internetcomputer.org/docs/references/ic-interface-spec#state-tree-canister-ranges
* @param {SubnetTreeOptions} options - The options for the subnet tree.
* @param {Uint8Array} options.subnetId - The ID of the subnet.
* @param {Uint8Array} options.subnetPublicKey - The DER-encoded public key of the subnet.
Expand All @@ -139,29 +140,80 @@ export function createSubnetTree({
canisterRanges,
date,
}: SubnetTreeOptions): HashTree {
let subnetSubtree: HashTree;

const canisterRangesSubtree = labeled('canister_ranges', leaf(Cbor.encode(canisterRanges)));
const publicKeySubtree = labeled('public_key', leaf(subnetPublicKey));

let subnetSubtree: HashTree = publicKeySubtree;
if (nodeIdentity) {
// prettier-ignore
subnetSubtree = fork(
fork(
canisterRangesSubtree,
labeled('node',
labeled(nodeIdentity.getPrincipal().toUint8Array(),
labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())),
),
labeled('node',
labeled(nodeIdentity.getPrincipal().toUint8Array(),
labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())),
),
),
publicKeySubtree,
subnetSubtree,
);
} else {
}

// prettier-ignore
let subnetTree: HashTree = labeled(
'subnet',
labeled(subnetId,
subnetSubtree,
),
);
if (canisterRanges.length > 0) {
// On mainnet, canister ranges should always be present for delegated subnets.
// Sometimes it's easier in tests to just not include them, unless we are testing the canister ranges checks.
// prettier-ignore
subnetTree = fork(
labeled('canister_ranges',
labeled(subnetId,
labeled(canisterRanges[0][0], leaf(Cbor.encode(canisterRanges))),
),
),
subnetTree,
);
}

// prettier-ignore
return fork(
subnetTree,
createTimeTree(date),
);
}

/**
* Creates a root subnet hash tree.
* @see https://internetcomputer.org/docs/references/ic-interface-spec#state-tree-canister-ranges
* @param {SubnetTreeOptions} options - The options for the root subnet tree.
* @param {Uint8Array} options.subnetId - The ID of the root subnet.
* @param {Uint8Array} options.subnetPublicKey - The DER-encoded public key of the root subnet.
* @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. Omit this for delegation trees.
* @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the root subnet.
* @param {Date} options.date - The timestamp for the tree.
* @returns {HashTree} A root subnet hash tree.
*/
export function createRootSubnetTree({
subnetId,
subnetPublicKey,
nodeIdentity,
canisterRanges,
date,
}: SubnetTreeOptions): HashTree {
const publicKeySubtree = labeled('public_key', leaf(subnetPublicKey));
const canisterRangesSubtree = labeled('canister_ranges', leaf(Cbor.encode(canisterRanges)));

let subnetSubtree: HashTree = fork(publicKeySubtree, canisterRangesSubtree);
if (nodeIdentity) {
// prettier-ignore
subnetSubtree = fork(
canisterRangesSubtree,
publicKeySubtree,
labeled('node',
labeled(nodeIdentity.getPrincipal().toUint8Array(),
labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())),
),
),
subnetSubtree,
);
}

Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/agent/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
AgentError,
MalformedLookupFoundValueErrorCode,
CertificateOutdatedErrorCode,
CertificateNotAuthorizedErrorCode,
} from '../../errors.ts';
import { AnonymousIdentity, type Identity } from '../../auth.ts';
import * as cbor from '../../cbor.ts';
Expand Down Expand Up @@ -60,6 +61,7 @@ import { request as subnetStatusRequest } from '../../subnetStatus/index.ts';
import { lookupNodeKeysFromCertificate, type SubnetNodeKeys } from '../../utils/readState.ts';
import {
Certificate,
check_canister_ranges,
getSubnetIdFromCertificate,
type HashTree,
lookup_path,
Expand Down Expand Up @@ -1436,6 +1438,19 @@ export class HttpAgent implements Agent {
principal: { canisterId: effectiveCanisterId },
agent: this,
});
if (!canisterCertificate.cert.delegation) {
const subnetId = Principal.selfAuthenticating(rootKey);
const canisterInRange = check_canister_ranges({
canisterId: effectiveCanisterId,
subnetId,
tree: canisterCertificate.cert.tree,
});
if (!canisterInRange) {
throw TrustError.fromCode(
new CertificateNotAuthorizedErrorCode(effectiveCanisterId, subnetId),
);
}
}

const subnetId = getSubnetIdFromCertificate(canisterCertificate.cert, rootKey);
const nodeKeys = lookupNodeKeysFromCertificate(canisterCertificate.cert, subnetId);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,15 +1002,15 @@ function getCanisterRangeShardPaths(canisterRangeShards: HashTree): Array<NodeLa
* - Elements at indices [partitionPoint, length) have values < canisterId
* @param shardPaths Sorted array of shard paths to search through
* @param canisterId The canister ID to compare against
* @returns The index of the first shard that is less than the canister ID, or shardPaths.length if all shards are >= canisterId
* @returns The index of the first shard that is less than the canister ID, or last index if all shards are >= canisterId
*/
function getCanisterRangeShardPartitionPoint(
shardPaths: Array<NodeLabel>,
canisterId: Principal,
): number {
const canisterIdBytes = canisterId.toUint8Array();
let left = 0;
let right = shardPaths.length;
let right = shardPaths.length - 1;

// Binary search for the first element where shard < canisterId
while (left < right) {
Expand Down
Loading