@crossbind/extension-updater 0.0.0-bootstrap.0 → 2.0.0-beta.69
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/LICENSE +21 -0
- package/README.md +33 -3
- package/crossbind.extension.js +48 -0
- package/native/darwin.mm +100 -0
- package/native/linux.cpp +69 -0
- package/native/monocypher/LICENCE.md +167 -0
- package/native/monocypher/README.md +10 -0
- package/native/monocypher/monocypher-ed25519.c +503 -0
- package/native/monocypher/monocypher-ed25519.h +140 -0
- package/native/monocypher/monocypher.c +2988 -0
- package/native/monocypher/monocypher.h +321 -0
- package/native/monocypher-ed25519.cpp +2 -0
- package/native/monocypher.cpp +2 -0
- package/native/updater.cpp +461 -0
- package/native/updater.h +27 -0
- package/native/windows.cpp +195 -0
- package/package.json +42 -6
- package/page.d.ts +12 -0
- package/page.js +21 -0
- package/src/update.js +146 -0
package/package.json
CHANGED
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
"name": "@crossbind/extension-updater",
|
|
3
|
+
"version": "2.0.0-beta.69",
|
|
4
|
+
"description": "crossbind app extension: a desktop app's own updates, each checked against its Ed25519 signature.",
|
|
5
|
+
"homepage": "https://github.com/crossbind/crossbind/tree/main/extensions/updater#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/crossbind/crossbind.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT AND (BSD-2-Clause OR CC0-1.0)",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
"./page": {
|
|
14
|
+
"types": "./page.d.ts",
|
|
15
|
+
"default": "./page.js"
|
|
16
|
+
},
|
|
17
|
+
"./crossbind.extension.js": "./crossbind.extension.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"crossbind.extension.js",
|
|
21
|
+
"native",
|
|
22
|
+
"page.d.ts",
|
|
23
|
+
"page.js",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"crossbind": {
|
|
27
|
+
"extension": "./crossbind.extension.js"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"crossbind": "^2.0.0-beta.69"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"crossbind": "^2.0.0-beta.69"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"crossbind-extension",
|
|
37
|
+
"crossbind",
|
|
38
|
+
"installer",
|
|
39
|
+
"updates",
|
|
40
|
+
"updater",
|
|
41
|
+
"app"
|
|
42
|
+
]
|
|
43
|
+
}
|
package/page.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A newer version of the app, which app.updater.url in crossbind.config.js offers. */
|
|
2
|
+
export interface Update {
|
|
3
|
+
version: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** One check or install runs at a time, and another rejects meanwhile. */
|
|
7
|
+
export declare const updater: {
|
|
8
|
+
/** The version app.updater.url offers when it is newer than the app's, or null. */
|
|
9
|
+
check(): Promise<Update | null>;
|
|
10
|
+
/** Downloads that version, checks that the key of app.updater.publicKey signed it and installs it in place of the app, which then quits and opens the new version. It rejects when that fails. */
|
|
11
|
+
install(): Promise<void>;
|
|
12
|
+
};
|
package/page.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { invoke } from 'crossbind/app/core';
|
|
2
|
+
|
|
3
|
+
const NO_UPDATES = 'crossbind: this app does not update itself: a desktop app does with app.updater in crossbind.config.js, an iOS or Android app through its store';
|
|
4
|
+
|
|
5
|
+
// A function of the updater's, which an app without one hears of by what it needs.
|
|
6
|
+
async function ofUpdater(name) {
|
|
7
|
+
try {
|
|
8
|
+
return await invoke(name);
|
|
9
|
+
} catch (error) {
|
|
10
|
+
throw error?.message === `crossbind: an app has no function ${name}` ? new Error(NO_UPDATES) : error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// One check or install runs at a time, and another rejects meanwhile.
|
|
15
|
+
export const updater = {
|
|
16
|
+
// The version app.updater.url in crossbind.config.js offers when it is newer than the app's, as { version }, or null.
|
|
17
|
+
check: async () => ofUpdater('updater.check'),
|
|
18
|
+
// Downloads that version, checks that the key of app.updater.publicKey signed it and installs it in place of the app,
|
|
19
|
+
// which then quits and opens the new version. It rejects when that fails.
|
|
20
|
+
install: async () => ofUpdater('updater.install'),
|
|
21
|
+
};
|
package/src/update.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// The private key that signs a desktop app's updates, named by the environment alone so that no secret reaches a file.
|
|
6
|
+
export const UPDATER_KEY = 'CROSSBIND_UPDATER_PRIVATE_KEY';
|
|
7
|
+
// The fixed DER around an Ed25519 key's 32 bytes, private and public.
|
|
8
|
+
const PRIVATE_DER = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
9
|
+
const PUBLIC_DER = Buffer.from('302a300506032b6570032100', 'hex');
|
|
10
|
+
// The names an update goes by, of the platforms as the host knows them.
|
|
11
|
+
const HOST_PLATFORMS = { macos: 'darwin', linux: 'linux', windows: 'win32' };
|
|
12
|
+
// The installer whose first file a Linux or Windows app updates itself from: an archive of the app's folder, which the
|
|
13
|
+
// host unpacks with tar, and a setup program, which it runs with /S /UPDATE /D=<the app's folder>.
|
|
14
|
+
const UPDATE_INSTALLERS = { linux: 'tar-gz', windows: 'nsis' };
|
|
15
|
+
const PLATFORM_NAMES = { linux: 'Linux', windows: 'Windows' };
|
|
16
|
+
|
|
17
|
+
// Semver's version: three numbers, then a prerelease and build metadata of dot-separated identifiers.
|
|
18
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
|
19
|
+
// What an update's files are named after: characters an address carries as they are, and no leading dot.
|
|
20
|
+
const UPDATE_NAME = /^[A-Za-z0-9_~-][A-Za-z0-9._~-]*$/;
|
|
21
|
+
// The Ed25519 public keys of the points of small order, whatever the sign of x, whose signatures anyone can forge: the
|
|
22
|
+
// list libsodium's ge25519_has_small_order checks.
|
|
23
|
+
const SMALL_ORDER_KEYS = [
|
|
24
|
+
'0000000000000000000000000000000000000000000000000000000000000000',
|
|
25
|
+
'0100000000000000000000000000000000000000000000000000000000000000',
|
|
26
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
|
|
27
|
+
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
28
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
29
|
+
'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
30
|
+
'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
31
|
+
].map((hex) => Buffer.from(hex, 'hex'));
|
|
32
|
+
|
|
33
|
+
const isSmallOrder = (key) => SMALL_ORDER_KEYS.some((point) => key.subarray(0, 31).equals(point.subarray(0, 31)) && (key[31] & 0x7f) === point[31]);
|
|
34
|
+
|
|
35
|
+
// Where a desktop app finds its updates, the key their signatures are checked with, and the version they are newer than:
|
|
36
|
+
// package.json's, which semver orders. Null without app.updater.
|
|
37
|
+
export function appUpdater(api) {
|
|
38
|
+
const { updater } = api.config.app;
|
|
39
|
+
if (updater === undefined) return null;
|
|
40
|
+
const keys = 'url and publicKey';
|
|
41
|
+
if (updater === null || typeof updater !== 'object' || Array.isArray(updater)) {
|
|
42
|
+
throw new Error(`crossbind: app.updater ${JSON.stringify(updater)} is no object of ${keys}, as { url: 'https://example.com/updates/<name>-{platform}-{arch}.update.json', publicKey: '…' } is.`);
|
|
43
|
+
}
|
|
44
|
+
const unknown = Object.keys(updater).filter((key) => !['url', 'publicKey'].includes(key));
|
|
45
|
+
if (unknown.length > 0) throw new Error(`crossbind: app.updater takes ${keys}; ${unknown.join(', ')} is none of them.`);
|
|
46
|
+
const url = URL.canParse(updater.url) ? new URL(updater.url) : null;
|
|
47
|
+
const isLocal = url?.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
48
|
+
// The host takes the address as a C string, as it is written, and the update's file from beside the manifest.
|
|
49
|
+
if (!url || !(url.protocol === 'https:' || isLocal) || /[^\x21-\x7e]|["\\]/.test(updater.url) || url.pathname.endsWith('/')) {
|
|
50
|
+
throw new Error(`crossbind: app.updater.url ${JSON.stringify(updater.url)} is no https address of a manifest, as https://example.com/updates/<name>-{platform}-{arch}.update.json is; http serves localhost alone.`);
|
|
51
|
+
}
|
|
52
|
+
const publicKey = Buffer.from(typeof updater.publicKey === 'string' ? updater.publicKey : '', 'base64');
|
|
53
|
+
if (publicKey.length !== 32 || publicKey.toString('base64') !== updater.publicKey) {
|
|
54
|
+
throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is no Ed25519 public key, which \`crossbind app updater-key\` prints.`);
|
|
55
|
+
}
|
|
56
|
+
if (isSmallOrder(publicKey)) {
|
|
57
|
+
throw new Error(`crossbind: app.updater.publicKey ${JSON.stringify(updater.publicKey)} is a key anyone can forge signatures for, which no key pair has. Use the one \`crossbind app updater-key\` prints.`);
|
|
58
|
+
}
|
|
59
|
+
const { version } = api.app;
|
|
60
|
+
if (typeof version !== 'string' || !SEMVER.test(version)) {
|
|
61
|
+
throw new Error(`crossbind: package.json's version ${JSON.stringify(version)} is no semver version, as 1.2.3 and 1.2.3-beta.1 are, which an app that updates needs to tell a newer one.`);
|
|
62
|
+
}
|
|
63
|
+
if (!UPDATE_NAME.test(api.project.name)) {
|
|
64
|
+
throw new Error(`crossbind: general.name ${JSON.stringify(api.project.name)} names an app's update files, which an app fetches by name: letters, digits, and . _ ~ - alone, not starting with a dot. Change general.name in crossbind.config.js.`);
|
|
65
|
+
}
|
|
66
|
+
return { url: updater.url, publicKey: updater.publicKey, version };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const publicKeyOf = (privateKey) => crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(PUBLIC_DER.length);
|
|
70
|
+
|
|
71
|
+
// A new key pair: the public key for app.updater.publicKey, the private one for CROSSBIND_UPDATER_PRIVATE_KEY.
|
|
72
|
+
export function newUpdaterKey() {
|
|
73
|
+
const { privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
74
|
+
return {
|
|
75
|
+
publicKey: publicKeyOf(privateKey).toString('base64'),
|
|
76
|
+
privateKey: privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PRIVATE_DER.length).toString('base64'),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The key a build signs updates with, or null without CROSSBIND_UPDATER_PRIVATE_KEY, or where a publisher's build removed
|
|
81
|
+
// app.updater. It has to be the private half of app.updater.publicKey, or the app would refuse what it signs.
|
|
82
|
+
export function updaterSigning(api, env = process.env) {
|
|
83
|
+
if (!env[UPDATER_KEY]) return null;
|
|
84
|
+
const updater = appUpdater(api);
|
|
85
|
+
const changes = api.publisherChanges;
|
|
86
|
+
if (!updater && changes && (changes.app === null || changes.app?.updater === null)) return null;
|
|
87
|
+
if (!updater) throw new Error(`crossbind: ${UPDATER_KEY} is set, but crossbind.config.js has no app.updater, which tells an app where its updates are.`);
|
|
88
|
+
const seed = Buffer.from(env[UPDATER_KEY], 'base64');
|
|
89
|
+
if (seed.length !== 32) throw new Error(`crossbind: ${UPDATER_KEY} is no Ed25519 private key, which \`crossbind app updater-key\` prints.`);
|
|
90
|
+
const privateKey = crypto.createPrivateKey({ key: Buffer.concat([PRIVATE_DER, seed]), format: 'der', type: 'pkcs8' });
|
|
91
|
+
if (!publicKeyOf(privateKey).equals(Buffer.from(updater.publicKey, 'base64'))) {
|
|
92
|
+
throw new Error(`crossbind: ${UPDATER_KEY} is not the private key of app.updater.publicKey, so the app would refuse the updates it signs.`);
|
|
93
|
+
}
|
|
94
|
+
return { privateKey, version: updater.version };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// What an update's signature covers: the app's id, the platform and arch it is for, its version and its file's SHA-512,
|
|
98
|
+
// so that no file passes for another app's, platform's or version's. The host checks the same text.
|
|
99
|
+
export function signedText(id, target, version, file) {
|
|
100
|
+
const hash = crypto.createHash('sha512').update(fs.readFileSync(file)).digest('hex');
|
|
101
|
+
return `crossbind update\n${id}\n${target}\n${version}\n${hash}\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What stops a build whose update could not be made: a key that could not sign it, or an app.installers that leaves out
|
|
105
|
+
// the installer whose file a Linux or Windows app updates itself from.
|
|
106
|
+
export function checkUpdate(api, { installers }) {
|
|
107
|
+
updaterSigning(api);
|
|
108
|
+
const needed = UPDATE_INSTALLERS[api.platform];
|
|
109
|
+
if (!needed || !appUpdater(api) || installers.includes(needed)) return;
|
|
110
|
+
const updates = `a ${PLATFORM_NAMES[api.platform]} app with app.updater updates itself from what its ${needed} installer makes`;
|
|
111
|
+
if (api.config.app.without?.includes(needed)) throw new Error(`crossbind: ${updates}, which app.without leaves out: take ${needed} out of it, or remove app.updater.`);
|
|
112
|
+
// A publisher's build may narrow the installers its store takes.
|
|
113
|
+
if (api.publisherChanges?.app?.installers !== undefined) {
|
|
114
|
+
throw new Error(`crossbind: ${updates}, which the publisher's build leaves out of app.installers: its build lists ${needed} too, or removes app.updater with { app: { updater: null } }.`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`crossbind: ${updates}, which app.installers leaves out: list ${needed} in it, or remove app.updater.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The file a desktop app updates itself from, beside it: a macOS app as a zip, which keeps its signature and Apple's
|
|
120
|
+
// ticket, and a Linux or Windows app's of its installers.
|
|
121
|
+
function updateFile(api, app, arch, made) {
|
|
122
|
+
const installer = UPDATE_INSTALLERS[api.platform];
|
|
123
|
+
if (installer) {
|
|
124
|
+
const file = made.find(({ name }) => name === installer)?.files[0];
|
|
125
|
+
if (!file) throw new Error(`crossbind: the ${installer} installer made no file of ${app} for its update.`);
|
|
126
|
+
return file;
|
|
127
|
+
}
|
|
128
|
+
const zip = path.join(path.dirname(app), `${api.project.name}-darwin-${arch}.app.zip`);
|
|
129
|
+
fs.rmSync(zip, { force: true });
|
|
130
|
+
api.run(['ditto', '-c', '-k', '--keepParent', app, zip], { cwd: path.dirname(app) });
|
|
131
|
+
return zip;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// With a key in the environment, the update a desktop app finds at app.updater.url, beside the app: its file, and after
|
|
135
|
+
// it, as a publisher puts them up in this order, the manifest that gives its version, the file's name and its signature,
|
|
136
|
+
// <name>-<platform>-<arch>.update.json. Without a key, none.
|
|
137
|
+
export function makeUpdate(api, { app, arch, made }) {
|
|
138
|
+
const signing = updaterSigning(api);
|
|
139
|
+
if (!signing) return [];
|
|
140
|
+
const file = updateFile(api, app, arch, made);
|
|
141
|
+
const target = `${HOST_PLATFORMS[api.platform]}-${arch}`;
|
|
142
|
+
const signature = crypto.sign(null, Buffer.from(signedText(api.app.id, target, signing.version, file)), signing.privateKey).toString('base64');
|
|
143
|
+
const manifest = path.join(path.dirname(app), `${api.project.name}-${target}.update.json`);
|
|
144
|
+
fs.writeFileSync(manifest, `${JSON.stringify({ version: signing.version, file: path.basename(file), signature }, null, 2)}\n`);
|
|
145
|
+
return [file, manifest];
|
|
146
|
+
}
|