From 54ceac46bd0b84dce0954fca3637133d1404b8ac Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 14 Sep 2026 13:43:17 +0000 Subject: [PATCH 1/3] feat(opencode-plugin): pin the SSH gateway host key from the Daytona API by default Strict host-key verification for sandbox git transfers existed only as an opt-in (DAYTONA_SSH_KNOWN_HOSTS), so most users were on trust-on-first-use - the gap reported in #46 item 3. The Daytona API now publishes the gateway host key on /config (sshGatewayHost, sshGatewayPort, sshGatewayHostKeys), fetched over the same TLS channel the plugin already trusts for the SSH access token itself, so consuming it adds no new trust root while making strict verification the default. Trust is resolved once per process, in order: 1. DAYTONA_SSH_KNOWN_HOSTS set -> that file is the only trust root (manual). 2. Otherwise, unless DAYTONA_SSH_AUTO_PIN=false, the published key is written to a plugin-managed known_hosts file (0600, atomic) and used as the only trust root (pinned). The pin file, not the API, 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 off), the SSH client's normal verification applies - the previous behavior. Pinning is fail-closed against change: if the API later publishes a key set that no longer contains the pinned key, transfers are refused with a message pointing at the security policy and the pin file, and no access token is minted. A rotation that publishes old and new keys together (as the security policy commits to) extends the pin without failing. The gateway endpoint is taken from the API as well, replacing the hardcoded hostname and producing [host]:port known_hosts entries for non-default ports. A manual file that disagrees with the published key logs a warning but is honored as configured. Docs: the README section now points at the published known_hosts line in the org-wide security policy instead of asking users to verify 'out of band' against nothing, and the repository README links the security policy for vulnerability reporting. Verified against the live gateway with a local /config double serving the real published key: auto-pinned strict push and pull succeed; a wrong pin is rejected by the gateway (the pin is the only trust root); a published key change refuses the transfer before any token is minted; rotation overlap extends the pin and the retired key drops out cleanly; API outage keeps an existing pin and otherwise degrades to inherited verification; DAYTONA_SSH_AUTO_PIN=false and DAYTONA_SSH_KNOWN_HOSTS behave as documented; inherited mode still transfers. Signed-off-by: Mislav Ivanda --- README.md | 4 + .../plugin/daytona/git/gateway-host-key.ts | 215 ++++++++++++++++++ .../plugin/daytona/git/host-git-manager.ts | 29 ++- .../plugin/daytona/git/session-git-manager.ts | 41 +++- .../.opencode/plugin/daytona/index.ts | 6 + packages/opencode-plugin/README.md | 23 +- 6 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts diff --git a/README.md b/README.md index d4d6db4..243a6fa 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts new file mode 100644 index 0000000..d253451 --- /dev/null +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts @@ -0,0 +1,215 @@ +/** + * Copyright Daytona Platforms Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs' +import { execFileSync } from 'child_process' +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 } + +/** How sandbox git transfers verify the gateway's host key for this process. */ +export type HostKeyTrust = + | { 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 + + 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 { + this.resolved ??= this.resolveOnce() + return this.resolved + } + + private async resolveOnce(): Promise { + 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 } + + 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) { + 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}. ` + + `This is either a key rotation or an attempt to substitute the gateway. Verify the published key against ${SECURITY_POLICY_URL}; ` + + `if it is legitimate, delete ${this.pinFile} 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 { + 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 + 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 = typeof body.sshGatewayPort === 'number' && body.sshGatewayPort >= 1 && body.sshGatewayPort <= 65535 ? body.sshGatewayPort : 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}` +} + +function keyOfEntry(entry: string): string { + const parts = entry.trim().split(/\s+/) + return `${parts[1]} ${parts[2]}` +} + +// Accept only what OpenSSH will load as a host key line; the API validates on its side +// too, but this is the trust boundary on ours. +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 + try { + execFileSync('ssh-keygen', ['-lf', '-'], { input: `${parts[0]} ${parts[1]}\n`, stdio: ['pipe', 'pipe', 'pipe'] }) + return true + } catch { + return false + } +} + +function fingerprintsOf(keyLines: string[]): string[] { + return keyLines.map((line) => { + try { + return execFileSync('ssh-keygen', ['-lf', '-'], { input: `${line}\n`, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) + .trim() + .split(/\s+/)[1] + } catch { + return 'unknown' + } + }) +} diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 47a8696..88ebe11 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -4,6 +4,7 @@ */ import { logger } from '../core/logger' +import type { HostKeyTrust } from './gateway-host-key' import { spawnSync } from 'child_process' import { realpathSync } from 'fs' import { isAbsolute, resolve as pathResolve } from 'path' @@ -55,28 +56,29 @@ 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 HostKeyTrust: a manual or auto-pinned +// known_hosts file becomes the ONLY trust 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, trust: HostKeyTrust): 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 (trust.mode !== 'inherited') { + if (trust.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. parts.push( '-o', - shellQuote(`UserKnownHostsFile="${knownHosts}"`), + shellQuote(`UserKnownHostsFile="${trust.knownHostsFile}"`), '-o', 'GlobalKnownHostsFile=/dev/null', '-o', @@ -281,6 +283,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 trust 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. @@ -289,6 +292,7 @@ export class HostGitManager { remoteName: string, remoteUrl: string, token: string, + trust: HostKeyTrust, branch: string, cwd: string, ): Promise { @@ -309,7 +313,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, trust) }) if (pushRes.ok) { logger.info(`✓ Pushed local changes to ${remoteName}`) return @@ -370,6 +374,7 @@ export class HostGitManager { remoteName: string, remoteUrl: string, token: string, + trust: HostKeyTrust, branch: string, cwd: string, localBranch?: string, @@ -384,7 +389,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, trust) }) if (!fetchRes.ok) throw new Error(fetchRes.stderr) const updateRefRes = execGit(['update-ref', `refs/heads/${localBranch}`, 'FETCH_HEAD'], { cwd }) @@ -400,7 +405,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, trust) }) if (!pullRes.ok) throw new Error(pullRes.stderr) logger.info('✓ Pulled latest changes from sandbox') } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index 19aafe2..c2ed660 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -9,8 +9,9 @@ import { toast } from '../core/toast' import { DaytonaSandboxGitManager } from './sandbox-git-manager' import { HostGitManager } from './host-git-manager' import type { PluginInput } from '@opencode-ai/plugin' +import type { GatewayHostKeyPin, HostKeyTrust } from './gateway-host-key' +import { knownHostsHost } from './gateway-host-key' -export const SSH_GATEWAY_HOST = 'ssh.app.daytona.io' /** * SessionGitManager: Combines DaytonaSandboxGitManager and HostGitManager for session lifecycle git operations. @@ -116,15 +117,41 @@ export class SessionGitManager { * failure) instead of living out its expiry. The token is handed to `fn` separately * from the URL so it is never embedded in anything git persists. */ - private async withSshAccess(fn: (access: { url: string; token: string }) => Promise): Promise { + private async withSshAccess( + fn: (access: { url: string; token: string; trust: HostKeyTrust }) => Promise, + ): Promise { + // Resolved BEFORE the token is minted: a fail-closed pin mismatch must not leave a + // live token behind, and the endpoint decides the URL. + const trust = await SessionGitManager.hostKeyTrust() + const { host, port } = trust.endpoint + const url = `ssh://${host}${port === 22 ? '' : `:${port}`}${this.repoPath}` const sshAccess = await this.sandbox.createSshAccess(10) try { - return await fn({ url: `ssh://${SSH_GATEWAY_HOST}${this.repoPath}`, token: sshAccess.token }) + return await fn({ url, token: sshAccess.token, trust }) } finally { await this.revokeWithRetry(sshAccess.token) } } + private static pin?: GatewayHostKeyPin + + /** Installed once at plugin load; every transfer resolves host-key trust through it. */ + static useHostKeyPin(pin: GatewayHostKeyPin): void { + SessionGitManager.pin = pin + } + + static hostKeyTrust(): Promise { + if (!SessionGitManager.pin) { + throw new Error('Gateway host key pin is not configured; SessionGitManager.useHostKeyPin was not called at plugin load') + } + return SessionGitManager.pin.resolve() + } + + /** The known_hosts host field for the resolved gateway endpoint, e.g. for documentation or diagnostics. */ + static async gatewayKnownHostsHost(): Promise { + return knownHostsHost((await SessionGitManager.hostKeyTrust()).endpoint) + } + // Revocation shortens the exposure window; the token still expires on its own, so a // revocation that keeps failing is logged loudly rather than failing a sync whose // git work already completed. @@ -175,8 +202,8 @@ export class SessionGitManager { } await this.sandboxGit.ensureRepo() - const pushed = await this.withSshAccess(({ url, token }) => - this.hostGit.pushLocalToSandboxRemote(this.remoteName, url, token, this.branch, this.worktree), + const pushed = await this.withSshAccess(({ url, token, trust }) => + this.hostGit.pushLocalToSandboxRemote(this.remoteName, url, token, trust, this.branch, this.worktree), ) if (pushed) { await this.sandboxGit.resetToRemote(this.branch) @@ -226,8 +253,8 @@ export class SessionGitManager { // Pull the branch the sandbox actually committed to, which may differ from the // initial 'opencode' branch, so commits are never left unsynced. const sandboxBranch = await this.sandboxGit.getCurrentBranch() - await this.withSshAccess(({ url, token }) => - this.hostGit.pull(this.remoteName, url, token, sandboxBranch, this.worktree, this.localBranch), + await this.withSshAccess(({ url, token, trust }) => + this.hostGit.pull(this.remoteName, url, token, trust, sandboxBranch, this.worktree, this.localBranch), ) toast.show({ title: 'Changes synced', diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index 6755428..e72edf0 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -31,6 +31,7 @@ import type { PluginInput } from '@opencode-ai/plugin' import { logger, setLogFilePath } from './core/logger' import { DaytonaSessionManager } from './core/session-manager' import { SessionGitManager } from './git/session-git-manager' +import { GatewayHostKeyPin } from './git/gateway-host-key' import { toast } from './core/toast' import { customTools } from './plugins/custom-tools' import { eventHandlers } from './plugins/session-events' @@ -53,6 +54,11 @@ const sessionManager = new DaytonaSessionManager( async function daytonaPlugin(ctx: PluginInput) { toast.initialize(ctx.client?.tui) + SessionGitManager.useHostKeyPin(new GatewayHostKeyPin(STORAGE_DIR)) + // Resolve eagerly so the outcome (pinned / manual / inherited / key mismatch) is logged + // at startup rather than surfacing only on the first sync; failures here are logged, + // and the first transfer re-raises them where they can be reported to the user. + SessionGitManager.hostKeyTrust().catch((err) => logger.error(`[host-key] ${err}`)) return { tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager, REPO_PATH), diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 899665a..d076dc8 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -58,19 +58,32 @@ The snapshot must already exist and be active in your organization; create one v Leave `DAYTONA_SNAPSHOT` unset to keep the default behavior. The plugin still creates `/home/daytona/project` and syncs your git branch into it. -#### Pinning the sandbox SSH host key +#### Verifying the sandbox SSH gateway host key -Git syncing transfers commits between your machine and the sandbox over SSH (`ssh.app.daytona.io`). By default, host verification follows your normal SSH configuration — on a machine that has never connected before, this means an interactive trust-on-first-use prompt, which blocks noninteractive environments such as CI or supervised agent runs. +Git syncing transfers commits between your machine and the sandbox over SSH through the Daytona SSH gateway (`ssh.app.daytona.io`). Like any SSH server, the gateway identifies itself with a host key, and the plugin verifies it before any transfer. Verification works in one of three modes, checked in this order: -To make syncing noninteractive and independently verifiable, point `DAYTONA_SSH_KNOWN_HOSTS` at a `known_hosts` file containing the `ssh.app.daytona.io` host keys: +| Mode | When | What is trusted | +| --- | --- | --- | +| **Manual pin** | `DAYTONA_SSH_KNOWN_HOSTS` is set | Only the keys in that file | +| **Auto-pin** (default) | Otherwise, unless `DAYTONA_SSH_AUTO_PIN=false` | Only the gateway host key published by the Daytona API (`/api/config`), pinned on first use into a plugin-managed file (`storage/daytona/gateway_known_hosts`) | +| **Inherited** | Auto-pin is disabled, or no key is published and none is pinned | Your SSH client's normal host verification (`~/.ssh/known_hosts`, trust-on-first-use prompts) | + +In the two pinned modes the pin file is the *only* trust root for sandbox transfers: system-wide known hosts are ignored and `StrictHostKeyChecking=yes` is set. SSH behavior for every other remote is unaffected. + +**Auto-pin is fail-closed against change.** The API is consulted once per plugin start; the pin file is what connections use, so an API outage never weakens verification. If the API later publishes a host key that does not include the pinned one, transfers are refused with a message pointing here. That means either the gateway rotated its key — the [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification) publishes old and new keys together during a rotation, so a healthy client should not hit this — or something between you and the API is not Daytona. Verify the published key against the security policy; if it is legitimate, delete the pin file to pin the new key. + +**Manual pin** is for environments that want a human-verified trust root independent of the API — supervised agent runs, CI, compliance-driven setups. The gateway host key is published as a ready-to-use `known_hosts` line in Daytona's [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification); copy it into a file and point the plugin at it: ```bash mkdir -p ~/.config/daytona -ssh-keyscan ssh.app.daytona.io > ~/.config/daytona/known_hosts +# paste the `ssh.app.daytona.io ssh-ed25519 ...` line from the security policy into: +$EDITOR ~/.config/daytona/known_hosts export DAYTONA_SSH_KNOWN_HOSTS=~/.config/daytona/known_hosts ``` -Verify the collected fingerprints out of band (`ssh-keygen -lf ~/.config/daytona/known_hosts`) before trusting the file. When `DAYTONA_SSH_KNOWN_HOSTS` is set, sandbox git transfers use that file as the only host-key database (`StrictHostKeyChecking=yes`, system-wide known hosts ignored); SSH behavior for every other remote is unaffected. When unset, behavior is unchanged. Paths containing spaces are supported; a literal `"` in the path is rejected. +To cross-check that the live gateway presents the published key, compare fingerprints: `ssh-keyscan ssh.app.daytona.io 2>/dev/null | ssh-keygen -lf -` must print the fingerprint listed in the security policy. Do not build the file from `ssh-keyscan` alone — that trusts whatever answered on first connection, which is exactly what pinning is meant to avoid. If the manual file disagrees with the key the API publishes, the plugin logs a warning but keeps using your file. + +Paths containing spaces are supported; a literal `"` in the path is rejected. ### Running OpenCode From fad416ffadbee0466d41667def4459238ca055ed Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 14 Sep 2026 13:59:29 +0000 Subject: [PATCH 2/3] refactor(opencode-plugin): name host-key resolution for what it is and explain the dormant-client case - Rename HostKeyTrust to HostKeyVerification. The type is the OUTPUT of resolution - which known_hosts file, if any, is the trust root and where it came from - not a trust toggle. No mode disables verification; the weakest outcome ('inherited') is the SSH client's own behavior. The old name read as a switch a user could flip. - The key-change refusal now names the benign cause: a machine that has not synced since before a rotation's overlap window sees only the new key and cannot tell rotation from substitution. The message states this and gives the exact recovery (verify against the policy, rm the pin file, sync again). Verified: all four rotation client states (active during overlap -> extended; after retirement -> follows; dormant past the window -> refused with the new message, old pin intact, recovers after rm; API down or publishing nothing -> existing pin kept, never treated as a change), no code path sets StrictHostKeyChecking=no, and live auto-pinned transfers plus wrong-pin rejection against the real gateway still pass. Signed-off-by: Mislav Ivanda --- .../plugin/daytona/git/gateway-host-key.ts | 20 +++++++++----- .../plugin/daytona/git/host-git-manager.ts | 26 +++++++++---------- .../plugin/daytona/git/session-git-manager.ts | 24 ++++++++--------- .../.opencode/plugin/daytona/index.ts | 2 +- 4 files changed, 39 insertions(+), 33 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts index d253451..7d4ae4e 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts @@ -15,8 +15,13 @@ const CONFIG_FETCH_TIMEOUT_MS = 5_000 export type GatewayEndpoint = { host: string; port: number } -/** How sandbox git transfers verify the gateway's host key for this process. */ -export type HostKeyTrust = +/** + * 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 } @@ -44,7 +49,7 @@ type PublishedGateway = { host: string; port: number; hostKeys: string[] } * verified against the policy and removed. */ export class GatewayHostKeyPin { - private resolved?: Promise + private resolved?: Promise constructor( private readonly storageDir: string, @@ -55,12 +60,12 @@ export class GatewayHostKeyPin { return join(this.storageDir, 'gateway_known_hosts') } - resolve(): Promise { + resolve(): Promise { this.resolved ??= this.resolveOnce() return this.resolved } - private async resolveOnce(): Promise { + private async resolveOnce(): Promise { const manual = process.env.DAYTONA_SSH_KNOWN_HOSTS?.trim() const published = await this.fetchPublishedGateway() const endpoint: GatewayEndpoint = published @@ -81,8 +86,9 @@ export class GatewayHostKeyPin { 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}. ` + - `This is either a key rotation or an attempt to substitute the gateway. Verify the published key against ${SECURITY_POLICY_URL}; ` + - `if it is legitimate, delete ${this.pinFile} to pin the new key.` + `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) } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 88ebe11..0235b5d 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -4,7 +4,7 @@ */ import { logger } from '../core/logger' -import type { HostKeyTrust } from './gateway-host-key' +import type { HostKeyVerification } from './gateway-host-key' import { spawnSync } from 'child_process' import { realpathSync } from 'fs' import { isAbsolute, resolve as pathResolve } from 'path' @@ -56,8 +56,8 @@ 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 HostKeyTrust: a manual or auto-pinned -// known_hosts file becomes the ONLY trust root (GlobalKnownHostsFile=/dev/null, so a +// 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. // @@ -66,19 +66,19 @@ function shellQuote(value: string): string { // 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, trust: HostKeyTrust): 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}`)] - if (trust.mode !== 'inherited') { - if (trust.knownHostsFile.includes('"')) { + 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') } parts.push( '-o', - shellQuote(`UserKnownHostsFile="${trust.knownHostsFile}"`), + shellQuote(`UserKnownHostsFile="${verification.knownHostsFile}"`), '-o', 'GlobalKnownHostsFile=/dev/null', '-o', @@ -283,7 +283,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 trust How the gateway host key is verified for this transfer. + * @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. @@ -292,7 +292,7 @@ export class HostGitManager { remoteName: string, remoteUrl: string, token: string, - trust: HostKeyTrust, + verification: HostKeyVerification, branch: string, cwd: string, ): Promise { @@ -313,7 +313,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, trust) }) + const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: transferEnv(token, verification) }) if (pushRes.ok) { logger.info(`✓ Pushed local changes to ${remoteName}`) return @@ -374,7 +374,7 @@ export class HostGitManager { remoteName: string, remoteUrl: string, token: string, - trust: HostKeyTrust, + verification: HostKeyVerification, branch: string, cwd: string, localBranch?: string, @@ -389,7 +389,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, trust) }) + 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 }) @@ -405,7 +405,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, trust) }) + 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') } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index c2ed660..3cecaed 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -9,7 +9,7 @@ import { toast } from '../core/toast' import { DaytonaSandboxGitManager } from './sandbox-git-manager' import { HostGitManager } from './host-git-manager' import type { PluginInput } from '@opencode-ai/plugin' -import type { GatewayHostKeyPin, HostKeyTrust } from './gateway-host-key' +import type { GatewayHostKeyPin, HostKeyVerification } from './gateway-host-key' import { knownHostsHost } from './gateway-host-key' @@ -118,16 +118,16 @@ export class SessionGitManager { * from the URL so it is never embedded in anything git persists. */ private async withSshAccess( - fn: (access: { url: string; token: string; trust: HostKeyTrust }) => Promise, + fn: (access: { url: string; token: string; verification: HostKeyVerification }) => Promise, ): Promise { // Resolved BEFORE the token is minted: a fail-closed pin mismatch must not leave a // live token behind, and the endpoint decides the URL. - const trust = await SessionGitManager.hostKeyTrust() - const { host, port } = trust.endpoint + const verification = await SessionGitManager.hostKeyVerification() + const { host, port } = verification.endpoint const url = `ssh://${host}${port === 22 ? '' : `:${port}`}${this.repoPath}` const sshAccess = await this.sandbox.createSshAccess(10) try { - return await fn({ url, token: sshAccess.token, trust }) + return await fn({ url, token: sshAccess.token, verification }) } finally { await this.revokeWithRetry(sshAccess.token) } @@ -135,12 +135,12 @@ export class SessionGitManager { private static pin?: GatewayHostKeyPin - /** Installed once at plugin load; every transfer resolves host-key trust through it. */ + /** Installed once at plugin load; every transfer resolves host-key verification through it. */ static useHostKeyPin(pin: GatewayHostKeyPin): void { SessionGitManager.pin = pin } - static hostKeyTrust(): Promise { + static hostKeyVerification(): Promise { if (!SessionGitManager.pin) { throw new Error('Gateway host key pin is not configured; SessionGitManager.useHostKeyPin was not called at plugin load') } @@ -149,7 +149,7 @@ export class SessionGitManager { /** The known_hosts host field for the resolved gateway endpoint, e.g. for documentation or diagnostics. */ static async gatewayKnownHostsHost(): Promise { - return knownHostsHost((await SessionGitManager.hostKeyTrust()).endpoint) + return knownHostsHost((await SessionGitManager.hostKeyVerification()).endpoint) } // Revocation shortens the exposure window; the token still expires on its own, so a @@ -202,8 +202,8 @@ export class SessionGitManager { } await this.sandboxGit.ensureRepo() - const pushed = await this.withSshAccess(({ url, token, trust }) => - this.hostGit.pushLocalToSandboxRemote(this.remoteName, url, token, trust, this.branch, this.worktree), + const pushed = await this.withSshAccess(({ url, token, verification }) => + this.hostGit.pushLocalToSandboxRemote(this.remoteName, url, token, verification, this.branch, this.worktree), ) if (pushed) { await this.sandboxGit.resetToRemote(this.branch) @@ -253,8 +253,8 @@ export class SessionGitManager { // Pull the branch the sandbox actually committed to, which may differ from the // initial 'opencode' branch, so commits are never left unsynced. const sandboxBranch = await this.sandboxGit.getCurrentBranch() - await this.withSshAccess(({ url, token, trust }) => - this.hostGit.pull(this.remoteName, url, token, trust, sandboxBranch, this.worktree, this.localBranch), + await this.withSshAccess(({ url, token, verification }) => + this.hostGit.pull(this.remoteName, url, token, verification, sandboxBranch, this.worktree, this.localBranch), ) toast.show({ title: 'Changes synced', diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index e72edf0..ae90675 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -58,7 +58,7 @@ async function daytonaPlugin(ctx: PluginInput) { // Resolve eagerly so the outcome (pinned / manual / inherited / key mismatch) is logged // at startup rather than surfacing only on the first sync; failures here are logged, // and the first transfer re-raises them where they can be reported to the user. - SessionGitManager.hostKeyTrust().catch((err) => logger.error(`[host-key] ${err}`)) + SessionGitManager.hostKeyVerification().catch((err) => logger.error(`[host-key] ${err}`)) return { tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager, REPO_PATH), From c6b4f7c1e5164c011e944eabc65a46b3ea414bb8 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Mon, 14 Sep 2026 14:04:56 +0000 Subject: [PATCH 3/3] fix(opencode-plugin): close the remaining trust bypasses in pinned host-key verification Review follow-ups on auto-pin: - Neutralize every ssh_config directive that can add or redirect host-key trust when a pin is in force, so the pin file is the only trust root: KnownHostsCommand=none (a configured program could supply extra accepted keys), UpdateHostKeys=no (a connected server could append further keys to the pin file), VerifyHostKeyDNS=no (SSHFP records as a trust source), and HostKeyAlias fixed to the pinned host so the lookup cannot be redirected. All are first-value-wins options on a command the plugin owns. - Validate published keys and compute fingerprints in-process instead of shelling out to ssh-keygen: a missing or failing optional executable silently downgraded every API response to inherited verification. - Require an integer port from the API; a fractional value produced an invalid endpoint. - When the API is unavailable, recover the gateway endpoint from the pin file's own host field ([host]:port or bare host) instead of defaulting to ssh.app.daytona.io:22, which made every strict transfer against a non-default gateway fail while its pin named another endpoint. - README: manual-pin instructions now cover non-default host/port ([host]:port entries, ssh-keyscan -p), and the outage claim is scoped to machines that already hold a pin. Verified live: with a wrong key pinned and a hostile ~/.ssh/config supplying the real key via KnownHostsCommand plus StrictHostKeyChecking no, the gateway is still rejected; a correct auto-pin still transfers with the hardened option set and the pin file stays a single line after connecting; ssh-keygen removed from PATH no longer prevents pinning and the in-process fingerprint equals ssh-keygen's; fractional, string, zero and out-of-range ports fall back to 22; API outage after a non-default pin recovers host and port from the pin entry. Signed-off-by: Mislav Ivanda --- .../plugin/daytona/git/gateway-host-key.ts | 53 +++++++++++++------ .../plugin/daytona/git/host-git-manager.ts | 15 ++++++ packages/opencode-plugin/README.md | 10 +++- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts index 7d4ae4e..de765e0 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/gateway-host-key.ts @@ -4,7 +4,7 @@ */ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs' -import { execFileSync } from 'child_process' +import { createHash } from 'crypto' import { join } from 'path' import { logger } from '../core/logger' @@ -81,6 +81,13 @@ export class GatewayHostKeyPin { } 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))) { @@ -126,7 +133,10 @@ export class GatewayHostKeyPin { 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 = typeof body.sshGatewayPort === 'number' && body.sshGatewayPort >= 1 && body.sshGatewayPort <= 65535 ? body.sshGatewayPort : 22 + 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}`) @@ -188,34 +198,43 @@ function knownHostsEntry(endpoint: GatewayEndpoint, keyLine: string): string { 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 what OpenSSH will load as a host key line; the API validates on its side -// too, but this is the trust boundary on ours. +// 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 - try { - execFileSync('ssh-keygen', ['-lf', '-'], { input: `${parts[0]} ${parts[1]}\n`, stdio: ['pipe', 'pipe', 'pipe'] }) - return true - } catch { - 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) => { - try { - return execFileSync('ssh-keygen', ['-lf', '-'], { input: `${line}\n`, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) - .trim() - .split(/\s+/)[1] - } catch { - return 'unknown' - } + const blob = Buffer.from(line.trim().split(/\s+/)[1], 'base64') + return `SHA256:${createHash('sha256').update(blob).digest('base64').replace(/=+$/, '')}` }) } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 0235b5d..4506a30 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -5,6 +5,7 @@ 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' @@ -76,6 +77,12 @@ function transferEnv(token: string, verification: HostKeyVerification): NodeJS.P if (verification.knownHostsFile.includes('"')) { throw new Error('The known_hosts path for sandbox transfers must not contain a double quote (") character') } + // 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="${verification.knownHostsFile}"`), @@ -83,6 +90,14 @@ function transferEnv(token: string, verification: HostKeyVerification): NodeJS.P '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 - diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index d076dc8..4d2276f 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -70,7 +70,7 @@ Git syncing transfers commits between your machine and the sandbox over SSH thro In the two pinned modes the pin file is the *only* trust root for sandbox transfers: system-wide known hosts are ignored and `StrictHostKeyChecking=yes` is set. SSH behavior for every other remote is unaffected. -**Auto-pin is fail-closed against change.** The API is consulted once per plugin start; the pin file is what connections use, so an API outage never weakens verification. If the API later publishes a host key that does not include the pinned one, transfers are refused with a message pointing here. That means either the gateway rotated its key — the [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification) publishes old and new keys together during a rotation, so a healthy client should not hit this — or something between you and the API is not Daytona. Verify the published key against the security policy; if it is legitimate, delete the pin file to pin the new key. +**Auto-pin is fail-closed against change.** The API is consulted once per plugin start; the pin file is what connections use, so once a key has been pinned an API outage never weakens verification. (On a machine that has never pinned anything, an unreachable API means there is nothing to pin yet and the plugin falls back to inherited verification — set `DAYTONA_SSH_KNOWN_HOSTS` if a first run must already be strict.) If the API later publishes a host key that does not include the pinned one, transfers are refused with a message pointing here. That means either the gateway rotated its key — the [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification) publishes old and new keys together during a rotation, so a healthy client should not hit this — or something between you and the API is not Daytona. Verify the published key against the security policy; if it is legitimate, delete the pin file to pin the new key. **Manual pin** is for environments that want a human-verified trust root independent of the API — supervised agent runs, CI, compliance-driven setups. The gateway host key is published as a ready-to-use `known_hosts` line in Daytona's [security policy](https://github.com/daytona/.github/blob/main/SECURITY.md#ssh-host-key-verification); copy it into a file and point the plugin at it: @@ -83,6 +83,14 @@ export DAYTONA_SSH_KNOWN_HOSTS=~/.config/daytona/known_hosts To cross-check that the live gateway presents the published key, compare fingerprints: `ssh-keyscan ssh.app.daytona.io 2>/dev/null | ssh-keygen -lf -` must print the fingerprint listed in the security policy. Do not build the file from `ssh-keyscan` alone — that trusts whatever answered on first connection, which is exactly what pinning is meant to avoid. If the manual file disagrees with the key the API publishes, the plugin logs a warning but keeps using your file. +The example above is for the shared gateway on the default SSH port. For a gateway on another host or port (self-hosted or dedicated regions — check `sshGatewayHost` and `sshGatewayPort` in `GET /api/config`), the `known_hosts` host field must use OpenSSH's `[host]:port` form, or the entry will never match: + +``` +[gateway.example.com]:2222 ssh-ed25519 AAAA... +``` + +and the cross-check becomes `ssh-keyscan -p 2222 gateway.example.com | ssh-keygen -lf -`. Auto-pin produces the correct form automatically. + Paths containing spaces are supported; a literal `"` in the path is rejected. ### Running OpenCode