@le-space/ui 0.9.9 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/react/index.d.ts +70 -1
- package/react/index.js +819 -77
- package/shared/index.d.ts +115 -14
- package/shared/index.js +798 -77
- package/svelte/SponsorRelayFab.svelte +33 -2
package/shared/index.js
CHANGED
|
@@ -96,7 +96,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
|
|
|
96
96
|
var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
|
|
97
97
|
|
|
98
98
|
// src/shared/package-version.ts
|
|
99
|
-
var UI_PACKAGE_VERSION = "0.
|
|
99
|
+
var UI_PACKAGE_VERSION = "0.11.0";
|
|
100
100
|
|
|
101
101
|
// src/shared/format.ts
|
|
102
102
|
function shortHash(value, head = 8, tail = 6) {
|
|
@@ -143,6 +143,16 @@ function joinRequiredPortForwards(portForwards) {
|
|
|
143
143
|
return `${entry.port}/${protocols}`;
|
|
144
144
|
}).join(" \xB7 ");
|
|
145
145
|
}
|
|
146
|
+
function formatCrnOptionLabel(option, confidential = false) {
|
|
147
|
+
const parts = [option.name ?? option.hash.slice(0, 8)];
|
|
148
|
+
if (option.score != null) parts.push(option.score.toFixed(2));
|
|
149
|
+
if (confidential) {
|
|
150
|
+
if (option.country) parts.push(option.country);
|
|
151
|
+
if (option.version) parts.push(`aleph-vm ${option.version}`);
|
|
152
|
+
if (option.operator) parts.push(`operator ${option.operator}`);
|
|
153
|
+
}
|
|
154
|
+
return parts.join(" \xB7 ");
|
|
155
|
+
}
|
|
146
156
|
|
|
147
157
|
// ../browser/src/http.ts
|
|
148
158
|
async function fetchWithTimeout(input, init = {}, timeoutMs = 15e3) {
|
|
@@ -952,54 +962,6 @@ function resolveManifestSource(args) {
|
|
|
952
962
|
|
|
953
963
|
// src/shared/wallet-controller.ts
|
|
954
964
|
import { getAddress } from "viem";
|
|
955
|
-
function getEthereumProvider() {
|
|
956
|
-
return globalThis.window?.ethereum ?? null;
|
|
957
|
-
}
|
|
958
|
-
function toChecksumAddress(address) {
|
|
959
|
-
try {
|
|
960
|
-
return getAddress(address);
|
|
961
|
-
} catch {
|
|
962
|
-
throw new Error("Invalid EVM address.");
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
async function connectWallet(provider = getEthereumProvider()) {
|
|
966
|
-
if (!provider) {
|
|
967
|
-
throw new Error("MetaMask provider not found.");
|
|
968
|
-
}
|
|
969
|
-
const accounts = await provider.request({
|
|
970
|
-
method: "eth_requestAccounts"
|
|
971
|
-
});
|
|
972
|
-
const chainId = await provider.request({ method: "eth_chainId" });
|
|
973
|
-
const address = accounts[0] ? toChecksumAddress(accounts[0]) : null;
|
|
974
|
-
return {
|
|
975
|
-
connected: Boolean(address),
|
|
976
|
-
address,
|
|
977
|
-
chainId,
|
|
978
|
-
isMetaMask: Boolean(provider.isMetaMask)
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
async function personalSign(address, message, provider = getEthereumProvider()) {
|
|
982
|
-
if (!provider) {
|
|
983
|
-
throw new Error("MetaMask provider not found.");
|
|
984
|
-
}
|
|
985
|
-
const payload = `0x${Array.from(new TextEncoder().encode(message)).map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
986
|
-
return provider.request({
|
|
987
|
-
method: "personal_sign",
|
|
988
|
-
params: [payload, address]
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
function watchWallet(onChange, provider = getEthereumProvider()) {
|
|
992
|
-
if (!provider?.on || !provider?.removeListener) {
|
|
993
|
-
return () => {
|
|
994
|
-
};
|
|
995
|
-
}
|
|
996
|
-
provider.on("accountsChanged", onChange);
|
|
997
|
-
provider.on("chainChanged", onChange);
|
|
998
|
-
return () => {
|
|
999
|
-
provider.removeListener?.("accountsChanged", onChange);
|
|
1000
|
-
provider.removeListener?.("chainChanged", onChange);
|
|
1001
|
-
};
|
|
1002
|
-
}
|
|
1003
965
|
|
|
1004
966
|
// ../browser/src/client.ts
|
|
1005
967
|
function normalizeApiHost(value) {
|
|
@@ -1090,18 +1052,65 @@ function createAlephBrowserClient(options = {}) {
|
|
|
1090
1052
|
};
|
|
1091
1053
|
}
|
|
1092
1054
|
|
|
1055
|
+
// ../browser/src/wallet.ts
|
|
1056
|
+
function getEthereumProvider() {
|
|
1057
|
+
return globalThis.window?.ethereum ?? null;
|
|
1058
|
+
}
|
|
1059
|
+
async function connectWallet(provider = getEthereumProvider(), options = {}) {
|
|
1060
|
+
if (!provider) {
|
|
1061
|
+
throw new Error("MetaMask provider not found.");
|
|
1062
|
+
}
|
|
1063
|
+
const accounts = await provider.request({ method: "eth_requestAccounts" });
|
|
1064
|
+
const chainId = await provider.request({ method: "eth_chainId" });
|
|
1065
|
+
const account = accounts?.[0];
|
|
1066
|
+
const address = account ? options.normaliseAddress?.(account) ?? account : null;
|
|
1067
|
+
return {
|
|
1068
|
+
connected: Boolean(address),
|
|
1069
|
+
address,
|
|
1070
|
+
chainId,
|
|
1071
|
+
isMetaMask: Boolean(provider.isMetaMask)
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
function watchWallet(onChange, provider = getEthereumProvider()) {
|
|
1075
|
+
if (!provider?.on || !provider?.removeListener) {
|
|
1076
|
+
return () => {
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
provider.on("accountsChanged", onChange);
|
|
1080
|
+
provider.on("chainChanged", onChange);
|
|
1081
|
+
return () => {
|
|
1082
|
+
provider.removeListener?.("accountsChanged", onChange);
|
|
1083
|
+
provider.removeListener?.("chainChanged", onChange);
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ../browser/src/evm.ts
|
|
1088
|
+
function assertProvider(provider) {
|
|
1089
|
+
if (!provider) {
|
|
1090
|
+
throw new Error("MetaMask provider not found.");
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async function personalSign(address, message, provider = getEthereumProvider()) {
|
|
1094
|
+
assertProvider(provider);
|
|
1095
|
+
const hexMessage = `0x${Array.from(new TextEncoder().encode(message)).map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1096
|
+
return provider.request({
|
|
1097
|
+
method: "personal_sign",
|
|
1098
|
+
params: [hexMessage, address]
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1093
1102
|
// ../browser/src/pricing.ts
|
|
1094
1103
|
var DEFAULT_ALEPH_AGGREGATE_ADDRESS = "0xFba561a84A537fCaa567bb7A2257e7142701ae2A";
|
|
1095
|
-
function parseInstancePricing(payload) {
|
|
1104
|
+
function parseInstancePricing(payload, kind = "instance") {
|
|
1096
1105
|
const data = payload;
|
|
1097
1106
|
const pricing = data.data?.pricing ?? data.pricing;
|
|
1098
|
-
const instance = pricing?.
|
|
1107
|
+
const instance = pricing?.[kind];
|
|
1099
1108
|
if (!instance?.price?.compute_unit || !instance.compute_unit || !Array.isArray(instance.tiers)) {
|
|
1100
|
-
throw new Error(
|
|
1109
|
+
throw new Error(`Aleph pricing aggregate does not contain ${kind} pricing.`);
|
|
1101
1110
|
}
|
|
1102
1111
|
return instance;
|
|
1103
1112
|
}
|
|
1104
|
-
async function fetchInstancePricing(apiHost = DEFAULT_ALEPH_API_HOST, aggregateAddress = DEFAULT_ALEPH_AGGREGATE_ADDRESS) {
|
|
1113
|
+
async function fetchInstancePricing(apiHost = DEFAULT_ALEPH_API_HOST, aggregateAddress = DEFAULT_ALEPH_AGGREGATE_ADDRESS, kind = "instance") {
|
|
1105
1114
|
const response = await fetchWithTimeout(`${apiHost}/api/v0/aggregates/${aggregateAddress}.json?keys=pricing`, {
|
|
1106
1115
|
cache: "no-cache"
|
|
1107
1116
|
});
|
|
@@ -1114,17 +1123,539 @@ async function fetchInstancePricing(apiHost = DEFAULT_ALEPH_API_HOST, aggregateA
|
|
|
1114
1123
|
throw new Error("Pricing aggregate response did not include a pricing key.");
|
|
1115
1124
|
}
|
|
1116
1125
|
return {
|
|
1117
|
-
pricing: parseInstancePricing({ pricing: pricingAggregate }),
|
|
1126
|
+
pricing: parseInstancePricing({ pricing: pricingAggregate }, kind),
|
|
1118
1127
|
fetchedAt: Date.now()
|
|
1119
1128
|
};
|
|
1120
1129
|
}
|
|
1121
1130
|
|
|
1131
|
+
// ../attest-wasm/js/index.mjs
|
|
1132
|
+
async function verifyAttestation({
|
|
1133
|
+
wasm,
|
|
1134
|
+
report,
|
|
1135
|
+
product,
|
|
1136
|
+
expectedMeasurements,
|
|
1137
|
+
expectedPolicy,
|
|
1138
|
+
minTcb,
|
|
1139
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
1140
|
+
nowSeconds = Date.now() / 1e3,
|
|
1141
|
+
sleep: sleep2
|
|
1142
|
+
}) {
|
|
1143
|
+
if (!Array.isArray(expectedMeasurements) || expectedMeasurements.length === 0) {
|
|
1144
|
+
throw new Error("verifyAttestation needs the launch measurements the INSTANCE message pins");
|
|
1145
|
+
}
|
|
1146
|
+
product = product ?? wasm.detectProduct(report);
|
|
1147
|
+
if (!product) throw new Error("the report does not name its AMD product line (older firmware); pass product");
|
|
1148
|
+
const info = wasm.reportInfo(report, product);
|
|
1149
|
+
const [vcek, crl] = await Promise.all([
|
|
1150
|
+
fetchBytes(fetchImpl, info.vcekUrl, "VCEK", { sleep: sleep2 }),
|
|
1151
|
+
fetchBytes(fetchImpl, info.crlUrl, "CRL", { sleep: sleep2 })
|
|
1152
|
+
]);
|
|
1153
|
+
return wasm.verifyReport(
|
|
1154
|
+
report,
|
|
1155
|
+
product,
|
|
1156
|
+
vcek,
|
|
1157
|
+
crl,
|
|
1158
|
+
nowSeconds,
|
|
1159
|
+
expectedMeasurements,
|
|
1160
|
+
expectedPolicy == null ? null : String(expectedPolicy),
|
|
1161
|
+
minTcb ? Uint8Array.from(minTcb) : null
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
var cache = /* @__PURE__ */ new Map();
|
|
1165
|
+
var CRL_TTL_MS = 60 * 60 * 1e3;
|
|
1166
|
+
async function fetchBytes(fetchImpl, url, what, { attempts = 5, sleep: sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
|
|
1167
|
+
const hit = cache.get(url);
|
|
1168
|
+
if (hit && (what === "VCEK" || Date.now() - hit.at < CRL_TTL_MS)) return hit.bytes;
|
|
1169
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1170
|
+
const response = await fetchImpl(url);
|
|
1171
|
+
if (response.ok) {
|
|
1172
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
1173
|
+
cache.set(url, { bytes, at: Date.now() });
|
|
1174
|
+
return bytes;
|
|
1175
|
+
}
|
|
1176
|
+
if ((response.status === 429 || response.status === 503) && attempt < attempts) {
|
|
1177
|
+
const retryAfter = Number(response.headers?.get?.("retry-after"));
|
|
1178
|
+
await sleep2(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) * 1e3 : 2e3 * 2 ** (attempt - 1));
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
throw new Error(`AMD KDS answered ${response.status} for the ${what}: ${url}`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ../attest-wasm/js/ratls.mjs
|
|
1186
|
+
var encoder = new TextEncoder();
|
|
1187
|
+
var decoder = new TextDecoder();
|
|
1188
|
+
var hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1189
|
+
var fromHex = (text) => Uint8Array.from(text.match(/../g) ?? [], (pair) => parseInt(pair, 16));
|
|
1190
|
+
function concat(a, b) {
|
|
1191
|
+
const out = new Uint8Array(a.length + b.length);
|
|
1192
|
+
out.set(a, 0);
|
|
1193
|
+
out.set(b, a.length);
|
|
1194
|
+
return out;
|
|
1195
|
+
}
|
|
1196
|
+
function websocketTransport(url, { WebSocketImpl = globalThis.WebSocket } = {}) {
|
|
1197
|
+
const socket = new WebSocketImpl(url);
|
|
1198
|
+
socket.binaryType = "arraybuffer";
|
|
1199
|
+
const opened = new Promise((resolve, reject) => {
|
|
1200
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
1201
|
+
socket.addEventListener("error", () => reject(new Error(`WebSocket to ${url} failed`)), { once: true });
|
|
1202
|
+
});
|
|
1203
|
+
return {
|
|
1204
|
+
opened,
|
|
1205
|
+
send: (bytes) => socket.send(bytes),
|
|
1206
|
+
onData: (callback) => socket.addEventListener("message", (event) => callback(new Uint8Array(event.data))),
|
|
1207
|
+
onClose: (callback) => socket.addEventListener("close", () => callback()),
|
|
1208
|
+
close: () => socket.close()
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
async function openAttestedChannel({ wasm, transport, expectedMeasurements, expectedPolicy, timeoutMs = 3e4 }) {
|
|
1212
|
+
const session = new wasm.RaTlsSession(expectedMeasurements, expectedPolicy == null ? null : String(expectedPolicy));
|
|
1213
|
+
let plaintext = new Uint8Array(0);
|
|
1214
|
+
let failure = null;
|
|
1215
|
+
let closed = false;
|
|
1216
|
+
const waiters = /* @__PURE__ */ new Set();
|
|
1217
|
+
const wake = () => {
|
|
1218
|
+
for (const waiter of waiters) waiter();
|
|
1219
|
+
};
|
|
1220
|
+
const flush = () => {
|
|
1221
|
+
const out = session.takeOutgoing();
|
|
1222
|
+
if (out.length > 0) transport.send(out);
|
|
1223
|
+
};
|
|
1224
|
+
transport.onData((bytes) => {
|
|
1225
|
+
try {
|
|
1226
|
+
session.pushIncoming(bytes);
|
|
1227
|
+
flush();
|
|
1228
|
+
const more = session.isHandshaking() ? new Uint8Array(0) : session.takePlaintext();
|
|
1229
|
+
if (more.length > 0) plaintext = concat(plaintext, more);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
1232
|
+
transport.close();
|
|
1233
|
+
}
|
|
1234
|
+
wake();
|
|
1235
|
+
});
|
|
1236
|
+
transport.onClose(() => {
|
|
1237
|
+
closed = true;
|
|
1238
|
+
wake();
|
|
1239
|
+
});
|
|
1240
|
+
const until = (predicate, what) => new Promise((resolve, reject) => {
|
|
1241
|
+
const deadline = setTimeout(() => {
|
|
1242
|
+
waiters.delete(check);
|
|
1243
|
+
reject(new Error(`timed out waiting for ${what}`));
|
|
1244
|
+
}, timeoutMs);
|
|
1245
|
+
const check = () => {
|
|
1246
|
+
if (failure) {
|
|
1247
|
+
clearTimeout(deadline);
|
|
1248
|
+
waiters.delete(check);
|
|
1249
|
+
reject(failure);
|
|
1250
|
+
} else if (predicate()) {
|
|
1251
|
+
clearTimeout(deadline);
|
|
1252
|
+
waiters.delete(check);
|
|
1253
|
+
resolve();
|
|
1254
|
+
} else if (closed) {
|
|
1255
|
+
clearTimeout(deadline);
|
|
1256
|
+
waiters.delete(check);
|
|
1257
|
+
reject(new Error(`the connection closed while waiting for ${what}`));
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
waiters.add(check);
|
|
1261
|
+
check();
|
|
1262
|
+
});
|
|
1263
|
+
if (transport.opened) await transport.opened;
|
|
1264
|
+
flush();
|
|
1265
|
+
await until(() => !session.isHandshaking(), "the attested TLS handshake");
|
|
1266
|
+
const request = async (method, path, { headers = {}, body } = {}) => {
|
|
1267
|
+
const payload = body == null ? new Uint8Array(0) : typeof body === "string" ? encoder.encode(body) : body;
|
|
1268
|
+
const lines = [`${method} ${path} HTTP/1.1`, "host: attestation.aleph.invalid", `content-length: ${payload.length}`];
|
|
1269
|
+
for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${value}`);
|
|
1270
|
+
plaintext = new Uint8Array(0);
|
|
1271
|
+
session.writePlaintext(concat(encoder.encode(`${lines.join("\r\n")}\r
|
|
1272
|
+
\r
|
|
1273
|
+
`), payload));
|
|
1274
|
+
flush();
|
|
1275
|
+
let response = null;
|
|
1276
|
+
await until(() => (response = parseResponse(plaintext)) !== null, `the answer to ${method} ${path}`);
|
|
1277
|
+
plaintext = new Uint8Array(0);
|
|
1278
|
+
return response;
|
|
1279
|
+
};
|
|
1280
|
+
return {
|
|
1281
|
+
session,
|
|
1282
|
+
request,
|
|
1283
|
+
certReport: session.certReport(),
|
|
1284
|
+
servedPublicKey: session.servedPublicKey(),
|
|
1285
|
+
close: () => {
|
|
1286
|
+
session.close();
|
|
1287
|
+
try {
|
|
1288
|
+
flush();
|
|
1289
|
+
} catch {
|
|
1290
|
+
}
|
|
1291
|
+
transport.close();
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
function parseResponse(bytes) {
|
|
1296
|
+
const text = decoder.decode(bytes);
|
|
1297
|
+
const end = text.indexOf("\r\n\r\n");
|
|
1298
|
+
if (end < 0) return null;
|
|
1299
|
+
const [statusLine, ...headerLines] = text.slice(0, end).split("\r\n");
|
|
1300
|
+
const status = Number(statusLine.split(" ")[1]);
|
|
1301
|
+
const headers = {};
|
|
1302
|
+
for (const line of headerLines) {
|
|
1303
|
+
const colon = line.indexOf(":");
|
|
1304
|
+
if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
1305
|
+
}
|
|
1306
|
+
const head = encoder.encode(text.slice(0, end + 4)).length;
|
|
1307
|
+
const rest = bytes.slice(head);
|
|
1308
|
+
if (headers["transfer-encoding"]?.toLowerCase() === "chunked") {
|
|
1309
|
+
const chunks = [];
|
|
1310
|
+
let offset = 0;
|
|
1311
|
+
for (; ; ) {
|
|
1312
|
+
const lineEnd = decoder.decode(rest.slice(offset)).indexOf("\r\n");
|
|
1313
|
+
if (lineEnd < 0) return null;
|
|
1314
|
+
const size = parseInt(decoder.decode(rest.slice(offset, offset + lineEnd)), 16);
|
|
1315
|
+
offset += lineEnd + 2;
|
|
1316
|
+
if (size === 0) break;
|
|
1317
|
+
if (rest.length < offset + size + 2) return null;
|
|
1318
|
+
chunks.push(rest.slice(offset, offset + size));
|
|
1319
|
+
offset += size + 2;
|
|
1320
|
+
}
|
|
1321
|
+
return { status, headers, body: chunks.reduce(concat, new Uint8Array(0)) };
|
|
1322
|
+
}
|
|
1323
|
+
const length = Number(headers["content-length"] ?? 0);
|
|
1324
|
+
if (rest.length < length) return null;
|
|
1325
|
+
return { status, headers, body: rest.slice(0, length) };
|
|
1326
|
+
}
|
|
1327
|
+
async function attestGuest({
|
|
1328
|
+
wasm,
|
|
1329
|
+
transport,
|
|
1330
|
+
product,
|
|
1331
|
+
expectedMeasurements,
|
|
1332
|
+
expectedPolicy,
|
|
1333
|
+
minTcb,
|
|
1334
|
+
fetch: fetch2,
|
|
1335
|
+
verify = verifyAttestation,
|
|
1336
|
+
nonce = globalThis.crypto.getRandomValues(new Uint8Array(32)),
|
|
1337
|
+
timeoutMs
|
|
1338
|
+
}) {
|
|
1339
|
+
const channel = await openAttestedChannel({ wasm, transport, expectedMeasurements, expectedPolicy, timeoutMs });
|
|
1340
|
+
try {
|
|
1341
|
+
const check = (report) => verify({ wasm, report, product, expectedMeasurements, expectedPolicy, minTcb, fetch: fetch2 });
|
|
1342
|
+
const certVerified = await check(channel.certReport);
|
|
1343
|
+
const response = await channel.request("GET", `/.well-known/attestation?nonce=${hex(nonce)}`);
|
|
1344
|
+
if (response.status !== 200) throw new Error(`the guest answered ${response.status} to the freshness challenge`);
|
|
1345
|
+
const dto = JSON.parse(decoder.decode(response.body));
|
|
1346
|
+
if (dto.tee_type !== "sev-snp") throw new Error(`the fresh report is ${dto.tee_type}, not sev-snp`);
|
|
1347
|
+
const freshReport = fromHex(String(dto.data ?? ""));
|
|
1348
|
+
const freshVerified = await check(freshReport);
|
|
1349
|
+
wasm.verifyFreshBinding(freshReport, channel.servedPublicKey, nonce);
|
|
1350
|
+
return { channel, servedPublicKey: channel.servedPublicKey, certVerified, freshVerified };
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
channel.close();
|
|
1353
|
+
throw error;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function canonicalSecretsJson(secrets) {
|
|
1357
|
+
const sorted = {};
|
|
1358
|
+
for (const key of Object.keys(secrets).sort()) sorted[key] = String(secrets[key]);
|
|
1359
|
+
return JSON.stringify(sorted);
|
|
1360
|
+
}
|
|
1361
|
+
async function injectSecrets({ wasm, attested, secrets, sign }) {
|
|
1362
|
+
const canonical = canonicalSecretsJson(secrets);
|
|
1363
|
+
const payload = wasm.injectSecretPayload(attested.servedPublicKey, canonical);
|
|
1364
|
+
const signature = await sign(payload);
|
|
1365
|
+
const response = await attested.channel.request("POST", "/confidential/inject-secret", {
|
|
1366
|
+
headers: { "content-type": "application/json" },
|
|
1367
|
+
body: JSON.stringify({ secrets: JSON.parse(canonical), signature })
|
|
1368
|
+
});
|
|
1369
|
+
const text = decoder.decode(response.body);
|
|
1370
|
+
if (response.status < 200 || response.status >= 300) {
|
|
1371
|
+
throw new Error(`the guest refused the secrets (${response.status}): ${text}`);
|
|
1372
|
+
}
|
|
1373
|
+
return JSON.parse(text);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ../attest-wasm/js/runtime.mjs
|
|
1377
|
+
var VM_IMAGES_ADDRESS = "0xFba561a84A537fCaa567bb7A2257e7142701ae2A";
|
|
1378
|
+
var DEFAULT_SNP_POLICY = 196608;
|
|
1379
|
+
var DEFAULT_API = "https://api2.aleph.im";
|
|
1380
|
+
var DEFAULT_IPFS = "https://ipfs.aleph.im/ipfs";
|
|
1381
|
+
var hex2 = (bytes) => Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1382
|
+
async function resolveInstanceRuntime({ apiHost = DEFAULT_API, name, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
1383
|
+
const response = await fetchImpl(`${apiHost}/api/v0/aggregates/${VM_IMAGES_ADDRESS}.json?keys=vm-images`);
|
|
1384
|
+
if (!response.ok) throw new Error(`vm-images aggregate answered ${response.status}`);
|
|
1385
|
+
const images = (await response.json()).data?.["vm-images"] ?? {};
|
|
1386
|
+
const key = name ?? images.defaults?.instance_runtime;
|
|
1387
|
+
const entry = images.instance_runtimes?.[key];
|
|
1388
|
+
if (!entry?.hash) throw new Error(`no instance runtime ${JSON.stringify(key)} in vm-images`);
|
|
1389
|
+
return { name: key, itemHash: entry.hash, deprecated: entry.deprecated === true };
|
|
1390
|
+
}
|
|
1391
|
+
async function fetchStoreBytes({ apiHost = DEFAULT_API, ipfsGateway = DEFAULT_IPFS, itemHash, fetch: fetchImpl = globalThis.fetch }) {
|
|
1392
|
+
const message = await (await fetchImpl(`${apiHost}/api/v0/messages/${itemHash}`)).json();
|
|
1393
|
+
const content = message.message?.content ?? message.content;
|
|
1394
|
+
if ((message.message?.type ?? message.type) !== "STORE" || !content?.item_hash) throw new Error(`${itemHash} is not a STORE message`);
|
|
1395
|
+
const url = content.item_type === "ipfs" ? `${ipfsGateway}/${content.item_hash}` : `${apiHost}/api/v0/storage/raw/${content.item_hash}`;
|
|
1396
|
+
const response = await fetchImpl(url);
|
|
1397
|
+
if (!response.ok) throw new Error(`fetching ${itemHash} answered ${response.status}`);
|
|
1398
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1399
|
+
}
|
|
1400
|
+
function untar(bytes) {
|
|
1401
|
+
const files = /* @__PURE__ */ new Map();
|
|
1402
|
+
const decoder2 = new TextDecoder();
|
|
1403
|
+
for (let offset = 0; offset + 512 <= bytes.length; ) {
|
|
1404
|
+
const header = bytes.subarray(offset, offset + 512);
|
|
1405
|
+
if (header.every((byte) => byte === 0)) break;
|
|
1406
|
+
const field = (start2, length) => decoder2.decode(header.subarray(start2, start2 + length)).replace(/\0.*$/s, "").trim();
|
|
1407
|
+
const prefix = field(345, 155);
|
|
1408
|
+
const name = (prefix ? `${prefix}/` : "") + field(0, 100);
|
|
1409
|
+
const size = parseInt(field(124, 12) || "0", 8);
|
|
1410
|
+
const type = field(156, 1);
|
|
1411
|
+
const start = offset + 512;
|
|
1412
|
+
if (type === "" || type === "0") files.set(name.replace(/^\.\//, ""), bytes.subarray(start, start + size));
|
|
1413
|
+
offset = start + Math.ceil(size / 512) * 512;
|
|
1414
|
+
}
|
|
1415
|
+
return files;
|
|
1416
|
+
}
|
|
1417
|
+
async function gunzip(bytes) {
|
|
1418
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
1419
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
1420
|
+
}
|
|
1421
|
+
async function fetchInstanceRuntime({ itemHash, apiHost, ipfsGateway, fetch: fetch2 }) {
|
|
1422
|
+
const manifest = JSON.parse(new TextDecoder().decode(await fetchStoreBytes({ apiHost, ipfsGateway, itemHash, fetch: fetch2 })));
|
|
1423
|
+
if (manifest.format !== "aleph-instance-runtime" || manifest.platform !== "sev_snp") {
|
|
1424
|
+
throw new Error(`${itemHash} is not an SEV-SNP instance runtime manifest`);
|
|
1425
|
+
}
|
|
1426
|
+
const bundle = await fetchStoreBytes({ apiHost, ipfsGateway, itemHash: manifest.bundle.ref, fetch: fetch2 });
|
|
1427
|
+
const digest = hex2(await globalThis.crypto.subtle.digest("SHA-256", bundle));
|
|
1428
|
+
if (digest !== manifest.bundle.sha256 || manifest.bundle.size && bundle.length !== manifest.bundle.size) {
|
|
1429
|
+
throw new Error(`the runtime bundle does not match its manifest (sha256 ${digest})`);
|
|
1430
|
+
}
|
|
1431
|
+
const files = untar(await gunzip(bundle));
|
|
1432
|
+
const member = (role) => {
|
|
1433
|
+
const data = files.get(manifest.bundle.members[role]);
|
|
1434
|
+
if (!data) throw new Error(`the runtime bundle has no ${role} (${manifest.bundle.members[role]})`);
|
|
1435
|
+
return data;
|
|
1436
|
+
};
|
|
1437
|
+
return { itemHash, manifest, ovmf: member("ovmf"), kernel: member("kernel"), initrd: member("initrd") };
|
|
1438
|
+
}
|
|
1439
|
+
function trustedExecution({ wasm, runtime, owner, vcpus, policy = DEFAULT_SNP_POLICY }) {
|
|
1440
|
+
const cmdline = wasm.instanceCmdline(runtime.manifest.boot.cmdline_template, owner);
|
|
1441
|
+
const measurements = wasm.launchMeasurements(runtime.ovmf, runtime.kernel, runtime.initrd, cmdline, vcpus, runtime.manifest.boot.cpu_models);
|
|
1442
|
+
return {
|
|
1443
|
+
mode: "sev_snp",
|
|
1444
|
+
policy,
|
|
1445
|
+
runtime: runtime.itemHash,
|
|
1446
|
+
measurements: measurements.map(({ vcpuType, launch }) => ({ platform: "sev_snp", registers: { launch }, vcpu_type: vcpuType }))
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// ../attest-wasm/js/instance.mjs
|
|
1451
|
+
var DEFAULT_API2 = "https://api2.aleph.im";
|
|
1452
|
+
async function confidentialTrustedExecution({
|
|
1453
|
+
wasm,
|
|
1454
|
+
owner,
|
|
1455
|
+
vcpus,
|
|
1456
|
+
policy = DEFAULT_SNP_POLICY,
|
|
1457
|
+
runtimeName,
|
|
1458
|
+
apiHost = DEFAULT_API2,
|
|
1459
|
+
fetch: fetch2,
|
|
1460
|
+
onProgress = () => {
|
|
1461
|
+
}
|
|
1462
|
+
}) {
|
|
1463
|
+
onProgress({ phase: "runtime", detail: "Resolving the SEV-SNP runtime" });
|
|
1464
|
+
const resolved = await resolveInstanceRuntime({ apiHost, name: runtimeName, fetch: fetch2 });
|
|
1465
|
+
onProgress({ phase: "bundle", detail: `Downloading runtime ${resolved.name}` });
|
|
1466
|
+
const runtime = await fetchInstanceRuntime({ itemHash: resolved.itemHash, apiHost, fetch: fetch2 });
|
|
1467
|
+
onProgress({ phase: "measure", detail: "Computing the launch measurements" });
|
|
1468
|
+
return trustedExecution({ wasm, runtime, owner, vcpus, policy });
|
|
1469
|
+
}
|
|
1470
|
+
function pinsOf(tee) {
|
|
1471
|
+
const expectedMeasurements = (tee?.measurements ?? []).filter((entry) => entry?.platform === "sev_snp" && typeof entry?.registers?.launch === "string").map((entry) => entry.registers.launch);
|
|
1472
|
+
if (expectedMeasurements.length === 0) throw new Error("the trusted_execution block pins no SEV-SNP measurements");
|
|
1473
|
+
return { expectedMeasurements, expectedPolicy: tee.policy };
|
|
1474
|
+
}
|
|
1475
|
+
var NOT_YET = /closed|timed out|Bad Gateway|WebSocket to .* failed|ECONNREFUSED|ECONNRESET/i;
|
|
1476
|
+
var VERDICT = /rejected|bind|measurement|policy|floor|KDS answered 4|signature|refused the secrets/i;
|
|
1477
|
+
async function unlockInstance({
|
|
1478
|
+
wasm,
|
|
1479
|
+
relayUrl,
|
|
1480
|
+
crnHash,
|
|
1481
|
+
instanceItemHash,
|
|
1482
|
+
trustedExecution: tee,
|
|
1483
|
+
passphrase,
|
|
1484
|
+
secrets = {},
|
|
1485
|
+
sign,
|
|
1486
|
+
attempts = 12,
|
|
1487
|
+
delayMs = 1e4,
|
|
1488
|
+
sleep: sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
1489
|
+
transport = (url) => websocketTransport(url),
|
|
1490
|
+
verify,
|
|
1491
|
+
fetch: fetch2,
|
|
1492
|
+
onProgress = () => {
|
|
1493
|
+
}
|
|
1494
|
+
}) {
|
|
1495
|
+
if (!passphrase) throw new Error("unlocking needs the passphrase of the encrypted rootfs");
|
|
1496
|
+
if ("luks_passphrase" in secrets) throw new Error("pass the passphrase as passphrase, not in secrets");
|
|
1497
|
+
const { expectedMeasurements, expectedPolicy } = pinsOf(tee);
|
|
1498
|
+
const url = `${relayUrl}${relayUrl.includes("?") ? "&" : "?"}crn=${crnHash}&vm=${instanceItemHash}`;
|
|
1499
|
+
let attested = null;
|
|
1500
|
+
for (let attempt = 1; !attested; attempt += 1) {
|
|
1501
|
+
onProgress({ phase: "attest", detail: `Attesting the guest (${attempt}/${attempts})` });
|
|
1502
|
+
try {
|
|
1503
|
+
attested = await attestGuest({
|
|
1504
|
+
wasm,
|
|
1505
|
+
transport: transport(url),
|
|
1506
|
+
expectedMeasurements,
|
|
1507
|
+
expectedPolicy,
|
|
1508
|
+
fetch: fetch2,
|
|
1509
|
+
...verify ? { verify } : {}
|
|
1510
|
+
});
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1513
|
+
if (VERDICT.test(message) || !NOT_YET.test(message) || attempt >= attempts) throw error;
|
|
1514
|
+
onProgress({ phase: "wait", detail: `The guest is not answering yet: ${message}` });
|
|
1515
|
+
await sleep2(delayMs);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
onProgress({ phase: "unlock", detail: "Sign the unlock in your wallet" });
|
|
1520
|
+
const injected = await injectSecrets({ wasm, attested, secrets: { ...secrets, luks_passphrase: passphrase }, sign });
|
|
1521
|
+
return {
|
|
1522
|
+
injected: injected.injected ?? [],
|
|
1523
|
+
measurement: attested.freshVerified.measurement,
|
|
1524
|
+
policy: attested.freshVerified.policy,
|
|
1525
|
+
launchTcb: attested.freshVerified.launchTcb
|
|
1526
|
+
};
|
|
1527
|
+
} finally {
|
|
1528
|
+
attested.channel.close();
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/shared/wallet-controller.ts
|
|
1533
|
+
function toChecksumAddress(address) {
|
|
1534
|
+
try {
|
|
1535
|
+
return getAddress(address);
|
|
1536
|
+
} catch {
|
|
1537
|
+
throw new Error("Invalid EVM address.");
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
async function connectWallet2(provider = getEthereumProvider()) {
|
|
1541
|
+
return connectWallet(provider, {
|
|
1542
|
+
normaliseAddress: toChecksumAddress
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1122
1546
|
// ../core/src/constants.ts
|
|
1123
1547
|
var DEFAULT_ALEPH_CHANNEL = "ALEPH-CLOUDSOLUTIONS";
|
|
1124
1548
|
|
|
1125
1549
|
// ../core/src/manifests.ts
|
|
1126
1550
|
var DEFAULT_ALEPH_API_HOST2 = "https://api.aleph.im";
|
|
1127
1551
|
|
|
1552
|
+
// ../core/src/confidential.ts
|
|
1553
|
+
var MIN_CONFIDENTIAL_ALEPH_VM_VERSION = "2.1.2";
|
|
1554
|
+
var SNP_POLICY_RESERVED_BIT_17 = 131072;
|
|
1555
|
+
function versionParts(value) {
|
|
1556
|
+
const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?/.exec(value.trim());
|
|
1557
|
+
if (!match) return null;
|
|
1558
|
+
return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
1559
|
+
}
|
|
1560
|
+
function compareAlephVmVersions(left, right) {
|
|
1561
|
+
const a = versionParts(left);
|
|
1562
|
+
const b = versionParts(right);
|
|
1563
|
+
if (!a || !b) return null;
|
|
1564
|
+
for (let i = 0; i < 3; i++) {
|
|
1565
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
1566
|
+
}
|
|
1567
|
+
return 0;
|
|
1568
|
+
}
|
|
1569
|
+
function supportsSevSnp(crn) {
|
|
1570
|
+
return Boolean(crn.tee?.sev_snp) || (crn.cpu_features ?? []).includes("sev_snp");
|
|
1571
|
+
}
|
|
1572
|
+
function supportsSev(crn) {
|
|
1573
|
+
if (supportsSevSnp(crn)) return true;
|
|
1574
|
+
if ((crn.cpu_features ?? []).includes("sev")) return true;
|
|
1575
|
+
return crn.confidential_support === true;
|
|
1576
|
+
}
|
|
1577
|
+
function isConfidentialCrn(crn, requirement = {}) {
|
|
1578
|
+
const mode = requirement.mode ?? "sev_snp";
|
|
1579
|
+
if (mode === "sev_snp" ? !supportsSevSnp(crn) : !supportsSev(crn)) return false;
|
|
1580
|
+
if (!crn.version) return false;
|
|
1581
|
+
const comparison = compareAlephVmVersions(crn.version, requirement.minVersion ?? MIN_CONFIDENTIAL_ALEPH_VM_VERSION);
|
|
1582
|
+
return comparison !== null && comparison >= 0;
|
|
1583
|
+
}
|
|
1584
|
+
function asStringArray(value) {
|
|
1585
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : null;
|
|
1586
|
+
}
|
|
1587
|
+
async function fetchJson(fetch2, url) {
|
|
1588
|
+
try {
|
|
1589
|
+
const response = await fetch2(url, { cache: "no-cache" });
|
|
1590
|
+
return response.ok ? await response.json() : null;
|
|
1591
|
+
} catch {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
async function probeCrnTee(crn, options) {
|
|
1596
|
+
const address = crn.address ? String(crn.address).replace(/\/+$/, "") : "";
|
|
1597
|
+
if (!address) return crn;
|
|
1598
|
+
const [usage, config] = await Promise.all([
|
|
1599
|
+
fetchJson(options.fetch, `${address}/about/usage/system`),
|
|
1600
|
+
fetchJson(options.fetch, `${address}/status/config`)
|
|
1601
|
+
]);
|
|
1602
|
+
const enriched = { ...crn };
|
|
1603
|
+
const properties = usage?.properties;
|
|
1604
|
+
const features = asStringArray(properties?.cpu?.features);
|
|
1605
|
+
if (features) enriched.cpu_features = features;
|
|
1606
|
+
if (properties && "tee" in properties) {
|
|
1607
|
+
enriched.tee = properties.tee && typeof properties.tee === "object" ? properties.tee : null;
|
|
1608
|
+
}
|
|
1609
|
+
const version = config?.version;
|
|
1610
|
+
if (typeof version === "string" && version) enriched.version = version;
|
|
1611
|
+
return enriched;
|
|
1612
|
+
}
|
|
1613
|
+
async function enrichCrnsWithTee(crns, options) {
|
|
1614
|
+
const result = [...crns];
|
|
1615
|
+
const pending = result.map((crn, index) => ({ crn, index })).filter(({ crn }) => !crn.version || crn.cpu_features == null && crn.tee == null);
|
|
1616
|
+
const concurrency = Math.max(1, Number(options.concurrency) || 8);
|
|
1617
|
+
for (let start = 0; start < pending.length; start += concurrency) {
|
|
1618
|
+
const batch = pending.slice(start, start + concurrency);
|
|
1619
|
+
const probed = await Promise.all(batch.map(({ crn }) => probeCrnTee(crn, { fetch: options.fetch })));
|
|
1620
|
+
batch.forEach(({ index }, i) => {
|
|
1621
|
+
result[index] = probed[i];
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return result;
|
|
1625
|
+
}
|
|
1626
|
+
var HEX_HASH = /^[a-fA-F0-9]{64}$/;
|
|
1627
|
+
function assertValidTrustedExecution(value) {
|
|
1628
|
+
const mode = value.mode ?? "sev";
|
|
1629
|
+
if (mode === "sev") {
|
|
1630
|
+
if (!value.firmware || !HEX_HASH.test(value.firmware)) {
|
|
1631
|
+
throw new Error("trusted_execution: legacy SEV needs a 64-character firmware item hash.");
|
|
1632
|
+
}
|
|
1633
|
+
if (value.runtime || value.measurements) {
|
|
1634
|
+
throw new Error("trusted_execution: runtime and measurements belong to measured modes, not legacy SEV.");
|
|
1635
|
+
}
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
if (value.firmware) {
|
|
1639
|
+
throw new Error(`trusted_execution: firmware is for legacy SEV only, not ${mode}.`);
|
|
1640
|
+
}
|
|
1641
|
+
if (!value.runtime || !HEX_HASH.test(value.runtime)) {
|
|
1642
|
+
throw new Error(`trusted_execution: ${mode} needs the runtime bundle's 64-character item hash.`);
|
|
1643
|
+
}
|
|
1644
|
+
const measurements = value.measurements ?? [];
|
|
1645
|
+
if (measurements.length === 0 || measurements.some((m) => m.platform !== mode || Object.keys(m.registers ?? {}).length === 0)) {
|
|
1646
|
+
throw new Error(`trusted_execution: ${mode} needs expected measurements for platform ${mode}.`);
|
|
1647
|
+
}
|
|
1648
|
+
if (mode === "sev_snp" && value.policy !== void 0 && (value.policy & SNP_POLICY_RESERVED_BIT_17) === 0) {
|
|
1649
|
+
throw new Error("trusted_execution: an SEV-SNP policy must set the reserved bit 17 (e.g. 0x30000).");
|
|
1650
|
+
}
|
|
1651
|
+
if (mode === "tdx" && value.policy !== void 0) {
|
|
1652
|
+
throw new Error("trusted_execution: tdx has no host-chosen policy; leave it unset.");
|
|
1653
|
+
}
|
|
1654
|
+
if (value.attestation_port !== void 0 && !(Number.isInteger(value.attestation_port) && value.attestation_port > 0 && value.attestation_port < 65536)) {
|
|
1655
|
+
throw new Error("trusted_execution: attestation_port must be a TCP port.");
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1128
1659
|
// ../core/src/crns.ts
|
|
1129
1660
|
var DEFAULT_CRN_LIST_URL2 = "https://crns-list.aleph.sh/crns.json";
|
|
1130
1661
|
var DEFAULT_CRN_AGGREGATE_ADDRESS2 = "0xa1B3bb7d2332383D96b7796B908fB7f7F3c2Be10";
|
|
@@ -1165,8 +1696,9 @@ function scoreSortedCrns(crns) {
|
|
|
1165
1696
|
});
|
|
1166
1697
|
}
|
|
1167
1698
|
function filterDeployableCrns(crns, options = {}) {
|
|
1699
|
+
const confidential = options.confidential;
|
|
1168
1700
|
return scoreSortedCrns(
|
|
1169
|
-
compatibleCrns(crns, options.excludedHashes).filter((crn) => hasCrnCapacity(crn, options.spec))
|
|
1701
|
+
compatibleCrns(crns, options.excludedHashes).filter((crn) => hasCrnCapacity(crn, options.spec)).filter((crn) => !confidential || isConfidentialCrn(crn, confidential))
|
|
1170
1702
|
);
|
|
1171
1703
|
}
|
|
1172
1704
|
async function fetchCrnsFromList2(options) {
|
|
@@ -1221,6 +1753,8 @@ function mapResourceNodesToCrns2(resourceNodes) {
|
|
|
1221
1753
|
if (name) record.name = name;
|
|
1222
1754
|
const score = aggregateScore(node);
|
|
1223
1755
|
if (score != null) record.score = score;
|
|
1756
|
+
const owner = asString2(node.owner);
|
|
1757
|
+
if (owner) record.owner = owner;
|
|
1224
1758
|
return record;
|
|
1225
1759
|
});
|
|
1226
1760
|
}
|
|
@@ -2364,6 +2898,9 @@ function createInstanceContent(args) {
|
|
|
2364
2898
|
if (!args.rootfsItemHash || !/^[a-fA-F0-9]{64}$/.test(args.rootfsItemHash)) {
|
|
2365
2899
|
throw new Error("rootfsItemHash must be a 64-character Aleph item hash.");
|
|
2366
2900
|
}
|
|
2901
|
+
if (args.trustedExecution) {
|
|
2902
|
+
assertValidTrustedExecution(args.trustedExecution);
|
|
2903
|
+
}
|
|
2367
2904
|
return {
|
|
2368
2905
|
address: args.address,
|
|
2369
2906
|
time: args.now ?? Date.now() / 1e3,
|
|
@@ -2375,7 +2912,8 @@ function createInstanceContent(args) {
|
|
|
2375
2912
|
aleph_api: true,
|
|
2376
2913
|
hypervisor: "qemu",
|
|
2377
2914
|
reproducible: false,
|
|
2378
|
-
shared_cache: false
|
|
2915
|
+
shared_cache: false,
|
|
2916
|
+
...args.trustedExecution ? { trusted_execution: args.trustedExecution } : {}
|
|
2379
2917
|
},
|
|
2380
2918
|
resources: {
|
|
2381
2919
|
vcpus: Number(args.vcpus),
|
|
@@ -3833,6 +4371,47 @@ function createDeploymentProgressEmitter() {
|
|
|
3833
4371
|
};
|
|
3834
4372
|
}
|
|
3835
4373
|
|
|
4374
|
+
// src/shared/unverifiable-crns.ts
|
|
4375
|
+
var TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
4376
|
+
var remembered = /* @__PURE__ */ new Map();
|
|
4377
|
+
function isUnverifiableNodeError(message) {
|
|
4378
|
+
return /AMD KDS answered 404/i.test(message);
|
|
4379
|
+
}
|
|
4380
|
+
function load(storageKey, now) {
|
|
4381
|
+
if (!storageKey) return;
|
|
4382
|
+
try {
|
|
4383
|
+
const stored = JSON.parse(globalThis.localStorage?.getItem(storageKey) ?? "{}");
|
|
4384
|
+
for (const [hash, until] of Object.entries(stored)) {
|
|
4385
|
+
if (/^[0-9a-f]{64}$/.test(hash) && typeof until === "number" && until > now) {
|
|
4386
|
+
remembered.set(hash, Math.max(until, remembered.get(hash) ?? 0));
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
} catch {
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
function unverifiableCrnHashes(storageKey, now = Date.now()) {
|
|
4393
|
+
load(storageKey, now);
|
|
4394
|
+
for (const [hash, until] of remembered) if (until <= now) remembered.delete(hash);
|
|
4395
|
+
return new Set(remembered.keys());
|
|
4396
|
+
}
|
|
4397
|
+
function rememberUnverifiableCrn(hash, storageKey, now = Date.now()) {
|
|
4398
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) return;
|
|
4399
|
+
load(storageKey, now);
|
|
4400
|
+
remembered.set(hash, now + TTL_MS);
|
|
4401
|
+
if (!storageKey) return;
|
|
4402
|
+
try {
|
|
4403
|
+
globalThis.localStorage?.setItem(storageKey, JSON.stringify(Object.fromEntries(remembered)));
|
|
4404
|
+
} catch {
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
function demoteUnverifiableCrns(crns, refused, pinned) {
|
|
4408
|
+
const demoted = (crn) => refused.has(crn.hash) && crn.hash !== pinned;
|
|
4409
|
+
return [...crns.filter((crn) => !demoted(crn)), ...crns.filter(demoted)];
|
|
4410
|
+
}
|
|
4411
|
+
function forgetUnverifiableCrns() {
|
|
4412
|
+
remembered.clear();
|
|
4413
|
+
}
|
|
4414
|
+
|
|
3836
4415
|
// ../shared-types/src/ucan-store-bootstrap.ts
|
|
3837
4416
|
function isRecord(value) {
|
|
3838
4417
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
@@ -4139,6 +4718,9 @@ function defaultState(props = {}) {
|
|
|
4139
4718
|
instanceName: props.instanceName ?? DEFAULT_INSTANCE_NAME,
|
|
4140
4719
|
ucanStoreBootstrap: defaultUcanStoreBootstrapInput(props),
|
|
4141
4720
|
tierId: DEFAULT_TIER_ID,
|
|
4721
|
+
confidential: props.confidential ?? false,
|
|
4722
|
+
confidentialStartAvailable: Boolean(props.confidentialStart),
|
|
4723
|
+
confidentialPassphrase: "",
|
|
4142
4724
|
showAdvanced: false,
|
|
4143
4725
|
showInstances: props.showInstances ?? true,
|
|
4144
4726
|
showPasteManifest: Boolean(props.manifestJson?.trim()),
|
|
@@ -4551,7 +5133,25 @@ function compatibleCrnsForTier(crns, state) {
|
|
|
4551
5133
|
state.pricingSummary.pricing,
|
|
4552
5134
|
state.pricingSummary.tier
|
|
4553
5135
|
);
|
|
4554
|
-
return filterDeployableCrns(crns, {
|
|
5136
|
+
return filterDeployableCrns(crns, {
|
|
5137
|
+
spec,
|
|
5138
|
+
confidential: state.confidential ? {} : null
|
|
5139
|
+
});
|
|
5140
|
+
}
|
|
5141
|
+
var CONFIDENTIAL_PROBE_LIMIT = 80;
|
|
5142
|
+
async function confidentialCandidates(crns) {
|
|
5143
|
+
const flagged = crns.filter(
|
|
5144
|
+
(crn) => crn.confidential_support === true || (crn.cpu_features ?? []).some((feature) => feature.startsWith("sev"))
|
|
5145
|
+
);
|
|
5146
|
+
const score = (crn) => Number(crn.score ?? Number.NEGATIVE_INFINITY);
|
|
5147
|
+
const pool = flagged.length > 0 ? flagged : [...crns].sort((left, right) => score(right) - score(left)).slice(0, CONFIDENTIAL_PROBE_LIMIT);
|
|
5148
|
+
return enrichCrnsWithTee(pool, {
|
|
5149
|
+
fetch: (url, init) => fetchWithTimeout(url, init, 8e3)
|
|
5150
|
+
});
|
|
5151
|
+
}
|
|
5152
|
+
function shortOperator(address) {
|
|
5153
|
+
if (!address) return null;
|
|
5154
|
+
return address.length > 12 ? `${address.slice(0, 6)}\u2026${address.slice(-4)}` : address;
|
|
4555
5155
|
}
|
|
4556
5156
|
var UI_DEPLOY_WAIT_ATTEMPTS = 60;
|
|
4557
5157
|
var UI_DEPLOY_WAIT_DELAY_MS = 5e3;
|
|
@@ -5625,8 +6225,8 @@ var SponsorRelayController = class {
|
|
|
5625
6225
|
});
|
|
5626
6226
|
}
|
|
5627
6227
|
mergeRememberedRuntimeDetails(itemHash, details) {
|
|
5628
|
-
const
|
|
5629
|
-
if (!
|
|
6228
|
+
const remembered2 = this.runtimeDetailsByHash.get(itemHash);
|
|
6229
|
+
if (!remembered2) {
|
|
5630
6230
|
return details;
|
|
5631
6231
|
}
|
|
5632
6232
|
if (details.messageStatus === "processed" && hasUsableRuntime(details)) {
|
|
@@ -5635,13 +6235,13 @@ var SponsorRelayController = class {
|
|
|
5635
6235
|
this.trace("runtime:retain-known-details", {
|
|
5636
6236
|
itemHash,
|
|
5637
6237
|
currentStatus: details.messageStatus,
|
|
5638
|
-
rememberedStatus:
|
|
6238
|
+
rememberedStatus: remembered2.messageStatus,
|
|
5639
6239
|
currentHasRuntime: hasUsableRuntime(details)
|
|
5640
6240
|
});
|
|
5641
6241
|
return {
|
|
5642
6242
|
...details,
|
|
5643
|
-
...
|
|
5644
|
-
error: details.error ??
|
|
6243
|
+
...remembered2,
|
|
6244
|
+
error: details.error ?? remembered2.error
|
|
5645
6245
|
};
|
|
5646
6246
|
}
|
|
5647
6247
|
async init() {
|
|
@@ -5732,7 +6332,10 @@ var SponsorRelayController = class {
|
|
|
5732
6332
|
const crnOptions = compatibleList.slice(0, 15).map((crn) => ({
|
|
5733
6333
|
hash: crn.hash,
|
|
5734
6334
|
name: crn.name ?? null,
|
|
5735
|
-
score: typeof crn.score === "number" ? crn.score : null
|
|
6335
|
+
score: typeof crn.score === "number" ? crn.score : null,
|
|
6336
|
+
country: crn.country_code ?? null,
|
|
6337
|
+
version: crn.version ?? null,
|
|
6338
|
+
operator: shortOperator(crn.owner ?? crn.payment_receiver_address)
|
|
5736
6339
|
}));
|
|
5737
6340
|
this.patch({
|
|
5738
6341
|
crnOptions,
|
|
@@ -5749,6 +6352,27 @@ var SponsorRelayController = class {
|
|
|
5749
6352
|
selectedCrn
|
|
5750
6353
|
});
|
|
5751
6354
|
}
|
|
6355
|
+
/**
|
|
6356
|
+
* Switch between a normal and a confidential VM. Pricing and the node list
|
|
6357
|
+
* differ between the two, so both are loaded again.
|
|
6358
|
+
*/
|
|
6359
|
+
async setConfidential(confidential) {
|
|
6360
|
+
if (confidential === this.state.confidential) return;
|
|
6361
|
+
this.patch({
|
|
6362
|
+
confidential,
|
|
6363
|
+
crnPinnedByUser: false,
|
|
6364
|
+
selectedCrn: null,
|
|
6365
|
+
crnOptions: []
|
|
6366
|
+
});
|
|
6367
|
+
await this.refresh();
|
|
6368
|
+
}
|
|
6369
|
+
/**
|
|
6370
|
+
* The encrypted rootfs's passphrase for a confidential start. Kept in
|
|
6371
|
+
* memory only until the unlock, then cleared; never persisted.
|
|
6372
|
+
*/
|
|
6373
|
+
setConfidentialPassphrase(passphrase) {
|
|
6374
|
+
this.patch({ confidentialPassphrase: passphrase });
|
|
6375
|
+
}
|
|
5752
6376
|
/** Pin a specific CRN as the first deploy attempt (null = automatic). */
|
|
5753
6377
|
selectCrn(hash) {
|
|
5754
6378
|
if (!hash) {
|
|
@@ -5779,7 +6403,7 @@ var SponsorRelayController = class {
|
|
|
5779
6403
|
statusText: "Connecting MetaMask"
|
|
5780
6404
|
});
|
|
5781
6405
|
try {
|
|
5782
|
-
const wallet = await
|
|
6406
|
+
const wallet = await connectWallet2();
|
|
5783
6407
|
this.patch({
|
|
5784
6408
|
wallet,
|
|
5785
6409
|
busy: { connectingWallet: false },
|
|
@@ -5799,7 +6423,7 @@ var SponsorRelayController = class {
|
|
|
5799
6423
|
return;
|
|
5800
6424
|
}
|
|
5801
6425
|
try {
|
|
5802
|
-
const wallet = await
|
|
6426
|
+
const wallet = await connectWallet2();
|
|
5803
6427
|
this.patch({ wallet });
|
|
5804
6428
|
await this.refresh();
|
|
5805
6429
|
} catch {
|
|
@@ -5827,10 +6451,15 @@ var SponsorRelayController = class {
|
|
|
5827
6451
|
});
|
|
5828
6452
|
try {
|
|
5829
6453
|
const [pricingSummary, crnList] = await Promise.all([
|
|
5830
|
-
fetchInstancePricing(
|
|
6454
|
+
fetchInstancePricing(
|
|
6455
|
+
this.client.apiHost,
|
|
6456
|
+
void 0,
|
|
6457
|
+
this.state.confidential ? "instance_confidential" : "instance"
|
|
6458
|
+
),
|
|
5831
6459
|
this.client.fetchCrnsWithSource(crnSourcePreference(this.props))
|
|
5832
6460
|
]);
|
|
5833
|
-
const {
|
|
6461
|
+
const { source: crnSource } = crnList;
|
|
6462
|
+
const crns = this.state.confidential ? await confidentialCandidates(crnList.crns) : crnList.crns;
|
|
5834
6463
|
if (crnList.listError) {
|
|
5835
6464
|
this.trace("crns:fallback", {
|
|
5836
6465
|
source: crnSource,
|
|
@@ -6023,6 +6652,19 @@ var SponsorRelayController = class {
|
|
|
6023
6652
|
this.patch({ errorText: "Connect MetaMask before deploying." });
|
|
6024
6653
|
return;
|
|
6025
6654
|
}
|
|
6655
|
+
const confidentialStart = this.state.confidential ? this.props.confidentialStart ?? null : null;
|
|
6656
|
+
if (this.state.confidential) {
|
|
6657
|
+
if (!confidentialStart) {
|
|
6658
|
+
this.patch({
|
|
6659
|
+
errorText: "This site has not set up confidential starts (confidentialStart: encrypted rootfs, attest relay, attestation WASM)."
|
|
6660
|
+
});
|
|
6661
|
+
return;
|
|
6662
|
+
}
|
|
6663
|
+
if (!this.state.confidentialPassphrase) {
|
|
6664
|
+
this.patch({ errorText: "Enter the passphrase of the encrypted rootfs to start a confidential VM." });
|
|
6665
|
+
return;
|
|
6666
|
+
}
|
|
6667
|
+
}
|
|
6026
6668
|
if (!this.state.manifest || this.state.rootfsHealth.tone !== "ok" || !this.state.pricingSummary.pricing || !this.state.pricingSummary.tier) {
|
|
6027
6669
|
this.patch({
|
|
6028
6670
|
errorText: this.state.rootfsHealth.tone === "error" ? `Cannot deploy: ${this.state.rootfsHealth.detail ?? this.state.rootfsHealth.label}` : "Manifest, rootfs, and pricing must be ready before deploying."
|
|
@@ -6065,10 +6707,15 @@ var SponsorRelayController = class {
|
|
|
6065
6707
|
}
|
|
6066
6708
|
});
|
|
6067
6709
|
const preferredCrnHash = this.state.selectedCrn?.hash ?? null;
|
|
6068
|
-
const
|
|
6710
|
+
const pinnedFirst = [
|
|
6069
6711
|
...compatibleCrns2.filter((crn) => crn.hash === preferredCrnHash),
|
|
6070
6712
|
...compatibleCrns2.filter((crn) => crn.hash !== preferredCrnHash)
|
|
6071
|
-
]
|
|
6713
|
+
];
|
|
6714
|
+
const rankedCrns = (this.state.confidential ? demoteUnverifiableCrns(
|
|
6715
|
+
pinnedFirst,
|
|
6716
|
+
unverifiableCrnHashes(this.props.confidentialStart?.refusedNodesStorageKey),
|
|
6717
|
+
this.state.crnPinnedByUser ? preferredCrnHash : null
|
|
6718
|
+
) : pinnedFirst).slice(0, 5);
|
|
6072
6719
|
const orderedCrns = this.state.crnSource === "aggregate" ? await filterReachableCrns(rankedCrns, {
|
|
6073
6720
|
limit: rankedCrns.length,
|
|
6074
6721
|
onDiagnostic: (message) => this.trace("crns:probe", { message })
|
|
@@ -6085,6 +6732,39 @@ var SponsorRelayController = class {
|
|
|
6085
6732
|
"No compatible CRN is currently available for this tier."
|
|
6086
6733
|
);
|
|
6087
6734
|
}
|
|
6735
|
+
let attestWasm = null;
|
|
6736
|
+
let trustedExecution2 = null;
|
|
6737
|
+
if (confidentialStart) {
|
|
6738
|
+
this.emitProgress({
|
|
6739
|
+
stage: "preparing-confidential",
|
|
6740
|
+
label: "Preparing the confidential launch",
|
|
6741
|
+
progress: 10,
|
|
6742
|
+
status: "info",
|
|
6743
|
+
detail: "Loading the attestation module."
|
|
6744
|
+
});
|
|
6745
|
+
attestWasm = await confidentialStart.loadWasm();
|
|
6746
|
+
trustedExecution2 = await confidentialTrustedExecution({
|
|
6747
|
+
wasm: attestWasm,
|
|
6748
|
+
owner: this.state.wallet.address,
|
|
6749
|
+
vcpus: spec.vcpus,
|
|
6750
|
+
runtimeName: confidentialStart.runtimeName,
|
|
6751
|
+
apiHost: this.client.apiHost,
|
|
6752
|
+
onProgress: ({ detail }) => this.emitProgress({
|
|
6753
|
+
stage: "preparing-confidential",
|
|
6754
|
+
label: "Preparing the confidential launch",
|
|
6755
|
+
progress: 12,
|
|
6756
|
+
status: "info",
|
|
6757
|
+
detail
|
|
6758
|
+
})
|
|
6759
|
+
});
|
|
6760
|
+
this.trace("deploy:confidential-measurements", trustedExecution2);
|
|
6761
|
+
}
|
|
6762
|
+
const rootfsItemHash = confidentialStart?.rootfsItemHash ?? this.state.manifest.rootfsItemHash;
|
|
6763
|
+
const rootfsSizeMiB = Math.max(
|
|
6764
|
+
confidentialStart?.rootfsSizeMiB ?? 0,
|
|
6765
|
+
this.state.manifest.rootfsSizeMiB,
|
|
6766
|
+
spec.diskMiB
|
|
6767
|
+
);
|
|
6088
6768
|
const attemptErrors = [];
|
|
6089
6769
|
let lastError = null;
|
|
6090
6770
|
for (const [candidateIndex, candidateCrn] of orderedCrns.entries()) {
|
|
@@ -6112,15 +6792,13 @@ var SponsorRelayController = class {
|
|
|
6112
6792
|
address: this.state.wallet.address,
|
|
6113
6793
|
name: this.state.instanceName.trim(),
|
|
6114
6794
|
sshPublicKey,
|
|
6115
|
-
rootfsItemHash
|
|
6116
|
-
rootfsSizeMiB
|
|
6117
|
-
this.state.manifest.rootfsSizeMiB,
|
|
6118
|
-
spec.diskMiB
|
|
6119
|
-
),
|
|
6795
|
+
rootfsItemHash,
|
|
6796
|
+
rootfsSizeMiB,
|
|
6120
6797
|
vcpus: spec.vcpus,
|
|
6121
6798
|
memoryMiB: spec.memoryMiB,
|
|
6122
6799
|
rootfsVersion: this.state.manifest.version,
|
|
6123
|
-
crnHash: candidateCrn.hash
|
|
6800
|
+
crnHash: candidateCrn.hash,
|
|
6801
|
+
trustedExecution: trustedExecution2 ?? void 0
|
|
6124
6802
|
});
|
|
6125
6803
|
const result = await deployInstance({
|
|
6126
6804
|
sender: this.state.wallet.address,
|
|
@@ -6146,7 +6824,7 @@ var SponsorRelayController = class {
|
|
|
6146
6824
|
});
|
|
6147
6825
|
this.lastCompletedDeploymentHash = null;
|
|
6148
6826
|
const inspection = await waitForDeploymentResult2(result.itemHash, {
|
|
6149
|
-
rootfsRef:
|
|
6827
|
+
rootfsRef: rootfsItemHash,
|
|
6150
6828
|
apiHost: this.client.apiHost,
|
|
6151
6829
|
fetch: (url, init) => fetch(url, init),
|
|
6152
6830
|
attempts: UI_DEPLOY_WAIT_ATTEMPTS,
|
|
@@ -6236,6 +6914,39 @@ var SponsorRelayController = class {
|
|
|
6236
6914
|
);
|
|
6237
6915
|
}
|
|
6238
6916
|
}
|
|
6917
|
+
if (confidentialStart && attestWasm && trustedExecution2) {
|
|
6918
|
+
const unlocked = await unlockInstance({
|
|
6919
|
+
wasm: attestWasm,
|
|
6920
|
+
relayUrl: confidentialStart.relayUrl,
|
|
6921
|
+
crnHash: candidateCrn.hash,
|
|
6922
|
+
instanceItemHash: result.itemHash,
|
|
6923
|
+
trustedExecution: trustedExecution2,
|
|
6924
|
+
passphrase: this.state.confidentialPassphrase,
|
|
6925
|
+
// The rootfs ignores the INSTANCE's authorized_keys (no
|
|
6926
|
+
// cloud-init), so the key, with the bootstrap token, comes here.
|
|
6927
|
+
secrets: { authorized_keys: `${sshPublicKey}
|
|
6928
|
+
` },
|
|
6929
|
+
sign: (payload) => personalSign(this.state.wallet.address, payload),
|
|
6930
|
+
onProgress: ({ phase, detail }) => this.emitProgress({
|
|
6931
|
+
stage: phase === "unlock" ? "unlocking-guest" : "attesting-guest",
|
|
6932
|
+
label: phase === "unlock" ? "Unlocking the confidential VM" : "Attesting the confidential VM",
|
|
6933
|
+
progress: phase === "unlock" ? 89 : 87,
|
|
6934
|
+
status: "info",
|
|
6935
|
+
itemHash: result.itemHash,
|
|
6936
|
+
detail
|
|
6937
|
+
})
|
|
6938
|
+
});
|
|
6939
|
+
this.patch({ confidentialPassphrase: "" });
|
|
6940
|
+
this.trace("deploy:confidential-unlocked", unlocked);
|
|
6941
|
+
this.emitProgress({
|
|
6942
|
+
stage: "unlocking-guest",
|
|
6943
|
+
label: "Confidential VM attested and unlocked",
|
|
6944
|
+
progress: 89,
|
|
6945
|
+
status: "success",
|
|
6946
|
+
itemHash: result.itemHash,
|
|
6947
|
+
detail: `Measurement ${unlocked.measurement.slice(0, 16)}\u2026, policy ${unlocked.policy}, TCB bootloader ${unlocked.launchTcb.bootloader} snp ${unlocked.launchTcb.snp} microcode ${unlocked.launchTcb.microcode}.`
|
|
6948
|
+
});
|
|
6949
|
+
}
|
|
6239
6950
|
this.emitProgress({
|
|
6240
6951
|
stage: "deployment-confirmed",
|
|
6241
6952
|
label: "Waiting for runtime",
|
|
@@ -6344,6 +7055,9 @@ var SponsorRelayController = class {
|
|
|
6344
7055
|
const attemptLabel = crnDisplayName;
|
|
6345
7056
|
const failedDueToInvalidIpv6 = /not globally routable|public guest IPv6/i.test(lastError.message);
|
|
6346
7057
|
attemptErrors.push(`${attemptLabel}: ${lastError.message}`);
|
|
7058
|
+
if (this.state.confidential && isUnverifiableNodeError(lastError.message)) {
|
|
7059
|
+
rememberUnverifiableCrn(candidateCrn.hash, confidentialStart?.refusedNodesStorageKey);
|
|
7060
|
+
}
|
|
6347
7061
|
this.trace("deploy:crn-attempt-error", {
|
|
6348
7062
|
crnHash: candidateCrn.hash,
|
|
6349
7063
|
crnName: candidateCrn.name,
|
|
@@ -6576,6 +7290,7 @@ var SponsorRelayController = class {
|
|
|
6576
7290
|
error
|
|
6577
7291
|
});
|
|
6578
7292
|
this.patch({
|
|
7293
|
+
confidentialPassphrase: "",
|
|
6579
7294
|
busy: { deploying: false },
|
|
6580
7295
|
errorText: message,
|
|
6581
7296
|
statusText: "Deployment failed"
|
|
@@ -6872,27 +7587,33 @@ export {
|
|
|
6872
7587
|
anchorPanelToLauncher,
|
|
6873
7588
|
buildSshCommand,
|
|
6874
7589
|
clampToViewport,
|
|
6875
|
-
connectWallet,
|
|
7590
|
+
connectWallet2 as connectWallet,
|
|
6876
7591
|
createDeploymentProgressEmitter,
|
|
6877
7592
|
createSponsorRelayController,
|
|
7593
|
+
demoteUnverifiableCrns,
|
|
6878
7594
|
deploymentFailureMessage,
|
|
6879
7595
|
describeRequest,
|
|
6880
7596
|
describeThrown,
|
|
6881
7597
|
dragExceedsThreshold,
|
|
7598
|
+
forgetUnverifiableCrns,
|
|
7599
|
+
formatCrnOptionLabel,
|
|
6882
7600
|
formatDateTime,
|
|
6883
7601
|
formatNumber,
|
|
6884
7602
|
formatTierSpecLabel,
|
|
6885
7603
|
getEthereumProvider,
|
|
7604
|
+
isUnverifiableNodeError,
|
|
6886
7605
|
joinMappedPorts,
|
|
6887
7606
|
joinRequiredPortForwards,
|
|
6888
7607
|
parseInstanceAddressPayload,
|
|
6889
7608
|
personalSign,
|
|
6890
7609
|
readStoredPosition,
|
|
7610
|
+
rememberUnverifiableCrn,
|
|
6891
7611
|
resolveCornerPlacement,
|
|
6892
7612
|
resolveManifestSource,
|
|
6893
7613
|
rootfsHealth,
|
|
6894
7614
|
shortHash,
|
|
6895
7615
|
toChecksumAddress,
|
|
7616
|
+
unverifiableCrnHashes,
|
|
6896
7617
|
watchWallet,
|
|
6897
7618
|
writeStoredPosition
|
|
6898
7619
|
};
|