@dfinity/agent
Version:
JavaScript and TypeScript library to interact with the Internet Computer
537 lines • 22.5 kB
JavaScript
import * as cbor from "./cbor.js";
import { CertificateHasTooManyDelegationsErrorCode, CertificateNotAuthorizedErrorCode, CertificateTimeErrorCode, CertificateVerificationErrorCode, DerKeyLengthMismatchErrorCode, DerPrefixMismatchErrorCode, ProtocolError, LookupErrorCode, TrustError, UnknownError, HashTreeDecodeErrorCode, UNREACHABLE_ERROR, MalformedLookupFoundValueErrorCode, MissingLookupValueErrorCode, UnexpectedErrorCode, } from "./errors.js";
import { Principal } from '@dfinity/principal';
import * as bls from "./utils/bls.js";
import { decodeTime } from "./utils/leb.js";
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
import { uint8Equals } from "./utils/buffer.js";
import { sha256 } from '@noble/hashes/sha2';
const MINUTES_TO_MSEC = 60 * 1000;
const HOURS_TO_MINUTES = 60;
const DAYS_TO_HOURS = 24;
const DAYS_TO_MINUTES = DAYS_TO_HOURS * HOURS_TO_MINUTES;
const DEFAULT_CERTIFICATE_MAX_AGE_IN_MINUTES = 5;
const DEFAULT_CERTIFICATE_MAX_MINUTES_IN_FUTURE = 5;
// For now, we don't want to set a strict timeout on the certificate delegation freshness,
// so we set the max age really far in the past.
const DEFAULT_CERTIFICATE_DELEGATION_MAX_AGE_IN_MINUTES = 30 * DAYS_TO_MINUTES;
export var NodeType;
(function (NodeType) {
NodeType[NodeType["Empty"] = 0] = "Empty";
NodeType[NodeType["Fork"] = 1] = "Fork";
NodeType[NodeType["Labeled"] = 2] = "Labeled";
NodeType[NodeType["Leaf"] = 3] = "Leaf";
NodeType[NodeType["Pruned"] = 4] = "Pruned";
})(NodeType || (NodeType = {}));
/**
* Make a human readable string out of a hash tree.
* @param tree The hash tree to convert to a string
*/
export function hashTreeToString(tree) {
const indent = (s) => s
.split('\n')
.map(x => ' ' + x)
.join('\n');
function labelToString(label) {
const decoder = new TextDecoder(undefined, { fatal: true });
try {
return JSON.stringify(decoder.decode(label));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (e) {
return `data(...${label.byteLength} bytes)`;
}
}
switch (tree[0]) {
case NodeType.Empty:
return '()';
case NodeType.Fork: {
if (tree[1] instanceof Array && tree[2] instanceof Uint8Array) {
const left = hashTreeToString(tree[1]);
const right = hashTreeToString(tree[2]);
return `sub(\n left:\n${indent(left)}\n---\n right:\n${indent(right)}\n)`;
}
else {
throw UnknownError.fromCode(new HashTreeDecodeErrorCode('Invalid tree structure for fork'));
}
}
case NodeType.Labeled: {
if (tree[1] instanceof Uint8Array && tree[2] instanceof Uint8Array) {
const label = labelToString(tree[1]);
const sub = hashTreeToString(tree[2]);
return `label(\n label:\n${indent(label)}\n sub:\n${indent(sub)}\n)`;
}
else {
throw UnknownError.fromCode(new HashTreeDecodeErrorCode('Invalid tree structure for labeled'));
}
}
case NodeType.Leaf: {
if (!tree[1]) {
throw UnknownError.fromCode(new HashTreeDecodeErrorCode('Invalid tree structure for leaf'));
}
else if (Array.isArray(tree[1])) {
return JSON.stringify(tree[1]);
}
return `leaf(...${tree[1].byteLength} bytes)`;
}
case NodeType.Pruned: {
if (!tree[1]) {
throw UnknownError.fromCode(new HashTreeDecodeErrorCode('Invalid tree structure for pruned'));
}
else if (Array.isArray(tree[1])) {
return JSON.stringify(tree[1]);
}
return `pruned(${bytesToHex(new Uint8Array(tree[1]))}`;
}
default: {
return `unknown(${JSON.stringify(tree[0])})`;
}
}
}
function isBufferGreaterThan(a, b) {
for (let i = 0; i < a.length; i++) {
if (a[i] > b[i]) {
return true;
}
}
return false;
}
export class Certificate {
#disableTimeVerification = false;
#agent = undefined;
/**
* Create a new instance of a certificate, automatically verifying it.
* @param {CreateCertificateOptions} options {@link CreateCertificateOptions}
* @throws if the verification of the certificate fails
*/
static async create(options) {
const cert = Certificate.createUnverified(options);
await cert.verify();
return cert;
}
static createUnverified(options) {
return new Certificate(options.certificate, options.rootKey, options.canisterId, options.blsVerify ?? bls.blsVerify, options.maxAgeInMinutes, options.disableTimeVerification, options.agent);
}
constructor(certificate, _rootKey, _canisterId, _blsVerify, _maxAgeInMinutes = DEFAULT_CERTIFICATE_MAX_AGE_IN_MINUTES, disableTimeVerification = false, agent) {
this._rootKey = _rootKey;
this._canisterId = _canisterId;
this._blsVerify = _blsVerify;
this._maxAgeInMinutes = _maxAgeInMinutes;
this.#disableTimeVerification = disableTimeVerification;
this.cert = cbor.decode(certificate);
if (agent && 'getTimeDiffMsecs' in agent && 'hasSyncedTime' in agent && 'syncTime' in agent) {
this.#agent = agent;
}
}
/**
* Lookup a path in the certificate tree, using {@link lookup_path}.
* @param path The path to lookup.
* @returns The result of the lookup.
*/
lookup_path(path) {
return lookup_path(path, this.cert.tree);
}
/**
* Lookup a subtree in the certificate tree, using {@link lookup_subtree}.
* @param path The path to lookup.
* @returns The result of the lookup.
*/
lookup_subtree(path) {
return lookup_subtree(path, this.cert.tree);
}
async verify() {
const rootHash = await reconstruct(this.cert.tree);
const derKey = await this._checkDelegationAndGetKey(this.cert.delegation);
const sig = this.cert.signature;
const key = extractDER(derKey);
const msg = concatBytes(domain_sep('ic-state-root'), rootHash);
const lookupTime = lookupResultToBuffer(this.lookup_path(['time']));
if (!lookupTime) {
// Should never happen - time is always present in IC certificates
throw ProtocolError.fromCode(new CertificateVerificationErrorCode('Certificate does not contain a time'));
}
// Certificate time verification checks
if (!this.#disableTimeVerification) {
const timeDiffMsecs = this.#agent?.getTimeDiffMsecs() ?? 0;
const maxAgeInMsec = this._maxAgeInMinutes * MINUTES_TO_MSEC;
const now = new Date();
const adjustedNow = now.getTime() + timeDiffMsecs;
const earliestCertificateTime = adjustedNow - maxAgeInMsec;
const latestCertificateTime = adjustedNow + DEFAULT_CERTIFICATE_MAX_MINUTES_IN_FUTURE * MINUTES_TO_MSEC;
const certTime = decodeTime(lookupTime);
const isCertificateTimePast = certTime.getTime() < earliestCertificateTime;
const isCertificateTimeFuture = certTime.getTime() > latestCertificateTime;
if ((isCertificateTimePast || isCertificateTimeFuture) &&
this.#agent &&
!this.#agent.hasSyncedTime()) {
await this.#agent.syncTime(this._canisterId);
return await this.verify();
}
if (isCertificateTimePast) {
throw TrustError.fromCode(new CertificateTimeErrorCode(this._maxAgeInMinutes, certTime, now, timeDiffMsecs, 'past'));
}
else if (isCertificateTimeFuture) {
if (this.#agent?.hasSyncedTime()) {
// This case should never happen, and it signals a bug in either the replica or the local system.
throw UnknownError.fromCode(new UnexpectedErrorCode('System time has been synced with the IC network, but certificate is still too far in the future.'));
}
throw TrustError.fromCode(new CertificateTimeErrorCode(5, certTime, now, timeDiffMsecs, 'future'));
}
}
try {
const sigVer = await this._blsVerify(key, sig, msg);
if (!sigVer) {
throw TrustError.fromCode(new CertificateVerificationErrorCode('Invalid signature'));
}
}
catch (err) {
throw TrustError.fromCode(new CertificateVerificationErrorCode('Signature verification failed', err));
}
}
async _checkDelegationAndGetKey(d) {
if (!d) {
return this._rootKey;
}
const cert = Certificate.createUnverified({
certificate: d.certificate,
rootKey: this._rootKey,
canisterId: this._canisterId,
blsVerify: this._blsVerify,
disableTimeVerification: this.#disableTimeVerification,
maxAgeInMinutes: DEFAULT_CERTIFICATE_DELEGATION_MAX_AGE_IN_MINUTES,
agent: this.#agent,
});
if (cert.cert.delegation) {
throw ProtocolError.fromCode(new CertificateHasTooManyDelegationsErrorCode());
}
await cert.verify();
const subnetIdBytes = d.subnet_id;
const subnetId = Principal.fromUint8Array(subnetIdBytes);
const canisterInRange = check_canister_ranges({
canisterId: this._canisterId,
subnetId,
tree: cert.cert.tree,
});
if (!canisterInRange) {
throw TrustError.fromCode(new CertificateNotAuthorizedErrorCode(this._canisterId, subnetId));
}
const publicKeyLookup = lookupResultToBuffer(cert.lookup_path(['subnet', subnetIdBytes, 'public_key']));
if (!publicKeyLookup) {
throw TrustError.fromCode(new MissingLookupValueErrorCode(`Could not find subnet key for subnet ID ${subnetId.toText()}`));
}
return publicKeyLookup;
}
}
const DER_PREFIX = hexToBytes('308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100');
const KEY_LENGTH = 96;
function extractDER(buf) {
const expectedLength = DER_PREFIX.byteLength + KEY_LENGTH;
if (buf.byteLength !== expectedLength) {
throw ProtocolError.fromCode(new DerKeyLengthMismatchErrorCode(expectedLength, buf.byteLength));
}
const prefix = buf.slice(0, DER_PREFIX.byteLength);
if (!uint8Equals(prefix, DER_PREFIX)) {
throw ProtocolError.fromCode(new DerPrefixMismatchErrorCode(DER_PREFIX, prefix));
}
return buf.slice(DER_PREFIX.byteLength);
}
/**
* Utility function to constrain the type of a lookup result
* @param result the result of a lookup
* @returns {Uint8Array | undefined} the value if the lookup was found, `undefined` otherwise
*/
export function lookupResultToBuffer(result) {
if (result.status !== LookupPathStatus.Found) {
return undefined;
}
if (result.value instanceof Uint8Array) {
return result.value;
}
return undefined;
}
/**
* @param t The hash tree to reconstruct
*/
export async function reconstruct(t) {
switch (t[0]) {
case NodeType.Empty:
return sha256(domain_sep('ic-hashtree-empty'));
case NodeType.Pruned:
return t[1];
case NodeType.Leaf:
return sha256(concatBytes(domain_sep('ic-hashtree-leaf'), t[1]));
case NodeType.Labeled:
return sha256(concatBytes(domain_sep('ic-hashtree-labeled'), t[1], await reconstruct(t[2])));
case NodeType.Fork:
return sha256(concatBytes(domain_sep('ic-hashtree-fork'), await reconstruct(t[1]), await reconstruct(t[2])));
default:
throw UNREACHABLE_ERROR;
}
}
/**
* Creates a domain separator for hashing by encoding the input string
* with its length as a prefix.
* @param s - The input string to encode.
* @returns A Uint8Array containing the encoded domain separator.
*/
export function domain_sep(s) {
const len = new Uint8Array([s.length]);
const str = new TextEncoder().encode(s);
return concatBytes(len, str);
}
function pathToLabel(path) {
return (typeof path[0] === 'string' ? utf8ToBytes(path[0]) : path[0]);
}
export var LookupPathStatus;
(function (LookupPathStatus) {
LookupPathStatus["Unknown"] = "Unknown";
LookupPathStatus["Absent"] = "Absent";
LookupPathStatus["Found"] = "Found";
LookupPathStatus["Error"] = "Error";
})(LookupPathStatus || (LookupPathStatus = {}));
export var LookupSubtreeStatus;
(function (LookupSubtreeStatus) {
LookupSubtreeStatus["Absent"] = "Absent";
LookupSubtreeStatus["Unknown"] = "Unknown";
LookupSubtreeStatus["Found"] = "Found";
})(LookupSubtreeStatus || (LookupSubtreeStatus = {}));
export var LookupLabelStatus;
(function (LookupLabelStatus) {
LookupLabelStatus["Absent"] = "Absent";
LookupLabelStatus["Unknown"] = "Unknown";
LookupLabelStatus["Found"] = "Found";
LookupLabelStatus["Less"] = "Less";
LookupLabelStatus["Greater"] = "Greater";
})(LookupLabelStatus || (LookupLabelStatus = {}));
/**
* Lookup a path in a tree. If the path is a subtree, use {@link lookup_subtree} instead.
* @param path the path to look up
* @param tree the tree to search
* @returns {LookupResult} the result of the lookup
*/
export function lookup_path(path, tree) {
if (path.length === 0) {
switch (tree[0]) {
case NodeType.Empty: {
return {
status: LookupPathStatus.Absent,
};
}
case NodeType.Leaf: {
if (!tree[1]) {
throw UnknownError.fromCode(new HashTreeDecodeErrorCode('Invalid tree structure for leaf'));
}
if (tree[1] instanceof Uint8Array) {
return {
status: LookupPathStatus.Found,
value: tree[1].slice(tree[1].byteOffset, tree[1].byteLength + tree[1].byteOffset),
};
}
throw UNREACHABLE_ERROR;
}
case NodeType.Pruned: {
return {
status: LookupPathStatus.Unknown,
};
}
case NodeType.Labeled:
case NodeType.Fork: {
return {
status: LookupPathStatus.Error,
};
}
default: {
throw UNREACHABLE_ERROR;
}
}
}
const label = pathToLabel(path);
const lookupResult = find_label(label, tree);
switch (lookupResult.status) {
case LookupLabelStatus.Found: {
return lookup_path(path.slice(1), lookupResult.value);
}
case LookupLabelStatus.Absent:
case LookupLabelStatus.Greater:
case LookupLabelStatus.Less: {
return {
status: LookupPathStatus.Absent,
};
}
case LookupLabelStatus.Unknown: {
return {
status: LookupPathStatus.Unknown,
};
}
default: {
throw UNREACHABLE_ERROR;
}
}
}
/**
* Lookup a subtree in a tree.
* @param path the path to look up
* @param tree the tree to search
* @returns {SubtreeLookupResult} the result of the lookup
*/
export function lookup_subtree(path, tree) {
if (path.length === 0) {
return {
status: LookupSubtreeStatus.Found,
value: tree,
};
}
const label = pathToLabel(path);
const lookupResult = find_label(label, tree);
switch (lookupResult.status) {
case LookupLabelStatus.Found: {
return lookup_subtree(path.slice(1), lookupResult.value);
}
case LookupLabelStatus.Unknown: {
return {
status: LookupSubtreeStatus.Unknown,
};
}
case LookupLabelStatus.Absent:
case LookupLabelStatus.Greater:
case LookupLabelStatus.Less: {
return {
status: LookupSubtreeStatus.Absent,
};
}
default: {
throw UNREACHABLE_ERROR;
}
}
}
/**
* If the tree is a fork, flatten it into an array of trees
* @param {HashTree} t the tree to flatten
* @returns {HashTree[]} the flattened tree
*/
export function flatten_forks(t) {
switch (t[0]) {
case NodeType.Empty:
return [];
case NodeType.Fork:
return flatten_forks(t[1]).concat(flatten_forks(t[2]));
default:
return [t];
}
}
/**
* Find a label in a tree
* @param label the label to find
* @param tree the tree to search
* @returns {LabelLookupResult} the result of the label lookup
*/
export function find_label(label, tree) {
switch (tree[0]) {
// if we have a labelled node, compare the node's label to the one we are
// looking for
case NodeType.Labeled:
// if the label we're searching for is greater than this node's label,
// we need to keep searching
if (isBufferGreaterThan(label, tree[1])) {
return {
status: LookupLabelStatus.Greater,
};
}
// if the label we're searching for is equal this node's label, we can
// stop searching and return the found node
if (uint8Equals(label, tree[1])) {
return {
status: LookupLabelStatus.Found,
value: tree[2],
};
}
// if the label we're searching for is not greater than or equal to this
// node's label, then it's less than this node's label, and we can stop
// searching because we've looked too far
return {
status: LookupLabelStatus.Less,
};
// if we have a fork node, we need to search both sides, starting with the left
case NodeType.Fork: {
// search in the left node
const leftLookupResult = find_label(label, tree[1]);
switch (leftLookupResult.status) {
// if the label we're searching for is greater than the left node lookup,
// we need to search the right node
case LookupLabelStatus.Greater: {
const rightLookupResult = find_label(label, tree[2]);
// if the label we're searching for is less than the right node lookup,
// then we can stop searching and say that the label is provably Absent
if (rightLookupResult.status === LookupLabelStatus.Less) {
return {
status: LookupLabelStatus.Absent,
};
}
// if the label we're searching for is less than or equal to the right
// node lookup, then we let the caller handle it
return rightLookupResult;
}
// if the left node returns an uncertain result, we need to search the
// right node
case LookupLabelStatus.Unknown: {
const rightLookupResult = find_label(label, tree[2]);
// if the label we're searching for is less than the right node lookup,
// then we also need to return an uncertain result
if (rightLookupResult.status === LookupLabelStatus.Less) {
return {
status: LookupLabelStatus.Unknown,
};
}
// if the label we're searching for is less than or equal to the right
// node lookup, then we let the caller handle it
return rightLookupResult;
}
// if the label we're searching for is not greater than the left node
// lookup, or the result is not uncertain, we stop searching and return
// whatever the result of the left node lookup was, which can be either
// Found or Absent
default: {
return leftLookupResult;
}
}
}
// if we encounter a Pruned node, we can't know for certain if the label
// we're searching for is present or not
case NodeType.Pruned:
return {
status: LookupLabelStatus.Unknown,
};
// if the current node is Empty, or a Leaf, we can stop searching because
// we know for sure that the label we're searching for is not present
default:
return {
status: LookupLabelStatus.Absent,
};
}
}
/**
* Check if a canister ID falls within the canister ranges of a given subnet
* @param params the parameters with which to check the canister ranges
* @param params.canisterId the canister ID to check
* @param params.subnetId the subnet ID from which to check the canister ranges
* @param params.tree the hash tree in which to lookup the subnet's canister ranges
* @returns {boolean} `true` if the canister is in the range, `false` otherwise
*/
export function check_canister_ranges(params) {
const { canisterId, subnetId, tree } = params;
const rangeLookup = lookup_path(['subnet', subnetId.toUint8Array(), 'canister_ranges'], tree);
if (rangeLookup.status !== LookupPathStatus.Found) {
throw ProtocolError.fromCode(new LookupErrorCode(`Could not find canister ranges for subnet ${subnetId.toText()}`, rangeLookup.status));
}
if (!(rangeLookup.value instanceof Uint8Array)) {
throw ProtocolError.fromCode(new MalformedLookupFoundValueErrorCode(`Could not find canister ranges for subnet ${subnetId.toText()}`));
}
const ranges_arr = cbor.decode(rangeLookup.value);
const ranges = ranges_arr.map(v => [
Principal.fromUint8Array(v[0]),
Principal.fromUint8Array(v[1]),
]);
const canisterInRange = ranges.some(r => r[0].ltEq(canisterId) && r[1].gtEq(canisterId));
return canisterInRange;
}
//# sourceMappingURL=certificate.js.map