ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / package-preview (push) Canceled after 0s
ci / package-installer (push) Canceled after 0s
ci / linux-agent (push) Canceled after 0s
ci / edge-service (push) Canceled after 0s
ci / coturn-pop (push) Canceled after 0s
ci / package-windows-host (push) Canceled after 0s
134 lines
5.2 KiB
JavaScript
134 lines
5.2 KiB
JavaScript
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
|
|
import { readFile, stat, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
|
|
const usage = `usage: node create-update-manifest.mjs \
|
|
--installer <RemoteDesk.msi> \
|
|
--installer-url <https-url> \
|
|
--private-key <ed25519-pkcs8.pem> \
|
|
--version <major.minor.patch> \
|
|
--output <stable.json> \
|
|
[--public-key-output <public-key.txt>] \
|
|
[--channel <stable>] [--target <windows-x64>] [--notes-file <notes.txt>]`
|
|
|
|
function parseArgs(values) {
|
|
const options = {}
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
const name = values[index]
|
|
if (name === '--help' || name === '-h') return { help: true }
|
|
if (!name.startsWith('--')) throw new Error(`unknown argument: ${name}`)
|
|
const value = values[index + 1]
|
|
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`)
|
|
if (Object.hasOwn(options, name)) throw new Error(`${name} was provided more than once`)
|
|
options[name] = value
|
|
index += 1
|
|
}
|
|
return options
|
|
}
|
|
|
|
function required(options, name) {
|
|
const value = options[name]
|
|
if (!value) throw new Error(`${name} is required`)
|
|
return value
|
|
}
|
|
|
|
function validateShortText(value, maximum, name) {
|
|
if (!value || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
throw new Error(`${name} is invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function validateUrl(value) {
|
|
if (value.length > 2048 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
throw new Error('installer URL is invalid')
|
|
}
|
|
const url = new URL(value)
|
|
if (url.protocol !== 'https:' || !url.hostname) throw new Error('installer URL must use HTTPS')
|
|
if (url.username || url.password || url.search || url.hash) {
|
|
throw new Error('installer URL must not contain credentials, query, or fragment')
|
|
}
|
|
if (!url.pathname.toLowerCase().endsWith('.msi')) {
|
|
throw new Error('installer URL must identify an MSI package')
|
|
}
|
|
return url.toString()
|
|
}
|
|
|
|
function rawEd25519PublicKey(privateKey) {
|
|
const jwk = createPublicKey(privateKey).export({ format: 'jwk' })
|
|
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') {
|
|
throw new Error('private key is not an Ed25519 key')
|
|
}
|
|
const raw = Buffer.from(jwk.x, 'base64url')
|
|
if (raw.length !== 32) throw new Error('Ed25519 public key must contain 32 bytes')
|
|
return raw
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2))
|
|
if (options.help) {
|
|
process.stdout.write(`${usage}\n`)
|
|
return
|
|
}
|
|
|
|
const installerPath = path.resolve(required(options, '--installer'))
|
|
const installerUrl = validateUrl(required(options, '--installer-url'))
|
|
const privateKeyPath = path.resolve(required(options, '--private-key'))
|
|
const outputPath = path.resolve(required(options, '--output'))
|
|
const publicKeyOutput = options['--public-key-output']
|
|
? path.resolve(options['--public-key-output'])
|
|
: null
|
|
const version = required(options, '--version')
|
|
if (!/^\d+\.\d+\.\d+$/u.test(version)) throw new Error('version must be major.minor.patch')
|
|
const channel = validateShortText(options['--channel'] ?? 'stable', 32, 'channel')
|
|
const target = validateShortText(options['--target'] ?? 'windows-x64', 32, 'target')
|
|
|
|
const installer = await readFile(installerPath)
|
|
const installerStats = await stat(installerPath)
|
|
if (!installerStats.isFile() || installerStats.size === 0) throw new Error('installer is empty')
|
|
if (installerStats.size > 1024 * 1024 * 1024) throw new Error('installer exceeds 1 GiB')
|
|
const notes = options['--notes-file']
|
|
? await readFile(path.resolve(options['--notes-file']), 'utf8')
|
|
: undefined
|
|
if (notes && (Buffer.byteLength(notes) > 4096 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(notes))) {
|
|
throw new Error('release notes are invalid')
|
|
}
|
|
|
|
const privateKey = createPrivateKey(await readFile(privateKeyPath, 'utf8'))
|
|
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('private key must use Ed25519')
|
|
const publicKey = rawEd25519PublicKey(privateKey)
|
|
const payload = Buffer.from(JSON.stringify({
|
|
product: 'remotedesk',
|
|
channel,
|
|
version,
|
|
published_at: new Date().toISOString(),
|
|
target,
|
|
installer: {
|
|
url: installerUrl,
|
|
sha256: createHash('sha256').update(installer).digest('hex'),
|
|
size_bytes: installerStats.size,
|
|
},
|
|
...(notes ? { notes } : {}),
|
|
}), 'utf8')
|
|
const signature = sign(null, payload, privateKey)
|
|
if (signature.length !== 64) throw new Error('Ed25519 signature must contain 64 bytes')
|
|
const envelope = {
|
|
schema: 1,
|
|
payload: payload.toString('base64'),
|
|
signature: signature.toString('base64'),
|
|
}
|
|
await writeFile(outputPath, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
|
|
const publicKeyBase64 = publicKey.toString('base64')
|
|
if (publicKeyOutput) {
|
|
await writeFile(publicKeyOutput, `${publicKeyBase64}\n`, { encoding: 'ascii', flag: 'wx' })
|
|
}
|
|
process.stdout.write(`Manifest: ${outputPath}\n`)
|
|
process.stdout.write(`Update public key: ${publicKeyBase64}\n`)
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
process.exitCode = 1
|
|
})
|