@renbostudios/edge-ota 0.4.0 → 0.4.2
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/README.md +313 -119
- package/dist/index.js +1148 -892
- package/package.json +18 -13
- package/dist/core/crypto.js +0 -87
- package/dist/core/index.js +0 -2
- package/dist/core/manifest.js +0 -108
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renbostudios/edge-ota",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"preferGlobal": true,
|
|
5
5
|
"description": "Zero-SDK, self-hostable OTA update CLI for Expo — push signed JS bundles to any EdgeOTA server",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "EdgeOTA Contributors",
|
|
8
8
|
"homepage": "https://github.com/renbostudios/edge-ota-cli",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
9
12
|
"repository": {
|
|
10
13
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/itskodaaa/edge-ota.git",
|
|
15
|
+
"directory": "apps/cli"
|
|
12
16
|
},
|
|
13
17
|
"bugs": {
|
|
14
18
|
"url": "https://github.com/renbostudios/edge-ota-cli/issues"
|
|
@@ -30,19 +34,20 @@
|
|
|
30
34
|
"dist",
|
|
31
35
|
"README.md"
|
|
32
36
|
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "tsc",
|
|
35
|
-
"dev": "tsc --watch",
|
|
36
|
-
"clean": "rm -rf dist",
|
|
37
|
-
"prepublishOnly": "npm run build"
|
|
38
|
-
},
|
|
39
37
|
"dependencies": {
|
|
40
|
-
"
|
|
41
|
-
"
|
|
38
|
+
"archiver": "^7.0.1",
|
|
39
|
+
"commander": "^12.1.0"
|
|
42
40
|
},
|
|
43
41
|
"devDependencies": {
|
|
44
|
-
"
|
|
42
|
+
"@types/archiver": "^6.0.2",
|
|
45
43
|
"@types/node": "^20.12.12",
|
|
46
|
-
"
|
|
44
|
+
"tsup": "^8.5.1",
|
|
45
|
+
"typescript": "^5.4.5",
|
|
46
|
+
"@renbostudios/edge-ota-core": "0.1.1"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"clean": "rm -rf dist"
|
|
47
52
|
}
|
|
48
|
-
}
|
|
53
|
+
}
|
package/dist/core/crypto.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* EdgeOTA Core - Cryptographic Utilities
|
|
3
|
-
*
|
|
4
|
-
* Implements ECDSA P-256 + SHA-256 signing and verification,
|
|
5
|
-
* compatible with both Node.js (via globalThis.crypto.subtle) and
|
|
6
|
-
* the Cloudflare Workers runtime (same Web Crypto API).
|
|
7
|
-
*
|
|
8
|
-
* Key format: PKCS#8 private key / SPKI public key — PEM-encoded.
|
|
9
|
-
* Signature format: raw DER bytes → base64 string (NOT base64url,
|
|
10
|
-
* to stay compatible with the existing expo-updates SDK verification).
|
|
11
|
-
*/
|
|
12
|
-
/** Generate a fresh ECDSA P-256 key pair encoded as PEM. */
|
|
13
|
-
export async function generateECDSAKeyPair() {
|
|
14
|
-
const keyPair = await globalThis.crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
|
15
|
-
const privateKeyDer = await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
|
|
16
|
-
const publicKeyDer = await globalThis.crypto.subtle.exportKey("spki", keyPair.publicKey);
|
|
17
|
-
return {
|
|
18
|
-
privateKey: derToPem(privateKeyDer, "PRIVATE KEY"),
|
|
19
|
-
publicKey: derToPem(publicKeyDer, "PUBLIC KEY")
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Sign an arbitrary string payload with a PEM-encoded ECDSA P-256 private key.
|
|
24
|
-
* Returns the raw signature bytes encoded as base64.
|
|
25
|
-
*/
|
|
26
|
-
export async function signPayload(payload, privateKeyPem) {
|
|
27
|
-
const privateKeyDer = pemToDer(privateKeyPem, "PRIVATE KEY");
|
|
28
|
-
const privateKey = await globalThis.crypto.subtle.importKey("pkcs8", privateKeyDer, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
29
|
-
const data = new TextEncoder().encode(payload);
|
|
30
|
-
const sigBuffer = await globalThis.crypto.subtle.sign({ name: "ECDSA", hash: { name: "SHA-256" } }, privateKey, data);
|
|
31
|
-
return arrayBufferToBase64(sigBuffer);
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Verify a base64-encoded ECDSA P-256 signature against a payload string
|
|
35
|
-
* using a PEM-encoded public key.
|
|
36
|
-
*/
|
|
37
|
-
export async function verifyPayload(payload, signatureBase64, publicKeyPem) {
|
|
38
|
-
try {
|
|
39
|
-
const publicKeyDer = pemToDer(publicKeyPem, "PUBLIC KEY");
|
|
40
|
-
const publicKey = await globalThis.crypto.subtle.importKey("spki", publicKeyDer, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
41
|
-
const data = new TextEncoder().encode(payload);
|
|
42
|
-
const signature = base64ToArrayBuffer(signatureBase64);
|
|
43
|
-
return await globalThis.crypto.subtle.verify({ name: "ECDSA", hash: { name: "SHA-256" } }, publicKey, signature, data);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* SHA-256 hash a buffer and return the hex string.
|
|
51
|
-
* Used to produce the per-asset `hash` field in the Expo manifest.
|
|
52
|
-
*/
|
|
53
|
-
export async function sha256Hex(buffer) {
|
|
54
|
-
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
|
|
55
|
-
return Array.from(new Uint8Array(digest))
|
|
56
|
-
.map(b => b.toString(16).padStart(2, "0"))
|
|
57
|
-
.join("");
|
|
58
|
-
}
|
|
59
|
-
// ─── Internal PEM / DER utilities ────────────────────────────────────────────
|
|
60
|
-
function derToPem(der, label) {
|
|
61
|
-
const base64 = arrayBufferToBase64(der);
|
|
62
|
-
const lines = base64.match(/.{1,64}/g) || [];
|
|
63
|
-
return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----`;
|
|
64
|
-
}
|
|
65
|
-
function pemToDer(pem, label) {
|
|
66
|
-
const cleanPem = pem
|
|
67
|
-
.replace(`-----BEGIN ${label}-----`, "")
|
|
68
|
-
.replace(`-----END ${label}-----`, "")
|
|
69
|
-
.replace(/\s+/g, "");
|
|
70
|
-
return base64ToArrayBuffer(cleanPem);
|
|
71
|
-
}
|
|
72
|
-
function arrayBufferToBase64(buffer) {
|
|
73
|
-
const bytes = new Uint8Array(buffer);
|
|
74
|
-
let binary = "";
|
|
75
|
-
for (let i = 0; i < bytes.byteLength; i++) {
|
|
76
|
-
binary += String.fromCharCode(bytes[i]);
|
|
77
|
-
}
|
|
78
|
-
return globalThis.btoa(binary);
|
|
79
|
-
}
|
|
80
|
-
function base64ToArrayBuffer(base64) {
|
|
81
|
-
const binary = globalThis.atob(base64);
|
|
82
|
-
const bytes = new Uint8Array(binary.length);
|
|
83
|
-
for (let i = 0; i < binary.length; i++) {
|
|
84
|
-
bytes[i] = binary.charCodeAt(i);
|
|
85
|
-
}
|
|
86
|
-
return bytes.buffer;
|
|
87
|
-
}
|
package/dist/core/index.js
DELETED
package/dist/core/manifest.js
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* EdgeOTA Core - Expo Updates Protocol v1 Manifest Engine
|
|
3
|
-
*
|
|
4
|
-
* Implements the exact wire format consumed by expo-updates SDK,
|
|
5
|
-
* matching the official expo/custom-expo-updates-server reference:
|
|
6
|
-
* - EAS Update-compatible manifest structure
|
|
7
|
-
* - Multipart/mixed response body with FormData-compatible boundary
|
|
8
|
-
* - expo-signature header for ECDSA bundle verification
|
|
9
|
-
* - fileExtension on launchAsset and assets
|
|
10
|
-
* - extra.expoClient config passthrough
|
|
11
|
-
*/
|
|
12
|
-
/**
|
|
13
|
-
* Convert a hex SHA-256 hash to base64url encoding.
|
|
14
|
-
* expo-updates verifies downloaded assets by computing SHA-256, base64url-encoding it,
|
|
15
|
-
* and comparing against the manifest `hash` field.
|
|
16
|
-
*/
|
|
17
|
-
function hexToBase64Url(hex) {
|
|
18
|
-
if (hex.length !== 64 || !/^[0-9a-fA-F]+$/.test(hex)) {
|
|
19
|
-
return hex;
|
|
20
|
-
}
|
|
21
|
-
const bytes = new Uint8Array(hex.length / 2);
|
|
22
|
-
for (let i = 0; i < hex.length; i += 2) {
|
|
23
|
-
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
24
|
-
}
|
|
25
|
-
let binString = "";
|
|
26
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
27
|
-
binString += String.fromCharCode(bytes[i]);
|
|
28
|
-
}
|
|
29
|
-
const base64 = btoa(binString);
|
|
30
|
-
return base64
|
|
31
|
-
.replace(/\+/g, '-')
|
|
32
|
-
.replace(/\//g, '_')
|
|
33
|
-
.replace(/=+$/, '');
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Build the Expo Updates v1 manifest object.
|
|
37
|
-
* Matches the format produced by expo/custom-expo-updates-server.
|
|
38
|
-
*/
|
|
39
|
-
export function generateExpoManifest(params) {
|
|
40
|
-
return {
|
|
41
|
-
id: params.updateId,
|
|
42
|
-
createdAt: params.createdAt,
|
|
43
|
-
runtimeVersion: params.runtimeVersion,
|
|
44
|
-
launchAsset: {
|
|
45
|
-
hash: hexToBase64Url(params.bundleHash),
|
|
46
|
-
key: params.bundleKey || params.bundleHash.slice(0, 32),
|
|
47
|
-
fileExtension: ".bundle",
|
|
48
|
-
contentType: "application/javascript",
|
|
49
|
-
url: params.bundleUrl
|
|
50
|
-
},
|
|
51
|
-
assets: params.assets.map(asset => ({
|
|
52
|
-
...asset,
|
|
53
|
-
hash: hexToBase64Url(asset.hash)
|
|
54
|
-
})),
|
|
55
|
-
metadata: params.metadata ?? {},
|
|
56
|
-
extra: params.extra ?? {}
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Build the response headers required by the Expo Updates protocol.
|
|
61
|
-
*
|
|
62
|
-
* expo-protocol-version: "1" — Signals v1 manifest format
|
|
63
|
-
* expo-sfv-version: "0" — Structured Fields version
|
|
64
|
-
* cache-control — Updates must never be stale; client always revalidates
|
|
65
|
-
* expo-signature — ECDSA P-256 + SHA-256 signature over the manifest JSON
|
|
66
|
-
*/
|
|
67
|
-
export function createExpoHeaders(signature) {
|
|
68
|
-
const headers = {
|
|
69
|
-
"expo-protocol-version": "1",
|
|
70
|
-
"expo-sfv-version": "0",
|
|
71
|
-
"cache-control": "private, max-age=0",
|
|
72
|
-
};
|
|
73
|
-
if (signature) {
|
|
74
|
-
headers["expo-signature"] = `sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"`;
|
|
75
|
-
}
|
|
76
|
-
return headers;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Build the multipart/mixed response body required by expo-updates protocol v1.
|
|
80
|
-
* Uses FormData-compatible boundary formatting matching the official Expo server.
|
|
81
|
-
*
|
|
82
|
-
* The expo-updates native client parses this multipart body to extract the manifest
|
|
83
|
-
* from the part named "manifest".
|
|
84
|
-
*/
|
|
85
|
-
export function buildMultipartManifestBody(manifest, signature) {
|
|
86
|
-
const boundary = "ota" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
87
|
-
const manifestJson = JSON.stringify(manifest);
|
|
88
|
-
// Build the manifest part headers exactly as FormData would
|
|
89
|
-
let manifestPart = `--${boundary}\r\n`;
|
|
90
|
-
manifestPart += `Content-Disposition: form-data; name="manifest"\r\n`;
|
|
91
|
-
manifestPart += `Content-Type: application/json; charset=utf-8\r\n`;
|
|
92
|
-
if (signature) {
|
|
93
|
-
manifestPart += `expo-signature: sig="${signature}", keyid="root", alg="ecdsa-p256-sha256"\r\n`;
|
|
94
|
-
}
|
|
95
|
-
manifestPart += `\r\n`;
|
|
96
|
-
manifestPart += manifestJson;
|
|
97
|
-
manifestPart += `\r\n`;
|
|
98
|
-
// Build the extensions part (included by official Expo server)
|
|
99
|
-
const extensions = { assetRequestHeaders: {} };
|
|
100
|
-
let extensionsPart = `--${boundary}\r\n`;
|
|
101
|
-
extensionsPart += `Content-Disposition: form-data; name="extensions"\r\n`;
|
|
102
|
-
extensionsPart += `Content-Type: application/json\r\n`;
|
|
103
|
-
extensionsPart += `\r\n`;
|
|
104
|
-
extensionsPart += JSON.stringify(extensions);
|
|
105
|
-
extensionsPart += `\r\n`;
|
|
106
|
-
const body = manifestPart + extensionsPart + `--${boundary}--\r\n`;
|
|
107
|
-
return { body, boundary, contentType: `multipart/mixed; boundary=${boundary}` };
|
|
108
|
-
}
|