diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc5e56e9..fb79fdd06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Renames `v3ResponseBody` to `v4ResponseBody` - Renames `isV3ResponseBody` to `isV4ResponseBody` - Renames `HttpV3ApiNotSupportedErrorCode` to `HttpV4ApiNotSupportedErrorCode` +- feat(agent)!: supports both subnet id and canister id for certificate verification + - The `canisterId` option has been replaced with the `principal` option in the `Certificate.create` options object - feat(assets)!: replaces `@dfinity/{agent,candid,principal}` deps with `@icp-sdk/core` - feat(assets)!: drops support for cjs for the `@dfinity/assets` package - feat(auth-client)!: `@dfinity/auth-client` has been deprecated. Migrate to [`@icp-sdk/auth`](https://js.icp.build/auth/latest/upgrading/v4) diff --git a/e2e/node/basic/basic.test.ts b/e2e/node/basic/basic.test.ts index 14ba8f97b..3075d3996 100644 --- a/e2e/node/basic/basic.test.ts +++ b/e2e/node/basic/basic.test.ts @@ -33,7 +33,7 @@ test('read_state', async () => { const cert = await Certificate.create({ certificate: response.certificate, rootKey: resolvedAgent.rootKey, - canisterId: ecid, + principal: { canisterId: ecid }, }); const timeLookup = cert.lookup_path(validTimePath); @@ -67,7 +67,7 @@ test('read_state with passed request', async () => { const cert = await Certificate.create({ certificate: response.certificate, rootKey: resolvedAgent.rootKey, - canisterId: canisterId, + principal: { canisterId: canisterId }, }); expect(cert.lookup_path([utf8ToBytes('Time')])).toEqual({ status: LookupPathStatus.Unknown, diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts index fd66fa3d6..c62bc73cc 100644 --- a/packages/assets/src/index.ts +++ b/packages/assets/src/index.ts @@ -532,7 +532,7 @@ class Asset { const cert = await Certificate.create({ certificate, rootKey: agent.rootKey, - canisterId, + principal: { canisterId }, }).catch(() => Promise.resolve()); if (!cert) { diff --git a/packages/core/src/agent/actor.test.ts b/packages/core/src/agent/actor.test.ts index ceb8917f5..429ae348b 100644 --- a/packages/core/src/agent/actor.test.ts +++ b/packages/core/src/agent/actor.test.ts @@ -599,7 +599,13 @@ describe('makeActor', () => { // Assert Certificate.create was called with the effectiveCanisterId expect(certificateCreateMock).toHaveBeenCalledTimes(1); const callArg = certificateCreateMock.mock.calls[0][0]; - expect(Principal.from(callArg.canisterId).toText()).toBe(effectiveCanisterId.toText()); + if ('canisterId' in callArg.principal) { + expect(Principal.from(callArg.principal.canisterId).toText()).toBe( + effectiveCanisterId.toText(), + ); + } else { + fail('subnetId should not be used for update calls'); + } }); it('should verify certificate using canisterId when effectiveCanisterId is not provided', async () => { @@ -672,7 +678,11 @@ describe('makeActor', () => { // Assert Certificate.create was called with the target canisterId (since no effectiveCanisterId provided) expect(certificateCreateMock).toHaveBeenCalledTimes(1); const callArg = certificateCreateMock.mock.calls[0][0]; - expect(Principal.from(callArg.canisterId).toText()).toBe(canisterId.toText()); + if ('canisterId' in callArg.principal) { + expect(Principal.from(callArg.principal.canisterId).toText()).toBe(canisterId.toText()); + } else { + fail('subnetId should not be used for update calls'); + } }); it('should verify certificate using effectiveCanisterId passed via withOptions for update calls', async () => { @@ -746,7 +756,13 @@ describe('makeActor', () => { // Assert Certificate.create was called with the effectiveCanisterId provided via withOptions expect(certificateCreateMock).toHaveBeenCalledTimes(1); const callArg = certificateCreateMock.mock.calls[0][0]; - expect(Principal.from(callArg.canisterId).toText()).toBe(effectiveCanisterId.toText()); + if ('canisterId' in callArg.principal) { + expect(Principal.from(callArg.principal.canisterId).toText()).toBe( + effectiveCanisterId.toText(), + ); + } else { + fail('subnetId should not be used for update calls'); + } }); }); diff --git a/packages/core/src/agent/actor.ts b/packages/core/src/agent/actor.ts index 59d511b0d..c822c8790 100644 --- a/packages/core/src/agent/actor.ts +++ b/packages/core/src/agent/actor.ts @@ -460,7 +460,7 @@ function _createActorMethod( certificate = await Certificate.create({ certificate: cert, rootKey: agent.rootKey, - canisterId: ecid, + principal: { canisterId: ecid }, blsVerify, agent, }); diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index af32df9b7..e95381e6f 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -1396,7 +1396,7 @@ export class HttpAgent implements Agent { const canisterCertificate = await Certificate.create({ certificate: canisterReadState.certificate, rootKey: this.rootKey!, - canisterId: effectiveCanisterId, + principal: { canisterId: effectiveCanisterId }, agent: this, }); diff --git a/packages/core/src/agent/canisterStatus/index.test.ts b/packages/core/src/agent/canisterStatus/index.test.ts index 843c57801..3d5b9076f 100644 --- a/packages/core/src/agent/canisterStatus/index.test.ts +++ b/packages/core/src/agent/canisterStatus/index.test.ts @@ -172,7 +172,7 @@ describe('node keys', () => { jest.setSystemTime(new Date(Date.parse('2023-09-27T19:38:58.129Z'))); await Cert.Certificate.create({ certificate: hexToBytes(mainnetApplicationLegacy), - canisterId: Principal.fromText('erxue-5aaaa-aaaab-qaagq-cai'), + principal: { canisterId: Principal.fromText('erxue-5aaaa-aaaab-qaagq-cai') }, rootKey: hexToBytes(IC_ROOT_KEY), }); @@ -190,7 +190,7 @@ describe('node keys', () => { jest.setSystemTime(new Date(Date.parse('2023-09-27T19:58:19.412Z'))); await Cert.Certificate.create({ certificate: hexToBytes(mainnetSystem), - canisterId: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai'), + principal: { canisterId: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai') }, rootKey: hexToBytes(IC_ROOT_KEY), }); @@ -208,7 +208,7 @@ describe('node keys', () => { jest.setSystemTime(new Date(Date.parse('2023-09-27T20:14:59.406Z'))); await Cert.Certificate.create({ certificate: hexToBytes(localApplication), - canisterId: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai'), + principal: { canisterId: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai') }, rootKey: hexToBytes(IC_ROOT_KEY), }); @@ -226,7 +226,7 @@ describe('node keys', () => { jest.setSystemTime(new Date(Date.parse('2023-09-27T20:15:03.406Z'))); await Cert.Certificate.create({ certificate: hexToBytes(localSystem), - canisterId: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai'), + principal: { canisterId: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai') }, rootKey: hexToBytes(IC_ROOT_KEY), }); diff --git a/packages/core/src/agent/canisterStatus/index.ts b/packages/core/src/agent/canisterStatus/index.ts index 81b66fbf0..31fe65746 100644 --- a/packages/core/src/agent/canisterStatus/index.ts +++ b/packages/core/src/agent/canisterStatus/index.ts @@ -175,7 +175,7 @@ export const request = async (options: CanisterStatusOptions): Promise { Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId, + principal: { canisterId }, blsVerify: async () => true, }), ).resolves.not.toThrow(); @@ -620,7 +620,7 @@ test('delegation check fails for canisters outside of the subnet range', async ( await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: canisterId, + principal: { canisterId }, }); } catch (error) { expect(error).toBeInstanceOf(TrustError); @@ -644,7 +644,7 @@ test('certificate verification fails for an invalid signature', async () => { await Cert.Certificate.create({ certificate: badCertEncoded, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, }); } catch (error) { expect(error).toBeInstanceOf(TrustError); @@ -660,7 +660,7 @@ test('certificate verification fails if the time of the certificate is > 5 minut await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, }); } catch (error) { @@ -683,7 +683,7 @@ test('certificate creation passes if the time of the certificate is > 5 minutes const cert = await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, disableTimeVerification: true, }); @@ -701,7 +701,7 @@ test('certificate creation fails if the time of the certificate is > 5 minutes i await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent: {} as Agent, }); @@ -734,7 +734,7 @@ test('certificate creation passes if the time of the certificate is > 5 minutes const cert = await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent, }); @@ -762,7 +762,7 @@ test('certificate creation passes if the time of the certificate is > 5 minutes const cert = await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent, }); @@ -789,7 +789,7 @@ test('certificate creation fails if the time of the certificate is > 5 minutes i await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent, }); @@ -831,7 +831,7 @@ test('certificate creation fails if the time of the certificate is > max age min await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, maxAgeInMinutes, agent, @@ -853,7 +853,7 @@ test('certificate verification fails if the time of the certificate is > 5 minut await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, }); } catch (error) { @@ -869,7 +869,7 @@ test('certificate creation passes if the time of the certificate is > 5 minutes const cert = await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, disableTimeVerification: true, }); @@ -884,7 +884,7 @@ test('certificate verification fails if the time of the certificate is > 5 minut await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent: {} as Agent, }); @@ -917,7 +917,7 @@ test('certificate creation passes if the time of the certificate is > 5 minutes const cert = await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent, }); @@ -945,7 +945,7 @@ test('certificate creation passes if the time of the certificate is > 5 minutes const cert = await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent, }); @@ -975,7 +975,7 @@ test('certificate creation fails if the time of the certificate is > 5 minutes i await Cert.Certificate.create({ certificate: SAMPLE_CERT_BYTES, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'), + principal: { canisterId: Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai') }, blsVerify: async () => true, agent, }); @@ -1014,7 +1014,7 @@ test('certificate verification fails on nested delegations', async () => { await Cert.Certificate.create({ certificate: overlyNested, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: canisterId, + principal: { canisterId }, }); } catch (error) { expect(error).toBeInstanceOf(ProtocolError); @@ -1026,7 +1026,7 @@ test('certificate verification fails on nested delegations', async () => { await Cert.Certificate.create({ certificate: overlyNested, rootKey: hexToBytes(IC_ROOT_KEY), - canisterId: canisterId, + principal: { canisterId }, }); } catch (error) { expect(error).toBeInstanceOf(ProtocolError); diff --git a/packages/core/src/agent/certificate.ts b/packages/core/src/agent/certificate.ts index 456cc1492..9be376f26 100644 --- a/packages/core/src/agent/certificate.ts +++ b/packages/core/src/agent/certificate.ts @@ -14,6 +14,7 @@ import { UNREACHABLE_ERROR, MissingLookupValueErrorCode, UnexpectedErrorCode, + CertificateNotAuthorizedForSubnetErrorCode, } from './errors.ts'; import { Principal } from '#principal'; import { compare as uint8Compare } from '#candid'; @@ -153,6 +154,21 @@ function isBufferGreaterThan(a: Uint8Array, b: Uint8Array): boolean { type VerifyFunc = (pk: Uint8Array, sig: Uint8Array, msg: Uint8Array) => Promise | boolean; +export type CertificatePrincipal = + | { + /** + * The effective canister ID of the request when verifying a response, or + * the signing canister ID when verifying a certified variable. + */ + canisterId: Principal; + } + | { + /** + * The subnet ID when verifying a certificate from a subnet. + */ + subnetId: Principal; + }; + export interface CreateCertificateOptions { /** * The bytes encoding the certificate to be verified @@ -164,10 +180,9 @@ export interface CreateCertificateOptions { */ rootKey: Uint8Array; /** - * The effective canister ID of the request when verifying a response, or - * the signing canister ID when verifying a certified variable. + * The principal for which the certificate is being verified. */ - canisterId: Principal; + principal: CertificatePrincipal; /** * BLS Verification strategy. Default strategy uses bls12_381 from @noble/curves */ @@ -218,7 +233,7 @@ export class Certificate { return new Certificate( options.certificate, options.rootKey, - options.canisterId, + options.principal, options.blsVerify ?? bls.blsVerify, options.maxAgeInMinutes, options.disableTimeVerification, @@ -229,7 +244,7 @@ export class Certificate { private constructor( certificate: Uint8Array, private _rootKey: Uint8Array, - private _canisterId: Principal, + private _principal: CertificatePrincipal, private _blsVerify: VerifyFunc, private _maxAgeInMinutes: number = DEFAULT_CERTIFICATE_MAX_AGE_IN_MINUTES, disableTimeVerification: boolean = false, @@ -296,7 +311,7 @@ export class Certificate { this.#agent && !this.#agent.hasSyncedTime() ) { - await this.#agent.syncTime(this._canisterId); + await this._syncTime(); return await this.verify(); } @@ -339,7 +354,7 @@ export class Certificate { const cert = Certificate.createUnverified({ certificate: d.certificate, rootKey: this._rootKey, - canisterId: this._canisterId, + principal: this._principal, blsVerify: this._blsVerify, disableTimeVerification: this.#disableTimeVerification, maxAgeInMinutes: DEFAULT_CERTIFICATE_DELEGATION_MAX_AGE_IN_MINUTES, @@ -352,30 +367,67 @@ export class Certificate { await cert.verify(); - const subnetIdBytes = d.subnet_id; - const subnetId = Principal.fromUint8Array(subnetIdBytes); + let subnetId: Principal; - const canisterInRange = check_canister_ranges({ - canisterId: this._canisterId, - subnetId, - tree: cert.cert.tree, - }); - if (!canisterInRange) { - throw TrustError.fromCode(new CertificateNotAuthorizedErrorCode(this._canisterId, subnetId)); + if (isCanisterPrincipal(this._principal)) { + const canisterId = this._principal.canisterId; + subnetId = Principal.fromUint8Array(d.subnet_id); + + const canisterInRange = check_canister_ranges({ + canisterId, + subnetId, + tree: cert.cert.tree, + }); + if (!canisterInRange) { + throw TrustError.fromCode(new CertificateNotAuthorizedErrorCode(canisterId, subnetId)); + } + } else if (isSubnetPrincipal(this._principal)) { + subnetId = this._principal.subnetId; + } else { + throw UNREACHABLE_ERROR; } const publicKeyLookup = lookupResultToBuffer( - cert.lookup_path(['subnet', subnetIdBytes, 'public_key']), + cert.lookup_path(['subnet', subnetId.toUint8Array(), 'public_key']), ); if (!publicKeyLookup) { - throw TrustError.fromCode( - new MissingLookupValueErrorCode( - `Could not find subnet key for subnet ID ${subnetId.toText()}`, - ), - ); + if (isSubnetPrincipal(this._principal)) { + throw TrustError.fromCode(new CertificateNotAuthorizedForSubnetErrorCode(subnetId)); + } else { + throw TrustError.fromCode( + new MissingLookupValueErrorCode( + `Could not find subnet key for subnet ID ${subnetId.toText()}`, + ), + ); + } } return publicKeyLookup; } + + private async _syncTime(): Promise { + if (!this.#agent) { + return; + } + + if (isCanisterPrincipal(this._principal)) { + await this.#agent.syncTime(this._principal.canisterId); + } else { + // TODO: sync time with subnet once the agent supports it + await this.#agent.syncTime(); + } + } +} + +function isSubnetPrincipal( + principal: T, +): principal is T & { subnetId: Principal } { + return 'subnetId' in principal; +} + +function isCanisterPrincipal( + principal: T, +): principal is T & { canisterId: Principal } { + return 'canisterId' in principal; } const DER_PREFIX = hexToBytes( diff --git a/packages/core/src/agent/errors.ts b/packages/core/src/agent/errors.ts index 2a54b459b..20a542c27 100644 --- a/packages/core/src/agent/errors.ts +++ b/packages/core/src/agent/errors.ts @@ -260,6 +260,19 @@ export class CertificateNotAuthorizedErrorCode extends ErrorCode { } } +export class CertificateNotAuthorizedForSubnetErrorCode extends ErrorCode { + public name = 'CertificateNotAuthorizedForSubnetErrorCode'; + + constructor(public readonly subnetId: Principal) { + super(); + Object.setPrototypeOf(this, CertificateNotAuthorizedForSubnetErrorCode.prototype); + } + + public toErrorMessage(): string { + return `The certificate is not authorized for subnet ${this.subnetId.toText()}`; + } +} + export class LookupErrorCode extends ErrorCode { public name = 'LookupErrorCode'; diff --git a/packages/core/src/agent/polling/index.ts b/packages/core/src/agent/polling/index.ts index eeb282168..e909a9508 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -156,7 +156,7 @@ export async function pollForResponse( const cert = await Certificate.create({ certificate: state.certificate, rootKey: agent.rootKey, - canisterId: canisterId, + principal: { canisterId }, blsVerify: options.blsVerify, agent, });