diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..a18d04f --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,21 @@ +# Thin wrapper: install the optional lint/test tools and run scripts/check. +# No duplicated lint logic in YAML — anyone can run the same script locally +# and get the same result. +name: check + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install optional tools + run: | + sudo apt-get update + sudo apt-get install -y shellcheck bats + pipx install ruff + - name: Run checks + run: scripts/check diff --git a/.gitignore b/.gitignore index effb667..633635a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .venv/ __pycache__/ *.pyc -lib/ui/xterm/ +prompts/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a1166e7 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,438 @@ +# Architecture + +This document describes the design of `ssh-unique-key` in enough detail to audit it. It complements the high-level "Architecture" section in [README.md](README.md), which is intentionally brief. + +The tool does not implement cryptography. It orchestrates `ssh-keygen`, `ssh-keyscan`, `ssh-copy-id`, OpenSSH itself, and (optionally) `opkssh`. The bespoke part — and therefore the part that must be airtight — is the *protocol around* those primitives: how identities are derived, where keys live on disk, how SSH is configured to find them, and how MITM detection works. + +A second design pillar is **dependency minimisation.** Every external package is supply-chain surface. Python code targets stdlib only; JavaScript is vendored, version-pinned, and checksum-recorded; no `pip install` or remote fetch runs at install time or runtime. + +--- + +## 1. Goals and non-goals + +### Goals + +1. **Anti-tracking.** A user's public key on host A must never appear on host B. No identifier shared across hosts. +2. **Blast radius reduction.** Compromise of one host yields one keypair; that keypair authenticates to nothing else. +3. **Zero-friction UX.** The user keeps typing `ssh user@host`. Provisioning, deployment, and config injection happen via `ssh-new`; everything else is invisible. +4. **MITM-resistant re-engagement.** After first contact, host identity is pinned and re-verified on every subsequent operation. +5. **Optional hardware-token and OpenPubKey paths.** YubiKey/FIDO2 and opkssh-issued certificates compose with the per-host model via templates. Hardware-bound keys also provide the only path to "private material that does not live under `$HOME`" — important when the X1 platform gate is unavailable. +6. **Minimal supply-chain surface.** Zero pip dependencies; vendored JavaScript with recorded checksums; no install-time or runtime network fetches for code. Every line of code shipped is either in the repo (and reviewable in `git log`) or in the system's `python3` / OpenSSH / bash distribution. + +### Non-goals + +- **Not a CA.** We do not sign certificates; opkssh does. +- **Not a key escrow.** Backups are explicit, user-driven, encrypted only at the user's discretion (`ssh-backup` produces a plain tarball). +- **Not a replacement for `ssh-agent`.** Keys live unencrypted on disk because they have to (legacy device support, hands-free provisioning). Users wanting passphrase-protected keys do not gain that from this tool. +- **Not a defence against same-user code with ordinary filesystem access.** See §2 X1. This is a deliberate platform gate, not a missing feature. +- **Not a generic web framework.** The web UI is stdlib Python + vendored JS. We will not grow it into anything that requires a dependency tree. + +--- + +## 2. Threat model + +| ID | Adversary | Capability | What we defend against | +| --- | --------- | ---------- | ---------------------- | +| A | Remote-server admin / partial server compromise | Read `authorized_keys` on one host they control | Cannot correlate the captured public key to any other host — per-host uniqueness makes the key worthless beyond that one server | +| B | Mass scanner (Shodan, Censys, internet-wide SSH probes) | Snapshot of public-facing SSH server keys and accepted auth | Cannot search by public key to find other servers belonging to the same user | +| C | On-path MITM during an existing host's reconnection | Substitute host keys | `ssh-new` and connect-time `%K`-based config lookup both pin to stored host keys; mismatch aborts the operation. Every connection `ssh-new`/`ssh-user-rotate` makes themselves (`ssh-copy-id`, key verification, the post-provision `ssh`) is pinned to the stored scan via `UserKnownHostsFile` + `StrictHostKeyChecking=yes` — never `accept-new`, which would both accept an unscanned key and write it into the default `known_hosts` | +| D | Local non-malicious footgun | User shares screenshot, runs a backup tool, `find` over `$HOME` | Keys are siloed under `~/.ssh/unique_keys/` (not the conventional `id_rsa`); 0700/0600 perms throughout | +| E | Web UI cross-origin attacker | Malicious page tries to drive the local UI server | Server binds to 127.0.0.1 only; random port; CORS pinned to the served port; auth via one-time URL token exchanged for HTTP-only `SameSite=Strict` cookie | +| F | Supply-chain attacker against our dependencies | Compromise pypi/unpkg/npm or our install-time download path | All Python = stdlib; all JS vendored at known versions with recorded SHA-384 checksums; no network fetch at install/runtime; CI verifies vendored files match recorded hashes | + +### Out-of-scope adversaries + +| ID | Adversary | Why out of scope | +| --- | --------- | ---------------- | +| X1 | Local same-user code with ordinary filesystem access (including AI coding agents, well-meaning automation, accidental `tar` of `$HOME`) | A user-mode design that keeps private key files on disk cannot defend against arbitrary code running as the user. This is a **deliberate platform gate**, not an oversight — if it matters in your environment, the answer is platform-level isolation (separate user accounts, OS sandboxing) or hardware-bound keys via `ssh-keygen-sk` templates that push private material out of `$HOME` entirely. We do not attempt encryption-at-rest with in-filesystem unlock material; that approach trades one platform problem for a much larger orchestration surface. | +| X2 | TOFU bypass on first scan | We trust the very first `ssh-keyscan` result. A network-position attacker present at provision time can pin themselves as the "real" host. This is the same trust model OpenSSH gives users on first connect; we do not improve on it | +| X3 | Supply-chain compromise of `opkssh`, OpenSSH, the OS, or the system Python interpreter | Beyond our boundary; we trust the OS distribution | +| X4 | Sophisticated attacker who can read the repo and craft a poisoned vendored JS update | Mitigated by: vendored bumps go through code review, the diff is human-inspectable, and SHA-384 records change visibly in the commit | + +### Known weaknesses (tracked for fix; details in `REFACTOR.md`) + +- **W1: UUID stability under partial scans.** Host UUID is `sha256(best-key)` selected from a sort of the full `ssh-keyscan` output. If a scan returns a subset of the host's keys, the "best" key may differ and the UUID changes — appearing as a new identity. The diff check in `ssh-new` catches this and aborts loudly, but the design is brittle. +- **W2: Comment / config injection via `--comment`.** The `ssh-new --comment` text is interpolated into a `Match host %h exec "..."` line. Sanitisation strips single quotes and backslashes; the surface area is fragile and per-call. Should be replaced with a structured config writer that writes the text to a separate file and `cat`s it. +- **W3: Triple command-construction.** The same operations are assembled three times: bash CLI argv, Python `subprocess.run` argv, shell-script strings in `launch_terminal_script`. Three opportunities for inconsistent escaping. +- **W4: Inconsistent auth on web UI.** `before_request` enforces auth globally, but some endpoints re-check redundantly and `/deploy` does not; `url_for('login')` is called but no login route exists. +- **W5: Install-time network fetch with no integrity check.** [install.sh](install.sh) currently `curl`s `xterm@latest` / `socket.io-client@4` from unpkg with no version pin and no hash. This is the largest unmitigated supply-chain hole. Will be replaced by vendored copies committed to the repo. +- **W9: `ssh-rotate` and `ssh-template-rotate` reference undefined helpers** (`validate_hostname`, `CONFIG_DIR`, `validate_template`, `get_hosts_using_template`, `log`). The scripts are unrunnable as shipped. Rebuilt in Phase 5. + +--- + +## 3. Trust boundaries + +The tool inserts itself between three trust domains and must not weaken any of them. + +```text +┌─────────────────────────────────────────────────────────────────────────┐ +│ USER'S MACHINE │ +│ │ +│ ┌──────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ ssh(1) │ │ ssh-unique-key │ │ ~/.ssh/unique_keys/ │ │ +│ │ (trusted)│◄──►│ (this tool) │◄──►│ (filesystem state, 0700) │ │ +│ └────┬─────┘ └────────┬─────────┘ └──────────────────────────┘ │ +│ │ │ │ +└────────┼───────────────────┼────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌─────────┐ ┌────────────┐ + │ remote │ │ opkssh / │ + │ sshd │ │ OIDC IdP │ + └─────────┘ └────────────┘ +``` + +- **OpenSSH ↔ this tool:** we communicate exclusively through `~/.ssh/config` `Include` directives and standard token expansion (`%K`, `%h`, `%r`). We never patch OpenSSH, never wrap `ssh`, never intercept its network traffic. +- **This tool ↔ filesystem:** every directory we own is `0700`, every private key `0600`, every public key/log `0644` or `0600`. We never `chmod` outside `~/.ssh/unique_keys/`. We rely on the OS to honour those permissions against other users; against the *same* user, see X1. +- **This tool ↔ remote sshd:** all network actions happen through `ssh-keyscan`, `ssh-copy-id`, and `ssh`. We never speak SSH wire protocol ourselves. +- **This tool ↔ third-party code:** Python = stdlib of the system interpreter. JavaScript = vendored files at `lib/ui/vendor/` with checksums recorded in `lib/ui/vendor/SHA384SUMS`. Nothing fetched at install or runtime. + +--- + +## 4. On-disk schema (formal) + +All paths are relative to `$BASE_DIR = ~/.ssh/unique_keys`. The base directory itself is `0700`. + +```text +~/.ssh/unique_keys/ +├── SCHEMA 0644 single integer; current = 2 +├── host-uuid/ 0700 parent of all host identities +│ └── / 0700 one host identity; = §5 +│ ├── known_host_keys 0644 sorted ssh-keyscan output (full key set) +│ ├── config 0600 host-specific ssh_config snippet +│ ├── trusted.conf 0600 by-key-path ssh_config (StrictHostKeyChecking no) +│ ├── notice.txt 0600 optional; ssh-new --comment payload (W2 fix) +│ └── / 0700 one per remote username +│ ├── identity 0600 private key OR symlink → templates//... +│ ├── identity.pub 0644 public key OR symlink (optional for cert-only) +│ └── identity-cert.pub 0644 OPK certificate symlink (OPK templates only) +├── by-key/ 0700 index by base64 host key +│ └── / → ../host-uuid/ symlink; key includes "/" → nested dirs +├── by-host/ 0700 index by hostname (fallback) +│ └── → ../host-uuid/ symlink +├── templates/ 0700 parent of all key templates +│ └── / 0700 one template +│ ├── config 0600 template-wide ssh_config snippet +│ ├── id_ed25519, id_ecdsa, id_rsa (standard templates) +│ ├── id_ed25519.pub, id_ecdsa.pub, id_rsa.pub +│ ├── id_ed25519_sk, id_ed25519_sk.pub (hardware-key templates) +│ ├── identity, identity.pub, identity-cert.pub (OPK templates) +│ ├── .type 0600 literal "opk" for OPK templates; absent otherwise +│ └── .issuer 0600 IdP URL for OPK templates (optional) +├── config-top.d/ 0700 user-supplied overrides, loaded first +├── config-bottom.d/ 0700 defaults, loaded last +└── history.jsonl 0600 one JSON object per line (schema v2) +``` + +### File-format invariants + +- `SCHEMA`: a single line containing the integer schema version. Bumped when on-disk layout changes. Absent file ⇒ legacy v1 (pre-refactor) install. +- `known_host_keys`: one key per line, format ` `, sorted lexicographically. Comments (`#…`) excluded. The hostname column is informational for identity purposes — equality checks against new scans strip it (`awk '{$1=""; print}'`) — but it is refreshed to the most recent provisioning target on every successful re-verification, because the file doubles as the pinned `UserKnownHostsFile` for provisioning-time connections (see §7). +- `history.jsonl`: one JSON object per line, schema `{"ts": , "user": , "action": , "target": , "details": }`. Tolerates extra fields. (Replaces the pipe-separated `history.log` from schema v1; see W6 below for the migration story.) +- `.type`: the single literal string `opk` or the file is absent. No other values defined. + +### Schema-level weaknesses (numbered for tracking) + +- **W6: legacy `history.log` had no `|` escaping.** Resolved by moving to JSONL at schema v2. +- **W7: no schema version marker** in v1. Resolved by adding `SCHEMA` at v2; `ssh-doctor` validates. +- **W8: `` in `by-key/` may contain `/`.** Handled by nesting directories under `by-key/`, but produces a surprising tree on inspection. Not broken; documented here. + +--- + +## 5. Identity derivation + +### Host UUID + +```text +UUID(host) = sha256(" " of awk_pick_best_key(ssh-keyscan host)) +``` + +Where `awk_pick_best_key` picks in priority order: `ssh-ed25519`, `ecdsa-sha2-*`, `ssh-rsa`. The input to sha256 is ` ` for the chosen key — the hostname column is stripped, so the identity is derived purely from key material. + +**Properties:** + +- Stable across hostname/IP changes (the UUID is derived from host keys, not naming). +- Stable across host-key reordering in scan output (we pick by type, not by position). +- **Unstable across partial scans** if the scan returns a strict subset of the host's keys — see W1. +- Collision-resistant under sha256. + +### Per-user identity + +Per-user identity is a directory `host-uuid///` containing `identity` and `identity.pub`. The private file is either a real key (generated locally) or a symlink into `templates//`. We never silently rotate; rotation is an explicit operation (`ssh-user-rotate`, `ssh-template-rotate`). + +### Template indirection + +Templates exist so that one logical credential (a YubiKey, an opkssh certificate) can authenticate many hosts. Per-user `identity` becomes a relative symlink into `templates//`. Standard templates use `id_` filenames; OPK templates use `identity`/`identity.pub`/`identity-cert.pub` (because opkssh expects that naming). The `.type=opk` sidecar disambiguates. + +--- + +## 6. SSH config integration + +`install.sh` prepends the following block to `~/.ssh/config`: + +```text +# --- SSH-UNIQUE-KEY START --- +Include ~/.ssh/unique_keys/config-top.d/* +Include ~/.ssh/unique_keys/by-key/%K/trusted.conf +IdentityFile ~/.ssh/unique_keys/by-key/%K/%r/identity +Include ~/.ssh/unique_keys/by-host/%h/config +IdentityFile ~/.ssh/unique_keys/by-host/%h/%r/identity +Include ~/.ssh/unique_keys/config-bottom.d/* +# --- SSH-UNIQUE-KEY END --- +``` + +### Token semantics + +- `%K` — the base64-encoded host public key OpenSSH has just verified. Only available *after* host-key verification, so anything `Include`d under `by-key/%K/` is keyed by a cryptographically attested identity. +- `%h` — the literal hostname/alias as typed by the user. Available before connection; used as a pre-verification fallback (and the only path on macOS's stock SSH, which lacks `%K`). +- `%r` — the remote username. + +### Why `StrictHostKeyChecking no` is safe inside `trusted.conf` + +The `by-key//` directory exists *only* if `ssh-new` previously stored `` in `known_host_keys` for this host. Reaching that path means OpenSSH has already verified the server presented exactly that key. The `Include` only fires when verification has already succeeded, so `StrictHostKeyChecking no` is a no-op — the path itself *is* the check. `UserKnownHostsFile /dev/null` then suppresses the redundant warning OpenSSH would emit about an unrecorded host. + +This is the single most important invariant in the design. If anything ever causes `by-key//trusted.conf` to exist for a host whose key has changed, the safety argument collapses. Currently enforced by: + +1. `by-key/` is only created/refreshed by `ssh-new` after a successful scan-vs-stored diff. +2. `ssh-rotate` (when fixed; see W9) is the only path that updates `by-key/` symlinks for an existing host. + +### Precedence + +`config-top.d/*` wins (user overrides); then by-key (cryptographic); then by-host (TOFU); then `config-bottom.d/*` (defaults). OpenSSH applies the *first* value seen for any setting that isn't list-typed. + +--- + +## 7. Control flow per operation + +### `ssh-new user@host` + +```text + 1. Parse args (template, legacy, comment, key-comment, no-connect) + 2. Pre-flight: probe publickey-auth support; warn-and-confirm if absent + 3. ssh-keyscan host → RAW_SCAN → sort → SCAN_SORTED + 4. UUID = sha256(best-key-in(SCAN_SORTED)) + 5. If host-uuid/UUID/ exists: + diff SCAN_SORTED against known_host_keys (modulo first column) + mismatch → ABORT with security warning + match → rewrite known_host_keys with SCAN_SORTED (same keys; + refreshes the hostname column to the current target) + Else: + create host-uuid/UUID/ (0700); write known_host_keys + 6. Create host-uuid/UUID// (0700) + 7. If /identity absent: + If --template: + If template is OPK: validate cert; if expired, run `opkssh login`, re-validate + Symlink identity/.pub/-cert.pub into templates// + Else: + ssh-keygen, picking type by host capability (ed25519 > ecdsa > rsa); rsa -b 4096 + 8. Emit host-uuid/UUID/config (with template Include if applicable) + 9. If --legacy: append legacy KexAlgorithms/Ciphers stanza +10. If --comment: write text to host-uuid/UUID/notice.txt; emit + Match host %h exec "cat ~/.ssh/unique_keys/host-uuid/UUID/notice.txt" + No interpolation of user text into the config file (W2 fix). +11. Write trusted.conf (StrictHostKeyChecking no, Include config) +12. ssh-copy-id -o UserKnownHostsFile=host-uuid/UUID/known_host_keys + -o StrictHostKeyChecking=yes + (unless --no-connect or cert-only auth; pins the deployment connection + to exactly the keys the UUID was derived from — a MITM appearing + between the scan and the copy aborts here) +13. Symlink by-host/ → host-uuid/UUID +14. For each key in SCAN_SORTED: symlink by-key/ → host-uuid/UUID +15. exec ssh user@host with the same UserKnownHostsFile pin (unless --no-connect) +``` + +### `ssh user@host` (after provisioning) + +```text +1. OpenSSH reads ~/.ssh/config; sees Include by-host//config +2. OpenSSH connects, performs host-key verification +3. After verification, OpenSSH re-evaluates Match-style includes with %K bound +4. Include by-key/%K/trusted.conf → IdentityFile by-key/%K/%r/identity +5. Authenticate with the unique per-host key +``` + +### `ssh-del [user@]host` + +```text +1. ssh-keyscan host (allow failure; fall back to by-host symlink if unreachable) +2. Resolve UUID +3. Confirm-prompt; remove host-uuid/UUID// +4. If other users remain: stop +5. Else: remove by-host/ symlink and by-key/ symlinks + if no other aliases: confirm-prompt full delete +``` + +--- + +## 8. Web UI architecture + +The web UI is **stdlib-only Python** (post-refactor) plus **vendored JavaScript** with no runtime dependencies. + +### Process model + +- One Python process. `python3 lib/ssh-ui.py` — no venv, no `pip install`, no first-run setup. +- HTTP server built on `http.server.ThreadingHTTPServer` (stdlib) with a hand-rolled router. +- WebSocket: a single ~200-LOC RFC 6455 handler we own, wired into the same `ThreadingHTTPServer` via HTTP `Upgrade`. No `socket.io`. No `gevent`/`eventlet`. Plain threads, one per WebSocket connection. +- PTY: `pty.fork()` + `select()` on the master FD, same as today, but the framing layer is ours, not flask_socketio's. +- Binds `127.0.0.1` on a random port. Single-process; no multi-worker model. + +### Auth + +- One-time auth token (`secrets.token_urlsafe(32)`) generated per process startup. +- First request with `?token=...` validates via `hmac.compare_digest`, sets an HTTP-only `SameSite=Strict` cookie, redirects to a clean URL. +- All subsequent requests authenticated by cookie. +- A `@require_auth` decorator wraps every handler; no global `before_request` hook to forget. +- WebSocket upgrade requests carry the same cookie and are validated before `pty.fork()`. + +### Rendering + +- `index.html` is **static**. The Python process serves it as a static file. No templating engine; no Jinja2. +- All dynamic data flows over JSON endpoints (`/api/identities`, `/api/templates`, `/api/history`). The browser fetches on load and renders client-side. +- This collapses the previous "SSR for dashboard, JSON for everything else" inconsistency into a single SPA pattern. + +### Endpoints (post-refactor) + +| Route | Method | Purpose | +| ----- | ------ | ------- | +| `/` | GET | Serve static `index.html` | +| `/assets/` | GET | Static asset (CSS, JS, vendored xterm.js) | +| `/api/identities` | GET | List host identities + users (replaces SSR data) | +| `/api/templates` | GET / POST | List / create templates | +| `/api/templates/` | DELETE | Remove template | +| `/api/history` | GET | Read `history.jsonl` (last N) | +| `/api/user/rotate` | POST | Shell out to `ssh-user-rotate --json` | +| `/api/user/delete` | POST | Shell out to `ssh-del --force --json` | +| `/api/deploy` | POST | Shell out to `ssh-copy-id` (no in-Python ssh-copy-id path) | +| `/ws` | GET (Upgrade) | WebSocket for PTY-backed flows | +| `/favicon.ico`, `/robots.txt` | GET | Trivial responses | + +Every state-mutating endpoint shells out to a `bin/ssh-*` script and reads `--json` output. Python does not call `ssh-keygen`/`ssh-keyscan`/`ssh-copy-id` directly. This eliminates the duplicated command-construction logic (W3) and ensures every mutation passes through `log_event`. + +### WebSocket protocol (hand-rolled) + +The browser uses the native `WebSocket` API (no `socket.io-client`). Messages are JSON envelopes: + +```text +client → server: {"op": "spawn", "cmd": "create"|"connect"|"template", "args": {...}} +client → server: {"op": "input", "data": ""} +client → server: {"op": "resize", "cols": , "rows": } + +server → client: {"op": "output", "data": ""} +server → client: {"op": "ended", "exit_code": } +server → client: {"op": "error", "message": ""} +``` + +Each WebSocket connection owns one PTY. `spawn` is rejected if a PTY is already attached. Reconnection of a detached browser window targets a known `term_id` (carried in a query param on the WS URL) — only one reconnect within a 15-second grace window before the PTY is reaped. + +### Attack surface inventory + +Every place untrusted data enters the system. Post-refactor, every entry goes through `lib/ssh_ui/validators.py` (Python) which has a paired bash module `bin/_validators.inc.sh`. The two modules are tested against a shared golden corpus. + +| # | Input | Validator | Notes | +| --- | ----- | --------- | ----- | +| 1 | `user_host` field | `validate_user_host` | regex `^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+$` | +| 2 | `template` name | `validate_template_name` | `^[a-zA-Z0-9_-]+$`, max 64 | +| 3 | `key_comment` | `validate_key_comment` | `^[a-zA-Z0-9 ._@-]+$`, max 128 | +| 4 | `comment` text (for `--comment`) | `validate_comment_text` | length-bounded; written to `notice.txt`, never interpolated (W2 fix) | +| 5 | `target_host` (deploy) | `validate_hostname` | `^[a-zA-Z0-9.-]+(:[0-9]+)?$` | +| 6 | `password` (deploy) | length-bounded | written to a `SSH_ASKPASS` script (0700, in `$XDG_RUNTIME_DIR` or `~/.ssh/unique_keys/tmp`), deleted post-run | +| 7 | `uuid` in API routes | `validate_uuid` | strict `^[a-f0-9]{64}$` (was `os.path.basename` only) | +| 8 | WebSocket payload fields | re-validated server-side at handler entry | Same validators as HTTP endpoints | +| 9 | Inbound URL `?token=...` | `hmac.compare_digest` | | +| 10 | `ssh-keyscan` output | `awk` + `sort` | Untrusted from a network MITM perspective; pinned on second use | +| 11 | `opkssh` stdout/stderr | not parsed; only side-effect on `identity-cert.pub` is checked via `check_cert_validity` | | + +### Static asset supply chain (W5 fix) + +All JS/CSS is vendored. The repo holds: + +```text +lib/ui/vendor/ +├── SHA384SUMS ← one line per file: " " +├── xterm-5.5.0.js +├── xterm-5.5.0.css +└── xterm-addon-fit-0.10.0.js +``` + +`scripts/check vendor` recomputes SHA-384 over every file in `vendor/` and compares to `SHA384SUMS`. CI runs the same check. Bumping a vendored file requires updating `SHA384SUMS` in the same commit — the diff is visible in code review. + +`socket.io-client` is *removed* from the vendor set; xterm.js does not depend on it, and our hand-rolled WebSocket uses the browser's native `WebSocket` API. + +`install.sh` no longer performs any network fetch. + +--- + +## 9. Logging + +Single stream, `history.jsonl`. Written by bash scripts via `log_event` (which JSON-encodes its arguments) and by the Python web UI through the same shell helpers (i.e. via shelled-out CLI calls — see [§8](#endpoints-post-refactor) note about every mutation passing through bash). + +Python `logger` to stderr is kept for WebSocket lifecycle / PTY-spawn debug output only. Operational events do not go there. + +--- + +## 10. Open questions / decisions deferred + +- **UUID derivation under partial scans.** Three options on the table; see W1. Decision in `REFACTOR.md` Phase 3. +- **Where does `opkssh login` actually deposit the certificate?** The current OPK flow asks the user for the path because we don't know. Pinning this (or wrapping opkssh) would remove a sharp edge. +- **Should the web UI support remote (non-localhost) operation?** No, by current decision. Going remote would require TLS, real auth, CSRF tokens. Out of scope. + +--- + +## 11. Quick reference: file layout map (post-refactor) + +```text +ssh-unique-key/ +├── ARCHITECTURE.md ← this document +├── REFACTOR.md ← phased refactor plan +├── README.md ← short overview, install, usage +├── LICENSE +├── install.sh ← installs symlinks into ~/bin, sets up ~/.ssh/config +├── scripts/ +│ ├── check ← local validation entry (lint/syntax/test/vendor) +│ └── install-hooks ← installs .git/hooks/pre-push → scripts/check +├── bin/ ← bash CLI +│ ├── _ssh-unique-key.inc.sh shared sourced helpers +│ ├── _validators.inc.sh input validation (paired with lib/ssh_ui/validators.py) +│ ├── ssh-new +│ ├── ssh-del +│ ├── ssh-conf +│ ├── ssh-template +│ ├── ssh-rotate (rebuilt in Phase 5) +│ ├── ssh-user-rotate +│ ├── ssh-template-rotate (rebuilt in Phase 5) +│ ├── ssh-backup +│ ├── ssh-restore +│ ├── ssh-history +│ ├── ssh-doctor (new in Phase 3) +│ └── ssh-ui exec python3 lib/ssh-ui.py +├── lib/ +│ ├── ssh-ui.py ← entry point: `python3 ssh-ui.py` +│ └── ssh_ui/ ← stdlib-only package +│ ├── __init__.py +│ ├── server.py ← ThreadingHTTPServer + router +│ ├── auth.py ← token, cookie, @require_auth +│ ├── ws.py ← RFC 6455 framing + handshake +│ ├── pty_manager.py ← active terminals, lifecycle, reader loop +│ ├── handlers.py ← HTTP handler functions per route +│ ├── identity_scan.py ← filesystem walk → JSON +│ ├── validators.py ← input validation (paired with bin/_validators.inc.sh) +│ └── subprocess_runner.py ← single wrapper around subprocess.run for bin/ssh-* +├── lib/ui/ +│ ├── index.html ← static SPA shell +│ ├── app.js ← native fetch + native WebSocket +│ ├── style.css +│ └── vendor/ +│ ├── SHA384SUMS +│ ├── xterm-.js +│ ├── xterm-.css +│ └── xterm-addon-fit-.js +├── tests/ +│ ├── bash/ ← bats suite +│ ├── python/ ← unittest / pytest +│ └── threat/ ← Phase 6 regression suite (per threat A–F) +└── .github/ + └── workflows/ + └── check.yml ← thin wrapper: runs scripts/check +``` + +The `lib/requirements-ui.txt` file is **removed** in Phase 1. No pip dependencies. diff --git a/README.md b/README.md index beeedf2..d08a450 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,11 @@ This installs commands to `~/bin` (ensure it's in your `$PATH`) and stores key d Options: -- `./install.sh --install-xterm` — include xterm.js assets for the web UI terminal -- `./install.sh --skip-xterm` — skip xterm.js assets - `./install.sh uninstall` — remove all symlinks and installed files -The web UI requires Python 3. Dependencies are installed automatically in a virtual environment on first run of `ssh-ui`. +The installer performs **no network fetches**: all web-UI assets (xterm.js) are vendored in the repo under `lib/ui/vendor/` with SHA-384 hashes recorded in `SHA384SUMS` and verified by `scripts/check vendor`. + +The web UI requires Python 3. Dependencies are installed automatically in a virtual environment on first run of `ssh-ui` (this dependency goes away in refactor Phase 1 — see [REFACTOR.md](REFACTOR.md)). ## Quick Start @@ -78,9 +78,9 @@ Enables deprecated algorithms (DSA, RSA-SHA1, CBC ciphers) only for that specifi | `ssh-del` | Remove keys for a user. Cleans up the host identity if no users remain. | | `ssh-conf` | Edit host-specific SSH options (Port, Forwarding, etc.). | | `ssh-template` | Manage key templates (standard, hardware, or OpenPubKey). | -| `ssh-rotate` | Rotate host keys when a server's host key changes. | +| `ssh-rotate` | *Disabled* — the shipped implementation was broken; rebuilt in refactor Phase 5. | | `ssh-user-rotate` | Rotate a user's keypair for a specific host. | -| `ssh-template-rotate` | Rotate keys within a template (ed25519, ecdsa, or rsa). | +| `ssh-template-rotate` | *Disabled* — the shipped implementation was broken; rebuilt in refactor Phase 5. | | `ssh-backup` | Create an encrypted archive of the key store. | | `ssh-restore` | Restore keys from a backup archive. | | `ssh-history` | View the operations log. | @@ -99,10 +99,10 @@ Starts a local web server on a random port (localhost only). A URL with a one-ti Features: - **Dashboard** — overview of all host identities, their users, and keys. Connect, rotate, or delete directly from the browser. -- **Templates** — create and manage key templates. Hardware key enrollment and opkssh login run in an embedded terminal (xterm.js over websockets). +- **Templates** — create and manage key templates. - **History** — searchable log of all operations. -The terminal integration handles interactive workflows (YubiKey touch prompts, OIDC browser login for opkssh) that would otherwise require the CLI. +The embedded xterm.js terminal (hardware key enrollment, opkssh login flows) is **temporarily disabled**: socket.io-client is no longer shipped, and the terminal returns in refactor Phase 1 on a hand-rolled WebSocket ([REFACTOR.md](REFACTOR.md)). Interactive flows fall back to plain form submissions or the CLI until then. ## Architecture @@ -160,6 +160,14 @@ The `--legacy` flag injects deprecated algorithms only into the specific host's - `generate-sk` creates FIDO2/U2F-backed keys (`ed25519-sk`) requiring physical touch. - `generate-opk` integrates with OpenPubKey via opkssh. `ssh-new` validates certificates and triggers `opkssh login` (with optional issuer) when sessions expire, verifying the credential was updated before connecting. +## Development + +Design and threat model live in [ARCHITECTURE.md](ARCHITECTURE.md); the phased refactor plan in [REFACTOR.md](REFACTOR.md). + +- `scripts/check` — run all local validation (or a stage: `syntax`, `lint`, `vendor`, `tests`, `secrets`). CI runs exactly this script. +- `scripts/install-hooks` — opt-in pre-push hook that runs `scripts/check`. +- `scripts/vendor-update` — the only sanctioned way to bump vendored JS: fetches pinned versions, cross-checks two CDNs byte-for-byte, rewrites `lib/ui/vendor/SHA384SUMS`. See `lib/ui/vendor/PROVENANCE`. + ## License See [LICENSE](LICENSE) for details. diff --git a/REFACTOR.md b/REFACTOR.md new file mode 100644 index 0000000..594ad6e --- /dev/null +++ b/REFACTOR.md @@ -0,0 +1,273 @@ +# Refactor Plan + +Companion to [ARCHITECTURE.md](ARCHITECTURE.md). Each phase has explicit entry/exit criteria. Phases are sequenced; do not start phase N+1 until phase N's exit gate is green. The refactor assumes pre-1.0 status — no on-disk backward compatibility is required. + +Issues are referenced by the `W#` IDs from [ARCHITECTURE.md §2 and §4](ARCHITECTURE.md#2-threat-model). + +--- + +## Phase 0 — Stop the bleeding, vendor JS, local check script (~1 week) + +> **Status: COMPLETE (2026-07-16, branch `refactor/phase-0`).** All exit-gate +> checks pass. Vendored assets are pinned at @xterm/xterm@5.5.0 + +> @xterm/addon-fit@0.10.0 via `scripts/vendor-update` (dual-CDN +> cross-check). One note: task 6's regression test is a plain-bash +> `tests/bash/test_*.sh` (always runs, even without bats); the bats suite +> proper arrives in Phase 5. + +**Goal:** the repo no longer ships broken code or fetches code from the internet at install time. A single local check script is the validation entry point — used by developers, by a pre-push hook, and (later) by CI as a thin wrapper. + +### Tasks (Phase 0) + +1. **Vendor xterm.js into the repo.** (W5 closed.) + - Create `lib/ui/vendor/`. + - Pin specific versions (e.g. `xterm-5.5.0.js`, `xterm-5.5.0.css`, `xterm-addon-fit-0.10.0.js`). Filename includes the version so a bump is a file rename plus a `SHA384SUMS` edit — both visible in the diff. + - Record SHA-384 hashes in `lib/ui/vendor/SHA384SUMS` (` ` per line — same format as `shasum -a 384`). + - **Drop `socket.io-client` entirely.** Not vendored; not needed. (Phase 1 replaces `flask_socketio` with a hand-rolled WebSocket and switches the client to the browser-native `WebSocket` API.) + - Update [`lib/ui/index.html`](lib/ui/index.html) and the install script's xterm block to reference the vendored paths. + - Update `.gitignore` to *un*-ignore `lib/ui/xterm/` if needed, or move to `vendor/` and remove that gitignore line. + - Remove the `download_latest` block and the `INSTALL_XTERM=ask` interactive prompt from [`install.sh`](install.sh). +2. **Build `scripts/check`.** A single bash entry point with subcommands: + + ```text + scripts/check # run all stages + scripts/check syntax # bash -n, python -m py_compile + scripts/check lint # shellcheck, ruff/pyflakes + scripts/check vendor # verify lib/ui/vendor/SHA384SUMS + scripts/check tests # bats + python tests (when they exist) + scripts/check secrets # grep for accidentally-committed secrets + ``` + + Stage modules live in `scripts/check.d/*.sh`. Each module: + - Exits 0 if its checks pass. + - Exits non-zero (≥1) on failure. + - Skips with a `[SKIP] tool 'foo' not installed` line if an optional tool is absent (and *does not* fail the overall run). + - Required tools: `bash`, `python3`, `shasum`/`sha384sum`. Optional: `shellcheck`, `ruff`, `bats`. +3. **Pre-push hook installer.** `scripts/install-hooks` writes `.git/hooks/pre-push` as a one-liner: `exec "$(git rev-parse --show-toplevel)/scripts/check"`. README mentions how to run it. The hook is opt-in (we never auto-install behind the user's back). +4. **Thin CI wrapper.** `.github/workflows/check.yml` does exactly one thing: install `shellcheck`/`ruff`/`bats` and run `scripts/check`. No duplicated lint logic in YAML. Anyone outside GitHub can run the same script locally and get the same result. +5. **Fix the broken bash scripts.** [`ssh-rotate`](bin/ssh-rotate) and [`ssh-template-rotate`](bin/ssh-template-rotate) reference undefined helpers. For Phase 0, choose one of: + - **Remove them** with a stub script that prints "rebuilt in Phase 5" — preferred, keeps the codebase honest about what works. + - Inline minimal `validate_hostname` / `validate_template_name` / `log` stubs in `_ssh-unique-key.inc.sh` so they at least syntax-check, while making it loud that they're untested. + + Document the decision in the commit. (W9 will be properly closed in Phase 5.) +6. **De-duplicate [`ssh-new`](bin/ssh-new) arg parsing.** Lines 13-14, 26-27, and 39-40 contain near-duplicate `--comment` blocks. One copy, one usage line. Add a test (under `tests/bash/`) that runs `ssh-new --help` and grep-asserts a single `--comment` mention. +7. **Bin `bin/__pycache__`.** Ensure it's gitignored; remove from the working tree. +8. **README updates.** + - Replace the install xterm prompt section. + - Add a "Development" section pointing at `scripts/check` and `scripts/install-hooks`. +9. **Pin provisioning-time connections to the stored scan.** *(Landed 2026-07-16, ahead of the phase.)* `ssh-new` and `ssh-user-rotate` previously connected with `StrictHostKeyChecking=accept-new`, which accepted whatever key was presented (not necessarily the scanned one) *and* wrote it into the default `~/.ssh/known_hosts` — silently trusting a provision-time MITM on the immediate follow-on connection. All connections these scripts make now use `-o UserKnownHostsFile= -o StrictHostKeyChecking=yes` (`ssh-user-rotate` rewrites the hostname column to the connect alias in a temp pin file). The remaining `accept-new` in `lib/ssh-ui.py:/api/deploy` is covered by the Phase 1 rewrite + Phase 4 `bin/ssh-deploy` (see Phase 4 task 4). +10. **UUID derivation over ` `.** *(Landed 2026-07-16, ahead of the phase.)* Previously the code hashed only the base64 column and ARCHITECTURE.md claimed the full ` ` line — both wrong. Now `sha256(" ")` of the best key; hostname column stripped. Pre-1.0: existing `host-uuid/` dirs no longer match the new derivation — re-provision (no migration shipped). + +### Exit gate (Phase 0) + +- `scripts/check` runs locally on a fresh clone and exits 0. +- `scripts/check vendor` succeeds against `lib/ui/vendor/SHA384SUMS`. +- `install.sh` makes zero network requests (verified by running it with `curl`/`wget` shadowed to fail). +- `git grep "validate_hostname\|CONFIG_DIR\|validate_template\|get_hosts_using_template" bin/` returns nothing OR returns only inside `_ssh-unique-key.inc.sh` (the stubs). +- `bash -n` clean on every `bin/*` script. +- `git grep "accept-new" bin/ install.sh` returns nothing (provisioning connections are pinned; task 9). + +--- + +## Phase 1 — Drop Flask: stdlib rewrite (~2 weeks) + +**Goal:** the web UI runs on the system `python3` with zero pip dependencies. `lib/requirements-ui.txt` is deleted. `lib/venv/` is no longer created. + +The existing [`lib/ssh-ui.py`](lib/ssh-ui.py) is replaced wholesale by a stdlib-only implementation. Because the old code is monolithic anyway, this phase doubles as the Python module split — we build the new layout from the start rather than splitting an inherited monolith later. + +### Target layout (Phase 1) + +```text +lib/ +├── ssh-ui.py ← entry point: starts the server +└── ssh_ui/ + ├── __init__.py + ├── server.py ← ThreadingHTTPServer + dispatch (~150 LOC) + ├── auth.py ← token gen, cookie set, @require_auth (~80 LOC) + ├── ws.py ← RFC 6455 handshake + frame codec (~200 LOC) + ├── pty_manager.py ← spawn/reap, reader loop, grace timer (~150 LOC) + ├── handlers.py ← one function per route (~250 LOC) + ├── identity_scan.py ← FS walk → dict; the get_identities/get_templates_list logic (~120 LOC) + ├── subprocess_runner.py ← single wrapper around subprocess.run for bin/ssh-* (~60 LOC) + └── validators.py ← input validators (Phase 2 ties them to bash twins) +``` + +No file over ~250 lines. Each module has a `tests/python/test_.py` companion (added in Phase 2 — Phase 1 ships the modules without exhaustive tests but with at least one smoke test per file). + +### Tasks (Phase 1) + +1. **Stdlib HTTP server.** `http.server.ThreadingHTTPServer` + a small dispatch table mapping `(method, path)` → handler function. Path params (`/api/templates/`) handled by route regex compiled once at startup. +2. **Static SPA.** [`lib/ui/index.html`](lib/ui/index.html) becomes a static file served directly. Jinja2 references removed: + - `{% if xterm_enabled %}` → JS feature-detects (or a `/api/capabilities` endpoint). + - `{% for identity in identities %}` table → `app.js` renders into the table after `fetch('/api/identities')`. + - `result_output` / `result_status` modal → replaced by client-side toast tied to API call results. +3. **Hand-rolled WebSocket.** `lib/ssh_ui/ws.py`: + - Validates the `Upgrade: websocket` handshake, computes the `Sec-WebSocket-Accept` SHA-1 response (stdlib `hashlib`), sends the 101. + - Implements RFC 6455 frame parsing and writing for text frames (opcode 0x1) and pings/pongs (0x9/0xA). Binary not used. + - Enforces a max frame size (e.g. 64 KiB) to bound memory. + - One thread per connection; reads via `select.select(...)` so both PTY-fd and websocket-fd can drive the loop. + - Includes a small fuzz suite in `tests/python/test_ws.py` against malformed frames. +4. **Plain-WebSocket client.** [`lib/ui/app.js`](lib/ui/app.js) drops `io.connect(...)` and uses `new WebSocket(...)`. Message envelopes match [ARCHITECTURE.md §8 "WebSocket protocol"](ARCHITECTURE.md#websocket-protocol-hand-rolled). +5. **Auth.** `auth.py` provides: + - `verify_request(handler) -> bool` (checks cookie via `hmac.compare_digest`). + - `@require_auth` decorator on every handler. + - Handshake path for the one-time URL token (same model as today). + - WebSocket handshake calls `verify_request` *before* upgrading. +6. **Subprocess wrapper.** `subprocess_runner.py` exposes `run_cli(name, *args, json=True)` that: + - Builds argv from `bin/` + args (no shell, no string concat). + - Captures stdout/stderr. + - Parses stdout as JSON if `json=True`. + - Returns a `result` dict the handler maps to an HTTP response. + - Phase 4 adds the `--json` flag to the CLI scripts; until then `json=False`. +7. **Delete the unused.** Remove `flask`, `flask_socketio`, `gevent`, `eventlet`, `webbrowser` (?), `requirements-ui.txt`, the `venv/` bootstrap from [`bin/ssh-ui`](bin/ssh-ui). The new `ssh-ui` is one line: `exec python3 "$LIB_DIR/ssh-ui.py" "$@"`. +8. **CSP header.** Every response includes `Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:; style-src 'self'; connect-src 'self' ws://*:* ws://localhost:* ws://127.0.0.1:*`. No `'unsafe-inline'`; inline styles in `index.html` are moved to `style.css` as part of this phase. + +### Exit gate (Phase 1) + +- `python3 lib/ssh-ui.py` runs on macOS and Linux with system Python (≥ 3.10), no `pip install` of anything. +- `wc -l lib/ssh_ui/*.py` — no single file over ~250 lines. +- All current UI features work: list identities, create identity (xterm flow), connect (xterm flow), rotate user key, delete user, list/create/delete templates, view history. Manual smoke-test checklist documented in `tests/manual/phase-1.md`. +- `grep -r "flask\|jinja\|gevent\|eventlet\|socketio" lib/ ssh-ui.py install.sh` returns nothing. + +--- + +## Phase 2 — Security hardening of the new surface (~1-2 weeks) + +**Goal:** every input crossing a trust boundary goes through *one* validator per language. Bash and Python validators are tested against the same golden corpus. + +### Tasks (Phase 2) + +1. **`bin/_validators.inc.sh` + `lib/ssh_ui/validators.py`.** Paired modules exposing the same function set with identical semantics: + - `validate_user_host`, `validate_hostname`, `validate_user`, `validate_template_name`, `validate_key_comment`, `validate_comment_text`, `validate_uuid`, `validate_key_type`. + - Each function: takes a string, returns the string unchanged on success, `err`s (bash) or raises (Python) on failure. No silent transformation. +2. **Golden corpus.** `tests/validators/inputs.tsv` — `\t\t` triples covering: nominal input, leading/trailing whitespace, embedded newlines, NUL bytes, shell metacharacters, very long strings, Unicode tricky cases (RTL override, zero-width joiner). Tests in both bash (`bats`) and Python (`unittest`) load the corpus and verify both languages agree. +3. **Route every endpoint through the validators.** Replace the ad-hoc `re.match` / `"".join(c for c in name if ...)` calls in `handlers.py`. Replace the per-script regex sanitisers in the bash CLI with `validate_*` calls. +4. **Structured config emitter for `--comment`** (W2 closed). Write the comment text to `host-uuid/UUID/notice.txt`; the config line becomes `Match host %h exec "cat ~/.ssh/unique_keys/host-uuid/UUID/notice.txt"`. No interpolation of user text. +5. **`hmac.compare_digest` everywhere a token is compared.** Currently `token == AUTH_TOKEN`; replace with constant-time compare. (Minor, but cheap.) +6. **`/deploy` password handling.** Move temp `SSH_ASKPASS` script creation into `$XDG_RUNTIME_DIR` when present (memory-backed on Linux), else into `~/.ssh/unique_keys/tmp` with 0700. `os.O_CREAT | os.O_EXCL | O_NOFOLLOW` to defeat races. Delete in a `finally` block, not after a bare success path. +7. **CSP, X-Content-Type-Options, X-Frame-Options.** Add `nosniff` and `DENY` headers to every response. +8. **WebSocket frame-size bound, per-IP rate limiting on `/ws`, message-rate limit per connection.** Defends against the trivial DoS where a malicious page on the user's machine repeatedly opens WSs to localhost. + +### Exit gate (Phase 2) + +- One validator per concept; `git grep "re.match\|^.join.*isalnum" lib/ssh_ui/` returns nothing. +- Golden corpus passes in both bash and Python. +- Three injection vectors (a `--comment` payload that escapes, a hostname containing `$(...)`, a template name with `../`) are encoded as regression tests; they fail without the validators and pass with them. + +--- + +## Phase 3 — Formal schema + migrations (~1 week) + +**Goal:** the on-disk layout is a versioned, validated artifact. `ssh-doctor` walks the tree and reports anomalies. + +### Tasks (Phase 3) + +1. **Introduce `$BASE_DIR/SCHEMA`.** Contains the integer `2`. `ensure_base_dirs` writes it on init. (W7 closed.) +2. **Migrate `history.log` → `history.jsonl`.** Phase 1 may temporarily keep both; this phase removes the legacy file. `ssh-history` reads JSONL; one-shot in-place rewrite of any existing v1 log. (W6 closed.) +3. **`bin/ssh-doctor`.** Walks `$BASE_DIR`, checks: perms (0700/0600/0644 as schemed), every symlink target is inside `$BASE_DIR`, every `known_host_keys` parses, every `identity` is either real-file or valid-symlink, no orphan `by-key`/`by-host` symlinks, SCHEMA matches expected. Reports anomalies; exits non-zero on any failure. +4. **CI fixtures.** A `tests/fixtures/` tree representing: clean install, after-provision, after-rotation, after-template-create. `scripts/check fixtures` runs `ssh-doctor` against each. +5. **Resolve W1 (UUID under partial scans).** Three options on the table: + - **(a)** Refuse to create new identity if the scan returns fewer than N (e.g. 2) keys. + - **(b)** Switch UUID derivation to `sha256(sorted(all-keys))` (over ` ` lines, hostname column stripped — consistent with the Phase 0 task 10 derivation fix). Partial scans cannot collide with a previous full scan; the diff check in `ssh-new` already covers the rest. + - **(c)** Keep current; document. + + Recommendation: **(b).** Cleanest, no thresholds to tune, no extra UX. Costs: a layout incompatibility — fine, we're pre-1.0. Decide and implement. + +### Exit gate (Phase 3) + +- `ssh-doctor` runs clean on every fixture tree in CI. +- Existing-install detection: running `ssh-doctor` against a v1 layout prints a clear "schema v1 detected; run `ssh-doctor --migrate` to upgrade or `rm -rf ~/.ssh/unique_keys` and re-provision" message. +- `cat $BASE_DIR/SCHEMA` returns `2` on a freshly installed tree. + +--- + +## Phase 4 — Web UI ↔ CLI seam (~1-2 weeks) + +**Goal:** every state mutation goes through a `bin/ssh-*` script with `--json` output. Python is pure orchestration. + +### Tasks (Phase 4) + +1. **Add `--json` mode to every state-mutating script.** Output an object per significant event to stdout; final line is the result object. Human-readable progress goes to stderr. +2. **Move standard-template key generation from `handlers.py` to `bin/ssh-template generate-keys --json`.** Today the Python `POST /api/templates` for `standard` type invokes `ssh-keygen` directly, bypassing `log_event`. After this, every state mutation passes through bash and lands in `history.jsonl`. +3. **Move `/api/user/delete`'s `shutil.rmtree`** to `bin/ssh-del --force --json`. Same rationale. +4. **Move `/api/deploy`'s direct `ssh-copy-id`** into a new `bin/ssh-deploy` that handles the `SSH_ASKPASS` setup and the post-run cleanup. Python becomes a thin caller. `ssh-deploy` must pin via `UserKnownHostsFile` + `StrictHostKeyChecking=yes` like `ssh-new` (Phase 0 task 9) — this retires the last `accept-new`, currently in `lib/ssh-ui.py`. +5. **Strict WebSocket message validation at handler entry.** Discriminated union on `op`; reject unknown ops; validate every field against the validators from Phase 2. + +### Exit gate (Phase 4) + +- `git grep "subprocess.run\|subprocess.Popen" lib/ssh_ui/` shows only calls into `bin/ssh-*`. +- `git grep "ssh-keygen\|ssh-keyscan\|ssh-copy-id" lib/ssh_ui/` returns nothing. +- Manually running `ssh-history` after a sequence of UI operations shows entries for every mutation. + +--- + +## Phase 5 — Bash cleanup and rotation rebuild (~1 week) + +**Goal:** every bash script is consistent, lint-clean, and tested. + +### Tasks (Phase 5) + +1. **Rebuild [`ssh-rotate`](bin/ssh-rotate)** from scratch against the current `_ssh-unique-key.inc.sh`. Use the symlink-updating logic from [`ssh-new`](bin/ssh-new) as the template. bats tests for: simple rotation, rotation when `by-key` symlinks include slashes, rotation refusing when keys haven't changed. (W9 closed.) +2. **Rebuild [`ssh-template-rotate`](bin/ssh-template-rotate)** using a new `get_hosts_using_template` helper that walks `host-uuid/*//identity` symlinks and filters by template name. +3. **Audit every script for `set -e` correctness.** Pipelines, `read` inside `while`, `grep | true`. Verify each script's intended behaviour on partial failures. +4. **Replace `sed -i.bak` patterns with structured config helpers.** `config-set ` that reads → parses → writes atomically. Defeats the regex-escape minefield in [`ssh-conf`](bin/ssh-conf). +5. **Convert `cat file | while read l` to `while read l < file`.** Avoids subshell-variable-scoping pitfall. +6. **Make every script honour `--json`.** From Phase 4 we need it on mutating scripts; in this phase finish the read-only ones (`ssh-history`, `ssh-template list`) too, for symmetry. + +### Exit gate (Phase 5) + +- `shellcheck` clean on every `bin/*` and `install.sh` with no excluded rules. +- `bats` suite (`tests/bash/`) covers: provision, re-provision (idempotence), delete, rotate, template rotate, backup+restore round trip, `--json` output well-formed. + +--- + +## Phase 6 — Threat-model regression tests (~1 week) + +**Goal:** every defended threat in [ARCHITECTURE.md §2](ARCHITECTURE.md#2-threat-model) has an automated test that *would* fail if the defence regressed. + +### Tasks (Phase 6) + +For each defended threat (A–F): + +1. **A: per-host uniqueness.** Provision two fixture hosts. Assert that the two `identity.pub` files have distinct contents. +2. **B: no public-key correlation.** Same as A but also assert that no file under `$BASE_DIR/host-uuid//` contains text equal to any file under `host-uuid//`. +3. **C: MITM on re-engagement.** Provision a fake host (write a `known_host_keys` by hand). Run `ssh-new` against a script that emits *different* keys. Assert non-zero exit and that the original `known_host_keys` is untouched. +4. **D: filesystem permissions.** Walk `$BASE_DIR` after every operation in the bats suite; assert every dir is 0700, every `identity` is 0600, every `*.pub` is 0644. +5. **E: web UI auth.** Start the server, hit `/api/templates` without a cookie — expect 401. With a wrong token — 401. Right token — 200. Open a WebSocket without the cookie — fail handshake. +6. **F: supply chain.** `scripts/check vendor` is already part of the check script; add a test that mutates one byte of a vendored file and asserts `scripts/check vendor` exits non-zero. +7. **W1 regression.** Construct a `known_host_keys` with ed25519+ecdsa+rsa. Simulate a re-scan returning only `rsa`. Assert behaviour matches the Phase 3 decision (e.g. recompute-from-sorted-all-keys still matches → identity preserved). + +### Exit gate (Phase 6) + +- All threat tests pass in CI on every commit. +- A failure of any of them blocks merge. + +--- + +## Sequencing summary + +| Phase | Focus | Approx duration | Blocks | +| ----- | ----- | --------------- | ------ | +| 0 | Stop the bleeding, vendor JS, local check | 1 wk | everything | +| 1 | Drop Flask: stdlib rewrite + module split | 2 wk | Phase 2+ | +| 2 | Security hardening + paired validators | 1-2 wk | Phase 4 | +| 3 | Formal schema + ssh-doctor | 1 wk | Phase 4, 5 | +| 4 | UI/CLI seam (`--json` everywhere) | 1-2 wk | Phase 5 (loosely) | +| 5 | Bash cleanup + rotation rebuild | 1 wk | Phase 6 | +| 6 | Threat-model regression tests | 1 wk | — | + +Total: ~8-9 weeks of focused work. Phase 3 and Phase 4 can overlap partially; Phase 5 can begin once Phase 4 has landed `--json` on the scripts it touches. + +--- + +## What this plan does *not* address + +Deliberate scope cuts; revisit post-1.0: + +- **Multi-machine sync.** Sharing `unique_keys/` across machines. Workaround today: `ssh-backup` to encrypted store on Syncthing / iCloud / etc. +- **TOFU bypass on first scan.** (X2.) Pinning the very first scan to an out-of-band-verified key would require user workflow change. +- **Same-user code reading the store** (X1). Out of scope as a deliberate platform gate; if it matters, the answers are platform isolation or hardware-bound keys via `ssh-keygen-sk` templates. See ARCHITECTURE.md §2 for the rationale. +- **Agent forwarding policies.** Per-host config can do this via `ssh-conf` + `ForwardAgent`. +- **Non-OpenSSH clients.** PuTTY, mosh, etc. +- **Windows support.** The PTY path uses `pty.fork()` which is Unix-only. +- **Remote (non-localhost) web UI.** Would require TLS, real auth, CSRF tokens. diff --git a/bin/_ssh-unique-key.inc.sh b/bin/_ssh-unique-key.inc.sh index 5af396b..5b3b825 100755 --- a/bin/_ssh-unique-key.inc.sh +++ b/bin/_ssh-unique-key.inc.sh @@ -92,7 +92,9 @@ get_full_host_scan() { get_host_uuid_from_scan_data() { local DATA="$1" local BEST_KEY - BEST_KEY=$(echo "$DATA" | awk '/ssh-ed25519/{k=$3} /ecdsa/{if(!k)k=$3} /ssh-rsa/{if(!k)k=$3} END{print k}') + # Hash " " — hostname column excluded so the identity is + # derived purely from key material (stable across hostname/IP changes). + BEST_KEY=$(echo "$DATA" | awk '/ssh-ed25519/{k=$2" "$3} /ecdsa/{if(!k)k=$2" "$3} /ssh-rsa/{if(!k)k=$2" "$3} END{print k}') if [ -z "$BEST_KEY" ]; then err "No usable key found in scan data."; fi local HASH diff --git a/bin/ssh-new b/bin/ssh-new index 1a4008f..38f3102 100755 --- a/bin/ssh-new +++ b/bin/ssh-new @@ -11,7 +11,6 @@ usage() { echo " --template Use a key template" echo " --legacy Enable outdated algorithms (rsa/dss/cbc)" echo " --comment \"text\" Display this message BEFORE connecting" - echo " --comment \"text\" Display this message BEFORE connecting" echo " --key-comment \"text\" Set the public key comment (description)" echo " -V, --verbose Enable verbose output" echo " -h, --help Show this help message" @@ -23,8 +22,6 @@ TEMPLATE_NAME="" LEGACY_MODE=0 NO_CONNECT=0 COMMENT_TEXT="" -NO_CONNECT=0 -COMMENT_TEXT="" KEY_COMMENT="" POSITIONAL_ARGS=() @@ -37,7 +34,6 @@ while [[ $# -gt 0 ]]; do --legacy) LEGACY_MODE=1; shift ;; --no-connect) NO_CONNECT=1; shift ;; --comment) COMMENT_TEXT="$2"; shift 2 ;; - --comment) COMMENT_TEXT="$2"; shift 2 ;; --key-comment) KEY_COMMENT="$2"; shift 2 ;; -*|--*) err "Unknown option $1"; usage ;; *) POSITIONAL_ARGS+=("$1"); shift ;; @@ -90,6 +86,9 @@ if [ -d "$CANONICAL" ]; then err "SECURITY WARNING: Host keys have changed! Possible MITM." else info "Host verification passed." + # Keys verified identical; refresh the (informational) hostname column + # so known_host_keys can pin connections to the current target name. + echo "$SCAN_SORTED" > "$CANONICAL/known_host_keys" fi else info "Creating new identity..." @@ -244,7 +243,7 @@ chmod 600 "$TRUSTED_CONF" if [ "$NO_CONNECT" -eq 0 ]; then if [ -f "$KEY_FILE.pub" ]; then info "Copying key..." - ssh-copy-id -i "$KEY_FILE.pub" -o StrictHostKeyChecking=accept-new "$USER_HOST_ARG" + ssh-copy-id -i "$KEY_FILE.pub" -o UserKnownHostsFile="$CANONICAL/known_host_keys" -o StrictHostKeyChecking=yes "$USER_HOST_ARG" else if [ -f "$KEY_FILE-cert.pub" ]; then warn "Certificate found. Assuming server trusts CA. Skipping copy." @@ -288,4 +287,4 @@ if [ "$NO_CONNECT" -eq 1 ]; then fi info "Connecting..." -exec ssh "$USER_HOST_ARG" +exec ssh -o UserKnownHostsFile="$CANONICAL/known_host_keys" -o StrictHostKeyChecking=yes "$USER_HOST_ARG" diff --git a/bin/ssh-rotate b/bin/ssh-rotate index cc464f9..406bbc3 100755 --- a/bin/ssh-rotate +++ b/bin/ssh-rotate @@ -1,86 +1,12 @@ #!/bin/bash +# Stub. The original ssh-rotate called helper functions that were never +# defined anywhere and was unrunnable as shipped (ARCHITECTURE.md W9 lists +# them). Rather than ship broken code, it is disabled until it is rebuilt +# from scratch in REFACTOR.md Phase 5. +# +# To rotate a single user's key on a host, use ssh-user-rotate. set -e - -SCRIPT_DIR=$(dirname "$0") -# shellcheck source=./_ssh-unique-key.inc.sh -source "${SCRIPT_DIR}/_ssh-unique-key.inc.sh" - -[ "$#" -ne 1 ] && err "Usage: ssh-rotate " -HOST_NAME="$1" - -validate_hostname "$HOST_NAME" - -CONFIG_SNIPPET_FILE="$CONFIG_DIR/$HOST_NAME" -[ ! -f "$CONFIG_SNIPPET_FILE" ] && err "No config found for '$HOST_NAME'." - -# Get current UUID and paths -OLD_PATH=$(get_uuid_path_from_host "$HOST_NAME") -[ -z "$OLD_PATH" ] && exit 1 -OLD_UUID=$(basename "$OLD_PATH") - -# Scan for new host key -echo "Scanning host $HOST_NAME for new keys..." -RAW_SCAN_DATA=$(get_full_host_scan "$HOST_NAME") -NEW_UUID=$(get_host_uuid_from_scan_data "$RAW_SCAN_DATA") - -if [ "$OLD_UUID" == "$NEW_UUID" ]; then - err "Host key has not changed. Nothing to do." -fi - -# Backup existing configuration -echo "Creating backup before rotation..." -"$SCRIPT_DIR/ssh-backup" - -# Create new canonical directory -NEW_PATH="$UUID_DIR/$NEW_UUID" -mkdir -p -m 700 "$NEW_PATH" - -echo "Rotating from $OLD_UUID to $NEW_UUID..." - -# Store new host keys -echo "$RAW_SCAN_DATA" > "$NEW_PATH/known_host_keys" -chmod 644 "$NEW_PATH/known_host_keys" - -# Migrate user keys and configs -if [ -d "$OLD_PATH" ]; then - # Copy all user directories - for user_dir in "$OLD_PATH"/*; do - if [ -d "$user_dir" ]; then - user=$(basename "$user_dir") - cp -a "$user_dir" "$NEW_PATH/" - echo "Migrated keys for user: $user" - fi - done - - # Copy host-specific config if it exists - if [ -f "$OLD_PATH/config" ]; then - cp "$OLD_PATH/config" "$NEW_PATH/" - chmod 600 "$NEW_PATH/config" - fi -fi - -# Update symlink in by-key -if [ -L "$KEY_DIR/$OLD_UUID" ]; then - rm "$KEY_DIR/$OLD_UUID" -fi -ln -s "../host-uuid/$NEW_UUID" "$KEY_DIR/$NEW_UUID" - -# Update config snippet -sed -i.bak "s/UUID: $OLD_UUID/UUID: $NEW_UUID/" "$CONFIG_SNIPPET_FILE" -rm "${CONFIG_SNIPPET_FILE}.bak" - -echo "Host key rotation completed successfully" -echo "Old UUID: $OLD_UUID" -echo "New UUID: $NEW_UUID" -echo "New identity path: $NEW_PATH" - -# Ask about cleaning up old identity -read -p "Delete old host identity? (y/N) " confirm -if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then - rm -rf "$OLD_PATH" - echo "Old identity removed." -else - echo "Old identity preserved at: $OLD_PATH" -fi - -log "INFO" "Rotated host key for $HOST_NAME from $OLD_UUID to $NEW_UUID" \ No newline at end of file +echo "ssh-rotate is not available: the shipped implementation was broken and" >&2 +echo "has been removed. It will be rebuilt in refactor Phase 5 (see REFACTOR.md)." >&2 +echo "To rotate one user's key on a host, use: ssh-user-rotate " >&2 +exit 1 diff --git a/bin/ssh-template-rotate b/bin/ssh-template-rotate index cefa124..9ddfdbf 100755 --- a/bin/ssh-template-rotate +++ b/bin/ssh-template-rotate @@ -1,122 +1,10 @@ #!/bin/bash +# Stub. The original ssh-template-rotate called helper functions that were +# never defined anywhere and was unrunnable as shipped (ARCHITECTURE.md W9 +# lists them). Rather than ship broken code, it is disabled until it is +# rebuilt from scratch in REFACTOR.md Phase 5. set -e - -SCRIPT_DIR=$(dirname "$0") -# shellcheck source=./_ssh-unique-key.inc.sh -source "${SCRIPT_DIR}/_ssh-unique-key.inc.sh" - -# --- Argument Parsing --- -KEY_TYPE="" -TEMPLATE_NAME="" -FORCE=0 - -usage() { - echo "Usage: ssh-template-rotate [-f|--force] -t " - echo "Options:" - echo " -t, --type Key type to rotate (ed25519, ecdsa, rsa)" - echo " -f, --force Skip confirmation prompts" - echo "Example: ssh-template-rotate -t ed25519 work" - exit 1 -} - -while [[ $# -gt 0 ]]; do - case $1 in - -t|--type) - KEY_TYPE="$2" - shift 2 - ;; - -f|--force) - FORCE=1 - shift - ;; - -h|--help) - usage - ;; - *) - if [ -z "$TEMPLATE_NAME" ]; then - TEMPLATE_NAME="$1" - shift - else - usage - fi - ;; - esac -done - -# --- Validation --- -[ -z "$TEMPLATE_NAME" ] && err "No template name specified" -[ -z "$KEY_TYPE" ] && err "No key type specified" - -validate_template "$TEMPLATE_NAME" -validate_key_type "$KEY_TYPE" - -TEMPLATE_PATH="$TEMPLATE_DIR/$TEMPLATE_NAME" -OLD_KEY="$TEMPLATE_PATH/id_$KEY_TYPE" -OLD_KEY_PUB="$OLD_KEY.pub" - -# Check if key exists -[ ! -f "$OLD_KEY" ] && err "No $KEY_TYPE key found in template $TEMPLATE_NAME" - -# Get affected hosts -AFFECTED_HOSTS=($(get_hosts_using_template "$TEMPLATE_NAME")) -if [ ${#AFFECTED_HOSTS[@]} -eq 0 ]; then - err "No hosts are using template $TEMPLATE_NAME" -fi - -# Show affected hosts -echo "The following hosts use template '$TEMPLATE_NAME':" -printf '%s\n' "${AFFECTED_HOSTS[@]}" - -if [ $FORCE -eq 0 ]; then - read -p "Rotate $KEY_TYPE key for these hosts? (y/N) " confirm - [[ "$confirm" != [yY] ]] && err "Operation cancelled" -fi - -# Create backup first -echo "Creating backup..." -"$SCRIPT_DIR/ssh-backup" - -# Generate new key -echo "Generating new $KEY_TYPE key for template..." -NEW_KEY_TEMP=$(mktemp) -NEW_KEY_PUB_TEMP=$(mktemp) -trap 'rm -f "$NEW_KEY_TEMP" "$NEW_KEY_PUB_TEMP"' EXIT - -ssh-keygen -t "$KEY_TYPE" -f "$NEW_KEY_TEMP" -N "" -C "template:$TEMPLATE_NAME ($(date +%Y-%m-%d))" - -# Process each host -for host in "${AFFECTED_HOSTS[@]}"; do - echo "Processing $host..." - - # Get host path - HOST_PATH=$(get_uuid_path_from_host "$host") - [ -z "$HOST_PATH" ] && continue - - # Find all user directories using this template - while IFS= read -r user_dir; do - if [ -L "$user_dir/id_$KEY_TYPE" ] && readlink "$user_dir/id_$KEY_TYPE" | grep -q "$TEMPLATE_NAME"; then - echo "Updating keys for user $(basename "$user_dir") on $host..." - - # Remove old symlinks - rm -f "$user_dir/id_$KEY_TYPE" "$user_dir/id_$KEY_TYPE.pub" - - # Create new symlinks - (cd "$user_dir" && \ - ln -s "../../templates/$TEMPLATE_NAME/id_$KEY_TYPE" "id_$KEY_TYPE" && \ - ln -s "../../templates/$TEMPLATE_NAME/id_$KEY_TYPE.pub" "id_$KEY_TYPE.pub") - fi - done < <(find "$HOST_PATH" -type d -mindepth 1 -maxdepth 1) -done - -# Replace the old key with the new one -mv "$NEW_KEY_TEMP" "$OLD_KEY" -mv "$NEW_KEY_PUB_TEMP" "$OLD_KEY_PUB" -chmod 600 "$OLD_KEY" -chmod 644 "$OLD_KEY_PUB" - -echo "Template key rotation completed successfully" -echo "New public key:" -cat "$OLD_KEY_PUB" -echo -echo "You may need to update this key on your remote hosts" -log "INFO" "Rotated $KEY_TYPE key for template $TEMPLATE_NAME affecting ${#AFFECTED_HOSTS[@]} hosts" \ No newline at end of file +echo "ssh-template-rotate is not available: the shipped implementation was" >&2 +echo "broken and has been removed. It will be rebuilt in refactor Phase 5" >&2 +echo "(see REFACTOR.md)." >&2 +exit 1 diff --git a/bin/ssh-user-rotate b/bin/ssh-user-rotate index f9afa8f..da5377a 100755 --- a/bin/ssh-user-rotate +++ b/bin/ssh-user-rotate @@ -53,11 +53,21 @@ fi info "Rotating key for user '$USER_NAME' on host '$TARGET_HOST' (UUID: $UUID)..." +# --- Pin all connections to the stored host keys --- +# The hostname column in known_host_keys holds the last-provisioned alias, +# which may differ from the by-host alias we resolved; rewrite it so the +# pinned lookup matches the name we connect to. +KNOWN_KEYS="$UUID_PATH/known_host_keys" +if [ ! -f "$KNOWN_KEYS" ]; then err "Stored host keys not found: $KNOWN_KEYS"; fi +PIN_FILE=$(mktemp) +awk -v h="$TARGET_HOST" '{$1=h; print}' "$KNOWN_KEYS" > "$PIN_FILE" +PIN_OPTS=(-o "UserKnownHostsFile=$PIN_FILE" -o "StrictHostKeyChecking=yes") + # --- 1. Generate New Key --- info "Generating new key..." NEW_KEY_TEMP=$(mktemp) NEW_KEY_PUB_TEMP="${NEW_KEY_TEMP}.pub" -trap 'rm -f "$NEW_KEY_TEMP" "$NEW_KEY_PUB_TEMP"' EXIT +trap 'rm -f "$NEW_KEY_TEMP" "$NEW_KEY_PUB_TEMP" "$PIN_FILE"' EXIT # Detect type from old key or default to ed25519 # We'll just use ed25519 for rotation unless legacy flag passed? @@ -67,15 +77,14 @@ ssh-keygen -t ed25519 -f "$NEW_KEY_TEMP" -N "" -C "$USER_NAME@$TARGET_HOST-rotat # --- 2. Install New Key --- info "Installing new key to remote host..." # Use the OLD key to install the NEW key -# strict host key checking accept-new to be safe export SSH_ASKPASS_REQUIRE=force # Ensure no prompt -if ! ssh-copy-id -i "$NEW_KEY_PUB_TEMP" -o IdentityFile="$OLD_KEY" -o StrictHostKeyChecking=accept-new "$USER_NAME@$TARGET_HOST"; then +if ! ssh-copy-id -i "$NEW_KEY_PUB_TEMP" -o IdentityFile="$OLD_KEY" "${PIN_OPTS[@]}" "$USER_NAME@$TARGET_HOST"; then err "Failed to install new key on remote host." fi # --- 3. Test New Key --- info "Verifying new key connection..." -if ! ssh -i "$NEW_KEY_TEMP" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10 "$USER_NAME@$TARGET_HOST" "echo Connection Verified"; then +if ! ssh -i "$NEW_KEY_TEMP" -o IdentitiesOnly=yes "${PIN_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=10 "$USER_NAME@$TARGET_HOST" "echo Connection Verified"; then err "New key validation failed! Aborting rotation." fi @@ -91,7 +100,7 @@ OLD_PUB_CONTENT=$(cat "$OLD_KEY_PUB" | awk '{print $2}') SAFE_BLOB=$(echo "$OLD_PUB_CONTENT" | sed 's/\//\\\//g') CMD="sed -i.bak '/$SAFE_BLOB/d' ~/.ssh/authorized_keys && rm -f ~/.ssh/authorized_keys.bak" -if ! ssh -i "$NEW_KEY_TEMP" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new "$USER_NAME@$TARGET_HOST" "$CMD"; then +if ! ssh -i "$NEW_KEY_TEMP" -o IdentitiesOnly=yes "${PIN_OPTS[@]}" "$USER_NAME@$TARGET_HOST" "$CMD"; then warn "Failed to remove old key from remote host. It might remain authorize but local key will be replaced." fi diff --git a/install.sh b/install.sh index 280f27a..e6abdbb 100755 --- a/install.sh +++ b/install.sh @@ -7,15 +7,12 @@ KEYS_DIR="${HOME}/.ssh/unique_keys" INSTALL_LIB="${KEYS_DIR}/bin" # Defaults -INSTALL_XTERM="ask" MODE="install" usage() { echo "Usage: $0 [options]" echo "" echo "Options:" - echo " --install-xterm Force installation of xterm.js assets." - echo " --skip-xterm Skip installation of xterm.js assets." echo " uninstall Uninstall the tool (removes symlinks and bin dir)." echo " -h, --help Show this help message." echo "" @@ -28,14 +25,6 @@ while [[ $# -gt 0 ]]; do MODE="uninstall" shift ;; - --install-xterm) - INSTALL_XTERM="yes" - shift - ;; - --skip-xterm) - INSTALL_XTERM="no" - shift - ;; -h|--help) usage exit 0 @@ -119,60 +108,15 @@ do_install() { find "$UI_SRC_DIR" -maxdepth 1 -type f -exec cp -f {} "$UI_DEST_DIR/" \; fi - # Xterm Asset Logic - if [ "$INSTALL_XTERM" == "ask" ]; then - # Check if tty - if [ -t 0 ]; then - exec < /dev/tty - read -p "Install xterm.js features (web terminal)? [Y/n] " response - case "$response" in - [yY]|[yY][eE][sS]|"") INSTALL_XTERM="yes" ;; - *) INSTALL_XTERM="no" ;; - esac - else - # Default to yes if non-interactive but not explicitly skipped? - # Or default to no for safety? - # User said "default is interactive", implying manual run. - # If scripts run this, they should use flags. - # Let's default to yes if not specified in non-interactive for backward compat - INSTALL_XTERM="yes" - warn "Non-interactive mode detected. Defaulting to installing xterm.js." - fi - fi - - if [ "$INSTALL_XTERM" == "yes" ]; then - # Download xterm assets - # Using unpkg @latest and socket.io major version for updates - UI_LIB_DIR="$UI_DEST_DIR/xterm" - - msg "Setting up xterm assets in $UI_LIB_DIR..." - mkdir -p "$UI_LIB_DIR" - - # Download helper - download_latest() { - local url="$1" - local dest="$2" - - msg "Downloading $(basename "$dest")..." - if command -v curl &>/dev/null; then - curl -L -s -o "$dest" "$url" - elif command -v wget &>/dev/null; then - wget -q -O "$dest" "$url" - else - warn "Could not download $(basename "$dest"): No curl or wget found." - fi - } - - download_latest "https://unpkg.com/xterm@latest/lib/xterm.js" "$UI_LIB_DIR/xterm.js" - download_latest "https://unpkg.com/xterm@latest/css/xterm.css" "$UI_LIB_DIR/xterm.css" - download_latest "https://unpkg.com/xterm-addon-fit@latest/lib/xterm-addon-fit.js" "$UI_LIB_DIR/xterm-addon-fit.js" - # Using socket.io-client@4 from unpkg for dynamic 4.x updates - download_latest "https://unpkg.com/socket.io-client@4/dist/socket.io.js" "$UI_LIB_DIR/socket.io.js" - else - msg "Skipping xterm.js assets." + # Vendored web-UI assets (xterm.js). No network fetch: every byte the UI + # serves is committed to the repo and hash-pinned in lib/ui/vendor/SHA384SUMS. + VENDOR_SRC_DIR="$UI_SRC_DIR/vendor" + if [ -d "$VENDOR_SRC_DIR" ]; then + msg "Installing vendored UI assets..." + mkdir -p "$UI_DEST_DIR/vendor" + find "$VENDOR_SRC_DIR" -maxdepth 1 -type f -exec cp -f {} "$UI_DEST_DIR/vendor/" \; fi - msg "Linking scripts to PATH ($TARGET_DIR)..." mkdir -p "$TARGET_DIR" for f in "$INSTALL_LIB"/*; do diff --git a/lib/ssh-ui.py b/lib/ssh-ui.py index aeb8fc3..ff73c41 100755 --- a/lib/ssh-ui.py +++ b/lib/ssh-ui.py @@ -255,7 +255,12 @@ def index(): templates=templates, result_output=result_output, result_status=result_status, - xterm_enabled=SOCKETIO_AVAILABLE) + # Web terminal disabled until Phase 1: socket.io-client + # is no longer shipped (vendored assets only; see + # lib/ui/vendor/PROVENANCE). The Phase 1 stdlib rewrite + # re-enables it over a native WebSocket. Non-terminal + # flows fall back to plain form POSTs in app.js. + xterm_enabled=False) # --- Legacy Terminal Launch (External) --- def launch_terminal_script(script_content, base_name): diff --git a/lib/ui/app.js b/lib/ui/app.js index 324f1b7..c875460 100644 --- a/lib/ui/app.js +++ b/lib/ui/app.js @@ -1,42 +1,78 @@ -var identitiesMap = {}; -var termHandle = null; -var socket = null; -var fitAddon = null; +let identitiesMap = {}; +let termHandle = null; +let socket = null; +let fitAddon = null; + +/** + * Reads application bootstrap data from the JSON data island embedded in the + * HTML by the Jinja2 template (id="app-data", type="application/json"). + * + * Using a data island instead of inline `globalThis.*` assignments avoids IDE + * false-positive parse errors caused by Jinja2 double-brace syntax being + * interpreted as JavaScript object literals. + * + * Populates: + * - globalThis.identitiesList {Array} - list of identity objects from the server + * - globalThis.xtermEnabled {boolean} - whether the xterm terminal is available + */ +function initFromDataIsland() { + const el = document.getElementById('app-data'); + if (!el) return; + try { + const data = JSON.parse(el.textContent); + globalThis.identitiesList = data.identities || []; + globalThis.xtermEnabled = !!data.xtermEnabled; + } catch (e) { + console.error('Failed to parse app-data island:', e); + } +} -// HTML escaping to prevent XSS +/** + * Escapes HTML special characters to prevent XSS when inserting + * server-supplied strings into innerHTML. + * + * @param {*} str - Value to escape. Non-strings are coerced via String(). + * @returns {string} HTML-safe string. + */ function escapeHtml(str) { if (!str) return ''; - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + return String(str) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', function () { - if (window.identitiesList) { - window.identitiesList.forEach(function (ident) { + initFromDataIsland(); + if (globalThis.identitiesList) { + globalThis.identitiesList.forEach(function (ident) { identitiesMap[ident.uuid] = ident; }); } // Key Comment Toggle initialization - var templateSelect = document.getElementById('create_template_select'); + const templateSelect = document.getElementById('create_template_select'); if (templateSelect) { toggleKeyComment(); } // Check for Popout Mode - var params = new URLSearchParams(window.location.search); + const params = new URLSearchParams(globalThis.location.search); if (params.has('popout')) { document.body.classList.add('popout-mode'); // Hide Detach button - var btnDetach = document.getElementById('btn-detach'); + const btnDetach = document.getElementById('btn-detach'); if (btnDetach) btnDetach.style.display = 'none'; - var payloadStr = params.get('payload'); + const payloadStr = params.get('payload'); if (payloadStr) { try { - var payload = JSON.parse(payloadStr); - if (window.xtermEnabled) { + const payload = JSON.parse(payloadStr); + if (globalThis.xtermEnabled) { setTimeout(() => openTerminal(payload), 100); } else { document.body.innerText = "Error: Terminal assets not loaded."; @@ -49,6 +85,13 @@ document.addEventListener('DOMContentLoaded', function () { }); // --- Navigation --- + +/** + * Switches the visible view section and marks the corresponding nav item active. + * Also triggers lazy-loads for 'templates' and 'history' views. + * + * @param {string} viewName - One of 'dashboard', 'templates', 'history'. + */ function switchView(viewName) { document.querySelectorAll('.view-section').forEach(el => el.style.display = 'none'); document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active')); @@ -59,16 +102,24 @@ function switchView(viewName) { } // --- User Actions --- + +/** + * Sends a key rotation request for a given user on a given identity UUID. + * Prompts for confirmation before proceeding and reloads on success. + * + * @param {string} uuid - UUID of the host identity. + * @param {string} user - Remote username to rotate the key for. + */ function rotateUserKey(uuid, user) { if (!confirm("Rotate key for " + user + "?\n\nThis will:\n1. Generate a new key\n2. Install it on remote\n3. Remove the old key directly.")) return; - var formData = new FormData(); + const formData = new FormData(); formData.append('uuid', uuid); formData.append('user', user); fetch('/api/user/rotate', { method: 'POST', body: formData }) .then(response => { if (response.ok) { alert("Rotation successful!"); - window.location.reload(); + globalThis.location.reload(); } else { response.text().then(t => alert("Error: " + t)); } @@ -76,32 +127,48 @@ function rotateUserKey(uuid, user) { .catch(e => alert("Network Error: " + e)); } +/** + * Deletes a user and their local keys for a given identity UUID. + * Requires two confirmation prompts before proceeding. + * + * @param {string} uuid - UUID of the host identity. + * @param {string} user - Remote username to delete. + */ function deleteUser(uuid, user) { if (!confirm("WARNING: Delete user " + user + "?\n\nThis is irreversible. Local keys will be deleted.")) return; if (!confirm("Double Check: Really delete " + user + "?")) return; - var formData = new FormData(); + const formData = new FormData(); formData.append('uuid', uuid); formData.append('user', user); fetch('/api/user/delete', { method: 'POST', body: formData }) .then(response => { - if (response.ok) { window.location.reload(); } else { response.text().then(t => alert("Error: " + t)); } + if (response.ok) { + globalThis.location.reload(); + } else { + response.text().then(t => alert("Error: " + t)); + } }) .catch(e => alert("Network Error: " + e)); } // --- Templates --- + +/** + * Fetches the template list from /api/templates and renders it into + * the #templateList element as an HTML table. + */ function fetchTemplates() { - var el = document.getElementById('templateList'); + const el = document.getElementById('templateList'); el.innerHTML = 'Loading...'; fetch('/api/templates') .then(res => res.json()) .then(data => { if (data.length === 0) { el.innerText = "No templates found."; return; } - var html = ''; + let html = '
NameTypeKeysActions
'; data.forEach(t => { - var typeLabel = escapeHtml(t.type || 'standard'); + let typeLabel = escapeHtml(t.type || 'standard'); if (t.issuer) typeLabel += ' (' + escapeHtml(t.issuer) + ')'; - var actions = ``; + const actions = ``; html += ``; }); html += '
NameTypeKeysActions
${escapeHtml(t.name)}${typeLabel}${escapeHtml(t.keys.join(', '))}${actions}
'; @@ -109,15 +176,22 @@ function fetchTemplates() { }); } +/** + * Handles the template creation form submission. Posts to /api/templates + * and opens a terminal for sk/opk key types that require interactive generation. + * Calls event.preventDefault() to suppress native form submission. + * + * @param {Event} event - The form submit event. + */ function handleTemplateCreate(event) { event.preventDefault(); - var name = document.getElementById('tmpl_create_name').value.trim(); - var type = document.getElementById('tmpl_create_type').value; - if (!name) return false; + const name = document.getElementById('tmpl_create_name').value.trim(); + const type = document.getElementById('tmpl_create_type').value; + if (!name) return; document.getElementById('templateCreateModal').style.display = 'none'; - var formData = new FormData(); + const formData = new FormData(); formData.append('name', name); formData.append('type', type); @@ -135,22 +209,31 @@ function handleTemplateCreate(event) { .catch(e => alert("Error: " + e.message)); document.getElementById('tmpl_create_name').value = ''; - return false; } +/** + * Deletes a named template via DELETE /api/templates/:name after confirmation. + * + * @param {string} name - Name of the template to delete. + */ function deleteTemplate(name) { if (!confirm("Delete template " + name + "?")) return; - fetch('/api/templates/' + name, { method: 'DELETE' }).then(r => fetchTemplates()); + fetch('/api/templates/' + name, { method: 'DELETE' }).then(() => fetchTemplates()); } // --- History --- + +/** + * Fetches operation history from /api/history and renders it into + * the #historyList tbody as table rows. + */ function fetchHistory() { - var el = document.getElementById('historyList'); + const el = document.getElementById('historyList'); el.innerHTML = 'Loading...'; fetch('/api/history') .then(res => res.json()) .then(data => { - var html = ''; + let html = ''; if (data.length === 0) html = 'No history.'; else { data.forEach(e => { @@ -162,6 +245,14 @@ function fetchHistory() { } // --- Modals --- + +/** + * Populates and opens the deploy modal for a specific user/host combination. + * + * @param {string} uuid - UUID of the host identity. + * @param {string} user - Remote username to deploy the key for. + * @param {string} host - Hostname or IP to deploy to. + */ function openDeployModal(uuid, user, host) { document.getElementById('deploy_uuid').value = uuid; document.getElementById('deploy_user_input').value = user; @@ -170,46 +261,71 @@ function openDeployModal(uuid, user, host) { document.getElementById('deployModal').style.display = 'block'; } +/** + * Populates and opens the info modal for a given identity UUID, + * displaying the UUID and any known host keys. + * + * @param {string} uuid - UUID of the host identity to show details for. + */ function openInfoModal(uuid) { - var data = identitiesMap[uuid]; + const data = identitiesMap[uuid]; if (!data) return; document.getElementById('info_uuid').innerText = data.uuid; - var tbody = document.getElementById('info_host_keys_body'); + const tbody = document.getElementById('info_host_keys_body'); tbody.innerHTML = ''; if (data.host_keys && data.host_keys.length > 0) { data.host_keys.forEach(k => { tbody.innerHTML += `${escapeHtml(k.type)}${escapeHtml(k.key)}`; }); - } else { tbody.innerHTML = 'No keys'; } + } else { + tbody.innerHTML = 'No keys'; + } document.getElementById('infoModal').style.display = 'block'; } +/** + * Copies text to the clipboard and briefly shows a toast notification. + * + * @param {string} text - Text to copy. + */ function copyToClipboard(text) { navigator.clipboard.writeText(text).then(function () { - var x = document.getElementById("toast"); + const x = document.getElementById("toast"); x.className = "show"; setTimeout(function () { x.className = x.className.replace("show", ""); }, 3000); }); } +/** + * Toggles visibility of the key comment field in the create modal. + * The field is only relevant when no template is selected (value === 'none'). + */ function toggleKeyComment() { - var sel = document.getElementById('create_template_select'); - var div = document.getElementById('create_key_comment_div'); + const sel = document.getElementById('create_template_select'); + const div = document.getElementById('create_key_comment_div'); if (sel && div) { - if (sel.value === 'none') { - div.style.display = 'block'; - } else { - div.style.display = 'none'; - } + div.style.display = sel.value === 'none' ? 'block' : 'none'; } } // --- XTerm Logic --- -var currentCmdPayload = null; - +let currentCmdPayload = null; + +/** + * Opens the terminal modal and initiates a Socket.IO-backed xterm.js session + * for the given command payload. + * + * Requires xterm.js, the FitAddon, and socket.io to be loaded as globals. + * Cleans up any existing terminal/socket before creating a new one. + * + * @param {Object} cmdPayload - Command descriptor. Shape varies by cmd: + * { cmd: 'connect', user, host } + * { cmd: 'create', user_host, template, key_comment, legacy } + * { cmd: 'template', action, name } + * All payloads receive a generated term_id if not already set. + */ function openTerminal(cmdPayload) { - // Check if enabled - if (!window.xtermEnabled) { + if (!globalThis.xtermEnabled) { alert("Terminal support disabled (backend dependencies missing)."); return; } @@ -225,7 +341,7 @@ function openTerminal(cmdPayload) { document.getElementById('terminalModal').style.display = 'block'; // Set Title - var title = "Terminal"; + let title = "Terminal"; if (cmdPayload.cmd === 'connect') { title = "Connect: " + cmdPayload.user + "@" + cmdPayload.host; } else if (cmdPayload.cmd === 'create') { @@ -247,8 +363,8 @@ function openTerminal(cmdPayload) { document.getElementById('terminal-container').innerHTML = ''; - // Init Socket - socket = io.connect(location.protocol + '//' + document.domain + ':' + location.port); + // Init Socket — location.hostname is preferred over the deprecated document.domain + socket = io.connect(location.protocol + '//' + location.hostname + ':' + location.port); // Init Terminal termHandle = new Terminal({ @@ -287,19 +403,19 @@ function openTerminal(cmdPayload) { socket.on('disconnect_msg', (msg) => { termHandle.write('\r\n' + msg.data + '\r\n'); - socket.disconnect(); // Ensure clean cut + socket.disconnect(); }); socket.on('session_ended', () => { if (document.body.classList.contains('popout-mode')) { - window.close(); + globalThis.close(); } else { closeTerminal(); } }); // Resize handler - window.addEventListener('resize', () => { + globalThis.addEventListener('resize', () => { if (fitAddon) { fitAddon.fit(); socket.emit('resize', { cols: termHandle.cols, rows: termHandle.rows }); @@ -313,62 +429,81 @@ function openTerminal(cmdPayload) { }, 300); } +/** + * Closes the terminal modal and disconnects the socket. + * In popout mode, closes the window instead. Reloads the page on close + * so that any changes made during the session (e.g. new identity) are reflected. + */ function closeTerminal() { - // If in popout mode, strictly close window if (document.body.classList.contains('popout-mode')) { - window.close(); + globalThis.close(); return; } document.getElementById('terminalModal').style.display = 'none'; if (socket) socket.disconnect(); - // Reload page to reflect changes (e.g. if 'create' was run) - window.location.reload(); + globalThis.location.reload(); } +/** + * Detaches the current terminal session into a new popup window, + * passing the command payload as a URL query parameter. + * Closes the in-page terminal modal after opening the popup. + */ function detachTerminal() { if (!currentCmdPayload) return; - // Construct URL - var params = new URLSearchParams(); + const params = new URLSearchParams(); params.set('popout', 'true'); params.set('payload', JSON.stringify(currentCmdPayload)); - var url = window.location.pathname + '?' + params.toString(); - - // Open new window - window.open(url, '_blank', 'width=900,height=700,menubar=no,toolbar=no,location=no,status=no'); + const url = globalThis.location.pathname + '?' + params.toString(); + globalThis.open(url, '_blank', 'width=900,height=700,menubar=no,toolbar=no,location=no,status=no'); closeTerminal(); } -// --- Connect Logic Override --- +// --- Connect Logic --- + +/** + * Initiates a connect flow for a given user on a given identity. + * If the identity has multiple aliases, opens the connect modal so the + * user can pick which hostname to connect to. If there is only one alias, + * connects directly. + * + * @param {string} uuid - UUID of the host identity. + * @param {string} user - Remote username to connect as. + */ function prepareConnect(uuid, user) { - var data = identitiesMap[uuid]; + const data = identitiesMap[uuid]; if (!data) return; - var aliases = data.aliases && data.aliases.length > 0 ? data.aliases : [data.short_uuid]; + const aliases = data.aliases && data.aliases.length > 0 ? data.aliases : [data.short_uuid]; if (aliases.length === 1) { triggerConnect(user, aliases[0]); return; } - // Multiple aliases - show modal - var container = document.getElementById('connect_aliases_list'); + // Multiple aliases — show picker modal + const container = document.getElementById('connect_aliases_list'); container.innerHTML = ''; document.getElementById('connect_user').value = user; aliases.forEach((alias, index) => { - var checked = index === 0 ? 'checked' : ''; + const checked = index === 0 ? 'checked' : ''; container.innerHTML += `
`; }); document.getElementById('connectModal').style.display = 'block'; } +/** + * Reads the selected radio button from the connect modal and calls triggerConnect. + * Closes the connect modal on success. + */ function triggerConnectSubmit() { - var user = document.getElementById('connect_user').value; - var radios = document.getElementsByName('connect_host'); - var host; - for (var i = 0; i < radios.length; i++) { - if (radios[i].checked) { host = radios[i].value; break; } + const user = document.getElementById('connect_user').value; + const radios = document.getElementsByName('connect_host'); + let host; + for (const radio of radios) { + if (radio.checked) { host = radio.value; break; } } if (host) { triggerConnect(user, host); @@ -376,33 +511,49 @@ function triggerConnectSubmit() { } } +/** + * Triggers a connection to a remote host as a given user. + * Uses the xterm terminal when available; falls back to a POST to /connect. + * + * @param {string} user - Remote username. + * @param {string} host - Hostname or IP to connect to. + */ function triggerConnect(user, host) { - if (window.xtermEnabled) { + if (globalThis.xtermEnabled) { openTerminal({ cmd: 'connect', user: user, host: host }); return; } // Legacy Fallback - var formData = new FormData(); + const formData = new FormData(); formData.append('user', user); formData.append('host', host); fetch('/connect', { method: 'POST', body: formData }) .then(response => { - if (response.ok) { } else { response.text().then(t => alert("Error: " + t)); } + if (!response.ok) { response.text().then(t => alert("Error: " + t)); } }) .catch(e => alert("Network Error: " + e)); } -// --- Create Logic Override --- +// --- Create Logic --- + +/** + * Intercepts the "Add New Identity" form submit when xterm is enabled. + * Extracts form field values and opens an interactive terminal session + * instead of performing a full-page POST. Falls through to native form + * submission when xterm is not available. + * + * @param {Event} event - The form submit event. + */ function handleCreateSubmit(event) { - if (window.xtermEnabled) { + if (globalThis.xtermEnabled) { event.preventDefault(); document.getElementById('createModal').style.display = 'none'; - var uh = document.getElementById('create_user_host').value; - var tmpl = document.getElementById('create_template_select').value; - var kc = document.getElementById('create_key_comment').value; - var legacy = document.getElementById('create_legacy').checked; + const uh = document.getElementById('create_user_host').value; + const tmpl = document.getElementById('create_template_select').value; + const kc = document.getElementById('create_key_comment').value; + const legacy = document.getElementById('create_legacy').checked; openTerminal({ cmd: 'create', @@ -411,19 +562,18 @@ function handleCreateSubmit(event) { key_comment: kc, legacy: legacy }); - return false; } - return true; // proceed with form POST + // xterm disabled: allow native form POST to proceed } // --- Security Check --- (function () { - var h = window.location.hostname; + const h = globalThis.location.hostname; if (h !== 'localhost' && h !== '127.0.0.1') { - var banner = document.getElementById('security-warning'); + const banner = document.getElementById('security-warning'); if (banner) { banner.style.display = 'block'; banner.innerText += " (Current: " + h + ")"; } } -})(); +}()); diff --git a/lib/ui/index.html b/lib/ui/index.html index be125b6..10ec53d 100644 --- a/lib/ui/index.html +++ b/lib/ui/index.html @@ -9,10 +9,9 @@ {% if xterm_enabled %} - - - - + + + {% endif %} @@ -28,9 +27,9 @@

SSH Key Manager