mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
feat(ssh): add verified relay artifact downloader
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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<string> {
|
||||
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<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<SshRelayArtifactDownloadResult> {
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export type SshRelayArtifactSelection =
|
||||
}
|
||||
| { kind: 'legacy'; reason: SshRelayArtifactLegacyReason }
|
||||
|
||||
export type SshRelaySelectedArtifact = Extract<SshRelayArtifactSelection, { kind: 'selected' }>
|
||||
|
||||
function selectedArtifact(
|
||||
tuple: VerifiedSshRelayRuntimeTuple,
|
||||
releaseTag: string
|
||||
|
||||
Reference in New Issue
Block a user