UNPKG

@dfinity/agent

Version:

JavaScript and TypeScript library to interact with the Internet Computer

537 lines 22.5 kB
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