fix(sftp): stabilize keyless server host key
CI / lint (pull_request) Successful in 1m47s
CI / format (pull_request) Successful in 2m13s
CI / install (pull_request) Successful in 6m12s
CI / typetest (pull_request) Successful in 2m4s
CI / node-tests (pull_request) Successful in 2m27s
CI / typecheck (pull_request) Successful in 2m32s
CI / browser-tests (pull_request) Successful in 4m12s

Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
This commit was merged in pull request #106.
This commit is contained in:
2026-07-13 00:20:18 +00:00
co-authored by Codex
parent b35a533164
commit b2d04de758
3 changed files with 39 additions and 3 deletions
+2
View File
@@ -2,6 +2,8 @@
This file records all work in this repository that was assisted or authored by an AI agent. It is append-only; merge conflicts are resolved by keeping all lines (see `.gitattributes`).
- **`@webnet/sftp` — stable keyless server identity**: GPT-5.6 Luna fixed `SFTPServer` to lazily generate and cache one ephemeral host key per server instance, and added a regression test covering sequential connections and host-key fingerprints.
This project was set up with the assistance of [Claude Code](https://claude.ai/code) (Anthropic). The following were written by Claude Code:
- The `tailscale` submodule fork and its Go-side `tsconnect` patches (the `webnet` branch)
+27 -2
View File
@@ -13,6 +13,7 @@ import {
SSHTransport,
authenticateClient,
SSHAuthError,
type HostKeyVerifier,
} from "@webnet/ssh/_internals"
import {
FXP,
@@ -51,10 +52,13 @@ class MockClient {
static async connect(
dialer: RawDialer,
opts: { user: string; password?: string },
opts: { user: string; password?: string; verifyHostKey?: HostKeyVerifier },
): Promise<MockClient> {
const raw = await dialer.dial("localhost", 22)
const t = await SSHTransport.create(raw, { role: "client" })
const t = await SSHTransport.create(raw, {
role: "client",
verifyHostKey: opts.verifyHostKey,
})
await authenticateClient(t, { user: opts.user, password: opts.password })
const mux = new ConnectionMux(t)
mux.start()
@@ -495,6 +499,27 @@ suite("SFTPServer", () => {
})
})
test("keyless server yields a stable fingerprint across connections", async () => {
const [listener, dialer] = loopbackListener()
const server = new SFTPServer({ vfs: new MemoryVFS() })
const running = server.listen(listener, { onError: () => {} })
const seen: string[] = []
try {
for (let i = 0; i < 2; i++) {
const client = await MockClient.connect(dialer, {
user: "u",
password: "p",
verifyHostKey: (key) => (seen.push(key.fingerprint), true),
})
await client.close()
}
} finally {
listener.close()
await running
}
assert.equal(seen[0], seen[1])
})
test("a malformed auth request closes the connection instead of leaking it", async () => {
await withServer({ vfs: new MemoryVFS() }, async (dialer) => {
const raw = await dialer.dial("localhost", 22)
+10 -1
View File
@@ -1,4 +1,5 @@
import type { RawListener, RawTransport } from "@webnet/transport"
import { generateHostKey } from "@webnet/ssh"
import { Session } from "./session.js"
import type { ListenOptions, SFTPServerOptions } from "./types.js"
@@ -8,11 +9,18 @@ const defaultOnError: NonNullable<ListenOptions["onError"]> = (transport, error)
export class SFTPServer {
readonly #options: SFTPServerOptions
#hostKey?: Promise<string>
constructor(options: SFTPServerOptions) {
this.#options = options
}
#getHostKey(): Promise<string> {
return (this.#hostKey ??= this.#options.hostKey
? Promise.resolve(this.#options.hostKey)
: generateHostKey())
}
async listen(
listener: RawListener,
{ onConnect, onError = defaultOnError }: ListenOptions = {},
@@ -23,7 +31,8 @@ export class SFTPServer {
await transport.close()
return
}
await new Session(transport, this.#options).run()
const hostKey = await this.#getHostKey()
await new Session(transport, { ...this.#options, hostKey }).run()
} catch (e) {
onError(transport, e)
}