Claude Code transcript

6 prompts · 16 messages · 2 tool calls · 0 commits · 2 pages

#1

I would like to understand the best way to add the following feature: there should be a "login terminal" window that can be opened onto a newly created rmeote machine/vm. As-is new machines will be listening for ssh connections, and provisioned with whatever ssh key was specified at creation time. However, a user of this application can only interact with browser-hosted code, which precludes running a real ssh client, nor making tcp connections on port 22 (because browser code can't make non-http connections). I am aware that there are various options for support for "web ssh" that use a browser-side component in conjunction with some sort of server-hosted proxy. I'd like to use one of those, but with the specific requirement that the "server side proxy" component be running on the new VM itself. We can provision the installation of packages and even complex scripted setup on the new machine, via cloud-init. Somehow however the private key has to be provisioned. I'd prefer to avoid using shared secret authentication as a workaround, if possible.

2 agent

Here's my analysis of the best approaches, given your architecture:

The Core Problem

Your browser has the SSH private key (in SecretStore). The VM has the corresponding public key (via cloud-init). But the browser can't speak SSH. You need a WebSocket-accessible proxy on the VM, and you want to avoid shared secrets for auth.

Two Viable Approaches

Approach 1: ttyd (Recommended — simplest)

ttyd is a ~3MB C binary that wraps a shell in a WebSocket terminal with built-in xterm.js. It spawns a PTY directly — no SSH involved at all. Cloud-init is trivial:

runcmd:
  - apt-get update && apt-get install -y ttyd
  - ttyd -p 7681 --writable bash

The auth question: Since ttyd itself only supports basic auth (a shared secret), you'd put it behind a reverse proxy. For asymmetric auth without shared secrets, you have two clean options:

Option A — Challenge-response using the existing SSH key: - Cloud-init installs a small auth proxy (nginx + a tiny auth service) - Browser requests a challenge nonce from the proxy - Browser signs the nonce with the SSH private key using WebCrypto (RSA/Ed25519 keys can be imported) - Proxy verifies the signature against the SSH public key already in ~/.ssh/authorized_keys - On success, proxy issues a short-lived session cookie and proxies WebSocket to ttyd on localhost

This is the most elegant approach — it reuses the SSH keypair for auth without any new secrets. The private key never leaves the browser.

Option B — JWT signed by the orchestrator: - At VM creation time, generate a keypair; put the public key on the VM via cloud-init - The orchestrator signs a short-lived JWT with the private key - Browser presents JWT to the proxy, which validates against the provisioned public key - Downside: requires the orchestrator to hold a signing key (but it's not a shared secret)

Approach 2: wetty with cloud-init-generated keypair

wetty (Node.js) is an SSH-over-WebSocket proxy. Run it with --sshkey so it connects to localhost sshd using a key that never leaves the VM:

runcmd:
  - apt-get update && apt-get install -y nodejs npm
  - npm install -g wetty
  - ssh-keygen -t ed25519 -f /etc/wetty/local-key -N ""
  - cat /etc/wetty/local-key.pub >> /home/deploy/.ssh/authorized_keys
  - wetty --sshkey /etc/wetty/local-key --sshuser deploy --sshhost localhost -p 7681

Same auth proxy question applies for browser-to-wetty authentication. Heavier install (~60MB for Node.js) but gives you proper SSH session semantics (PAM, audit log, user switching).

My Recommendation

ttyd + challenge-response auth using the SSH keypair is the best fit because:

  1. No shared secrets — the SSH keypair you already provision does double duty
  2. Private key never transits — browser signs a challenge locally via WebCrypto
  3. Lightest weight — single binary, 3 lines of cloud-init for ttyd itself
  4. No SSH overhead — direct PTY, no localhost SSH hop
  5. Built-in xterm.js — or you can use your own xterm.js instance and connect via the WebSocket API directly

Architecture Sketch

Browser (xterm.js)
    |
    |  1. GET /auth/challenge  →  returns nonce
    |  2. Sign nonce with SSH private key (WebCrypto)
    |  3. POST /auth/verify { signature, pubkey_fingerprint }  →  session cookie
    |  4. WSS /terminal  (cookie auth)  →  proxied to ttyd on localhost:7681
    |
    ▼
[Caddy or nginx on VM, port 443 with auto-TLS]
    |
    ├── /auth/*  →  small auth service (validates signatures against authorized_keys)
    └── /terminal  →  reverse proxy to ttyd (localhost:7681, bound to 127.0.0.1)

The auth service can be a small Go or Python binary (~100 lines) provisioned via cloud-init. It reads the public key from ~/.ssh/authorized_keys, verifies the challenge signature, and sets a session cookie.

Browser-Side Integration

On the browser side, you'd add: - An xterm.js terminal component (or use ttyd's built-in one in an iframe) - WebCrypto code to import the SSH private key and sign challenges - A new UI panel/modal for the terminal session

The main complexity is the WebCrypto part — SSH keys need to be parsed from PEM/OpenSSH format into a format WebCrypto can import. Libraries like sshpk (JS) handle this conversion.


Want me to dive deeper into any of these areas — the cloud-init setup, the auth service design, the WebCrypto key signing, or the browser-side terminal integration?

#2

Thanks, very interesting. Reading this I wondered about another approach: could we use Webauthn to make a strongly secure authentication mechanism for the reverse proxy? User either uses a Yubikey, or passkey support in the browser. The newly installed reverse proxy on the target server somehow onboards them via Webauthn and subsequently authentication works like any web site that uses passkeys?

Interesting idea. It can work, but there are some structural challenges worth understanding before committing to it.

How It Could Work

Orchestrator UI (origin: app.example.com)
    |
    |  1. User registers passkey (one-time, RP ID = app.example.com)
    |  2. Orchestrator stores credential ID + public key
    |  3. At VM creation, cloud-init provisions the credential public key
    |
    |  --- on "connect terminal" ---
    |
    |  4. Browser fetches challenge from VM proxy
    |  5. navigator.credentials.get() → user taps Yubikey / uses passkey
    |  6. Signed assertion sent to VM proxy
    |  7. VM proxy verifies signature against provisioned credential public key
    |  8. Session established → WebSocket terminal opens
    |
    ▼
[VM proxy verifies WebAuthn assertions, proxies to ttyd]

The Key Constraints

WebAuthn is origin-bound. The credential is tied to the Relying Party ID (RP ID), which is a domain. This has several implications:

1. The RP must be your orchestrator's origin, not the VM's. Since each VM is a different host (IP or subdomain), you can't register a passkey per-VM — the user would need to tap their Yubikey for a new registration on every VM creation. Instead, the passkey is registered once at the orchestrator's origin, and the VM proxy accepts assertions scoped to that origin.

2. The assertion ceremony must happen in the orchestrator's browsing context. navigator.credentials.get() checks that the RP ID matches the current page's origin. So the WebAuthn challenge-response must be triggered from your orchestrator UI, then the resulting assertion is forwarded to the VM. This works fine — the assertion is just a signed blob you can send anywhere.

3. The VM proxy needs to know the orchestrator's origin. When verifying the assertion, the proxy checks that clientDataJSON.origin matches an expected value. This is straightforward to provision via cloud-init.

The Bootstrapping Problem

This is the main wrinkle. WebAuthn registration itself must be authenticated — otherwise anyone could register their passkey with your orchestrator. So you still need an initial auth mechanism to gate registration:

  • If the orchestrator is a local app (localhost), this may be acceptable — only the local user can access it
  • If it's hosted, you need some identity layer for the initial enrollment (OAuth, email link, etc.)

After that first enrollment, all subsequent VM connections use the passkey. The credential public key gets provisioned to every new VM via cloud-init.

Practical Assessment

What it gives you over the SSH-key challenge-response approach: - Hardware-bound keys (Yubikey's private key is non-extractable) - Biometric option (fingerprint/face via platform passkeys) - Phishing resistance (browser enforces origin binding) - Standardized protocol with broad library support

What it costs you: - Registration flow in the orchestrator UI (one-time, but still needs building) - WebAuthn server-side verification library on the VM (Python: py_webauthn ~50KB; Go: go-webauthn ~5MB binary; Node: @simplewebauthn/server) - The bootstrapping problem above - More moving parts than raw signature verification

Compared to the SSH key approach: | | SSH key challenge-response | WebAuthn | |---|---|---| | Key material | SSH private key in SecretStore (software) | Yubikey/passkey (can be hardware-bound) | | Extractability | Key is in JS memory | Non-extractable from hardware authenticator | | Browser API | WebCrypto (import PEM, sign) | navigator.credentials.get() (standardized) | | VM-side verification | Parse authorized_keys, verify sig (~100 lines) | WebAuthn library (~same complexity) | | Provisioning data | SSH public key (already there) | Credential ID + public key (new data path) | | Registration | None (key already exists) | One-time enrollment flow needed |

My Take

WebAuthn is a solid choice if you value hardware-bound keys (Yubikey) or want the standard passkey UX. The orchestrator already has a natural place for the one-time registration, and the credential public key is small enough to provision via cloud-init alongside the SSH public key.

However, if your users are already managing SSH private keys in the browser's SecretStore, the SSH-key challenge-response approach is simpler and reuses infrastructure you already have — no new registration flow, no new credential to provision, and the VM already has the public key.

The two approaches aren't mutually exclusive either — you could support both, letting users choose passkey auth if they have a Yubikey, or SSH key auth as a fallback.

Want me to sketch out the implementation plan for either (or both) approaches?

#3

No let me think about the options for a bit. I was aware that the RP ID is a domain, but we assume the new VM will be provisioned with a DNS hostname that we know. However I hadn't considered that the RP ID has to match the orchestrator origin domain. It won't! Perhaps there's a way around that by opening a new browser page directly to the proxy on the target VM, via a URL specifying its host name. There would need to be a way to pass some nonce between the orchestrator and that new page. Also while the user would be able to interact manually with that new page in a terminal session, if we wanted to send shell commands programatically to the target VM from the orchestrator application, that wouldn't work because auth would be bound to the other page.

You're thinking through this well. Let me address both points.

Opening a new page on the VM's origin

This does solve the RP ID problem — if the browser navigates to https://vm-abc.vms.example.com, the WebAuthn ceremony can use that domain (or a parent like vms.example.com) as the RP ID. The user registers a passkey scoped to vms.example.com, and it works across all VMs under that domain.

Passing a nonce from orchestrator to the new page: Several options: - URL fragment (#nonce=...) — not sent to server, readable by JS on the new page - window.open() + postMessage() — orchestrator opens the page and sends the nonce cross-origin - Short-lived token in the URL path/query — the VM proxy validates it once to bootstrap the WebAuthn registration

But this leads to your second point, which is the real problem.

Programmatic access from the orchestrator

Exactly right. If auth is bound to a page on the VM's origin, the orchestrator app (different origin) can't use that session to send commands. You'd have two isolated contexts:

  • VM page: has the authenticated WebSocket, user can type interactively
  • Orchestrator page: can create VMs, manage secrets, but can't send commands to the terminal

You could bridge them with postMessage() between the two windows, but that's fragile — the user could close the VM tab, and you're building a cross-origin RPC layer on top of a hack.

Where this lands

WebAuthn is fundamentally designed to bind auth to an origin. That's its strength for preventing phishing, but it works against you here because you have two origins that both need authenticated access to the same resource.

This brings it back to the SSH key challenge-response approach, which doesn't have the origin-binding constraint. The SSH private key is just bytes in your SecretStore — you can sign a challenge from any page, send the signature to any server, and it verifies the same way. You get:

  • Single origin (orchestrator) controls everything
  • Interactive terminal via xterm.js embedded in the orchestrator UI
  • Programmatic command execution via the same authenticated WebSocket
  • No second browser window needed

The tradeoff is software keys vs. hardware-bound keys. If hardware binding is important to you, there might be a hybrid: use WebAuthn at the orchestrator origin purely to gate access to the SSH private key in SecretStore (i.e., "tap your Yubikey to unlock the terminal feature"), then use the SSH key for the actual VM auth. That gives you a physical confirmation step without the origin-binding problem.

#4

Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.

#5

/exit exit

#6

Bye!