Host key recovery: fail closed on a lost pin, read keys only (review pr85-review-r1)

- Key check: ssh writes the key during the key exchange and is stopped at once;
  no authentication, agent, forwarding, commands (-N), terminal or local
  command, all forced ahead of ~/.ssh/config, and -F none unless a jump host
  needs the config. A device accepting "none" gets nothing.
- accept-new only for a headset that never had an identity: the saved key's
  fingerprint is recorded in devices.json, and a recorded identity, an
  existing pin file or a connection that ever worked makes a deleted, empty,
  unreadable or unparseable pin fail closed ("missing or can't be read").
- forget-identity is refused (it let the next contact save any key unseen).
- A key read before the headset's port, user or that address changed can't be
  trusted.
- Without a ControlMaster (Windows) the handshake's stderr goes to a file read
  as it grows, not a pipe.
- Pin files and the check's throwaway known_hosts get make_private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-10-12 14:32:08 +11:00
1 parent 5fb02770db
commit 8aeeaef666
6 files changed
+438 -89

No files matched your search

+27 -12
View File
@@ -104,7 +104,9 @@ connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again.
headset makes the connector start again. There ssh's stderr goes to a file that
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
to a redirected pipe.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
@@ -121,25 +123,38 @@ lease that moved) would look like a new host there. The app keeps one known_host
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does: until a key is saved, every ssh
command gets `StrictHostKeyChecking=accept-new` (the first contact saves it,
whichever command makes it), and from then on `StrictHostKeyChecking=yes`.
`accept-new` never replaces a saved key, so it can't hide a change.
the key it shows, as Set Up Connection does: while a headset has never had an
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
first contact saves it, whichever command makes it), and from then on
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
can't hide a change. The saved key's fingerprint is also recorded in
`devices.json` (`identity`). A headset counts as having had an identity if one is
recorded, if its pin file exists at all, or if a connection to it ever worked; then
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
(*Checking the headset's identity* fails, "missing or can't be read") rather than
trusting whatever answers, and only **Check its key…** saves a key again. It
says when the key it reads matches the recorded fingerprint.
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
same address, or a different device answering there), nothing is accepted by
itself. The connection fails at *Checking the headset's identity*; the banner
and the headset on the Devices tab say so, with **Check the new key…** (also
**Check its key…** on the Devices tab at any time). That reads the key the
headset shows now, with ssh, a throwaway known_hosts file and no login
(`PreferredAuthentications=none`, so nothing of this computer's is offered to a
device that may not be the headset), and shows its SHA256 fingerprint next to
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
file during the key exchange, before any login, and is stopped as soon as it
has. Every other kind of contact is ruled out on the command line, ahead of
anything `~/.ssh/config` says: no authentication of any kind, no agent
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
or remote command (`-N`), no terminal, and `-F none` unless the alias goes
through a jump host. It shows the key's SHA256 fingerprint next to
the saved one, with the command that prints it on the headset
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
**This is my headset: trust its new key** then saves exactly that key, replacing
the old one, and reconnects. The server keeps the key it showed for 10 minutes
and saves it only if the page sends back the same fingerprint; it never saves
whatever answers next. Set Up Connection can't fix a changed key: it checks your
and saves it only if the page sends back the same fingerprint, and only while
the headset's port, user and that address are unchanged; it never saves whatever
answers next. The API's old `forget-identity`, which deleted the pin so that the
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
own `known_hosts`, not the headset's file.
**When the headset refuses this computer's key** (`Permission denied
@@ -168,7 +183,7 @@ All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `matches`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `name-network`, `setup` (alias, optional host), `retry` |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
@@ -185,7 +200,7 @@ share the format later (it still connects to one host; see
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"],
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"],
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
+22 -4
View File
@@ -8,7 +8,9 @@ every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.
With $FAKESSH_PINS set, host keys are checked as OpenSSH does against UserKnownHostsFile under
HostKeyAlias: "wrong" is then just a different key from the others' (a reinstalled headset),
StrictHostKeyChecking=yes refuses a key not saved, accept-new saves a first one, and a saved key
that differs is refused. Like ssh, the first value of an option counts."""
that differs is refused. "evil" is a device with its own key that lets anyone in without
authentication ("none") and asks for the agent: it records in $FAKESSH_DIR what it got
(agent-forwarded, command-ran). Like ssh, the first value of an option counts."""
import base64
import hashlib
import json
@@ -24,7 +26,7 @@ hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
opts = {}
i = 0
while i < len(args) and args[i].startswith("-"):
if args[i] in ("-o", "-O", "-p", "-l"):
if args[i] in ("-o", "-O", "-p", "-l", "-F"):
key = args[i]
val = args[i + 1]
if key == "-o":
@@ -37,6 +39,12 @@ while i < len(args) and args[i].startswith("-"):
opts[args[i]] = True
i += 1
alias = args[i] if i < len(args) else ""
command = args[i + 1:]
if opts.get("-F") != "none":
# $FAKESSH_CONFIG: what ~/.ssh/config says (JSON, lower-case option -> value), below the
# command line's options, as ssh has it; `-F none` reads no config.
for k, v in json.loads(os.environ.get("FAKESSH_CONFIG", "{}")).items():
opts.setdefault(k, v)
host = opts.get("hostname", alias).replace("%%", "%")
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
@@ -62,11 +70,11 @@ if what == "slow":
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
if os.environ.get("FAKESSH_PINS") and "hostkeyalias" in opts and "userknownhostsfile" in opts:
name, path = opts["hostkeyalias"], os.path.expanduser(opts["userknownhostsfile"])
blob = base64.b64encode(b"fake-" + (b"other" if what == "wrong" else b"headset") + b"-key").decode()
blob = base64.b64encode(b"fake-" + {"wrong": b"other", "evil": b"evil"}.get(what, b"headset") + b"-key").decode()
fp = base64.b64encode(hashlib.sha256(base64.b64decode(blob)).digest()).decode().rstrip("=")
say(f"debug1: Server host key: ssh-ed25519 SHA256:{fp}")
try:
saved = [ln.split() for ln in open(path).read().splitlines() if ln.strip()]
saved = [ln.split() for ln in open(path, errors="replace").read().splitlines() if ln.strip()]
except OSError:
saved = []
saved = [f[2] for f in saved if len(f) >= 3 and name in f[0].split(",")]
@@ -90,6 +98,16 @@ if os.environ.get("FAKESSH_PINS") and "hostkeyalias" in opts and "userknownhosts
else:
say(f"debug1: Host '{name}' is known and matches the ED25519 host key.")
say("debug1: Next authentication method: publickey")
if what == "evil":
say(f'Authenticated to {host} ([127.0.0.1]:22) using "none".')
got = os.path.join(os.environ["FAKESSH_DIR"], "evil-")
if opts.get("forwardagent", "no") != "no" and opts.get("identityagent", "") != "none":
open(got + "agent-forwarded", "w").close()
if "-N" not in opts and (command or opts.get("remotecommand", "none") != "none"):
open(got + "command-ran", "w").close()
sys.exit(0)
time.sleep(30) # -N: holds the session open
sys.exit(0)
if opts.get("preferredauthentications") == "none":
say(f"tester@{host}: Permission denied (publickey,password).")
sys.exit(255)
+156 -2
View File
@@ -291,15 +291,169 @@ class Connecting(unittest.TestCase):
self.assertIn("Permanently added", r.stderr)
self.assertTrue(fd.pinned(d["id"]))
self.assertIn("StrictHostKeyChecking=yes", self.link.host_opts(d, "127.0.0.1")) # ...then it's checked
fd.forget_pin(d["id"])
self.assertEqual(self.reg.get(d["id"])["identity"], [k[1] for k in fd.pinned_keys(d["id"])]) # and recorded
# The connector's first handshake, for another new headset.
self.reg.remove_device(d["id"])
d = self.device("localhost")
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertTrue(fd.pinned(d["id"]))
self.assertTrue(self.reg.get(d["id"])["identity"])
alias, opts = self.routes[-1]
self.assertIn("StrictHostKeyChecking=yes", opts) # every command after it checks strictly
self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
def lose_pin(self, device_id, how):
f = fd.known_hosts(device_id)
if how == "deleted":
f.unlink()
elif how == "empty":
f.write_text("")
elif how == "unreadable":
f.chmod(0)
self.addCleanup(lambda: f.exists() and f.chmod(0o600))
elif how == "another alias":
f.write_text(f.read_text().replace(f"frame-control-{device_id}", "frame-control-someone-else"))
elif how == "not utf-8":
f.write_bytes(b"\xff\xfe" + f.read_bytes())
def test_a_lost_or_unusable_pin_fails_closed(self):
# Review pr85-review-r1: accept-new can't tell a new headset from one whose saved key
# went missing; real OpenSSH then trusted a replacement key. Once a headset had an
# identity, nothing is trusted again until the person checks it.
self.pins()
cases = ["deleted", "empty", "another alias", "not utf-8"]
if os.name == "posix" and os.geteuid() != 0:
cases.append("unreadable") # root reads anything
for n, how in enumerate(cases):
with self.subTest(how=how):
self.hosts({"localhost": "ok"})
d = self.reg.add_device(f"frame-l{n}", port=self.port, hosts=[])
self.reg.add_address(d["id"], "localhost", kind="lan")
self.link.override = None
self.reg.set_active(d["id"])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.link.close_master()
d = self.reg.get(d["id"])
self.lose_pin(d["id"], how)
self.hosts({"localhost": "wrong"}) # another device, or a reinstalled headset
r = self.command(d)
self.assertEqual(r.returncode, 255, r.stderr)
self.assertIn("Host key verification failed", r.stderr)
self.assertNotIn("Permanently added", r.stderr)
self.link.connect(["dropped"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
self.assertEqual(s["error"]["hostkey"]["state"], "missing")
self.assertIn("missing or can't be read", s["error"]["message"])
view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
self.assertTrue(view["identity_lost"])
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertFalse(scan["matches"] or scan["recorded_match"]) # not the key it had
self.hosts({"localhost": "ok"})
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertTrue(scan["recorded_match"]) # its own key, as recorded
self.assertFalse(scan["matches"]) # but not usable from the file: trusting restores it
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": scan["fingerprint"]}, None)
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
self.link.close_master()
def test_a_key_check_only_reads_the_key(self):
# Review pr85-review-r1: with ForwardAgent yes in ~/.ssh/config, a device that accepts
# "none" got the agent and ran a command before anyone trusted it.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "evil"})
os.environ["FAKESSH_CONFIG"] = json.dumps({"forwardagent": "yes", "remotecommand": "touch /tmp/x",
"permitlocalcommand": "yes", "requesttty": "force"})
self.addCleanup(os.environ.pop, "FAKESSH_CONFIG", None)
for proxied in (False, True):
with self.subTest(proxied=proxied), \
mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", proxied)):
t0 = time.monotonic()
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertLess(time.monotonic() - t0, 10) # stopped once it had the key, not left open
self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
self.assertEqual([p.name for p in self.dir.iterdir() if p.name.startswith("evil-")], [])
call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1]
self.assertEqual("-F" in call and call[call.index("-F") + 1] == "none", not proxied)
self.assertIn("-N", call)
for o in ("ForwardAgent=no", "IdentityAgent=none", "ClearAllForwardings=yes", "RemoteCommand=none",
"PermitLocalCommand=no", "RequestTTY=no", "PubkeyAuthentication=no"):
self.assertIn(o, call)
self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("HostKeyAlias=frame-control-"
+ d["id"]))
self.assertFalse(fd.pinned(d["id"]))
self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), []) # no scan files
def test_a_key_read_before_the_address_or_port_changed_isnt_trusted(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
for change in ({"action": "update", "id": d["id"], "port": self.port + 1},
{"action": "address-remove", "id": d["id"], "host": "localhost"}):
with self.subTest(change=change["action"]):
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
fl.devices_action(self.link, change, None)
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": scan["fingerprint"]}, None)
self.assertFalse(fd.pinned(d["id"]))
self.reg.update_device(d["id"], port=self.port)
if not self.reg.get(d["id"])["addresses"]:
self.reg.add_address(d["id"], "localhost", kind="lan")
def test_forgetting_an_identity_is_refused(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
saved = fd.known_hosts(d["id"]).read_text()
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "forget-identity", "id": d["id"]}, None)
self.assertIn("identity-scan", str(caught.exception))
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved)
def test_without_a_shared_connection_ssh_writes_to_a_file_not_a_pipe(self):
# Windows: no ControlMaster, and its OpenSSH can block writing to a stderr pipe.
self.pins()
tmp = self.dir / "tmp"
tmp.mkdir()
link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
apply=lambda *a: None, explain=explain)
self.addCleanup(link.stop)
real = subprocess.Popen
seen = []
def popen(argv, **kw):
if "-v" in argv: # the handshake (run_ssh calls go through Popen too)
seen.append(kw.get("stderr"))
return real(argv, **kw)
with mock.patch.object(tempfile, "tempdir", str(tmp)), mock.patch.object(fl.subprocess, "Popen", popen):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
link.connect(["start"])
s = link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
self.assertIn("saved this headset's identity", next(st for st in s["stages"] if st["id"] == "identity")["detail"])
self.reg.update_device(d["id"], user="nobody")
self.hosts({"localhost": "denied"})
link.connect(["switch"])
s = link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"], s["error"].get("auth")), ("failed", "login", "denied"))
self.assertTrue(seen and all(isinstance(x, int) for x in seen)) # a file descriptor, never PIPE
self.assertNotIn(subprocess.PIPE, seen)
for _ in range(50): # the reader removes its file once ssh has exited
if not list(tmp.iterdir()):
break
time.sleep(0.05)
self.assertEqual(list(tmp.iterdir()), [])
def test_a_changed_key_is_shown_never_accepted_and_trusted_on_request(self):
# 0.4.2 on Linux and Windows: "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", with
# "Run Set Up Connection again" as the advice, which can't replace the saved key.
@@ -609,7 +763,7 @@ class Connecting(unittest.TestCase):
{"action": "update", "id": d["id"], "port": 2222},
{"action": "address-remove", "id": d["id"], "host": "localhost"},
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
{"action": "forget-identity", "id": d["id"]}):
{"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:x"}):
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
# Renaming, or changing another headset, is fine.
+37 -4
View File
@@ -437,6 +437,7 @@ def _write_pin(device_id, lines):
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(pin_dir()))
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write("\n".join(dict.fromkeys(lines)) + "\n")
frame_host.make_private(tmp) # Windows: this user only, not the folder's inherited ACL
os.replace(tmp, target) # whole file at once: ssh never sees half of it
@@ -487,9 +488,26 @@ def scan_file(device_id):
headset's key to, without touching the saved one. `~` as in known_hosts_opt."""
name = f".scan-{device_id}-{secrets.token_hex(4)}"
path = pin_dir() / name
path.touch(**({} if frame_host.WINDOWS else {"mode": 0o600}))
frame_host.make_private(path)
return path, (str(path) if os.environ.get("FRAME_CONTROL_SSH_DIR") else f"~/.ssh/{PIN_DIR}/{name}")
def identity_established(device):
"""Whether this headset ever had an SSH identity here: one recorded in devices.json, a
pin file at all (even empty, unreadable or not parseable), or a connection that worked
(which saved one). Then a missing or unusable pin fails closed, needing Check its key;
only a headset with none of these may save the first key it meets (accept-new)."""
if device.get("identity"):
return True
try:
if os.path.lexists(known_hosts(device["id"])):
return True
except OSError:
return True
return any(a.get("last_ok") for a in device.get("addresses") or ())
def forget_pin(device_id):
"""Drop a headset's saved key (the headset was removed, or the API's forget-identity).
The next connection trusts whatever key the headset shows, as a first connection does;
@@ -777,6 +795,21 @@ class Registry:
a["last_rtt_ms"] = rtt_ms
self.save()
def record_identity(self, device_id, fingerprints):
"""Remember the fingerprints of the key saved for a headset, so that losing the pin
file later can't turn into trusting whatever answers (identity_established)."""
fingerprints = [f for f in fingerprints if isinstance(f, str) and f.startswith("SHA256:")][:8]
if not fingerprints:
return
with self._changing():
try:
d = self._find(device_id)
except DeviceError:
return
if d.get("identity") != fingerprints:
d["identity"] = fingerprints
self.save()
def undismiss(self, alias):
"""Set Up Connection is about to run for this alias: import its block again."""
with self._changing():
@@ -860,15 +893,15 @@ class Registry:
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
d["config_host"] = host
changed = True
if seed and host:
seed_pin(d["id"], [host], b["port"])
if seed and host and seed_pin(d["id"], [host], b["port"]):
d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or d.get("identity")
elif host and host != d.get("config_host"):
# Set Up Connection ran again and found the headset somewhere new.
d["config_host"] = host
if not any(a["host"] == host for a in d["addresses"]):
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
if seed:
seed_pin(d["id"], [host], b["port"])
if seed and seed_pin(d["id"], [host], b["port"]) and not d.get("identity"):
d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or None
changed = True
if d.get("config_login") != [user, b["port"]]:
# Set Up Connection (or an edit) changed who to log in as, or the port.
+188 -64
View File
@@ -28,11 +28,13 @@ Python stdlib only. Runs on this computer, never on the Frame.
import copy
import hashlib
import ipaddress
import os
import queue
import re
import socket
import subprocess
import sys
import tempfile
import threading
import time
@@ -258,6 +260,29 @@ def ssh_target(host, ip):
return host
def _unlink(path):
try:
os.unlink(path)
except OSError:
pass
def _scanned_key(path, name):
"""The first whole, valid known_hosts line for `name` that a key check wrote, or None.
Only lines ssh has finished (ending in a newline): a half-written key mustn't count."""
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError):
return None
for line in text.splitlines(True):
if line.endswith("\n"):
entry = frame_devices.key_entry(line.strip(), name)
if entry:
return entry
return None
def probe_raw(host, port, result):
"""ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it."""
return {"unresolved": f"ssh: Could not resolve hostname {host}: not found",
@@ -508,12 +533,13 @@ class Link:
if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says
return ["-o", "HostName=no-address.invalid"]
# Whatever ~/.ssh/config says for Host *, every command checks the headset's pinned key:
# StrictHostKeyChecking=yes. Until a key is saved (a headset just added, or one whose
# file is gone), accept-new: the first contact saves it, as Set Up Connection and the
# connector's first handshake do, rather than every command failing with "No host key
# is known" until the connector gets there. accept-new still refuses a key that differs
# from a saved one, so it never replaces a pin; only Trust a new key does that.
strict = "yes" if frame_devices.pinned(device["id"]) else "accept-new"
# StrictHostKeyChecking=yes. Only a headset that never had an identity here (just
# added, never connected) gets accept-new: the first contact saves its key, as Set Up
# Connection and the connector's first handshake do, rather than every command failing
# with "No host key is known" until the connector gets there. Once it had one, a pin
# file that's gone, empty or unreadable fails closed (identity_established): accept-new
# can't tell that from a new headset and would trust whatever answers.
strict = self.strictness(device)
if strict == "accept-new":
try:
frame_devices.pin_dir() # ssh won't create the folder its known_hosts file goes in
@@ -524,6 +550,15 @@ class Link:
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
"-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
def strictness(self, device):
"""yes, or accept-new for a headset that never had an identity. A key saved since (by
any command's first contact) is recorded, so losing its file later fails closed."""
if not frame_devices.identity_established(device):
return "accept-new"
if not device.get("identity") and frame_devices.pinned(device["id"]):
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
return "yes"
def public_device(self, d):
return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")}
@@ -880,6 +915,7 @@ class Link:
if device.get("transient"):
return
self.reg.record_success(device["id"], host, net.get("id"), rtt)
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
# Set Up Connection may have changed the block while this attempt ran: take that
# in (and reconnect) rather than writing this attempt's older settings over it.
self.watch_config()
@@ -952,15 +988,9 @@ class Link:
return "ok"
extra = []
if not device.get("transient"):
if frame_devices.pinned(device["id"]):
extra = ["-o", "StrictHostKeyChecking=yes"]
else:
# First connection since this headset was added: trust what it shows
# (as Set Up Connection does), and pin it from now on. ssh won't create
# the folder its known_hosts file goes in.
extra = ["-o", "StrictHostKeyChecking=accept-new"]
pins = frame_devices.known_hosts(device["id"]).parent
pins.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
# A headset's first connection trusts what it shows (as Set Up Connection does) and
# pins it; any later one checks the pin strictly (see host_opts).
extra = ["-o", f"StrictHostKeyChecking={self.strictness(device)}"]
if self.control:
# No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket.
# `extra` first: ssh takes the first value of an option, and it may say accept-new.
@@ -968,15 +998,29 @@ class Link:
"-o", "ServerAliveCountMax=2", "-N", alias]
else:
argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ConnectTimeout=10", alias, "true"]
lines = queue.Queue()
collecting = [True]
errfile = None
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, **frame_host.DETACHED)
if self.control:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, **frame_host.DETACHED)
else:
# No master (Windows): ssh runs `true` and exits. Its stderr goes to a file read
# as it grows, not a pipe: Windows' OpenSSH can block writing to a redirected
# pipe (see frame_host.run_ssh), and the handshake would never finish.
fd_, errfile = tempfile.mkstemp(prefix="frame-ssh-")
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=fd_, **frame_host.DETACHED)
finally:
os.close(fd_)
except OSError as e:
if errfile:
_unlink(errfile)
self.fail("ssh", f"Couldn't run ssh: {e}", str(e))
return "stop"
self.pending = proc # so stop() can end it mid-handshake
lines = queue.Queue()
collecting = [True]
def read():
for raw in iter(proc.stderr.readline, b""):
@@ -984,7 +1028,31 @@ class Link:
lines.put(raw.decode("utf-8", "replace").rstrip())
lines.put(None)
proc.stderr.close()
threading.Thread(target=read, daemon=True).start()
def tail():
buf = b""
try:
with open(errfile, "rb") as fh:
while True:
chunk = fh.read()
if not chunk:
if proc.poll() is None:
time.sleep(0.05)
continue
chunk = fh.read() # whatever it wrote just before exiting
if not chunk:
break
buf += chunk
*done, buf = buf.split(b"\n")
for raw in done:
lines.put(raw.decode("utf-8", "replace").rstrip())
except OSError:
pass
if buf:
lines.put(buf.decode("utf-8", "replace").rstrip())
lines.put(None)
_unlink(errfile)
threading.Thread(target=read if errfile is None else tail, daemon=True).start()
step, said, authed = "ssh", [], False
deadline = time.monotonic() + HANDSHAKE_TIMEOUT
@@ -1065,6 +1133,8 @@ class Link:
self.master = proc
if "StrictHostKeyChecking=accept-new" in extra and frame_devices.pinned(device["id"]):
# The first connection saved the key: from now on every command checks it strictly.
frame_host.make_private(frame_devices.known_hosts(device["id"])) # ssh wrote it
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
with self.route_lock:
if self.attempt_gen == self.gen:
self.opts = self.host_opts(device, ssh_target(a["host"], found.get("ip")))
@@ -1087,10 +1157,17 @@ class Link:
# type any more: either way not the identity saved. Never accepted here: the page
# shows the new key's fingerprint and the person decides (Trust a new key).
name = (device or {}).get("name") or alias
self.fail("identity", f"This address answered with a different SSH identity from the one saved for {name}. "
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust it "
"on the Devices tab. If not, a different device may be answering here.", text,
hostkey={"state": "changed", "type": shown[0] if shown else None,
lost = bool(device) and not frame_devices.pinned(device["id"]) and \
frame_devices.identity_established(device)
if lost:
message = (f"The SSH identity saved for {name} is missing or can't be read, so Frame Control won't "
"trust whatever answers. Check its key and trust it on the Devices tab.")
else:
message = (f"This address answered with a different SSH identity from the one saved for {name}. "
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
"it on the Devices tab. If not, a different device may be answering here.")
self.fail("identity", message, text,
hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
"fingerprint": shown[1] if shown else None, "host": host})
return "next"
# A refused key is the same at every address: stop. (Judged by ssh's own words, not
@@ -1173,12 +1250,27 @@ class Link:
self.devices_changed()
# ---- Trust a new key ----
# Every way a key check could do more than read the key: no login of any kind, no agent
# (nor forwarding it), no X11, port or tunnel forwarding, no local or remote command, no
# terminal, no shared connection, no learning keys. Given first, so ssh takes these over
# anything ~/.ssh/config (or host_opts) says. -N: no command, even if a device accepts "none".
SCAN_OPTS = ["BatchMode=yes", "ControlMaster=no", "ControlPath=none", "ConnectTimeout=8",
"StrictHostKeyChecking=accept-new", "UpdateHostKeys=no", "HashKnownHosts=no",
"PreferredAuthentications=none", "PubkeyAuthentication=no", "PasswordAuthentication=no",
"KbdInteractiveAuthentication=no", "HostbasedAuthentication=no", "IdentityAgent=none",
"ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no", "ClearAllForwardings=yes",
"Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none", "RequestTTY=no"]
SCAN_TIMEOUT = 20
def scan_identity(self, device_id):
"""Ask the headset for its host key without trusting it: ssh with a throwaway
known_hosts file and no login (PreferredAuthentications=none, so nothing of ours is
offered to a device that may not be the headset). The address that showed a changed
key goes first, then the rest in order. -> {"type", "fingerprint", "host", "saved",
"matches"}; the key is kept (SCAN_TTL) for trust_identity, which pins exactly it."""
"""Read the headset's host key without trusting it or logging in. ssh writes it to a
throwaway known_hosts file during the key exchange, before any authentication, and is
stopped as soon as it has (or when it gives up); SCAN_OPTS and -N rule out anything else.
`-F none` too, unless ~/.ssh/config sends the alias through a jump host (then it's
needed for that, and SCAN_OPTS still win). The address that showed the changed key goes
first, then the rest in order. -> {"type", "fingerprint", "host", "saved", "recorded",
"matches", "recorded_match"}; the key is kept (SCAN_TTL) for trust_identity, which pins
exactly it."""
device = self.reg.get(device_id)
if not device["addresses"]:
raise frame_devices.DeviceError("This headset has no addresses to ask")
@@ -1190,47 +1282,71 @@ class Link:
if first in hosts:
hosts.remove(first)
hosts.insert(0, first)
name = frame_devices.host_key_alias(device_id)
no_config = [] if ssh_g(device["alias"])[3] else ["-F", "none"]
reasons = []
for host in hosts:
path, opt = frame_devices.scan_file(device_id)
# Ours first: ssh takes an option's first value, and host_opts says yes and the saved file.
argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={opt}",
"-o", "UpdateHostKeys=no",
"-o", "PreferredAuthentications=none",
*self.host_opts(device, host), device["alias"], "true"]
try:
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=25)
err_text = r.stderr or ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
lines = []
except (OSError, subprocess.TimeoutExpired):
err_text, lines = f"Timed out talking to {device['alias']}", []
finally:
try:
path.unlink()
except OSError:
pass
key = next((e for e in (frame_devices.key_entry(line, name) for line in lines) if e), None)
key, err_text = self._scan_one(device, host, no_config)
if key:
saved = frame_devices.pinned_keys(device_id)
scan = {"type": key[0], "blob": key[1], "fingerprint": key[2], "host": host, "at": now()}
recorded = [f for f in device.get("identity") or [] if isinstance(f, str)]
scan = {"type": key[0], "blob": key[1], "fingerprint": key[2], "host": host,
"port": device["port"], "user": device["user"], "at": now()}
with self.cond:
self.scans[device_id] = scan
return {"type": key[0], "fingerprint": key[2], "host": host,
"saved": [{"type": t, "fingerprint": f} for t, f in saved],
"matches": any(f == key[2] for _, f in saved)}
"saved": [{"type": t, "fingerprint": f} for t, f in saved], "recorded": recorded,
"matches": any(f == key[2] for _, f in saved), "recorded_match": key[2] in recorded}
text = err_text.strip()
reasons.append(f"{host}: " + (self.explain(text) or (text.splitlines() or ["no answer"])[-1]))
raise frame_devices.DeviceError("Couldn't read the headset's key. " + "; ".join(reasons))
def _scan_one(self, device, host, no_config):
"""-> ((type, blob, fingerprint) or None, what ssh said)."""
name = frame_devices.host_key_alias(device["id"])
path, opt = frame_devices.scan_file(device["id"])
opts = [a for o in [*self.SCAN_OPTS, f"UserKnownHostsFile={opt}"] for a in ("-o", o)]
argv = [self.mux_base[0], *no_config, *opts, *self.host_opts(device, host), "-N", device["alias"]]
fd_, errfile = tempfile.mkstemp(prefix="frame-scan-") # a file, not a pipe (see frame_host.run_ssh)
key, proc = None, None
try:
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=fd_,
**frame_host.DETACHED)
finally:
os.close(fd_)
deadline = time.monotonic() + self.SCAN_TIMEOUT
while key is None:
ended = proc.poll() is not None
key = _scanned_key(path, name)
if key or ended or self.stopped or time.monotonic() > deadline:
break
time.sleep(0.05)
except OSError as e:
return None, str(e)
finally:
if proc and proc.poll() is None:
proc.kill() # it has the key (or gave up): nothing more to do with that device
if proc:
try:
proc.wait(5)
except subprocess.TimeoutExpired:
pass
try:
with open(errfile, "rb") as fh:
said = fh.read().decode("utf-8", "replace")
except OSError:
said = ""
_unlink(errfile)
_unlink(path)
if key is None and not said.strip() and proc and proc.returncode is not None and proc.returncode < 0:
said = f"Timed out talking to {device['alias']}"
return key, said
def trust_identity(self, device_id, fingerprint):
"""Pin the key scan_identity read, and only if `fingerprint` is the one it showed (what
the person saw and confirmed), replacing whatever was saved. -> the scan."""
the person saw and confirmed), replacing whatever was saved; and only while the headset
is still reached as it was then (same port and user, the address still one of its own).
-> the scan."""
with self.cond:
scan = self.scans.get(device_id)
if not scan or now() - scan["at"] > SCAN_TTL:
@@ -1239,8 +1355,13 @@ class Link:
if not isinstance(fingerprint, str) or fingerprint != scan["fingerprint"]:
raise frame_devices.DeviceError("That isn't the key the headset showed. Check its key again")
self.scans.pop(device_id)
self.reg.get(device_id) # still a headset here
d = self.reg.get(device_id) # still a headset here
if (d["port"], d["user"]) != (scan["port"], scan["user"]) or \
scan["host"] not in [a["host"] for a in d["addresses"]]:
raise frame_devices.DeviceError("The headset's address, port or user changed since its key was read. "
"Check its key again")
frame_devices.pin_key(device_id, scan["type"], scan["blob"])
self.reg.record_identity(device_id, [scan["fingerprint"]])
return scan
@@ -1262,6 +1383,8 @@ def devices_view(link):
view["active"] = d["id"] == active["id"]
view["pinned"] = frame_devices.pinned(d["id"])
view["keys"] = [{"type": t, "fingerprint": f} for t, f in frame_devices.pinned_keys(d["id"])]
# It had an identity, but the saved key is gone or unusable: nothing is trusted until checked.
view["identity_lost"] = not view["pinned"] and frame_devices.identity_established(d)
view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]])
for a in d["addresses"]]
devices.append(view)
@@ -1294,7 +1417,7 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
is_active = did == active["id"]
moves = action == "use" or (action == "retry" and link.alive()) or (is_active and (
# (a retry while connected would cut the install's connection)
action in ("remove", "address-remove", "forget-identity", "identity-trust")
action in ("remove", "address-remove", "identity-trust")
or (action == "update" and login_change(reg, did, body))
or (action == "address-update" and body.get("newHost") not in (None, body.get("host")))))
if moves and busy():
@@ -1374,11 +1497,12 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
threading.Thread(target=link.test, args=(did,), daemon=True).start()
msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}"
elif action == "forget-identity":
d = reg.get(did)
frame_devices.forget_pin(did)
if is_active:
link.kick("switch")
msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows"
# Dropping the pin used to let the next contact save whatever key answered, unseen.
# A new key is now always shown first: identity-scan, then identity-trust.
reg.get(did)
raise frame_devices.DeviceError("Forgetting a headset's identity is no longer supported: use Check its key "
"on the Devices tab (identity-scan, then identity-trust), which shows the "
"new key before saving it")
elif action == "identity-scan":
d = reg.get(did)
scan = link.scan_identity(did)
+8 -3
View File
@@ -4800,7 +4800,8 @@ function renderDetail() {
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
const err = d.active && link.s && link.s.phase === "failed" ? link.s.error : null;
const problem = err && err.hostkey ? `<div class="item" role="alert"><div class="grow"><div class="t">Its SSH identity has changed</div>
const problem = err && err.hostkey ? `<div class="item" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
? "Its saved SSH identity is missing" : "Its SSH identity has changed"}</div>
<div class="s">${esc(err.message)}</div></div><button class="small action" id="dvTrustNow">Check the new key…</button></div>`
: err && err.auth === "denied" ? `<div class="item" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
<div class="s">Set Up Connection pairs this computer with it again. On the headset, Developer Mode must be on.</div></div>
@@ -4832,6 +4833,7 @@ function renderDetail() {
<div class="found" id="dvFound"></div>
<div class="hint">Identity: ${d.pinned ? `saved${(d.keys || []).length ? ` (${esc(d.keys.map(k => k.fingerprint).join(", "))})` : ""}.
A different device answering at one of these addresses is refused.`
: d.identity_lost ? "the saved key is missing or can't be read, so nothing is trusted until you check its key."
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
@@ -4937,14 +4939,17 @@ async function openTrust(id) {
try { res = await api("/api/devices", { action: "identity-scan", id }); }
catch (e) { $("trustText").textContent = e.message; return; }
const scan = res.scan;
const saved = (scan.saved || []).map(k => `${k.type} ${k.fingerprint}`).join("\n") || "None";
const saved = (scan.saved || []).map(k => `${k.type} ${k.fingerprint}`).join("\n")
|| ((scan.recorded || []).length ? `${scan.recorded.join("\n")}\n(recorded; the saved file is missing or unreadable)` : "None");
$("trustKeys").innerHTML = [["Saved key", saved], ["It shows now", `${scan.type} ${scan.fingerprint}`], ["At", scan.host]]
.map(([k, v]) => `<dt>${esc(k)}</dt><dd style="white-space:pre-wrap; word-break:break-all"><code>${esc(v)}</code></dd>`).join("");
if (scan.matches) {
$("trustText").textContent = `${name} shows the key already saved for it, so there's nothing to change. If it still won't connect, try Test now.`;
return;
}
$("trustText").textContent = (scan.saved || []).length
$("trustText").textContent = scan.recorded_match
? `${name} shows the key recorded for it before, but the saved copy is missing or can't be read. Trust it to save it again.`
: (scan.saved || []).length || (scan.recorded || []).length
? `${name} now shows a different SSH key from the one saved. That's expected after SteamOS was reinstalled or reset, or if this is a new headset at the same address. If none of that happened, don't trust it: another device may be answering in its place.`
: `No key is saved for ${name} yet. Trust this one if it's your headset.`;
$("trustCmd").textContent = `ssh-keygen -lf /etc/ssh/ssh_host_${HOST_KEY_FILE[scan.type] || "ecdsa"}_key.pub`;