@hot-updater/plugin-core 0.36.7 → 1.0.0-rc.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/dist/assetStorageLayout.cjs +36 -18
- package/dist/assetStorageLayout.d.cts +21 -6
- package/dist/assetStorageLayout.d.mts +21 -6
- package/dist/assetStorageLayout.mjs +36 -18
- package/dist/bundleStorageLayout.cjs +2 -3
- package/dist/bundleStorageLayout.mjs +2 -3
- package/dist/contentAddressedAssets.cjs +5 -0
- package/dist/contentAddressedAssets.d.cts +2 -1
- package/dist/contentAddressedAssets.d.mts +2 -1
- package/dist/contentAddressedAssets.mjs +5 -1
- package/dist/createDatabasePlugin.cjs +775 -284
- package/dist/createDatabasePlugin.d.cts +20 -64
- package/dist/createDatabasePlugin.d.mts +20 -64
- package/dist/createDatabasePlugin.mjs +773 -284
- package/dist/createStorageKeyBuilder.cjs +9 -2
- package/dist/createStorageKeyBuilder.mjs +9 -2
- package/dist/createStoragePlugin.cjs +6 -145
- package/dist/createStoragePlugin.d.cts +7 -52
- package/dist/createStoragePlugin.d.mts +7 -52
- package/dist/createStoragePlugin.mjs +5 -143
- package/dist/databaseClient.cjs +112 -0
- package/dist/databaseClient.d.cts +33 -0
- package/dist/databaseClient.d.mts +33 -0
- package/dist/databaseClient.mjs +110 -0
- package/dist/databaseClientReads.cjs +113 -0
- package/dist/databaseClientReads.mjs +112 -0
- package/dist/databaseClientUpdates.cjs +39 -0
- package/dist/databaseClientUpdates.d.cts +11 -0
- package/dist/databaseClientUpdates.d.mts +11 -0
- package/dist/databaseClientUpdates.mjs +36 -0
- package/dist/databaseJsonValue.cjs +37 -0
- package/dist/databaseJsonValue.d.cts +5 -0
- package/dist/databaseJsonValue.d.mts +5 -0
- package/dist/databaseJsonValue.mjs +37 -0
- package/dist/databaseMetadata.cjs +13 -0
- package/dist/databaseMetadata.mjs +12 -0
- package/dist/databasePluginCrud.cjs +86 -0
- package/dist/databasePluginCrud.d.cts +1 -0
- package/dist/databasePluginCrud.d.mts +1 -0
- package/dist/databasePluginCrud.mjs +86 -0
- package/dist/databasePluginCrudValidation.d.cts +1 -0
- package/dist/databasePluginCrudValidation.d.mts +1 -0
- package/dist/databasePluginCrudValidationErrors.cjs +10 -0
- package/dist/databasePluginCrudValidationErrors.d.cts +9 -0
- package/dist/databasePluginCrudValidationErrors.d.mts +9 -0
- package/dist/databasePluginCrudValidationErrors.mjs +10 -0
- package/dist/databasePluginCrudValidationFields.cjs +291 -0
- package/dist/databasePluginCrudValidationFields.d.cts +1 -0
- package/dist/databasePluginCrudValidationFields.d.mts +1 -0
- package/dist/databasePluginCrudValidationFields.mjs +281 -0
- package/dist/databasePluginCrudValidationMutations.cjs +88 -0
- package/dist/databasePluginCrudValidationMutations.d.cts +1 -0
- package/dist/databasePluginCrudValidationMutations.d.mts +1 -0
- package/dist/databasePluginCrudValidationMutations.mjs +82 -0
- package/dist/databasePluginCrudValidationQueries.cjs +103 -0
- package/dist/databasePluginCrudValidationQueries.d.cts +1 -0
- package/dist/databasePluginCrudValidationQueries.d.mts +1 -0
- package/dist/databasePluginCrudValidationQueries.mjs +97 -0
- package/dist/databasePluginCrudValidationRows.cjs +55 -0
- package/dist/databasePluginCrudValidationRows.d.cts +1 -0
- package/dist/databasePluginCrudValidationRows.d.mts +1 -0
- package/dist/databasePluginCrudValidationRows.mjs +53 -0
- package/dist/databasePluginTransaction.cjs +7 -0
- package/dist/databasePluginTransaction.mjs +7 -0
- package/dist/databaseRows.cjs +149 -0
- package/dist/databaseRows.d.cts +34 -0
- package/dist/databaseRows.d.mts +34 -0
- package/dist/databaseRows.mjs +141 -0
- package/dist/index.cjs +76 -24
- package/dist/index.d.cts +22 -12
- package/dist/index.d.mts +22 -12
- package/dist/index.mjs +21 -14
- package/dist/internal.cjs +6 -0
- package/dist/internal.d.cts +9 -0
- package/dist/internal.d.mts +9 -0
- package/dist/internal.mjs +3 -0
- package/dist/node_modules/.pnpm/verkit@0.3.2/node_modules/verkit/dist/index.cjs +345 -0
- package/dist/node_modules/.pnpm/verkit@0.3.2/node_modules/verkit/dist/index.mjs +341 -0
- package/dist/paginateBundles.d.cts +6 -4
- package/dist/paginateBundles.d.mts +6 -4
- package/dist/parseStorageUri.cjs +46 -30
- package/dist/parseStorageUri.d.cts +20 -17
- package/dist/parseStorageUri.d.mts +20 -17
- package/dist/parseStorageUri.mjs +46 -30
- package/dist/queryBundles.cjs +1 -7
- package/dist/queryBundles.d.cts +3 -1
- package/dist/queryBundles.d.mts +3 -1
- package/dist/queryBundles.mjs +1 -7
- package/dist/releaseCatalogCompiler.cjs +391 -0
- package/dist/releaseCatalogCompiler.d.cts +55 -0
- package/dist/releaseCatalogCompiler.d.mts +55 -0
- package/dist/releaseCatalogCompiler.mjs +386 -0
- package/dist/releaseCatalogMutation.cjs +303 -0
- package/dist/releaseCatalogMutation.d.cts +86 -0
- package/dist/releaseCatalogMutation.d.mts +86 -0
- package/dist/releaseCatalogMutation.mjs +297 -0
- package/dist/releaseManagement.cjs +231 -0
- package/dist/releaseManagement.d.cts +48 -0
- package/dist/releaseManagement.d.mts +48 -0
- package/dist/releaseManagement.mjs +224 -0
- package/dist/remoteBundleSigning.cjs +296 -0
- package/dist/remoteBundleSigning.d.cts +43 -0
- package/dist/remoteBundleSigning.d.mts +43 -0
- package/dist/remoteBundleSigning.mjs +291 -0
- package/dist/requestBundleCache.cjs +22 -0
- package/dist/requestBundleCache.d.cts +9 -0
- package/dist/requestBundleCache.d.mts +9 -0
- package/dist/requestBundleCache.mjs +22 -0
- package/dist/semverSatisfies.cjs +3 -4
- package/dist/semverSatisfies.mjs +1 -1
- package/dist/storageDownloadPath.cjs +48 -0
- package/dist/storageDownloadPath.d.cts +15 -0
- package/dist/storageDownloadPath.d.mts +15 -0
- package/dist/storageDownloadPath.mjs +46 -0
- package/dist/types/database.d.cts +4 -0
- package/dist/types/database.d.mts +4 -0
- package/dist/types/databaseFields.cjs +92 -0
- package/dist/types/databaseFields.d.cts +12 -0
- package/dist/types/databaseFields.d.mts +12 -0
- package/dist/types/databaseFields.mjs +92 -0
- package/dist/types/databaseOperations.d.cts +173 -0
- package/dist/types/databaseOperations.d.mts +173 -0
- package/dist/types/databasePlugin.d.cts +240 -0
- package/dist/types/databasePlugin.d.mts +240 -0
- package/dist/types/databaseQuery.d.cts +47 -0
- package/dist/types/databaseQuery.d.mts +47 -0
- package/dist/types/databaseRows.d.cts +120 -0
- package/dist/types/databaseRows.d.mts +120 -0
- package/dist/types/index.d.cts +120 -162
- package/dist/types/index.d.mts +120 -162
- package/dist/types/internal.d.cts +7 -0
- package/dist/types/internal.d.mts +7 -0
- package/dist/types/public.d.cts +3 -0
- package/dist/types/public.d.mts +3 -0
- package/dist/uuidv7.cjs +9 -0
- package/dist/uuidv7.d.cts +4 -1
- package/dist/uuidv7.d.mts +4 -1
- package/dist/uuidv7.mjs +8 -1
- package/package.json +10 -5
- package/dist/bundleUnitOfWork.cjs +0 -158
- package/dist/bundleUnitOfWork.mjs +0 -158
- package/dist/bundleUnitOfWorkStore.cjs +0 -15
- package/dist/bundleUnitOfWorkStore.mjs +0 -15
- package/dist/createBlobDatabasePlugin.cjs +0 -382
- package/dist/createBlobDatabasePlugin.d.cts +0 -29
- package/dist/createBlobDatabasePlugin.d.mts +0 -29
- package/dist/createBlobDatabasePlugin.mjs +0 -381
- package/dist/legacyAssetStorageLayout.cjs +0 -12
- package/dist/legacyAssetStorageLayout.mjs +0 -12
- package/dist/requestUpdateBundleState.cjs +0 -26
- package/dist/requestUpdateBundleState.d.cts +0 -12
- package/dist/requestUpdateBundleState.d.mts +0 -12
- package/dist/requestUpdateBundleState.mjs +0 -24
- package/dist/resolveUpdateInfoFromBundles.cjs +0 -14
- package/dist/resolveUpdateInfoFromBundles.d.cts +0 -16
- package/dist/resolveUpdateInfoFromBundles.d.mts +0 -16
- package/dist/resolveUpdateInfoFromBundles.mjs +0 -13
- package/dist/storageProfile.cjs +0 -15
- package/dist/storageProfile.d.cts +0 -9
- package/dist/storageProfile.d.mts +0 -9
- package/dist/storageProfile.mjs +0 -12
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
//#region src/remoteBundleSigning.ts
|
|
2
|
+
const REMOTE_BUNDLE_SIGNING_PATH = "/_hot-updater/signing";
|
|
3
|
+
const REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION = 1;
|
|
4
|
+
const REMOTE_BUNDLE_SIGNING_ALGORITHM = "RSA-SHA256";
|
|
5
|
+
const REMOTE_BUNDLE_SIGNING_TOKEN_HEADER = "x-hot-updater-signing-token";
|
|
6
|
+
const MESSAGE_BYTES = 32;
|
|
7
|
+
const REQUEST_BODY_LIMIT = 4 * 1024;
|
|
8
|
+
const RESPONSE_BODY_LIMIT = 32 * 1024;
|
|
9
|
+
const SIGNATURE_BYTES_LIMIT = 16 * 1024;
|
|
10
|
+
const TOKEN_BYTES_LIMIT = 4 * 1024;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
12
|
+
const JSON_CONTENT_TYPE_PATTERN = /^application\/json(?:\s*;|$)/iu;
|
|
13
|
+
const BASE64_PATTERN = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u;
|
|
14
|
+
const PUBLIC_KEY_PATTERN = /^-----BEGIN PUBLIC KEY-----\s+([A-Za-z\d+/=\s]+)\s+-----END PUBLIC KEY-----$/u;
|
|
15
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
const hasExactKeys = (value, expected) => {
|
|
17
|
+
const actual = Object.keys(value).sort();
|
|
18
|
+
const sortedExpected = [...expected].sort();
|
|
19
|
+
return actual.length === sortedExpected.length && actual.every((key, index) => key === sortedExpected[index]);
|
|
20
|
+
};
|
|
21
|
+
const bytesToBase64 = (value) => {
|
|
22
|
+
let binary = "";
|
|
23
|
+
for (let offset = 0; offset < value.byteLength; offset += 32768) binary += String.fromCharCode(...value.subarray(offset, offset + 32768));
|
|
24
|
+
return btoa(binary);
|
|
25
|
+
};
|
|
26
|
+
const base64ToBytes = (value) => {
|
|
27
|
+
if (!value || !BASE64_PATTERN.test(value)) return null;
|
|
28
|
+
try {
|
|
29
|
+
const binary = atob(value);
|
|
30
|
+
const result = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
31
|
+
return bytesToBase64(result) === value ? result : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const bytesToHex = (value) => Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
37
|
+
const toArrayBuffer = (value) => new Uint8Array(value).buffer;
|
|
38
|
+
const canonicalizePublicKey = async (publicKey) => {
|
|
39
|
+
const match = PUBLIC_KEY_PATTERN.exec(publicKey.trim());
|
|
40
|
+
const der = match?.[1] ? base64ToBytes(match[1].replaceAll(/\s/gu, "")) : null;
|
|
41
|
+
if (!der) throw new Error("invalid public key");
|
|
42
|
+
const cryptoKey = await crypto.subtle.importKey("spki", toArrayBuffer(der), {
|
|
43
|
+
hash: "SHA-256",
|
|
44
|
+
name: "RSASSA-PKCS1-v1_5"
|
|
45
|
+
}, true, ["verify"]);
|
|
46
|
+
const modulusLength = Reflect.get(cryptoKey.algorithm, "modulusLength");
|
|
47
|
+
if (![
|
|
48
|
+
2048,
|
|
49
|
+
3072,
|
|
50
|
+
4096
|
|
51
|
+
].includes(modulusLength)) throw new Error("unsupported public key");
|
|
52
|
+
const canonicalDer = new Uint8Array(await crypto.subtle.exportKey("spki", cryptoKey));
|
|
53
|
+
const lines = bytesToBase64(canonicalDer).match(/.{1,64}/gu) ?? [];
|
|
54
|
+
return {
|
|
55
|
+
cryptoKey,
|
|
56
|
+
keyId: bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalDer))),
|
|
57
|
+
publicKey: `-----BEGIN PUBLIC KEY-----\n${lines.join("\n")}\n-----END PUBLIC KEY-----\n`
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const assertSafePath = (path) => {
|
|
61
|
+
if (!path.startsWith("/") || path.includes("\\") || path.includes("//")) throw new Error("invalid path");
|
|
62
|
+
for (const segment of path.split("/")) {
|
|
63
|
+
let decoded;
|
|
64
|
+
try {
|
|
65
|
+
decoded = decodeURIComponent(segment);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error("invalid path");
|
|
68
|
+
}
|
|
69
|
+
if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) throw new Error("invalid path");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const normalizeEndpoint = (endpoint) => {
|
|
73
|
+
let url;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(endpoint);
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error("Remote bundle signing requires a valid endpoint.");
|
|
78
|
+
}
|
|
79
|
+
const rawPath = /^https?:\/\/[^/?#]*(\/[^?#]*)?/iu.exec(endpoint)?.[1] ?? "/";
|
|
80
|
+
try {
|
|
81
|
+
assertSafePath(rawPath);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error("Remote bundle signing requires a valid endpoint.");
|
|
84
|
+
}
|
|
85
|
+
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
86
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback) || url.username || url.password || url.search || url.hash) throw new Error("Remote bundle signing requires a valid endpoint.");
|
|
87
|
+
const prefix = url.pathname.replace(/\/+$/u, "");
|
|
88
|
+
url.pathname = `${prefix}${REMOTE_BUNDLE_SIGNING_PATH}`;
|
|
89
|
+
return url.href;
|
|
90
|
+
};
|
|
91
|
+
const normalizeHandlerPath = (endpointPath) => {
|
|
92
|
+
try {
|
|
93
|
+
assertSafePath(endpointPath);
|
|
94
|
+
const url = new URL(endpointPath, "https://hot-updater.invalid");
|
|
95
|
+
if (url.origin !== "https://hot-updater.invalid" || url.search || url.hash || url.pathname !== endpointPath || !endpointPath.endsWith("/_hot-updater/signing")) throw new Error("invalid path");
|
|
96
|
+
return endpointPath;
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error("Bundle signing handler requires a valid endpoint path.");
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const readLimitedBody = async (body, contentLength, limit) => {
|
|
102
|
+
if (contentLength !== null) {
|
|
103
|
+
if (!/^\d+$/u.test(contentLength) || Number(contentLength) > limit) throw new Error("body too large");
|
|
104
|
+
}
|
|
105
|
+
if (!body) return "";
|
|
106
|
+
const reader = body.getReader();
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
try {
|
|
110
|
+
while (true) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done) break;
|
|
113
|
+
size += value.byteLength;
|
|
114
|
+
if (size > limit) {
|
|
115
|
+
await reader.cancel();
|
|
116
|
+
throw new Error("body too large");
|
|
117
|
+
}
|
|
118
|
+
chunks.push(value);
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
reader.releaseLock();
|
|
122
|
+
}
|
|
123
|
+
const joined = new Uint8Array(size);
|
|
124
|
+
let offset = 0;
|
|
125
|
+
for (const chunk of chunks) {
|
|
126
|
+
joined.set(chunk, offset);
|
|
127
|
+
offset += chunk.byteLength;
|
|
128
|
+
}
|
|
129
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(joined);
|
|
130
|
+
};
|
|
131
|
+
const parseJsonBody = async (body, headers, limit) => {
|
|
132
|
+
if (!JSON_CONTENT_TYPE_PATTERN.test(headers.get("content-type") ?? "")) throw new Error("invalid content type");
|
|
133
|
+
return JSON.parse(await readLimitedBody(body, headers.get("content-length"), limit));
|
|
134
|
+
};
|
|
135
|
+
const parseMetadata = async (value) => {
|
|
136
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
137
|
+
"algorithm",
|
|
138
|
+
"keyId",
|
|
139
|
+
"protocolVersion",
|
|
140
|
+
"publicKey"
|
|
141
|
+
]) || value.protocolVersion !== 1 || value.algorithm !== "RSA-SHA256" || typeof value.keyId !== "string" || !/^[a-f\d]{64}$/u.test(value.keyId) || typeof value.publicKey !== "string") throw new Error("invalid metadata");
|
|
142
|
+
const resolved = await canonicalizePublicKey(value.publicKey);
|
|
143
|
+
if (resolved.keyId !== value.keyId) throw new Error("invalid metadata");
|
|
144
|
+
return resolved;
|
|
145
|
+
};
|
|
146
|
+
const remoteRequest = async (fetchImplementation, endpoint, resolveToken, init) => {
|
|
147
|
+
const token = await resolveToken();
|
|
148
|
+
if (typeof token !== "string" || !token.trim()) throw new Error("missing token");
|
|
149
|
+
const response = await fetchImplementation(endpoint, {
|
|
150
|
+
...init,
|
|
151
|
+
headers: {
|
|
152
|
+
Accept: "application/json",
|
|
153
|
+
...init.headers,
|
|
154
|
+
[REMOTE_BUNDLE_SIGNING_TOKEN_HEADER]: token
|
|
155
|
+
},
|
|
156
|
+
redirect: "error",
|
|
157
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
158
|
+
});
|
|
159
|
+
if (response.status !== 200) throw new Error("invalid status");
|
|
160
|
+
return parseJsonBody(response.body, response.headers, RESPONSE_BODY_LIMIT);
|
|
161
|
+
};
|
|
162
|
+
/** Signs through a server implementing the Hot Updater signing protocol. */
|
|
163
|
+
const remoteSigning = ({ endpoint, fetch: fetchImplementation = globalThis.fetch, publicKeyPath, signingToken }) => {
|
|
164
|
+
if (!publicKeyPath.trim()) throw new Error("Remote bundle signing public key path is required.");
|
|
165
|
+
if (typeof fetchImplementation !== "function") throw new Error("Remote bundle signing requires fetch.");
|
|
166
|
+
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
|
167
|
+
let resolvedKey;
|
|
168
|
+
const resolveToken = async () => {
|
|
169
|
+
const token = typeof signingToken === "function" ? await signingToken() : signingToken ?? process.env.HOT_UPDATER_SIGNING_TOKEN;
|
|
170
|
+
if (!token?.trim()) throw new Error("remoteSigning requires signingToken or HOT_UPDATER_SIGNING_TOKEN.");
|
|
171
|
+
return token;
|
|
172
|
+
};
|
|
173
|
+
const getResolvedKey = () => {
|
|
174
|
+
if (resolvedKey) return resolvedKey;
|
|
175
|
+
resolvedKey = remoteRequest(fetchImplementation, normalizedEndpoint, resolveToken, { method: "GET" }).then(parseMetadata).catch(() => {
|
|
176
|
+
resolvedKey = void 0;
|
|
177
|
+
throw new Error("Failed to load the remote bundle signing public key.");
|
|
178
|
+
});
|
|
179
|
+
return resolvedKey;
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
name: "remoteSigning",
|
|
183
|
+
publicKeyPath,
|
|
184
|
+
async getPublicKey() {
|
|
185
|
+
const { publicKey } = await getResolvedKey();
|
|
186
|
+
return { publicKey };
|
|
187
|
+
},
|
|
188
|
+
async sign({ message }) {
|
|
189
|
+
if (!(message instanceof Uint8Array) || message.byteLength !== MESSAGE_BYTES) throw new Error("Remote bundle signing messages must be exactly 32 bytes.");
|
|
190
|
+
const resolved = await getResolvedKey();
|
|
191
|
+
let value;
|
|
192
|
+
try {
|
|
193
|
+
value = await remoteRequest(fetchImplementation, normalizedEndpoint, resolveToken, {
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
algorithm: REMOTE_BUNDLE_SIGNING_ALGORITHM,
|
|
196
|
+
keyId: resolved.keyId,
|
|
197
|
+
message: bytesToBase64(message),
|
|
198
|
+
protocolVersion: 1
|
|
199
|
+
}),
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
method: "POST"
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
throw new Error("Remote bundle signer failed to sign the message.");
|
|
205
|
+
}
|
|
206
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
207
|
+
"algorithm",
|
|
208
|
+
"keyId",
|
|
209
|
+
"protocolVersion",
|
|
210
|
+
"signature"
|
|
211
|
+
]) || value.protocolVersion !== 1 || value.algorithm !== "RSA-SHA256" || value.keyId !== resolved.keyId || typeof value.signature !== "string") throw new Error("Remote bundle signer returned an invalid signature.");
|
|
212
|
+
const signature = base64ToBytes(value.signature);
|
|
213
|
+
if (!signature || signature.byteLength > SIGNATURE_BYTES_LIMIT) throw new Error("Remote bundle signer returned an invalid signature.");
|
|
214
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", resolved.cryptoKey, toArrayBuffer(signature), toArrayBuffer(message))) throw new Error("Remote bundle signer returned an unverifiable signature.");
|
|
215
|
+
return { signature: new Uint8Array(signature) };
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
const jsonResponse = (value, status = 200, headers) => Response.json(value, {
|
|
220
|
+
headers: {
|
|
221
|
+
"Cache-Control": "private, no-store",
|
|
222
|
+
...headers
|
|
223
|
+
},
|
|
224
|
+
status
|
|
225
|
+
});
|
|
226
|
+
const tokensMatch = async (expected, actual) => {
|
|
227
|
+
if (!expected || actual === null || new TextEncoder().encode(expected).byteLength > TOKEN_BYTES_LIMIT || new TextEncoder().encode(actual).byteLength > TOKEN_BYTES_LIMIT) return false;
|
|
228
|
+
const [expectedHash, actualHash] = await Promise.all([crypto.subtle.digest("SHA-256", new TextEncoder().encode(expected)), crypto.subtle.digest("SHA-256", new TextEncoder().encode(actual))]);
|
|
229
|
+
const left = new Uint8Array(expectedHash);
|
|
230
|
+
const right = new Uint8Array(actualHash);
|
|
231
|
+
let difference = 0;
|
|
232
|
+
for (let index = 0; index < left.byteLength; index += 1) difference |= left[index] ^ right[index];
|
|
233
|
+
return difference === 0;
|
|
234
|
+
};
|
|
235
|
+
/** Handles the portable managed signing protocol in Web API runtimes. */
|
|
236
|
+
const createBundleSigningHandler = async ({ endpointPath = REMOTE_BUNDLE_SIGNING_PATH, publicKey, request, sign, token }) => {
|
|
237
|
+
const normalizedEndpointPath = normalizeHandlerPath(endpointPath);
|
|
238
|
+
const url = new URL(request.url);
|
|
239
|
+
if (url.pathname !== normalizedEndpointPath) return null;
|
|
240
|
+
if (url.search || url.hash) return jsonResponse({ error: "Bad request" }, 400);
|
|
241
|
+
if (!await tokensMatch(token, request.headers.get("x-hot-updater-signing-token"))) return jsonResponse({ error: "Unauthorized" }, 401);
|
|
242
|
+
if (request.method !== "GET" && request.method !== "POST") return jsonResponse({ error: "Method not allowed" }, 405, { Allow: "GET, POST" });
|
|
243
|
+
let resolved;
|
|
244
|
+
try {
|
|
245
|
+
resolved = await canonicalizePublicKey(publicKey);
|
|
246
|
+
} catch {
|
|
247
|
+
return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
248
|
+
}
|
|
249
|
+
const metadata = {
|
|
250
|
+
algorithm: REMOTE_BUNDLE_SIGNING_ALGORITHM,
|
|
251
|
+
keyId: resolved.keyId,
|
|
252
|
+
protocolVersion: 1,
|
|
253
|
+
publicKey: resolved.publicKey
|
|
254
|
+
};
|
|
255
|
+
if (request.method === "GET") return jsonResponse(metadata);
|
|
256
|
+
let body;
|
|
257
|
+
try {
|
|
258
|
+
body = await parseJsonBody(request.body, request.headers, REQUEST_BODY_LIMIT);
|
|
259
|
+
} catch {
|
|
260
|
+
return jsonResponse({ error: "Bad request" }, 400);
|
|
261
|
+
}
|
|
262
|
+
if (!isRecord(body) || !hasExactKeys(body, [
|
|
263
|
+
"algorithm",
|
|
264
|
+
"keyId",
|
|
265
|
+
"message",
|
|
266
|
+
"protocolVersion"
|
|
267
|
+
]) || body.protocolVersion !== 1 || body.algorithm !== "RSA-SHA256" || body.keyId !== resolved.keyId || typeof body.message !== "string") return jsonResponse({ error: "Bad request" }, 400);
|
|
268
|
+
const message = base64ToBytes(body.message);
|
|
269
|
+
if (!message || message.byteLength !== MESSAGE_BYTES) return jsonResponse({ error: "Bad request" }, 400);
|
|
270
|
+
let signature;
|
|
271
|
+
try {
|
|
272
|
+
const result = await sign(new Uint8Array(message));
|
|
273
|
+
if (!(result instanceof Uint8Array) || result.byteLength === 0 || result.byteLength > SIGNATURE_BYTES_LIMIT) throw new Error("invalid signature");
|
|
274
|
+
signature = result;
|
|
275
|
+
} catch {
|
|
276
|
+
return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", resolved.cryptoKey, toArrayBuffer(signature), toArrayBuffer(message))) return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
280
|
+
} catch {
|
|
281
|
+
return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
282
|
+
}
|
|
283
|
+
return jsonResponse({
|
|
284
|
+
algorithm: REMOTE_BUNDLE_SIGNING_ALGORITHM,
|
|
285
|
+
keyId: resolved.keyId,
|
|
286
|
+
protocolVersion: 1,
|
|
287
|
+
signature: bytesToBase64(signature)
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
//#endregion
|
|
291
|
+
exports.REMOTE_BUNDLE_SIGNING_ALGORITHM = REMOTE_BUNDLE_SIGNING_ALGORITHM;
|
|
292
|
+
exports.REMOTE_BUNDLE_SIGNING_PATH = REMOTE_BUNDLE_SIGNING_PATH;
|
|
293
|
+
exports.REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION = REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION;
|
|
294
|
+
exports.REMOTE_BUNDLE_SIGNING_TOKEN_HEADER = REMOTE_BUNDLE_SIGNING_TOKEN_HEADER;
|
|
295
|
+
exports.createBundleSigningHandler = createBundleSigningHandler;
|
|
296
|
+
exports.remoteSigning = remoteSigning;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BundleSigningPlugin } from "./types/index.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/remoteBundleSigning.d.ts
|
|
4
|
+
declare const REMOTE_BUNDLE_SIGNING_PATH = "/_hot-updater/signing";
|
|
5
|
+
declare const REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION: 1;
|
|
6
|
+
declare const REMOTE_BUNDLE_SIGNING_ALGORITHM: "RSA-SHA256";
|
|
7
|
+
declare const REMOTE_BUNDLE_SIGNING_TOKEN_HEADER = "x-hot-updater-signing-token";
|
|
8
|
+
type Fetch = typeof globalThis.fetch;
|
|
9
|
+
interface RemoteSigningOptions {
|
|
10
|
+
readonly endpoint: string;
|
|
11
|
+
readonly fetch?: Fetch;
|
|
12
|
+
readonly publicKeyPath: string;
|
|
13
|
+
/**
|
|
14
|
+
* Dedicated signing token or a lazy resolver for provider integrations.
|
|
15
|
+
* Defaults to HOT_UPDATER_SIGNING_TOKEN.
|
|
16
|
+
*/
|
|
17
|
+
readonly signingToken?: string | (() => string | Promise<string>);
|
|
18
|
+
}
|
|
19
|
+
interface BundleSigningHandlerOptions {
|
|
20
|
+
/** Exact request pathname. Defaults to the root managed signing path. */
|
|
21
|
+
readonly endpointPath?: string;
|
|
22
|
+
readonly publicKey: string;
|
|
23
|
+
readonly request: Request;
|
|
24
|
+
readonly sign: (message: Uint8Array) => Uint8Array | Promise<Uint8Array>;
|
|
25
|
+
readonly token: string;
|
|
26
|
+
}
|
|
27
|
+
/** Signs through a server implementing the Hot Updater signing protocol. */
|
|
28
|
+
declare const remoteSigning: ({
|
|
29
|
+
endpoint,
|
|
30
|
+
fetch: fetchImplementation,
|
|
31
|
+
publicKeyPath,
|
|
32
|
+
signingToken
|
|
33
|
+
}: RemoteSigningOptions) => BundleSigningPlugin;
|
|
34
|
+
/** Handles the portable managed signing protocol in Web API runtimes. */
|
|
35
|
+
declare const createBundleSigningHandler: ({
|
|
36
|
+
endpointPath,
|
|
37
|
+
publicKey,
|
|
38
|
+
request,
|
|
39
|
+
sign,
|
|
40
|
+
token
|
|
41
|
+
}: BundleSigningHandlerOptions) => Promise<Response | null>;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { BundleSigningHandlerOptions, REMOTE_BUNDLE_SIGNING_ALGORITHM, REMOTE_BUNDLE_SIGNING_PATH, REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION, REMOTE_BUNDLE_SIGNING_TOKEN_HEADER, RemoteSigningOptions, createBundleSigningHandler, remoteSigning };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BundleSigningPlugin } from "./types/index.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/remoteBundleSigning.d.ts
|
|
4
|
+
declare const REMOTE_BUNDLE_SIGNING_PATH = "/_hot-updater/signing";
|
|
5
|
+
declare const REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION: 1;
|
|
6
|
+
declare const REMOTE_BUNDLE_SIGNING_ALGORITHM: "RSA-SHA256";
|
|
7
|
+
declare const REMOTE_BUNDLE_SIGNING_TOKEN_HEADER = "x-hot-updater-signing-token";
|
|
8
|
+
type Fetch = typeof globalThis.fetch;
|
|
9
|
+
interface RemoteSigningOptions {
|
|
10
|
+
readonly endpoint: string;
|
|
11
|
+
readonly fetch?: Fetch;
|
|
12
|
+
readonly publicKeyPath: string;
|
|
13
|
+
/**
|
|
14
|
+
* Dedicated signing token or a lazy resolver for provider integrations.
|
|
15
|
+
* Defaults to HOT_UPDATER_SIGNING_TOKEN.
|
|
16
|
+
*/
|
|
17
|
+
readonly signingToken?: string | (() => string | Promise<string>);
|
|
18
|
+
}
|
|
19
|
+
interface BundleSigningHandlerOptions {
|
|
20
|
+
/** Exact request pathname. Defaults to the root managed signing path. */
|
|
21
|
+
readonly endpointPath?: string;
|
|
22
|
+
readonly publicKey: string;
|
|
23
|
+
readonly request: Request;
|
|
24
|
+
readonly sign: (message: Uint8Array) => Uint8Array | Promise<Uint8Array>;
|
|
25
|
+
readonly token: string;
|
|
26
|
+
}
|
|
27
|
+
/** Signs through a server implementing the Hot Updater signing protocol. */
|
|
28
|
+
declare const remoteSigning: ({
|
|
29
|
+
endpoint,
|
|
30
|
+
fetch: fetchImplementation,
|
|
31
|
+
publicKeyPath,
|
|
32
|
+
signingToken
|
|
33
|
+
}: RemoteSigningOptions) => BundleSigningPlugin;
|
|
34
|
+
/** Handles the portable managed signing protocol in Web API runtimes. */
|
|
35
|
+
declare const createBundleSigningHandler: ({
|
|
36
|
+
endpointPath,
|
|
37
|
+
publicKey,
|
|
38
|
+
request,
|
|
39
|
+
sign,
|
|
40
|
+
token
|
|
41
|
+
}: BundleSigningHandlerOptions) => Promise<Response | null>;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { BundleSigningHandlerOptions, REMOTE_BUNDLE_SIGNING_ALGORITHM, REMOTE_BUNDLE_SIGNING_PATH, REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION, REMOTE_BUNDLE_SIGNING_TOKEN_HEADER, RemoteSigningOptions, createBundleSigningHandler, remoteSigning };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
//#region src/remoteBundleSigning.ts
|
|
2
|
+
const REMOTE_BUNDLE_SIGNING_PATH = "/_hot-updater/signing";
|
|
3
|
+
const REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION = 1;
|
|
4
|
+
const REMOTE_BUNDLE_SIGNING_ALGORITHM = "RSA-SHA256";
|
|
5
|
+
const REMOTE_BUNDLE_SIGNING_TOKEN_HEADER = "x-hot-updater-signing-token";
|
|
6
|
+
const MESSAGE_BYTES = 32;
|
|
7
|
+
const REQUEST_BODY_LIMIT = 4 * 1024;
|
|
8
|
+
const RESPONSE_BODY_LIMIT = 32 * 1024;
|
|
9
|
+
const SIGNATURE_BYTES_LIMIT = 16 * 1024;
|
|
10
|
+
const TOKEN_BYTES_LIMIT = 4 * 1024;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
12
|
+
const JSON_CONTENT_TYPE_PATTERN = /^application\/json(?:\s*;|$)/iu;
|
|
13
|
+
const BASE64_PATTERN = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u;
|
|
14
|
+
const PUBLIC_KEY_PATTERN = /^-----BEGIN PUBLIC KEY-----\s+([A-Za-z\d+/=\s]+)\s+-----END PUBLIC KEY-----$/u;
|
|
15
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
const hasExactKeys = (value, expected) => {
|
|
17
|
+
const actual = Object.keys(value).sort();
|
|
18
|
+
const sortedExpected = [...expected].sort();
|
|
19
|
+
return actual.length === sortedExpected.length && actual.every((key, index) => key === sortedExpected[index]);
|
|
20
|
+
};
|
|
21
|
+
const bytesToBase64 = (value) => {
|
|
22
|
+
let binary = "";
|
|
23
|
+
for (let offset = 0; offset < value.byteLength; offset += 32768) binary += String.fromCharCode(...value.subarray(offset, offset + 32768));
|
|
24
|
+
return btoa(binary);
|
|
25
|
+
};
|
|
26
|
+
const base64ToBytes = (value) => {
|
|
27
|
+
if (!value || !BASE64_PATTERN.test(value)) return null;
|
|
28
|
+
try {
|
|
29
|
+
const binary = atob(value);
|
|
30
|
+
const result = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
31
|
+
return bytesToBase64(result) === value ? result : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const bytesToHex = (value) => Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
37
|
+
const toArrayBuffer = (value) => new Uint8Array(value).buffer;
|
|
38
|
+
const canonicalizePublicKey = async (publicKey) => {
|
|
39
|
+
const match = PUBLIC_KEY_PATTERN.exec(publicKey.trim());
|
|
40
|
+
const der = match?.[1] ? base64ToBytes(match[1].replaceAll(/\s/gu, "")) : null;
|
|
41
|
+
if (!der) throw new Error("invalid public key");
|
|
42
|
+
const cryptoKey = await crypto.subtle.importKey("spki", toArrayBuffer(der), {
|
|
43
|
+
hash: "SHA-256",
|
|
44
|
+
name: "RSASSA-PKCS1-v1_5"
|
|
45
|
+
}, true, ["verify"]);
|
|
46
|
+
const modulusLength = Reflect.get(cryptoKey.algorithm, "modulusLength");
|
|
47
|
+
if (![
|
|
48
|
+
2048,
|
|
49
|
+
3072,
|
|
50
|
+
4096
|
|
51
|
+
].includes(modulusLength)) throw new Error("unsupported public key");
|
|
52
|
+
const canonicalDer = new Uint8Array(await crypto.subtle.exportKey("spki", cryptoKey));
|
|
53
|
+
const lines = bytesToBase64(canonicalDer).match(/.{1,64}/gu) ?? [];
|
|
54
|
+
return {
|
|
55
|
+
cryptoKey,
|
|
56
|
+
keyId: bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalDer))),
|
|
57
|
+
publicKey: `-----BEGIN PUBLIC KEY-----\n${lines.join("\n")}\n-----END PUBLIC KEY-----\n`
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const assertSafePath = (path) => {
|
|
61
|
+
if (!path.startsWith("/") || path.includes("\\") || path.includes("//")) throw new Error("invalid path");
|
|
62
|
+
for (const segment of path.split("/")) {
|
|
63
|
+
let decoded;
|
|
64
|
+
try {
|
|
65
|
+
decoded = decodeURIComponent(segment);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error("invalid path");
|
|
68
|
+
}
|
|
69
|
+
if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) throw new Error("invalid path");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const normalizeEndpoint = (endpoint) => {
|
|
73
|
+
let url;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(endpoint);
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error("Remote bundle signing requires a valid endpoint.");
|
|
78
|
+
}
|
|
79
|
+
const rawPath = /^https?:\/\/[^/?#]*(\/[^?#]*)?/iu.exec(endpoint)?.[1] ?? "/";
|
|
80
|
+
try {
|
|
81
|
+
assertSafePath(rawPath);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error("Remote bundle signing requires a valid endpoint.");
|
|
84
|
+
}
|
|
85
|
+
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
86
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback) || url.username || url.password || url.search || url.hash) throw new Error("Remote bundle signing requires a valid endpoint.");
|
|
87
|
+
const prefix = url.pathname.replace(/\/+$/u, "");
|
|
88
|
+
url.pathname = `${prefix}${REMOTE_BUNDLE_SIGNING_PATH}`;
|
|
89
|
+
return url.href;
|
|
90
|
+
};
|
|
91
|
+
const normalizeHandlerPath = (endpointPath) => {
|
|
92
|
+
try {
|
|
93
|
+
assertSafePath(endpointPath);
|
|
94
|
+
const url = new URL(endpointPath, "https://hot-updater.invalid");
|
|
95
|
+
if (url.origin !== "https://hot-updater.invalid" || url.search || url.hash || url.pathname !== endpointPath || !endpointPath.endsWith("/_hot-updater/signing")) throw new Error("invalid path");
|
|
96
|
+
return endpointPath;
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error("Bundle signing handler requires a valid endpoint path.");
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const readLimitedBody = async (body, contentLength, limit) => {
|
|
102
|
+
if (contentLength !== null) {
|
|
103
|
+
if (!/^\d+$/u.test(contentLength) || Number(contentLength) > limit) throw new Error("body too large");
|
|
104
|
+
}
|
|
105
|
+
if (!body) return "";
|
|
106
|
+
const reader = body.getReader();
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
try {
|
|
110
|
+
while (true) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done) break;
|
|
113
|
+
size += value.byteLength;
|
|
114
|
+
if (size > limit) {
|
|
115
|
+
await reader.cancel();
|
|
116
|
+
throw new Error("body too large");
|
|
117
|
+
}
|
|
118
|
+
chunks.push(value);
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
reader.releaseLock();
|
|
122
|
+
}
|
|
123
|
+
const joined = new Uint8Array(size);
|
|
124
|
+
let offset = 0;
|
|
125
|
+
for (const chunk of chunks) {
|
|
126
|
+
joined.set(chunk, offset);
|
|
127
|
+
offset += chunk.byteLength;
|
|
128
|
+
}
|
|
129
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(joined);
|
|
130
|
+
};
|
|
131
|
+
const parseJsonBody = async (body, headers, limit) => {
|
|
132
|
+
if (!JSON_CONTENT_TYPE_PATTERN.test(headers.get("content-type") ?? "")) throw new Error("invalid content type");
|
|
133
|
+
return JSON.parse(await readLimitedBody(body, headers.get("content-length"), limit));
|
|
134
|
+
};
|
|
135
|
+
const parseMetadata = async (value) => {
|
|
136
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
137
|
+
"algorithm",
|
|
138
|
+
"keyId",
|
|
139
|
+
"protocolVersion",
|
|
140
|
+
"publicKey"
|
|
141
|
+
]) || value.protocolVersion !== 1 || value.algorithm !== "RSA-SHA256" || typeof value.keyId !== "string" || !/^[a-f\d]{64}$/u.test(value.keyId) || typeof value.publicKey !== "string") throw new Error("invalid metadata");
|
|
142
|
+
const resolved = await canonicalizePublicKey(value.publicKey);
|
|
143
|
+
if (resolved.keyId !== value.keyId) throw new Error("invalid metadata");
|
|
144
|
+
return resolved;
|
|
145
|
+
};
|
|
146
|
+
const remoteRequest = async (fetchImplementation, endpoint, resolveToken, init) => {
|
|
147
|
+
const token = await resolveToken();
|
|
148
|
+
if (typeof token !== "string" || !token.trim()) throw new Error("missing token");
|
|
149
|
+
const response = await fetchImplementation(endpoint, {
|
|
150
|
+
...init,
|
|
151
|
+
headers: {
|
|
152
|
+
Accept: "application/json",
|
|
153
|
+
...init.headers,
|
|
154
|
+
[REMOTE_BUNDLE_SIGNING_TOKEN_HEADER]: token
|
|
155
|
+
},
|
|
156
|
+
redirect: "error",
|
|
157
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
158
|
+
});
|
|
159
|
+
if (response.status !== 200) throw new Error("invalid status");
|
|
160
|
+
return parseJsonBody(response.body, response.headers, RESPONSE_BODY_LIMIT);
|
|
161
|
+
};
|
|
162
|
+
/** Signs through a server implementing the Hot Updater signing protocol. */
|
|
163
|
+
const remoteSigning = ({ endpoint, fetch: fetchImplementation = globalThis.fetch, publicKeyPath, signingToken }) => {
|
|
164
|
+
if (!publicKeyPath.trim()) throw new Error("Remote bundle signing public key path is required.");
|
|
165
|
+
if (typeof fetchImplementation !== "function") throw new Error("Remote bundle signing requires fetch.");
|
|
166
|
+
const normalizedEndpoint = normalizeEndpoint(endpoint);
|
|
167
|
+
let resolvedKey;
|
|
168
|
+
const resolveToken = async () => {
|
|
169
|
+
const token = typeof signingToken === "function" ? await signingToken() : signingToken ?? process.env.HOT_UPDATER_SIGNING_TOKEN;
|
|
170
|
+
if (!token?.trim()) throw new Error("remoteSigning requires signingToken or HOT_UPDATER_SIGNING_TOKEN.");
|
|
171
|
+
return token;
|
|
172
|
+
};
|
|
173
|
+
const getResolvedKey = () => {
|
|
174
|
+
if (resolvedKey) return resolvedKey;
|
|
175
|
+
resolvedKey = remoteRequest(fetchImplementation, normalizedEndpoint, resolveToken, { method: "GET" }).then(parseMetadata).catch(() => {
|
|
176
|
+
resolvedKey = void 0;
|
|
177
|
+
throw new Error("Failed to load the remote bundle signing public key.");
|
|
178
|
+
});
|
|
179
|
+
return resolvedKey;
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
name: "remoteSigning",
|
|
183
|
+
publicKeyPath,
|
|
184
|
+
async getPublicKey() {
|
|
185
|
+
const { publicKey } = await getResolvedKey();
|
|
186
|
+
return { publicKey };
|
|
187
|
+
},
|
|
188
|
+
async sign({ message }) {
|
|
189
|
+
if (!(message instanceof Uint8Array) || message.byteLength !== MESSAGE_BYTES) throw new Error("Remote bundle signing messages must be exactly 32 bytes.");
|
|
190
|
+
const resolved = await getResolvedKey();
|
|
191
|
+
let value;
|
|
192
|
+
try {
|
|
193
|
+
value = await remoteRequest(fetchImplementation, normalizedEndpoint, resolveToken, {
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
algorithm: REMOTE_BUNDLE_SIGNING_ALGORITHM,
|
|
196
|
+
keyId: resolved.keyId,
|
|
197
|
+
message: bytesToBase64(message),
|
|
198
|
+
protocolVersion: 1
|
|
199
|
+
}),
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
method: "POST"
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
throw new Error("Remote bundle signer failed to sign the message.");
|
|
205
|
+
}
|
|
206
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
207
|
+
"algorithm",
|
|
208
|
+
"keyId",
|
|
209
|
+
"protocolVersion",
|
|
210
|
+
"signature"
|
|
211
|
+
]) || value.protocolVersion !== 1 || value.algorithm !== "RSA-SHA256" || value.keyId !== resolved.keyId || typeof value.signature !== "string") throw new Error("Remote bundle signer returned an invalid signature.");
|
|
212
|
+
const signature = base64ToBytes(value.signature);
|
|
213
|
+
if (!signature || signature.byteLength > SIGNATURE_BYTES_LIMIT) throw new Error("Remote bundle signer returned an invalid signature.");
|
|
214
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", resolved.cryptoKey, toArrayBuffer(signature), toArrayBuffer(message))) throw new Error("Remote bundle signer returned an unverifiable signature.");
|
|
215
|
+
return { signature: new Uint8Array(signature) };
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
const jsonResponse = (value, status = 200, headers) => Response.json(value, {
|
|
220
|
+
headers: {
|
|
221
|
+
"Cache-Control": "private, no-store",
|
|
222
|
+
...headers
|
|
223
|
+
},
|
|
224
|
+
status
|
|
225
|
+
});
|
|
226
|
+
const tokensMatch = async (expected, actual) => {
|
|
227
|
+
if (!expected || actual === null || new TextEncoder().encode(expected).byteLength > TOKEN_BYTES_LIMIT || new TextEncoder().encode(actual).byteLength > TOKEN_BYTES_LIMIT) return false;
|
|
228
|
+
const [expectedHash, actualHash] = await Promise.all([crypto.subtle.digest("SHA-256", new TextEncoder().encode(expected)), crypto.subtle.digest("SHA-256", new TextEncoder().encode(actual))]);
|
|
229
|
+
const left = new Uint8Array(expectedHash);
|
|
230
|
+
const right = new Uint8Array(actualHash);
|
|
231
|
+
let difference = 0;
|
|
232
|
+
for (let index = 0; index < left.byteLength; index += 1) difference |= left[index] ^ right[index];
|
|
233
|
+
return difference === 0;
|
|
234
|
+
};
|
|
235
|
+
/** Handles the portable managed signing protocol in Web API runtimes. */
|
|
236
|
+
const createBundleSigningHandler = async ({ endpointPath = REMOTE_BUNDLE_SIGNING_PATH, publicKey, request, sign, token }) => {
|
|
237
|
+
const normalizedEndpointPath = normalizeHandlerPath(endpointPath);
|
|
238
|
+
const url = new URL(request.url);
|
|
239
|
+
if (url.pathname !== normalizedEndpointPath) return null;
|
|
240
|
+
if (url.search || url.hash) return jsonResponse({ error: "Bad request" }, 400);
|
|
241
|
+
if (!await tokensMatch(token, request.headers.get("x-hot-updater-signing-token"))) return jsonResponse({ error: "Unauthorized" }, 401);
|
|
242
|
+
if (request.method !== "GET" && request.method !== "POST") return jsonResponse({ error: "Method not allowed" }, 405, { Allow: "GET, POST" });
|
|
243
|
+
let resolved;
|
|
244
|
+
try {
|
|
245
|
+
resolved = await canonicalizePublicKey(publicKey);
|
|
246
|
+
} catch {
|
|
247
|
+
return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
248
|
+
}
|
|
249
|
+
const metadata = {
|
|
250
|
+
algorithm: REMOTE_BUNDLE_SIGNING_ALGORITHM,
|
|
251
|
+
keyId: resolved.keyId,
|
|
252
|
+
protocolVersion: 1,
|
|
253
|
+
publicKey: resolved.publicKey
|
|
254
|
+
};
|
|
255
|
+
if (request.method === "GET") return jsonResponse(metadata);
|
|
256
|
+
let body;
|
|
257
|
+
try {
|
|
258
|
+
body = await parseJsonBody(request.body, request.headers, REQUEST_BODY_LIMIT);
|
|
259
|
+
} catch {
|
|
260
|
+
return jsonResponse({ error: "Bad request" }, 400);
|
|
261
|
+
}
|
|
262
|
+
if (!isRecord(body) || !hasExactKeys(body, [
|
|
263
|
+
"algorithm",
|
|
264
|
+
"keyId",
|
|
265
|
+
"message",
|
|
266
|
+
"protocolVersion"
|
|
267
|
+
]) || body.protocolVersion !== 1 || body.algorithm !== "RSA-SHA256" || body.keyId !== resolved.keyId || typeof body.message !== "string") return jsonResponse({ error: "Bad request" }, 400);
|
|
268
|
+
const message = base64ToBytes(body.message);
|
|
269
|
+
if (!message || message.byteLength !== MESSAGE_BYTES) return jsonResponse({ error: "Bad request" }, 400);
|
|
270
|
+
let signature;
|
|
271
|
+
try {
|
|
272
|
+
const result = await sign(new Uint8Array(message));
|
|
273
|
+
if (!(result instanceof Uint8Array) || result.byteLength === 0 || result.byteLength > SIGNATURE_BYTES_LIMIT) throw new Error("invalid signature");
|
|
274
|
+
signature = result;
|
|
275
|
+
} catch {
|
|
276
|
+
return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", resolved.cryptoKey, toArrayBuffer(signature), toArrayBuffer(message))) return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
280
|
+
} catch {
|
|
281
|
+
return jsonResponse({ error: "Signing service unavailable" }, 503);
|
|
282
|
+
}
|
|
283
|
+
return jsonResponse({
|
|
284
|
+
algorithm: REMOTE_BUNDLE_SIGNING_ALGORITHM,
|
|
285
|
+
keyId: resolved.keyId,
|
|
286
|
+
protocolVersion: 1,
|
|
287
|
+
signature: bytesToBase64(signature)
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
//#endregion
|
|
291
|
+
export { REMOTE_BUNDLE_SIGNING_ALGORITHM, REMOTE_BUNDLE_SIGNING_PATH, REMOTE_BUNDLE_SIGNING_PROTOCOL_VERSION, REMOTE_BUNDLE_SIGNING_TOKEN_HEADER, createBundleSigningHandler, remoteSigning };
|