Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Versioning is **per package**, automated with [release-please](https://github.co
- Keep each PR scoped to **no more than one package or app** — for packages, release routing is by changed file path, not the scope text.
- Each package/app is self-contained (its own dependency manifest); develop within its folder.

## Security

Security policy, vulnerability reporting, and the SSH gateway host key fingerprints are maintained in the organization-wide [Daytona security policy](https://github.com/daytona/.github/blob/main/SECURITY.md). Please report vulnerabilities through the channels described there — not through public issues.

## License

[Apache-2.0](LICENSE), unless a package declares otherwise — each package includes its own `LICENSE`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* Copyright Daytona Platforms Inc.
* SPDX-License-Identifier: Apache-2.0
*/

import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
import { createHash } from 'crypto'
import { join } from 'path'
import { logger } from '../core/logger'

export const DEFAULT_GATEWAY_HOST = 'ssh.app.daytona.io'
const DEFAULT_API_URL = 'https://app.daytona.io/api'
const SECURITY_POLICY_URL = 'https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification'
const CONFIG_FETCH_TIMEOUT_MS = 5_000

export type GatewayEndpoint = { host: string; port: number }

/**
* Which known_hosts file (if any) is the trust root for sandbox git transfers in this
* process, and where it came from. Always the OUTPUT of resolution, never a user
* input: there is no mode that disables host verification. The weakest outcome,
* 'inherited', is the SSH client's own verification - the behavior before this existed.
*/
export type HostKeyVerification =
| { mode: 'manual'; knownHostsFile: string; endpoint: GatewayEndpoint }
| { mode: 'pinned'; knownHostsFile: string; endpoint: GatewayEndpoint; fingerprints: string[] }
| { mode: 'inherited'; endpoint: GatewayEndpoint; reason: string }

type PublishedGateway = { host: string; port: number; hostKeys: string[] }

/**
* Decides, once per process, how transfers to the SSH gateway verify its host key:
*
* 1. DAYTONA_SSH_KNOWN_HOSTS set -> that file is the only trust root ("manual").
* 2. Otherwise, unless DAYTONA_SSH_AUTO_PIN=false, the gateway host key published by the
* Daytona API (/config: sshGatewayHostKeys) is written to a plugin-managed known_hosts
* file and used as the only trust root ("pinned"). The API is consulted once per
* process; the pin file is what connections use, so an API outage after the first pin
* never weakens verification.
* 3. If nothing can be pinned (older API, API unreachable, auto-pin disabled), transfers
* fall back to the SSH client's normal host verification ("inherited") - exactly the
* behavior before this feature existed.
*
* Pinning is fail-closed against change: if the API later publishes a key set that no
* longer contains the pinned key, the pin is NOT replaced. Either the gateway rotated its
* key (the security policy publishes old and new keys concurrently during a rotation, so
* this should not happen for a healthy client) or something between the client and the
* API is lying; only a human can tell, so transfers are refused until the pin file is
* verified against the policy and removed.
*/
export class GatewayHostKeyPin {
private resolved?: Promise<HostKeyVerification>

constructor(
private readonly storageDir: string,
private readonly apiUrl: string = process.env.DAYTONA_API_URL?.trim() || DEFAULT_API_URL,
) {}

get pinFile(): string {
return join(this.storageDir, 'gateway_known_hosts')
}

resolve(): Promise<HostKeyVerification> {
this.resolved ??= this.resolveOnce()
return this.resolved
}

private async resolveOnce(): Promise<HostKeyVerification> {
const manual = process.env.DAYTONA_SSH_KNOWN_HOSTS?.trim()
const published = await this.fetchPublishedGateway()
const endpoint: GatewayEndpoint = published
? { host: published.host, port: published.port }
: { host: DEFAULT_GATEWAY_HOST, port: 22 }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

if (manual) {
this.warnIfManualDisagrees(manual, published)
return { mode: 'manual', knownHostsFile: manual, endpoint }
}
if (process.env.DAYTONA_SSH_AUTO_PIN?.trim().toLowerCase() === 'false') {
return { mode: 'inherited', endpoint, reason: 'DAYTONA_SSH_AUTO_PIN=false' }
}

const existing = this.readPin()
if (!published && existing) {
// The API is unavailable: the pin file's own host field carries the endpoint it was
// pinned for, so a non-default host or port keeps working instead of the URL
// silently reverting to the default gateway while the pin names another.
const recovered = endpointOfEntry(existing.entries[0])
if (recovered) Object.assign(endpoint, recovered)
}
if (published) {
const publishedEntries = published.hostKeys.map((key) => knownHostsEntry(endpoint, key))
if (existing && !existing.entries.some((entry) => publishedEntries.includes(entry))) {
const message =
`The SSH gateway host key published by ${this.apiUrl}/config no longer matches the key pinned in ${this.pinFile}. ` +
`Either the gateway rotated its key and this machine has not synced since before the rotation's overlap window ` +
`(the security policy publishes old and new keys together for at least 30 days), or something between this machine and the API is not Daytona. ` +
`Verify the published fingerprint against ${SECURITY_POLICY_URL}; if it matches, run \`rm ${this.pinFile}\` and sync again to pin the new key.`
logger.error(`[host-key] ${message}`)
throw new Error(message)
}
if (!existing || existing.entries.length !== publishedEntries.length || publishedEntries.some((e) => !existing.entries.includes(e))) {
this.writePin(publishedEntries)
logger.info(
`[host-key] pinned ${published.host}:${published.port} host key(s) ${fingerprintsOf(published.hostKeys).join(', ')} from ${this.apiUrl}/config`,
)
}
return { mode: 'pinned', knownHostsFile: this.pinFile, endpoint, fingerprints: fingerprintsOf(published.hostKeys) }
}

if (existing) {
logger.warn(`[host-key] ${this.apiUrl}/config did not provide a gateway host key; using the existing pin in ${this.pinFile}`)
return { mode: 'pinned', knownHostsFile: this.pinFile, endpoint, fingerprints: fingerprintsOf(existing.entries.map(keyOfEntry)) }
}
logger.warn(
`[host-key] no gateway host key is published by ${this.apiUrl}/config and none is pinned; falling back to the SSH client's own host verification. ` +
`For strict noninteractive verification set DAYTONA_SSH_KNOWN_HOSTS (see ${SECURITY_POLICY_URL}).`,
)
return { mode: 'inherited', endpoint, reason: 'no published or pinned host key' }
}

private async fetchPublishedGateway(): Promise<PublishedGateway | undefined> {
try {
const response = await fetch(`${this.apiUrl.replace(/\/+$/, '')}/config`, {
signal: AbortSignal.timeout(CONFIG_FETCH_TIMEOUT_MS),
headers: { accept: 'application/json' },
})
if (!response.ok) {
logger.warn(`[host-key] ${this.apiUrl}/config returned HTTP ${response.status}`)
return undefined
}
const body = (await response.json()) as Record<string, unknown>
const hostKeys = Array.isArray(body.sshGatewayHostKeys) ? body.sshGatewayHostKeys.filter(isValidKeyLine) : []
if (hostKeys.length === 0) return undefined
const host = typeof body.sshGatewayHost === 'string' && /^[A-Za-z0-9.\-[\]:]+$/.test(body.sshGatewayHost) ? body.sshGatewayHost : DEFAULT_GATEWAY_HOST
const port =
Number.isInteger(body.sshGatewayPort) && (body.sshGatewayPort as number) >= 1 && (body.sshGatewayPort as number) <= 65535
? (body.sshGatewayPort as number)
: 22
return { host, port, hostKeys }
} catch (err) {
logger.warn(`[host-key] could not fetch ${this.apiUrl}/config: ${err}`)
return undefined
}
}

private warnIfManualDisagrees(manualFile: string, published: PublishedGateway | undefined): void {
if (!published || !existsSync(manualFile)) return
try {
const manualKeys = readFileSync(manualFile, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map(keyOfEntry)
if (!manualKeys.some((key) => published.hostKeys.includes(key))) {
logger.warn(
`[host-key] DAYTONA_SSH_KNOWN_HOSTS (${manualFile}) contains none of the host keys published by ${this.apiUrl}/config (${fingerprintsOf(published.hostKeys).join(', ')}). ` +
`The manual file is still used as configured; verify both against ${SECURITY_POLICY_URL}.`,
)
}
} catch (err) {
logger.warn(`[host-key] could not read DAYTONA_SSH_KNOWN_HOSTS for comparison: ${err}`)
}
}

private readPin(): { entries: string[] } | undefined {
if (!existsSync(this.pinFile)) return undefined
const entries = readFileSync(this.pinFile, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
return entries.length ? { entries } : undefined
}

private writePin(entries: string[]): void {
mkdirSync(this.storageDir, { recursive: true })
const tmp = `${this.pinFile}.${process.pid}.tmp`
writeFileSync(tmp, entries.join('\n') + '\n', { mode: 0o600 })
try {
renameSync(tmp, this.pinFile)
} catch {
writeFileSync(this.pinFile, entries.join('\n') + '\n', { mode: 0o600 })
} finally {
try {
rmSync(tmp, { force: true })
} catch {}
}
}
}

/** OpenSSH known_hosts host field: bare host on port 22, `[host]:port` otherwise. */
export function knownHostsHost(endpoint: GatewayEndpoint): string {
return endpoint.port === 22 ? endpoint.host : `[${endpoint.host}]:${endpoint.port}`
}

function knownHostsEntry(endpoint: GatewayEndpoint, keyLine: string): string {
const [type, blob] = keyLine.trim().split(/\s+/)
return `${knownHostsHost(endpoint)} ${type} ${blob}`
}

/** Inverse of knownHostsHost(): `[host]:port` -> {host, port}; bare host -> port 22. */
function endpointOfEntry(entry: string): GatewayEndpoint | undefined {
const hostField = entry.trim().split(/\s+/)[0]
if (!hostField) return undefined
const bracketed = /^\[(.+)\]:(\d{1,5})$/.exec(hostField)
if (bracketed) {
const port = Number(bracketed[2])
return port >= 1 && port <= 65535 ? { host: bracketed[1], port } : undefined
}
return { host: hostField, port: 22 }
}

function keyOfEntry(entry: string): string {
const parts = entry.trim().split(/\s+/)
return `${parts[1]} ${parts[2]}`
}

// Accept only well-formed OpenSSH public key lines of host-key types. Validation is done
// in-process: it must not depend on an optional executable, because a missing or failing
// `ssh-keygen` would otherwise silently downgrade every API response to inherited
// verification. The API validates the full wire encoding on its side; the blob will be
// rejected by ssh at connect time if it is not a usable key, which fails closed.
function isValidKeyLine(value: unknown): value is string {
if (typeof value !== 'string') return false
const parts = value.trim().split(/\s+/)
if (parts.length < 2 || !/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521))$/.test(parts[0])) return false
if (!/^[A-Za-z0-9+/]+=*$/.test(parts[1])) return false
const blob = Buffer.from(parts[1], 'base64')
if (blob.length < 4 || blob.toString('base64') !== parts[1]) return false
const typeLength = blob.readUInt32BE(0)
if (typeLength === 0 || typeLength > 64 || blob.length < 4 + typeLength) return false
return blob.subarray(4, 4 + typeLength).toString('ascii') === parts[0]
}

function fingerprintsOf(keyLines: string[]): string[] {
return keyLines.map((line) => {
const blob = Buffer.from(line.trim().split(/\s+/)[1], 'base64')
return `SHA256:${createHash('sha256').update(blob).digest('base64').replace(/=+$/, '')}`
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/

import { logger } from '../core/logger'
import type { HostKeyVerification } from './gateway-host-key'
import { knownHostsHost } from './gateway-host-key'
import { spawnSync } from 'child_process'
import { realpathSync } from 'fs'
import { isAbsolute, resolve as pathResolve } from 'path'
Expand Down Expand Up @@ -55,32 +57,47 @@ function shellQuote(value: string): string {
// DAYTONA_SSH_BINARY, an absolute path with no arguments, which keeps the option set
// under plugin control.
//
// Host verification follows the resolved HostKeyVerification: a manual or auto-pinned
// known_hosts file becomes the ONLY verification root (GlobalKnownHostsFile=/dev/null, so a
// matching entry in /etc/ssh/ssh_known_hosts cannot satisfy verification either);
// "inherited" leaves the SSH client's normal verification in place.
//
// Values cross two parsers. sh splits GIT_SSH_COMMAND into ssh's argv (outer single
// quotes), then OpenSSH splits the UserKnownHostsFile VALUE on whitespace as a file
// list (inner double quotes keep a spaced path as one file). OpenSSH's config grammar
// has no escape for a literal double quote inside a quoted value, so such paths are
// rejected instead of being silently mis-pinned.
function transferEnv(token: string): NodeJS.ProcessEnv {
function transferEnv(token: string, verification: HostKeyVerification): NodeJS.ProcessEnv {
const binary = process.env.DAYTONA_SSH_BINARY?.trim() || 'ssh'
if (binary !== 'ssh' && !isAbsolute(binary)) {
throw new Error('DAYTONA_SSH_BINARY must be an absolute path to an OpenSSH client binary')
}
const parts = [binary === 'ssh' ? 'ssh' : shellQuote(binary), '-o', shellQuote(`User=${token}`)]
const knownHosts = process.env.DAYTONA_SSH_KNOWN_HOSTS?.trim()
if (knownHosts) {
if (knownHosts.includes('"')) {
throw new Error('DAYTONA_SSH_KNOWN_HOSTS must not contain a double quote (") character')
if (verification.mode !== 'inherited') {
if (verification.knownHostsFile.includes('"')) {
throw new Error('The known_hosts path for sandbox transfers must not contain a double quote (") character')
}
// GlobalKnownHostsFile=/dev/null: otherwise a matching entry in the system-wide
// /etc/ssh/ssh_known_hosts would also be accepted and verification would not be
// pinned to the configured file alone.
// The pin file must be the ONLY trust root, so every ssh_config directive that can
// add or redirect host-key trust is neutralized here (first value wins, and we own
// the command): KnownHostsCommand can supply extra accepted keys from a program;
// UpdateHostKeys lets a connected server append further keys to the pin file;
// VerifyHostKeyDNS admits SSHFP records as a trust source; HostKeyAlias changes
// which name is looked up, so it is fixed to the pinned host.
parts.push(
'-o',
shellQuote(`UserKnownHostsFile="${knownHosts}"`),
shellQuote(`UserKnownHostsFile="${verification.knownHostsFile}"`),
'-o',
'GlobalKnownHostsFile=/dev/null',
'-o',
'StrictHostKeyChecking=yes',
'-o',
'KnownHostsCommand=none',
'-o',
'UpdateHostKeys=no',
'-o',
'VerifyHostKeyDNS=no',
'-o',
shellQuote(`HostKeyAlias=${knownHostsHost(verification.endpoint)}`),
)
}
// GIT_SSH_VARIANT=ssh: git otherwise applies the user's ssh.variant to OUR command -
Expand Down Expand Up @@ -281,6 +298,7 @@ export class HostGitManager {
* @param remoteName Numbered remote (e.g. sandbox-2) matching opencode/N.
* @param remoteUrl Credential-free SSH URL of the sandbox repository.
* @param token Short-lived access token, supplied to the git invocation only.
* @param verification How the gateway host key is verified for this transfer.
* @param branch The branch to push to.
* @param cwd Worktree path to run git in.
* @returns true if push succeeded, false if no repo exists. Throws if the push fails.
Expand All @@ -289,6 +307,7 @@ export class HostGitManager {
remoteName: string,
remoteUrl: string,
token: string,
verification: HostKeyVerification,
branch: string,
cwd: string,
): Promise<boolean> {
Expand All @@ -309,7 +328,7 @@ export class HostGitManager {
this.setRemote(remoteName, remoteUrl, cwd)
let attempts = 0
while (attempts < 3) {
const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: transferEnv(token) })
const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: transferEnv(token, verification) })
if (pushRes.ok) {
logger.info(`✓ Pushed local changes to ${remoteName}`)
return
Expand Down Expand Up @@ -370,6 +389,7 @@ export class HostGitManager {
remoteName: string,
remoteUrl: string,
token: string,
verification: HostKeyVerification,
branch: string,
cwd: string,
localBranch?: string,
Expand All @@ -384,7 +404,7 @@ export class HostGitManager {
if (localBranch) {
// Fetch into FETCH_HEAD only (never into refs/heads) so we don't hit
// "refusing to fetch into branch checked out" when this branch is checked out.
const fetchRes = execGit(['fetch', remoteName, branch], { cwd, env: transferEnv(token) })
const fetchRes = execGit(['fetch', remoteName, branch], { cwd, env: transferEnv(token, verification) })
if (!fetchRes.ok) throw new Error(fetchRes.stderr)

const updateRefRes = execGit(['update-ref', `refs/heads/${localBranch}`, 'FETCH_HEAD'], { cwd })
Expand All @@ -400,7 +420,7 @@ export class HostGitManager {

logger.info(`✓ Force pulled latest changes from sandbox into ${localBranch}`)
} else {
const pullRes = execGit(['pull', remoteName, branch], { cwd, env: transferEnv(token) })
const pullRes = execGit(['pull', remoteName, branch], { cwd, env: transferEnv(token, verification) })
if (!pullRes.ok) throw new Error(pullRes.stderr)
logger.info('✓ Pulled latest changes from sandbox')
}
Expand Down
Loading
Loading