Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(agent): Check subnet canister ranges #580

Merged
merged 18 commits into from Jun 28, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion demos/ledgerhq/package.json
Expand Up @@ -27,5 +27,5 @@
"test:coverage": "",
"test": ""
},
"version": "0.11.3"
"version": "0.12.0"
}
2 changes: 1 addition & 1 deletion demos/sample-javascript/package.json
Expand Up @@ -24,5 +24,5 @@
"test:coverage": "",
"test": ""
},
"version": "0.11.3"
"version": "0.12.0"
}
10 changes: 10 additions & 0 deletions docs/generated/changelog.html
Expand Up @@ -10,6 +10,16 @@
<h1>Agent-JS Changelog</h1>

<section>
<h2>Version 0.12.0</h2>
<ul>
<li>
Changed the certificate verification interface and fixed its logic. The public constructor
is now static and asynchronous. There is no separate verification method, the check is
done automatically in the constructor and newly also checks that the delegation is
authoritative for the given canister ID, as required by the Internet Computer interface
specification.
</li>
</ul>
<h2>Version 0.11.2</h2>
<ul>
<li>
Expand Down
9 changes: 4 additions & 5 deletions e2e/node/basic/basic.test.ts
Expand Up @@ -10,13 +10,12 @@ test('read_state', async () => {
const resolvedAgent = await agent;
const now = Date.now() / 1000;
const path = [new TextEncoder().encode('time')];
const response = await resolvedAgent.readState(Principal.fromHex('00000000000000000001'), {
const canisterId = Principal.fromHex('00000000000000000001');
const response = await resolvedAgent.readState(canisterId, {
paths: [path],
});
const cert = new Certificate(response, resolvedAgent);

expect(() => cert.lookup(path)).toThrow(/Cannot lookup unverified certificate/);
expect(await cert.verify()).toBe(true);
if (resolvedAgent.rootKey == null) throw new Error(`The agent doesn't have a root key yet`);
const cert = await Certificate.create(response.certificate, resolvedAgent.rootKey, canisterId);
expect(cert.lookup([new TextEncoder().encode('Time')])).toBe(undefined);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const rawTime = cert.lookup(path)!;
Expand Down
2 changes: 1 addition & 1 deletion e2e/node/basic/mitm.test.ts
Expand Up @@ -11,6 +11,6 @@ if (!process.env['MITM']) {
jest.setTimeout(30000);
mitmTest('mitm greet', async () => {
const { actor: counter } = await counterCanister();
await expect(counter.greet('counter')).rejects.toThrow(/Fail to verify certificate/);
await expect(counter.greet('counter')).rejects.toThrow(/Invalid certificate/);
expect(await counter.queryGreet('counter')).toEqual('Hullo, counter!');
});
10 changes: 5 additions & 5 deletions e2e/node/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@do-not-publish/ic-node-e2e-tests",
"version": "0.11.3",
"version": "0.12.0",
"scripts": {
"ci": "npm run e2e",
"e2e": "jest --verbose",
Expand All @@ -16,10 +16,10 @@
"test": ""
},
"dependencies": {
"@dfinity/agent": "^0.11.3",
"@dfinity/authentication": "^0.11.3",
"@dfinity/identity": "^0.11.3",
"@dfinity/principal": "^0.11.3",
"@dfinity/agent": "^0.12.0",
"@dfinity/authentication": "^0.12.0",
"@dfinity/identity": "^0.12.0",
"@dfinity/principal": "^0.12.0",
"@trust/webcrypto": "^0.9.2",
"@types/base64-js": "^1.2.5",
"@types/jest": "^26.0.23",
Expand Down
2 changes: 1 addition & 1 deletion package.json
@@ -1,6 +1,6 @@
{
"name": "@dfinity/agent-monorepo",
"version": "0.11.3",
"version": "0.12.0",
"private": true,
"description": "Use an Agent to interact with the Internet Computer from your JavaScript program.",
"dependencies": {
Expand Down
6 changes: 3 additions & 3 deletions packages/agent/package.json
@@ -1,6 +1,6 @@
{
"name": "@dfinity/agent",
"version": "0.11.3",
"version": "0.12.0",
"author": "DFINITY Stiftung <sdk@dfinity.org>",
"license": "Apache-2.0",
"description": "JavaScript and TypeScript library to interact with the Internet Computer",
Expand Down Expand Up @@ -49,8 +49,8 @@
"tslint": "tslint --project tsconfig.json --config tslint.json"
},
"peerDependencies": {
"@dfinity/candid": "^0.11.3",
"@dfinity/principal": "^0.11.3"
"@dfinity/candid": "^0.12.0",
"@dfinity/principal": "^0.12.0"
},
"dependencies": {
"base64-arraybuffer": "^0.2.0",
Expand Down
8 changes: 1 addition & 7 deletions packages/agent/src/canisterStatus/index.ts
Expand Up @@ -87,13 +87,7 @@ export const request = async (options: {
const response = await agent.readState(canisterId, {
paths: [encodedPaths[index]],
});
const cert = new Certificate(response, agent);
const verified = await cert.verify();
if (!verified) {
throw new Error(
'There was a problem certifying the response data. Please verify your connection to the mainnet, or be sure to call fetchRootKey on your agent if you are developing locally',
);
}
const cert = await Certificate.create(response.certificate, agent.rootKey, canisterId);

const data = cert.lookup(encodePath(uniquePaths[index], canisterId));
if (!data) {
Expand Down
65 changes: 65 additions & 0 deletions packages/agent/src/certificate.test.ts
Expand Up @@ -6,6 +6,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';

function label(str: string): ArrayBuffer {
return new TextEncoder().encode(str);
Expand All @@ -15,6 +17,13 @@ function pruned(str: string): ArrayBuffer {
return fromHex(str);
}

// Root public key for the IC main net, encoded as hex
const IC_ROOT_KEY =
'308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814' +
'c0e6ec71fab583b08bd81373c255c3c371b2e84863c98a4f1e08b74235d14fb5d9c0cd546d968' +
'5f913a0c0b2cc5341583bf4b4392e467db96d65b9bb4cb717112f8472e0d5a4d14505ffd7484' +
'b01291091c5f87b98883463f98091a0baaae';

test('hash tree', async () => {
const cborEncode = fromHex(
'8301830183024161830183018302417882034568656c6c6f810083024179820345776f726c64' +
Expand Down Expand Up @@ -124,3 +133,59 @@ test('lookup', () => {
expect(toText(Cert.lookup_path([fromText('d')], tree))).toEqual('morning');
expect(Cert.lookup_path([fromText('e')], tree)).toEqual(undefined);
});

// 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';

test('delegation works for canisters within the subnet range', async () => {
const canisterId = Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai');
await expect(
Cert.Certificate.create(fromHex(SAMPLE_CERT), fromHex(IC_ROOT_KEY), canisterId),
).resolves.not.toThrow();
});

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would have to check the allowed ranges myself here. Would it help to provide the allowed range(s) as a comment here?

also, since it would be cheap: would it make sense to add tests for the boundary cases at the (closed) ends of a range?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH I don't even know what the subnet ranges in this certificate are - I just copied these over from the Rust tests. But even if I did know what they were, I don't know if our textual encoding preserves the ordering?

Boundary cases totally make sense, I'll have a look at those.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can actually decode this (it's cbor, https://cbor.me comes in handy).

So the first level of decoding reveals

"delegation": {"subnet_id": h'D77B2A2F7199B9A8AEC93FE6FB588661358CF12223E9A3AF7B4EBAC402', "certificate": h'D9D9F7A26474726565830182045820AE023F28C3B9D966C8FB09F9ED755C828AADB5152E00AAF700B18C9C067294B483018302467375626E6574830182045820E83BB025F6574C8F31233DC0FE289FF546DFA1E49BD6116DD6E8896D90A4946E830182045820E782619092D69D5BEBF0924138BD4116B0156B5A95E25C358EA8CF7E7161A661830183018204582062513FA926C9A9EF803AC284D620F303189588E1D3904349AB63B6470856FC4883018204582060E9A344CED2C9C4A96A0197FD585F2D259DBD193E4EADA56239CAC26087F9C58302581DD77B2A2F7199B9A8AEC93FE6FB588661358CF12223E9A3AF7B4EBAC402830183024F63616E69737465725F72616E6765738203581BD9D9F781824A000000000020000001014A00000000002FFFFF010183024A7075626C69635F6B657982035885308182301D060D2B0601040182DC7C0503010201060C2B0601040182DC7C050302010361009933E1F89E8A3C4D7FDCCCDBD518089E2BD4D8180A261F18D9C247A52768EBCE98DC7328A39814A8F911086A1DD50CBE015E2A53B7BF78B55288893DAA15C346640E8831D72A12BDEDD979D28470C34823B8D1C3F4795D9C3984A247132E94FE82045820996F17BB926BE3315745DEA7282005A793B58E76AFEB5D43D1A28CE29D2D158583024474696D6582034995B8AAC0E4EDA2EA16697369676E61747572655830ACE9FCDD9BC977E05D6328F889DC4E7C99114C737A494653CB27A1F55C06F4555E0F160980AF5EAD098ACC195010B2F7'}

The included delegation cert then specifies for the subnet (in addition to it's public key) the canister_ranges D9D9F781824A000000000020000001014A00000000002FFFFF0101 (which is again cbor. Decoded this results in the following ranges:

[(CanisterId(jrlun-jiaaa-aaaab-aaaaa-cai), CanisterId(v2nog-2aaaa-aaaab-p777q-cai))]

Which are the following principals:
Principal(PrincipalInner { len: 10, bytes: [0, 0, 0, 0, 0, 32, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }), Principal(PrincipalInner { len: 10, bytes: [0, 0, 0, 0, 0, 47, 255, 255, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] })

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @frederikrothenberger !

I now realized that the interface spec also provides handy tools for decoding the text representation (which indeed doesn't preserve the lexicographic ordering, due to the use of crc-32).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the checks now, switched to a hex representation of the principals as that makes the ranges clearly visible

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks for addressing this @oggy-dfin !

const canisterId = Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai');
await expect(
Cert.Certificate.create(fromHex(SAMPLE_CERT), fromHex(IC_ROOT_KEY), canisterId),
).rejects.toThrow(/Invalid certificate/);
});

// The only situation in which one can read state of the IC management canister
// is when the user calls provisional_create_canister_with_cycles. In this case,
// we shouldn't check the delegations.
test('delegation check succeeds for the management canister', async () => {
await expect(
Cert.Certificate.create(
fromHex(SAMPLE_CERT),
fromHex(IC_ROOT_KEY),
Principal.managementCanister(),
),
).resolves.not.toThrow();
});

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

test('certificate verification fails for an invalid signature', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding these tests!

The following comment suggests to add more tests, generally for cert verification. I think that should not be part of this PR however, but maybe it makes sense to create a separate ticket / PR for it, WDYT?


IIUC these are now all the tests we have for cert verification right? It seems to me that there is potential to improve the testing, e.g. for the following cases:

  • Cert without delegation verifies
  • Cert with length 1 delegation chain verifies
  • Cert with length >1 delegation chain verifies
  • Cert with wrong range in single delegation fails
  • Cert with wrong range in >1 depth delegation chain fails
  • Cert with invalid signature in single delegation fails
  • Cert with invalid signature in >1 depth delegation chain fails
  • Tests where the tree reconstruction or path lookup fails

@frederikrothenberger wrote Rust code to generate certificates for tests and actually used that code to generate certificate test vectors in the context of the service worker, see here. IIUC this could be used to generate test vectors for the above cases as well!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes total sense, certification is important. Created a ticket now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, thanks for creating the ticket.

let badCert: FakeCert = cbor.decode(fromHex(SAMPLE_CERT));
badCert.signature = new ArrayBuffer(badCert.signature.byteLength);
const badCertEncoded = cbor.encode(badCert);
await expect(
Cert.Certificate.create(
badCertEncoded,
fromHex(IC_ROOT_KEY),
Principal.fromText('ivg37-qiaaa-aaaab-aaaga-cai'),
),
).rejects.toThrow('Invalid certificate');
});
105 changes: 68 additions & 37 deletions packages/agent/src/certificate.ts
@@ -1,17 +1,16 @@
import { Agent, getDefaultAgent, ReadStateResponse } from './agent';
import * as cbor from './cbor';
import { AgentError } from './errors';
import { hash } from './request_id';
import { blsVerify } from './utils/bls';
import { concat, fromHex, toHex } from './utils/buffer';
import { Principal } from '@dfinity/principal';

/**
* A certificate needs to be verified (using {@link Certificate.prototype.verify})
* before it can be used.
* A certificate may fail verification with respect to the provided public key
*/
export class UnverifiedCertificateError extends AgentError {
constructor() {
super(`Cannot lookup unverified certificate. Call 'verify()' first.`);
export class CertificateVerificationError extends AgentError {
constructor(reason: string) {
super(`Invalid certificate: ${reason}`);
}
}

Expand Down Expand Up @@ -101,57 +100,89 @@ function isBufferEqual(a: ArrayBuffer, b: ArrayBuffer): boolean {

export class Certificate {
private readonly cert: Cert;
private verified = false;
private _rootKey: ArrayBuffer | null = null;

constructor(response: ReadStateResponse, private _agent: Agent = getDefaultAgent()) {
this.cert = cbor.decode(new Uint8Array(response.certificate));
/**
* Create a new instance of a certificate, automatically verifying it.
* @throws {CertificateVerificationError}
*/
public static async create(
krpeacock marked this conversation as resolved.
Show resolved Hide resolved
certificate: ArrayBuffer,
rootKey: ArrayBuffer,
canisterId: Principal,
): Promise<Certificate> {
const cert = new Certificate(certificate, rootKey, canisterId);
await cert.verify();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like that you can now only create certs that are valid! 👍

return cert;
}

private constructor(
certificate: ArrayBuffer,
private _rootKey: ArrayBuffer,
private _canisterId: Principal,
) {
this.cert = cbor.decode(new Uint8Array(certificate));
}

public lookup(path: Array<ArrayBuffer | string>): ArrayBuffer | undefined {
this.checkState();
return lookup_path(path, this.cert.tree);
}

public async verify(): Promise<boolean> {
private async verify(): Promise<void> {
const rootHash = await reconstruct(this.cert.tree);
const derKey = await this._checkDelegation(this.cert.delegation);
const derKey = await this._checkDelegationAndGetKey(this.cert.delegation);
const sig = this.cert.signature;
const key = extractDER(derKey);
const msg = concat(domain_sep('ic-state-root'), rootHash);
const res = await blsVerify(new Uint8Array(key), new Uint8Array(sig), new Uint8Array(msg));
this.verified = res;
return res;
}

protected checkState(): void {
if (!this.verified) {
throw new UnverifiedCertificateError();
let sigVer = false;
try {
sigVer = await blsVerify(new Uint8Array(key), new Uint8Array(sig), new Uint8Array(msg));
} catch (err) {
sigVer = false;
}
if (!sigVer) {
throw new CertificateVerificationError('Signature verification failed');
}
}

private async _checkDelegation(d?: Delegation): Promise<ArrayBuffer> {
private async _checkDelegationAndGetKey(d?: Delegation): Promise<ArrayBuffer> {
if (!d) {
if (!this._rootKey) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why removing this logic? fetchRootKey() should only be called in local replica. The root key for the mainnet is hardcoded and doesn't require explicit fetching.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this - my fix had made things worse! However, I do think that this is the wrong place for this logic - there's no real need for Certificate to know anything about the agent IMO (I ended up staring at this piece of code for a while when first looking it, and obviously got it wrong), it just needs a root key.

I changed the code now so that the callers use the root key if available. We can probably extract a convenience method on Agent to do this.

if (this._agent.rootKey) {
this._rootKey = this._agent.rootKey;
return this._rootKey;
}

throw new Error(`Agent does not have a rootKey. Do you need to call 'fetchRootKey'?`);
}
return this._rootKey;
}
const cert: Certificate = new Certificate(d as any, this._agent);
if (!(await cert.verify())) {
throw new Error('fail to verify delegation certificate');
}
const cert: Certificate = await Certificate.create(
d.certificate,
this._rootKey,
this._canisterId,
);

if (this._canisterId.compareTo(Principal.managementCanister()) !== 'eq') {
const rangeLookup = cert.lookup(['subnet', d.subnet_id, 'canister_ranges']);
if (!rangeLookup) {
throw new CertificateVerificationError(
`Could not find canister ranges for subnet 0x${toHex(d.subnet_id)}`,
);
}
const ranges_arr: Array<[Uint8Array, Uint8Array]> = cbor.decode(rangeLookup);
const ranges: Array<[Principal, Principal]> = ranges_arr.map(v => [
Principal.fromUint8Array(v[0]),
Principal.fromUint8Array(v[1]),
]);

const lookup = cert.lookup(['subnet', d.subnet_id, 'public_key']);
if (!lookup) {
const canisterInRange = ranges.some(
r => r[0].ltEq(this._canisterId) && r[1].gtEq(this._canisterId),
);
if (!canisterInRange) {
throw new CertificateVerificationError(
`Canister ${this._canisterId} not in range of delegations for subnet 0x${toHex(
d.subnet_id,
)}`,
);
}
}
const publicKeyLookup = cert.lookup(['subnet', d.subnet_id, 'public_key']);
if (!publicKeyLookup) {
throw new Error(`Could not find subnet key for subnet 0x${toHex(d.subnet_id)}`);
}
return lookup;
return publicKeyLookup;
}
}

Expand Down
7 changes: 2 additions & 5 deletions packages/agent/src/polling/index.ts
Expand Up @@ -29,11 +29,8 @@ export async function pollForResponse(
): Promise<ArrayBuffer> {
const path = [new TextEncoder().encode('request_status'), requestId];
const state = await agent.readState(canisterId, { paths: [path] });
const cert = new Certificate(state, agent);
const verified = await cert.verify();
if (!verified) {
throw new Error('Fail to verify certificate');
}
if (agent.rootKey == null) throw new Error('Agent root key not initialized before polling');
const cert = await Certificate.create(state.certificate, agent.rootKey, canisterId);
const maybeBuf = cert.lookup([...path, new TextEncoder().encode('status')]);
let status;
if (typeof maybeBuf === 'undefined') {
Expand Down
10 changes: 5 additions & 5 deletions packages/auth-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@dfinity/auth-client",
"version": "0.11.3",
"version": "0.12.0",
"author": "DFINITY Stiftung <sdk@dfinity.org>",
"license": "Apache-2.0",
"description": "JavaScript and TypeScript library to provide a simple integration with an IC Internet Identity",
Expand Down Expand Up @@ -45,10 +45,10 @@
"test:coverage": "jest --verbose --collectCoverage"
},
"peerDependencies": {
"@dfinity/agent": "^0.11.3",
"@dfinity/authentication": "^0.11.3",
"@dfinity/identity": "^0.11.3",
"@dfinity/principal": "^0.11.3"
"@dfinity/agent": "^0.12.0",
"@dfinity/authentication": "^0.12.0",
"@dfinity/identity": "^0.12.0",
"@dfinity/principal": "^0.12.0"
},
"devDependencies": {
"@trust/webcrypto": "^0.9.2",
Expand Down