diff --git a/src/main/ssh/ssh-relay-artifact-download.test.ts b/src/main/ssh/ssh-relay-artifact-download.test.ts new file mode 100644 index 00000000000..4c7b70567ec --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-download.test.ts @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import nacl from 'tweetnacl' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { downloadSshRelayArtifact } from './ssh-relay-artifact-download' +import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest' +import { + selectSshRelayArtifact, + type SshRelaySelectedArtifact +} from './ssh-relay-artifact-selector' +import { + signSshRelayArtifactManifest, + sshRelayManifestKeyId, + verifySshRelayArtifactManifest +} from './ssh-relay-manifest-signature' + +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) +const bytes = Buffer.from('verified relay runtime bytes') +const sha256 = `sha256:${createHash('sha256').update(bytes).digest('hex')}` as const +const temporaryDirectories: string[] = [] + +function selectedArtifact(): SshRelaySelectedArtifact { + const manifest = createSshRelayArtifactTestManifest() + manifest.tuples[0].archive.size = bytes.length + manifest.tuples[0].archive.sha256 = sha256 + manifest.signatures = [signSshRelayArtifactManifest(manifest, keyPair.secretKey)] + const verified = verifySshRelayArtifactManifest(manifest, [ + { keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey } + ]) + const selected = selectSshRelayArtifact(verified, { + os: 'linux', + architecture: 'x64', + processTranslated: false, + kernelVersion: '6.8', + libc: { family: 'glibc', version: '2.39' }, + libstdcxxVersion: '6.0.33', + glibcxxVersion: '3.4.33' + }) + if (selected.kind !== 'selected') { + throw new Error('Expected compatible test artifact') + } + return selected +} + +async function destination(name = 'runtime.partial'): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-relay-download-')) + temporaryDirectories.push(directory) + return join(directory, name) +} + +function exactResponse(body: BodyInit = bytes): Response { + return new Response(body, { status: 200, headers: { 'content-length': String(bytes.length) } }) +} + +afterEach(async () => { + netFetchMock.mockReset() + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('SSH relay artifact download', () => { + it('streams exact signed bytes into an exclusive temporary file', async () => { + const artifact = selectedArtifact() + const destinationPath = await destination() + netFetchMock.mockResolvedValueOnce(exactResponse()) + + await expect(downloadSshRelayArtifact({ artifact, destinationPath })).resolves.toEqual({ + destinationPath, + finalUrl: artifact.archive.downloadUrl, + size: bytes.length, + sha256 + }) + expect(await readFile(destinationPath)).toEqual(bytes) + expect(netFetchMock).toHaveBeenCalledWith(artifact.archive.downloadUrl, { + credentials: 'omit', + headers: { Accept: 'application/octet-stream' }, + redirect: 'manual', + signal: expect.any(AbortSignal) + }) + }) + + it('follows one approved GitHub asset redirect without forwarding sensitive headers', async () => { + const artifact = selectedArtifact() + const destinationPath = await destination() + const redirectedUrl = 'https://release-assets.githubusercontent.com/example/runtime?sig=secret' + netFetchMock + .mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: redirectedUrl } }) + ) + .mockResolvedValueOnce(exactResponse()) + + await downloadSshRelayArtifact({ artifact, destinationPath }) + + expect(netFetchMock).toHaveBeenCalledTimes(2) + expect(netFetchMock.mock.calls[1]).toEqual([ + redirectedUrl, + { + credentials: 'omit', + headers: { Accept: 'application/octet-stream' }, + redirect: 'manual', + signal: expect.any(AbortSignal) + } + ]) + }) + + it.each([ + ['http://release-assets.githubusercontent.com/runtime', /https/i], + ['https://example.com/runtime', /origin/i], + ['https://user:secret@release-assets.githubusercontent.com/runtime', /credentials/i], + ['https://release-assets.githubusercontent.com:444/runtime', /port/i], + [null, /location/i] + ])( + 'rejects an unsafe redirect location %s and removes partial output', + async (location, error) => { + const destinationPath = await destination() + netFetchMock.mockResolvedValueOnce( + new Response(null, { status: 302, headers: location ? { location } : undefined }) + ) + + await expect( + downloadSshRelayArtifact({ artifact: selectedArtifact(), destinationPath }) + ).rejects.toThrow(error) + await expect(readFile(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + ) + + it('rejects redirect chains and non-200 responses', async () => { + for (const responses of [ + [new Response(null, { status: 404 })], + [new Response(null, { status: 429 })], + [new Response(null, { status: 503 })], + [ + new Response(null, { + status: 302, + headers: { location: 'https://release-assets.githubusercontent.com/first' } + }), + new Response(null, { + status: 302, + headers: { location: 'https://release-assets.githubusercontent.com/second' } + }) + ] + ]) { + const destinationPath = await destination() + netFetchMock.mockReset() + netFetchMock.mockResolvedValueOnce(responses[0]) + if (responses[1]) { + netFetchMock.mockResolvedValueOnce(responses[1]) + } + + await expect( + downloadSshRelayArtifact({ artifact: selectedArtifact(), destinationPath }) + ).rejects.toThrow(/status|redirect/i) + await expect(readFile(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) + + it.each([ + [new Response(null, { status: 200 }), /body/i], + [new Response(bytes.subarray(1)), /size/i], + [new Response(Buffer.concat([bytes, bytes])), /size/i], + [new Response(Buffer.alloc(bytes.length, 1)), /sha-?256/i] + ])('rejects malformed or unverified response bytes', async (response, error) => { + const destinationPath = await destination() + netFetchMock.mockResolvedValueOnce(response) + + await expect( + downloadSshRelayArtifact({ artifact: selectedArtifact(), destinationPath }) + ).rejects.toThrow(error) + await expect(readFile(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('cancels a response whose declared length disagrees with the signed manifest', async () => { + const destinationPath = await destination() + const cancelled = vi.fn() + const body = new ReadableStream({ + start(stream) { + stream.enqueue(bytes) + }, + cancel: cancelled + }) + netFetchMock.mockResolvedValueOnce( + new Response(body, { headers: { 'content-length': String(bytes.length + 1) } }) + ) + + await expect( + downloadSshRelayArtifact({ artifact: selectedArtifact(), destinationPath }) + ).rejects.toThrow(/length/i) + expect(cancelled).toHaveBeenCalledOnce() + await expect(readFile(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('cancels a stalled body and removes partial output', async () => { + const destinationPath = await destination() + const controller = new AbortController() + const cancelled = vi.fn() + const body = new ReadableStream({ + start(stream) { + stream.enqueue(bytes.subarray(0, 3)) + }, + cancel: cancelled + }) + netFetchMock.mockResolvedValueOnce( + new Response(body, { headers: { 'content-length': String(bytes.length) } }) + ) + + const download = downloadSshRelayArtifact({ + artifact: selectedArtifact(), + destinationPath, + signal: controller.signal + }) + await vi.waitFor(() => expect(netFetchMock).toHaveBeenCalledOnce()) + controller.abort(new Error('cancel relay download')) + + await expect(download).rejects.toThrow(/cancel relay download/i) + expect(cancelled).toHaveBeenCalledOnce() + await expect(readFile(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('never overwrites an existing destination', async () => { + const destinationPath = await destination() + await writeFile(destinationPath, 'owner bytes') + + await expect( + downloadSshRelayArtifact({ artifact: selectedArtifact(), destinationPath }) + ).rejects.toMatchObject({ code: 'EEXIST' }) + expect(await readFile(destinationPath, 'utf8')).toBe('owner bytes') + expect(netFetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-artifact-download.ts b/src/main/ssh/ssh-relay-artifact-download.ts new file mode 100644 index 00000000000..ef998f30c65 --- /dev/null +++ b/src/main/ssh/ssh-relay-artifact-download.ts @@ -0,0 +1,187 @@ +import { createHash } from 'node:crypto' +import { open, rm, type FileHandle } from 'node:fs/promises' + +import { net } from 'electron' + +import type { SshRelaySelectedArtifact } from './ssh-relay-artifact-selector' + +const DOWNLOAD_TIMEOUT_MS = 5 * 60_000 +const RELEASE_ASSET_HOST = 'release-assets.githubusercontent.com' +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) +const REQUEST_INIT = { + credentials: 'omit', + headers: { Accept: 'application/octet-stream' }, + redirect: 'manual' +} as const + +export type SshRelayArtifactDownloadResult = { + destinationPath: string + finalUrl: string + size: number + sha256: SshRelaySelectedArtifact['archive']['sha256'] +} + +async function discardResponse(response: Response): Promise { + await response.body?.cancel().catch(() => {}) +} + +function approvedRedirect(response: Response, sourceUrl: string): string { + const location = response.headers.get('location') + if (!location) { + throw new Error('SSH relay artifact redirect is missing Location') + } + const url = new URL(location, sourceUrl) + if (url.protocol !== 'https:') { + throw new Error('SSH relay artifact redirect must use HTTPS') + } + if (url.hostname !== RELEASE_ASSET_HOST) { + throw new Error('SSH relay artifact redirect has an unapproved origin') + } + if (url.username || url.password) { + throw new Error('SSH relay artifact redirect must not contain credentials') + } + if (url.port) { + throw new Error('SSH relay artifact redirect must not use a custom port') + } + return url.href +} + +async function fetchArtifactResponse( + artifact: SshRelaySelectedArtifact, + signal: AbortSignal +): Promise<{ response: Response; finalUrl: string }> { + const initialUrl = artifact.archive.downloadUrl + const initial = await net.fetch(initialUrl, { ...REQUEST_INIT, signal }) + if (initial.status === 200) { + return { response: initial, finalUrl: initialUrl } + } + if (!REDIRECT_STATUSES.has(initial.status)) { + await discardResponse(initial) + throw new Error(`SSH relay artifact download failed with status ${initial.status}`) + } + + let finalUrl: string + try { + finalUrl = approvedRedirect(initial, initialUrl) + } finally { + await discardResponse(initial) + } + // Why: every request uses a fresh fixed header set and omits session credentials so a signed CDN + // redirect cannot inherit authorization, cookies, or caller-controlled headers. + const redirected = await net.fetch(finalUrl, { ...REQUEST_INIT, signal }) + if (REDIRECT_STATUSES.has(redirected.status)) { + await discardResponse(redirected) + throw new Error('SSH relay artifact download exceeded the approved redirect limit') + } + if (redirected.status !== 200) { + await discardResponse(redirected) + throw new Error(`SSH relay artifact download failed with status ${redirected.status}`) + } + return { response: redirected, finalUrl } +} + +async function writeCompleteChunk(file: FileHandle, buffer: Buffer): Promise { + let offset = 0 + while (offset < buffer.length) { + const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null) + if (bytesWritten <= 0) { + throw new Error('SSH relay artifact download could not persist response bytes') + } + offset += bytesWritten + } +} + +async function streamVerifiedResponse( + response: Response, + artifact: SshRelaySelectedArtifact, + signal: AbortSignal, + file: FileHandle +): Promise<{ size: number; sha256: SshRelaySelectedArtifact['archive']['sha256'] }> { + const expected = artifact.archive + const contentLength = response.headers.get('content-length') + if (contentLength !== null) { + const parsed = Number(contentLength) + if (!Number.isSafeInteger(parsed) || parsed !== expected.size) { + await discardResponse(response) + throw new Error('SSH relay artifact Content-Length disagrees with the signed manifest') + } + } + if (!response.body) { + throw new Error('SSH relay artifact response body is missing') + } + + const hash = createHash('sha256') + const reader = response.body.getReader() + let size = 0 + let complete = false + const cancelRead = (): void => { + void reader.cancel(signal.reason).catch(() => {}) + } + signal.addEventListener('abort', cancelRead, { once: true }) + try { + while (true) { + signal.throwIfAborted() + const { done, value } = await reader.read() + signal.throwIfAborted() + if (done) { + complete = true + break + } + const buffer = Buffer.from(value) + size += buffer.length + if (size > expected.size) { + throw new Error('SSH relay artifact response exceeds the signed size') + } + hash.update(buffer) + await writeCompleteChunk(file, buffer) + } + } finally { + signal.removeEventListener('abort', cancelRead) + if (!complete) { + await reader.cancel(signal.aborted ? signal.reason : undefined).catch(() => {}) + } + reader.releaseLock() + } + + if (size !== expected.size) { + throw new Error('SSH relay artifact response size does not match the signed manifest') + } + const sha256 = `sha256:${hash.digest('hex')}` as SshRelaySelectedArtifact['archive']['sha256'] + if (sha256 !== expected.sha256) { + throw new Error('SSH relay artifact response SHA-256 does not match the signed manifest') + } + return { size, sha256 } +} + +export async function downloadSshRelayArtifact({ + artifact, + destinationPath, + signal +}: { + artifact: SshRelaySelectedArtifact + destinationPath: string + signal?: AbortSignal +}): Promise { + const effectiveSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]) + : AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) + effectiveSignal.throwIfAborted() + + // Why: this boundary owns only a new staging file; cache publication and replacement are later, + // separately gated transactions. + const file = await open(destinationPath, 'wx', 0o600) + let complete = false + try { + const { response, finalUrl } = await fetchArtifactResponse(artifact, effectiveSignal) + const verified = await streamVerifiedResponse(response, artifact, effectiveSignal, file) + await file.sync() + effectiveSignal.throwIfAborted() + complete = true + return { destinationPath, finalUrl, ...verified } + } finally { + await file.close().catch(() => {}) + if (!complete) { + await rm(destinationPath, { force: true }).catch(() => {}) + } + } +} diff --git a/src/main/ssh/ssh-relay-artifact-selector.ts b/src/main/ssh/ssh-relay-artifact-selector.ts index d3cf74818cd..c8088f21891 100644 --- a/src/main/ssh/ssh-relay-artifact-selector.ts +++ b/src/main/ssh/ssh-relay-artifact-selector.ts @@ -67,6 +67,8 @@ export type SshRelayArtifactSelection = } | { kind: 'legacy'; reason: SshRelayArtifactLegacyReason } +export type SshRelaySelectedArtifact = Extract + function selectedArtifact( tuple: VerifiedSshRelayRuntimeTuple, releaseTag: string