UNPKG

@phala/dstack-sdk

Version:
654 lines (647 loc) 21.7 kB
'use strict'; var fs = require('fs'); var sha512 = require('@noble/hashes/sha512'); var http = require('http'); var https = require('https'); var net = require('net'); var sha256 = require('@noble/hashes/sha256'); var utils = require('@noble/hashes/utils'); var sha3 = require('@noble/hashes/sha3'); var secp256k1 = require('@noble/curves/secp256k1'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var fs__default = /*#__PURE__*/_interopDefault(fs); var http__default = /*#__PURE__*/_interopDefault(http); var https__default = /*#__PURE__*/_interopDefault(https); var net__default = /*#__PURE__*/_interopDefault(net); // src/index.ts var __version__ = "0.5.6"; function send_rpc_request(endpoint, path, payload, timeoutMs) { return new Promise((resolve, reject) => { const abortController = new AbortController(); let isCompleted = false; const safeReject = (error) => { if (!isCompleted) { isCompleted = true; reject(error); } }; const safeResolve = (result) => { if (!isCompleted) { isCompleted = true; resolve(result); } }; const timeout = setTimeout(() => { abortController.abort(); safeReject(new Error("request timed out")); }, timeoutMs || 3e4); const cleanup = () => { clearTimeout(timeout); abortController.signal.removeEventListener("abort", onAbort); }; const onAbort = () => { cleanup(); safeReject(new Error("request aborted")); }; abortController.signal.addEventListener("abort", onAbort); const isHttp = endpoint.startsWith("http://") || endpoint.startsWith("https://"); if (isHttp) { const url = new URL(path, endpoint); const options = { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), "User-Agent": `dstack-sdk-js/${__version__}` } }; const req = (url.protocol === "https:" ? https__default.default : http__default.default).request(url, options, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { cleanup(); try { const result = JSON.parse(data); safeResolve(result); } catch (error) { safeReject(new Error("failed to parse response")); } }); }); req.on("error", (error) => { cleanup(); safeReject(error); }); abortController.signal.addEventListener("abort", () => { req.destroy(); }); req.write(payload); req.end(); } else { const client = net__default.default.createConnection({ path: endpoint }, () => { client.write(`POST ${path} HTTP/1.1\r `); client.write(`Host: localhost\r `); client.write(`Content-Type: application/json\r `); client.write(`Content-Length: ${payload.length}\r `); client.write("\r\n"); client.write(payload); }); let data = ""; let headers = {}; let headersParsed = false; let contentLength = 0; let bodyData = ""; client.on("data", (chunk) => { data += chunk; if (!headersParsed) { const headerEndIndex = data.indexOf("\r\n\r\n"); if (headerEndIndex !== -1) { const headerLines = data.slice(0, headerEndIndex).split("\r\n"); headerLines.forEach((line) => { const [key, value] = line.split(": "); if (key && value) { headers[key.toLowerCase()] = value; } }); headersParsed = true; contentLength = parseInt(headers["content-length"] || "0", 10); bodyData = data.slice(headerEndIndex + 4); } } else { bodyData += chunk; } if (headersParsed && bodyData.length >= contentLength) { client.end(); } }); client.on("end", () => { cleanup(); try { const result = JSON.parse(bodyData.slice(0, contentLength)); safeResolve(result); } catch (error) { safeReject(new Error("failed to parse response")); } }); client.on("error", (error) => { cleanup(); safeReject(error); }); abortController.signal.addEventListener("abort", () => { client.destroy(); }); } }); } function sortObject(obj) { if (obj === void 0 || obj === null) { return obj; } if (Array.isArray(obj)) { return obj.map(sortObject); } else if (obj && typeof obj === "object" && obj.constructor === Object) { return Object.keys(obj).sort().reduce((result, key) => { const value = obj[key]; result[key] = sortObject(value); return result; }, {}); } return obj; } function preprocessAppCompose(dic) { const obj = { ...dic }; if (obj.runner === "bash" && "docker_compose_file" in obj) { delete obj.docker_compose_file; } else if (obj.runner === "docker-compose" && "bash_script" in obj) { delete obj.bash_script; } if ("pre_launch_script" in obj && !obj.pre_launch_script) { delete obj.pre_launch_script; } return obj; } function toDeterministicJson(dic) { const ordered = sortObject(dic); return JSON.stringify(ordered, (key, value) => { if (typeof value === "number" && (isNaN(value) || !isFinite(value))) { return null; } return value; }); } function getComposeHash(app_compose, normalize = false) { if (normalize) { app_compose = preprocessAppCompose(app_compose); } const manifest_str = toDeterministicJson(app_compose); return utils.bytesToHex(sha256.sha256(new TextEncoder().encode(manifest_str))); } var DEFAULT_MAX_AGE_SECONDS = 300; function bigintToBeBytes(value, length) { const bytes = new Uint8Array(length); for (let i = length - 1; i >= 0; i--) { bytes[i] = Number(value & 0xffn); value >>= 8n; } return bytes; } function hexToBytes(hex) { if (hex.startsWith("0x") || hex.startsWith("0X")) hex = hex.slice(2); if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } return bytes; } function bytesToHex2(bytes) { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } function concat(...parts) { const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } function recoverSigner(messageHash, signature) { try { const sigBytes = signature.slice(0, 64); const recovery = signature[64]; const recoveredPubKey = secp256k1.secp256k1.Signature.fromCompact(sigBytes).addRecoveryBit(recovery).recoverPublicKey(messageHash); return "0x" + bytesToHex2(recoveredPubKey.toRawBytes(true)); } catch (error) { console.error("signature verification failed:", error); return null; } } function verifyEnvEncryptPublicKey(publicKey, signature, appId, timestamp, options) { if (signature.length !== 65) return null; const ts = typeof timestamp === "bigint" ? timestamp : BigInt(timestamp); const maxAge = options?.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; const now = BigInt(Math.floor(Date.now() / 1e3)); const age = now - ts; if (age < -60n) { console.error("timestamp is too far in the future"); return null; } if (age > BigInt(maxAge)) { console.error(`timestamp is too old: ${age}s > ${maxAge}s`); return null; } const appIdBytes = hexToBytes(appId); if (!appIdBytes) return null; const prefix = new TextEncoder().encode("dstack-env-encrypt-pubkey"); const separator = new TextEncoder().encode(":"); const timestampBytes = bigintToBeBytes(ts, 8); const message = concat( prefix, separator, appIdBytes, timestampBytes, publicKey ); return recoverSigner(sha3.keccak_256(message), signature); } function verifyEnvEncryptPublicKeyLegacy(publicKey, signature, appId) { if (signature.length !== 65) return null; const appIdBytes = hexToBytes(appId); if (!appIdBytes) return null; const prefix = new TextEncoder().encode("dstack-env-encrypt-pubkey"); const separator = new TextEncoder().encode(":"); const message = concat(prefix, separator, appIdBytes, publicKey); return recoverSigner(sha3.keccak_256(message), signature); } // src/index.ts function to_hex(data) { if (typeof data === "string") { return Buffer.from(data).toString("hex"); } if (data instanceof Uint8Array) { return Buffer.from(data).toString("hex"); } return data.toString("hex"); } function x509key_to_uint8array(pem, max_length) { const content = pem.replace(/-----BEGIN PRIVATE KEY-----/, "").replace(/-----END PRIVATE KEY-----/, "").replace(/\n/g, ""); const binaryDer = atob(content); if (!max_length) { max_length = binaryDer.length; } const result = new Uint8Array(max_length); for (let i = 0; i < max_length; i++) { result[i] = binaryDer.charCodeAt(i); } return result; } function replay_rtmr(history) { const INIT_MR = "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; if (history.length === 0) { return INIT_MR; } let mr = Buffer.from(INIT_MR, "hex"); for (const content of history) { let contentBuffer = Buffer.from(content, "hex"); if (contentBuffer.length < 48) { const padding = Buffer.alloc(48 - contentBuffer.length, 0); contentBuffer = Buffer.concat([contentBuffer, padding]); } mr = Buffer.from(sha512.sha384(Buffer.concat([mr, contentBuffer]))); } return mr.toString("hex"); } function reply_rtmrs(event_log) { const rtmrs = []; for (let idx = 0; idx < 4; idx++) { const history = event_log.filter((event) => event.imr === idx).map((event) => event.digest); rtmrs[idx] = replay_rtmr(history); } return rtmrs; } var SECP256K1_ALGORITHMS = /* @__PURE__ */ new Set(["secp256k1", "k256", ""]); var DstackClient = class { constructor(endpoint = void 0) { if (endpoint === void 0) { if (process.env.DSTACK_SIMULATOR_ENDPOINT) { console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`); endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT; } else { const socketPaths = [ "/var/run/dstack.sock", "/run/dstack.sock", "/var/run/dstack/dstack.sock", "/run/dstack/dstack.sock" ]; endpoint = socketPaths.find((p) => fs__default.default.existsSync(p)) ?? socketPaths[0]; } } if (endpoint.startsWith("/") && !fs__default.default.existsSync(endpoint)) { throw new Error(`Unix socket file ${endpoint} does not exist`); } this.endpoint = endpoint; } async ensureAlgorithmSupported(algorithm) { if (SECP256K1_ALGORITHMS.has(algorithm)) return; try { await this.version(); } catch { throw new Error(`algorithm "${algorithm}" is not supported: OS version too old (Version RPC unavailable)`); } } async ensureTlsKeyOptionsSupported(featureNames) { try { await this.version(); } catch { throw new Error(`TLS key options [${featureNames.join(", ")}] are not supported: OS version too old (Version RPC unavailable)`); } } async getKey(path = "", purpose = "", algorithm = "secp256k1") { await this.ensureAlgorithmSupported(algorithm); const payload = JSON.stringify({ path, purpose, algorithm }); const result = await send_rpc_request(this.endpoint, "/GetKey", payload); return Object.freeze({ key: new Uint8Array(Buffer.from(result.key, "hex")), signature_chain: result.signature_chain.map((sig) => new Uint8Array(Buffer.from(sig, "hex"))), __name__: "GetKeyResponse" }); } async getTlsKey(options = {}) { const { subject = "", altNames = [], usageRaTls = false, usageServerAuth = true, usageClientAuth = false, notBefore, notAfter, withAppInfo } = options; const newFeatures = []; if (notBefore !== void 0) newFeatures.push("notBefore"); if (notAfter !== void 0) newFeatures.push("notAfter"); if (withAppInfo !== void 0) newFeatures.push("withAppInfo"); if (newFeatures.length > 0) { await this.ensureTlsKeyOptionsSupported(newFeatures); } let raw = { subject, usage_ra_tls: usageRaTls, usage_server_auth: usageServerAuth, usage_client_auth: usageClientAuth }; if (altNames && altNames.length) { raw["alt_names"] = altNames; } if (notBefore !== void 0) { raw["not_before"] = notBefore; } if (notAfter !== void 0) { raw["not_after"] = notAfter; } if (withAppInfo !== void 0) { raw["with_app_info"] = withAppInfo; } const payload = JSON.stringify(raw); const result = await send_rpc_request(this.endpoint, "/GetTlsKey", payload); const asUint8Array = (length) => x509key_to_uint8array(result.key, length); return Object.freeze({ ...result, asUint8Array, __name__: "GetTlsKeyResponse" }); } async getQuote(report_data) { let hex = to_hex(report_data); if (hex.length > 128) { throw new Error(`Report data is too large, it should be less than 64 bytes.`); } const payload = JSON.stringify({ report_data: hex }); const result = await send_rpc_request(this.endpoint, "/GetQuote", payload); if ("error" in result) { const err = result["error"]; throw new Error(err); } Object.defineProperty(result, "replayRtmrs", { get: () => () => reply_rtmrs(JSON.parse(result.event_log)), enumerable: true, configurable: false }); return Object.freeze(result); } async attest(report_data) { let hex = to_hex(report_data); if (hex.length > 128) { throw new Error(`Report data is too large, it should be less than 64 bytes.`); } const payload = JSON.stringify({ report_data: hex }); const result = await send_rpc_request(this.endpoint, "/Attest", payload); if ("error" in result) { const err = result["error"]; throw new Error(err); } return Object.freeze({ __name__: "AttestResponse", attestation: result.attestation }); } async info() { const result = await send_rpc_request(this.endpoint, "/Info", "{}"); return Object.freeze({ ...result, tcb_info: JSON.parse(result.tcb_info) }); } /** * Query the guest-agent version. * * Returns the version on OS >= 0.5.7. * Throws on older OS versions that lack the Version RPC. */ async version() { const result = await send_rpc_request(this.endpoint, "/Version", "{}"); return Object.freeze({ ...result, __name__: "VersionResponse" }); } async isReachable() { try { await send_rpc_request(this.endpoint, "/Info", "{}", 500); return true; } catch (error) { return false; } } /** * Emit an event. This extends the event to RTMR3 on TDX platform. * * Requires dstack OS 0.5.0 or later. * * @param event The event name * @param payload The event data as string or Buffer or Uint8Array */ async emitEvent(event, payload) { if (!event) { throw new Error("Event name cannot be empty"); } const hexPayload = to_hex(payload); await send_rpc_request( this.endpoint, "/EmitEvent", JSON.stringify({ event, payload: hexPayload }) ); } /** * Signs a payload using a derived key. * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") * @param data The data to sign. If algorithm is "secp256k1_prehashed", this must be a 32-byte hash. * @returns A SignResponse containing the signature, signature chain, and public key. */ async sign(algorithm, data) { const hexData = to_hex(data); if (algorithm === "secp256k1_prehashed" && hexData.length !== 64) { throw new Error(`Pre-hashed signing requires a 32-byte digest, but received ${hexData.length / 2} bytes`); } const payload = JSON.stringify({ algorithm, data: hexData }); const result = await send_rpc_request(this.endpoint, "/Sign", payload); return Object.freeze({ signature: new Uint8Array(Buffer.from(result.signature, "hex")), signature_chain: result.signature_chain.map((sig) => new Uint8Array(Buffer.from(sig, "hex"))), public_key: new Uint8Array(Buffer.from(result.public_key, "hex")), __name__: "SignResponse" }); } /** * Verifies a payload signature. * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") * @param data The data that was signed. * @param signature The signature to verify. * @param publicKey The public key to use for verification. * @returns A VerifyResponse indicating if the signature is valid. */ async verify(algorithm, data, signature, publicKey) { const payload = JSON.stringify({ algorithm, data: to_hex(data), signature: to_hex(signature), public_key: to_hex(publicKey) }); const result = await send_rpc_request(this.endpoint, "/Verify", payload); return Object.freeze({ ...result, __name__: "VerifyResponse" }); } // // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. // These methods don't mean fully compatible as past, but we keep them here until next major version. // /** * @deprecated Use getKey instead. * @param path The path to the key. * @param subject The subject of the key. * @param altNames The alternative names of the key. * @returns The key. */ async deriveKey(path, subject, altNames) { throw new Error("deriveKey is deprecated, please use getKey instead."); } /** * @deprecated Use getQuote instead. * @param report_data The report data. * @param hash_algorithm The hash algorithm. * @returns The quote. */ async tdxQuote(report_data, hash_algorithm) { console.warn("tdxQuote is deprecated, please use getQuote instead"); if (hash_algorithm !== "raw") { throw new Error("tdxQuote only supports raw hash algorithm."); } return this.getQuote(report_data); } }; var TappdClient = class extends DstackClient { constructor(endpoint = void 0) { if (endpoint === void 0) { if (process.env.TAPPD_SIMULATOR_ENDPOINT) { console.warn(`Using tappd endpoint: ${process.env.TAPPD_SIMULATOR_ENDPOINT}`); endpoint = process.env.TAPPD_SIMULATOR_ENDPOINT; } else { const socketPaths = [ "/var/run/tappd.sock", "/run/tappd.sock", "/var/run/dstack/tappd.sock", "/run/dstack/tappd.sock" ]; endpoint = socketPaths.find((p) => fs__default.default.existsSync(p)) ?? socketPaths[0]; } } console.warn("TappdClient is deprecated, please use DstackClient instead"); super(endpoint); } /** * @deprecated Use getKey instead. * @param path The path to the key. * @param subject The subject of the key. * @param altNames The alternative names of the key. * @returns The key. */ async deriveKey(path, subject, alt_names) { console.warn("deriveKey is deprecated, please use getKey instead"); let raw = { path: path || "", subject: subject || path || "" }; if (alt_names && alt_names.length) { raw["alt_names"] = alt_names; } const payload = JSON.stringify(raw); const result = await send_rpc_request(this.endpoint, "/prpc/Tappd.DeriveKey", payload); const asUint8Array = (length) => x509key_to_uint8array(result.key, length); return Object.freeze({ ...result, asUint8Array, __name__: "GetTlsKeyResponse" }); } /** * @deprecated Use getQuote instead. * @param report_data The report data. * @param hash_algorithm The hash algorithm. * @returns The quote. */ async tdxQuote(report_data, hash_algorithm) { console.warn("tdxQuote is deprecated, please use getQuote instead"); let hex = to_hex(report_data); if (hash_algorithm === "raw") { if (hex.length > 128) { throw new Error(`Report data is too large, it should less then 64 bytes when hash_algorithm is raw.`); } if (hex.length < 128) { hex = hex.padStart(128, "0"); } } const payload = JSON.stringify({ report_data: hex, hash_algorithm }); const result = await send_rpc_request(this.endpoint, "/prpc/Tappd.TdxQuote", payload); if ("error" in result) { const err = result["error"]; throw new Error(err); } Object.defineProperty(result, "replayRtmrs", { get: () => () => reply_rtmrs(JSON.parse(result.event_log)), enumerable: true, configurable: false }); return Object.freeze(result); } async isReachable() { try { await send_rpc_request(this.endpoint, "/prpc/Tappd.Info", "{}", 500); return true; } catch (error) { return false; } } }; exports.DstackClient = DstackClient; exports.TappdClient = TappdClient; exports.getComposeHash = getComposeHash; exports.to_hex = to_hex; exports.verifyEnvEncryptPublicKey = verifyEnvEncryptPublicKey; exports.verifyEnvEncryptPublicKeyLegacy = verifyEnvEncryptPublicKeyLegacy;