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
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user