Add secure native runtime installation
This commit is contained in:
+111
-18
@@ -1,9 +1,9 @@
|
||||
import { app } from 'electron'
|
||||
import { app, dialog } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { createHash } from 'crypto'
|
||||
import { createReadStream } from 'fs'
|
||||
import { access, readFile } from 'fs/promises'
|
||||
import { isAbsolute, join, resolve, sep } from 'path'
|
||||
import { access, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm } from 'fs/promises'
|
||||
import { basename, isAbsolute, join, resolve, sep } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type { AuroraNativeRuntimeManifest, AuroraRuntimeStatus } from '../shared/types'
|
||||
|
||||
@@ -65,7 +65,7 @@ export function validateNativeRuntimeManifest(value: unknown): AuroraNativeRunti
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRoot(): string {
|
||||
export function runtimeRoot(): string {
|
||||
return join(app.getPath('userData'), 'runtimes', `${process.platform}-${process.arch}`)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,112 @@ async function sha256(path: string): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyRuntimeAt(root: string): Promise<AuroraNativeRuntimeManifest> {
|
||||
const manifest = validateNativeRuntimeManifest(
|
||||
JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
|
||||
)
|
||||
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
|
||||
throw new Error(
|
||||
`This runtime targets ${manifest.platform}-${manifest.arch}, not ${process.platform}-${process.arch}.`
|
||||
)
|
||||
const canonicalRoot = resolve(root)
|
||||
for (const component of manifest.components) {
|
||||
const executable = resolve(root, component.executable)
|
||||
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
|
||||
throw new Error(`Unsafe executable path for ${component.id}.`)
|
||||
await access(executable)
|
||||
if ((await sha256(executable)) !== component.sha256.toLowerCase())
|
||||
throw new Error(`Checksum verification failed for ${component.id}.`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function rejectLinks(root: string, current = root): Promise<void> {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
const path = join(current, entry.name)
|
||||
const stat = await lstat(path)
|
||||
if (stat.isSymbolicLink())
|
||||
throw new Error(`Runtime archive contains a symbolic link: ${path.slice(root.length + 1)}`)
|
||||
if (stat.isDirectory()) await rejectLinks(root, path)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArchiveEntries(output: string): void {
|
||||
const entries = output.split(/\r?\n/).filter(Boolean)
|
||||
if (!entries.length || entries.length > 50000)
|
||||
throw new Error('Runtime archive has an invalid file count.')
|
||||
for (const entry of entries) {
|
||||
const normalized = entry.replace(/^\.\//, '')
|
||||
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..'))
|
||||
throw new Error(`Unsafe runtime archive entry: ${entry}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function installNativeRuntimeArchive(source: string): Promise<AuroraRuntimeStatus> {
|
||||
if (!source.endsWith('.tar.gz'))
|
||||
throw new Error('Aurora Native runtimes must be .tar.gz archives.')
|
||||
const runtimes = join(app.getPath('userData'), 'runtimes')
|
||||
await mkdir(runtimes, { recursive: true })
|
||||
const staging = await mkdtemp(join(runtimes, '.install-'))
|
||||
const target = runtimeRoot()
|
||||
const backup = `${target}.previous`
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tar', ['-tzf', source], { maxBuffer: 16 * 1024 * 1024 })
|
||||
validateArchiveEntries(stdout)
|
||||
await execFileAsync(
|
||||
'tar',
|
||||
['-xzf', source, '--no-same-owner', '--no-same-permissions', '-C', staging],
|
||||
{ maxBuffer: 16 * 1024 * 1024 }
|
||||
)
|
||||
await rejectLinks(staging)
|
||||
await verifyRuntimeAt(staging)
|
||||
await rm(backup, { recursive: true, force: true })
|
||||
try {
|
||||
await rename(target, backup)
|
||||
} catch {
|
||||
/* first installation */
|
||||
}
|
||||
try {
|
||||
await rename(staging, target)
|
||||
await rm(backup, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
try {
|
||||
await rename(backup, target)
|
||||
} catch {
|
||||
/* no previous runtime */
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return getRuntimeStatus()
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickAndInstallNativeRuntime(): Promise<AuroraRuntimeStatus | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Aurora Native Runtime', extensions: ['gz'] }]
|
||||
})
|
||||
return result.canceled || !result.filePaths[0]
|
||||
? null
|
||||
: installNativeRuntimeArchive(result.filePaths[0])
|
||||
}
|
||||
|
||||
export async function installBundledNativeRuntime(): Promise<AuroraRuntimeStatus> {
|
||||
const directory = join(process.resourcesPath, 'native-runtime')
|
||||
const expected = `aurora-native-0.1.0-${process.platform === 'win32' ? 'win32' : process.platform}-${process.arch}.tar.gz`
|
||||
const candidates = await readdir(directory)
|
||||
const archive =
|
||||
candidates.find((entry) => entry === expected) ??
|
||||
candidates.find((entry) => entry.endsWith(`-${process.platform}-${process.arch}.tar.gz`))
|
||||
if (!archive)
|
||||
throw new Error(
|
||||
`No bundled Aurora Native runtime is available for ${process.platform}-${process.arch}.`
|
||||
)
|
||||
return installNativeRuntimeArchive(join(directory, basename(archive)))
|
||||
}
|
||||
|
||||
async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
|
||||
if (!supportedPlatforms.has(process.platform) || !supportedArchitectures.has(process.arch))
|
||||
return {
|
||||
@@ -90,20 +196,7 @@ async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
|
||||
}
|
||||
const root = runtimeRoot()
|
||||
try {
|
||||
const manifest = validateNativeRuntimeManifest(
|
||||
JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
|
||||
)
|
||||
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
|
||||
throw new Error('The installed runtime targets a different platform.')
|
||||
const canonicalRoot = resolve(root)
|
||||
for (const component of manifest.components) {
|
||||
const executable = resolve(root, component.executable)
|
||||
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
|
||||
throw new Error(`Unsafe executable path for ${component.id}.`)
|
||||
await access(executable)
|
||||
if ((await sha256(executable)) !== component.sha256.toLowerCase())
|
||||
throw new Error(`Checksum verification failed for ${component.id}.`)
|
||||
}
|
||||
const manifest = await verifyRuntimeAt(root)
|
||||
return {
|
||||
available: true,
|
||||
platform: process.platform,
|
||||
|
||||
Reference in New Issue
Block a user