Proper Known Hosts handling

- Dialog offers to Add/Update host keys
- `host_key_policy` setting
This commit is contained in:
Emanuele 2021-01-05 02:32:11 +01:00
parent 9108221e91
commit 057e227438
3 changed files with 130 additions and 47 deletions

View file

@ -105,15 +105,15 @@ All the settings are optional.
Connection parameters are provided as a dictionary with the following keys (all optional):
| Parameter | Values | Comments |
| ------------------------ | -------------------------------------- | ------------------------------------- |
| `address` | IP of remarkable | tool prompts for it if missing |
| `auth_method` | Either `"password"` or `"key"` | defaults to password if key not given |
| `username` | Username for ssh access on reMarkable | default: `"root"` |
| `password` | Password provided by reMarkable | not needed if key provided |
| `key` | Local path to key for ssh | not needed if password provided |
| `timeout` | Connection timeout in seconds | default: 1 |
| `insecure_auto_add_host` | Ignores the check on the fingerprint | default: `false` |
| Parameter | Values | Comments |
| ----------------- | ------------------------------------------------------- | ------------------------------------- |
| `address` | IP of remarkable | tool prompts for it if missing |
| `auth_method` | Either `"password"` or `"key"` | defaults to password if key not given |
| `username` | Username for ssh access on reMarkable | default: `"root"` |
| `password` | Password provided by reMarkable | not needed if key provided |
| `key` | Local path to key for ssh | not needed if password provided |
| `timeout` | Connection timeout in seconds | default: 1 |
| `host_key_policy` | `"ask"`, `"ignore_new"`, `"ignore_all"`, `"auto_add"` | default: `"ask"` (description below) |
The `address` parameter can be either:
- a single string, in which case the address is used for connection
@ -124,17 +124,24 @@ To establish a connection with the tablet, you can use any of the following:
- Specify `"auth_method": "key"` to use a SSH key. In case an SSH key hasn't already been associated with the tablet, you can provide its path with the `key` setting.
- Provide a `password` in settings
If `auth_method` is `password` but no password is specified, then the tool will ask for the password on connection.
As a security measure, the keys used by known hosts are checked at each connection to prevent man-in-the-middle attacks.
The first time you connect to the tablet, it will not be among the known hosts.
In this situation rMview will present the option to add it to the known hosts, which should be done in a trusted network.
Updates to the tablet's firmware modify the key used by it, so the next connection would see the mismatch between the old key and the new.
Again rMview would prompt the user in this case with the option to update the key. Again this should be done in a trusted network.
The `host_key_policy` parameter controls this behaviour:
- `"ask"` is the default behaviour and prompts the user with a choice when the host key is unknown or not matching.
- `"ignore_new"` ignores unknown keys but reports mismatches.
- `"ignore_all"` ignores both unknown and not matching keys. Use at your own risk.
- `"auto_add"` adds unknown keys without prompting but reports mismatches.
The old `"insecure_auto_add_host": true` parameter is deprecated and equivalent to `"ignore_all"`.
In case your `~/.ssh/known_hosts` file contains the relevant key associations, rMview should pick them up.
If you use the "Add/Update" feature when prompted by rMview (for example after a tablet update) then `~/.ssh/known_hosts` will be ignored from then on.
:warning: **Connecting after an update:**
An update to the reMarkable tablet would change its "fingerprint" i.e. the identifier that signals we are connecting to the expected device (and not somebody impersonating it).
When this happens you may get an error message upon connection.
There are two main ways to fix this:
1. Change your `~/.ssh/known_hosts` file to match the new fingerprint (you can get instructions by connecting manually via ssh).
2. Set the `insecure_auto_add_host` setting to `true`, which will make rmview ignore the check.
This is not recommended unless you are in a trusted network.
:warning: **Key format error:**
If you get an error when connect using a key, but the key seems ok when connecting manually with ssh, you probably need to convert the key to the PEM format (or re-generate it using the `-m PEM` option of `ssh-keygen`). See [here](https://github.com/paramiko/paramiko/issues/340#issuecomment-492448662) for details.

View file

@ -6,6 +6,7 @@ from PyQt5.QtWidgets import *
import paramiko
import struct
import time
from binascii import hexlify
import sys
import os
@ -13,6 +14,43 @@ import logging
log = logging.getLogger('rmview')
class UnknownHostKeyException(paramiko.SSHException):
def __init__(self, hostname, key):
paramiko.SSHException.__init__(self, hostname, key)
self.hostname = hostname
self.key = key
def __str__(self):
msg = "Unknown host key for server '{}': got '{}'"
return msg.format(
self.hostname,
self.key.get_base64(),
)
AddNewHostKey = paramiko.AutoAddPolicy
class RejectNewHostKey(paramiko.MissingHostKeyPolicy):
def missing_host_key(self, client, hostname, key):
raise UnknownHostKeyException(hostname, key)
class IgnoreNewHostKey(paramiko.MissingHostKeyPolicy):
def missing_host_key(self, client, hostname, key):
log.warning("Unknown %s host key for %s: %s", key.get_name(), hostname, hexlify(key.get_fingerprint()))
HOST_KEY_POLICY = {
"ask": RejectNewHostKey,
"ignore_new": IgnoreNewHostKey,
"ignore_all": IgnoreNewHostKey,
"auto_add": AddNewHostKey
}
class rMConnectSignals(QObject):
onConnect = pyqtSignal(object)
@ -23,7 +61,8 @@ class rMConnect(QRunnable):
_exception = None
def __init__(self, address='10.11.99.1', username='root', password=None, key=None, timeout=1, onConnect=None, onError=None, insecure_auto_add_host=False, **kwargs):
def __init__(self, address='10.11.99.1', username='root', password=None, key=None, timeout=1,
onConnect=None, onError=None, host_key_policy=None, known_hosts=None, **kwargs):
super(rMConnect, self).__init__()
self.address = address
self.signals = rMConnectSignals()
@ -31,13 +70,23 @@ class rMConnect(QRunnable):
self.signals.onConnect.connect(onConnect)
if callable(onError):
self.signals.onError.connect(onError)
# self.key = key
try:
self.client = paramiko.SSHClient()
if insecure_auto_add_host:
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
else:
self.client.load_system_host_keys()
if host_key_policy != "ignore_all":
if known_hosts and os.path.isfile(known_hosts):
self.client.load_host_keys(known_hosts)
log.info("LOADED %s", known_hosts)
else:
# ideally we would want to always load the system ones
# and have the local keys have precedence, but paramiko gives
# always precedence to system keys
self.client.load_system_host_keys()
policy = HOST_KEY_POLICY.get(host_key_policy, RejectNewHostKey)
self.client.set_missing_host_key_policy(policy())
if key is not None:
key = os.path.expanduser(key)
@ -59,7 +108,6 @@ class rMConnect(QRunnable):
'password': password,
'pkey': pkey,
'timeout': timeout,
'look_for_keys': False
}
except Exception as e:
self._exception = e

View file

@ -4,10 +4,10 @@ from PyQt5.QtCore import *
from . import resources
from .workers import FrameBufferWorker, PointerWorker
from .connection import rMConnect
from .connection import rMConnect, RejectNewHostKey, AddNewHostKey, UnknownHostKeyException
from .viewer import QtImageViewer
from paramiko import BadHostKeyException, SSHException
from paramiko import BadHostKeyException, HostKeys
from .rmparams import *
@ -38,8 +38,9 @@ class rMViewApp(QApplication):
def __init__(self, args):
super(rMViewApp, self).__init__(args)
self.DEFAULT_CONFIG = QStandardPaths.standardLocations(QStandardPaths.ConfigLocation)[0]
self.DEFAULT_CONFIG = os.path.join(self.DEFAULT_CONFIG, 'rmview.json')
self.CONFIG_DIR = QStandardPaths.standardLocations(QStandardPaths.ConfigLocation)[0]
self.DEFAULT_CONFIG = os.path.join(self.CONFIG_DIR, 'rmview.json')
self.LOCAL_KNOWN_HOSTS = os.path.join(self.CONFIG_DIR, 'rmview_known_hosts')
config_files = [] if len(args) < 2 else [args[1]]
config_files += ['rmview.json', self.DEFAULT_CONFIG]
@ -175,13 +176,27 @@ class rMViewApp(QApplication):
else:
return False
# backwards compatibility
if self.config['ssh'].get('insecure_auto_add_host') and 'host_key_policy' not in self.config['ssh']:
log.warning("The 'insecure_auto_add_host' setting is deprecated, see documentation.")
self.config['ssh']['host_key_policy'] = "ignore_all"
if self.config['ssh']['host_key_policy'] == "auto_add":
if not os.path.isfile(self.LOCAL_KNOWN_HOSTS):
open(self.LOCAL_KNOWN_HOSTS, 'a').close()
log.info(self.config)
return True
def requestConnect(self):
def requestConnect(self, host_key_policy=None):
self.viewer.setWindowTitle("rMview - Connecting...")
args = self.config.get('ssh')
if host_key_policy:
args = args.copy()
args['host_key_policy'] = host_key_policy
self.threadpool.start(
rMConnect(**self.config.get('ssh'),
rMConnect(**args,
known_hosts=self.LOCAL_KNOWN_HOSTS,
onError=self.connectionError,
onConnect=self.connected ) )
@ -200,8 +215,6 @@ class rMViewApp(QApplication):
self.ssh = ssh
self.viewer.setWindowTitle("rMview - " + self.config.get('ssh').get('address'))
# check we are dealing with RM1
# cat /sys/devices/soc0/machine -> reMarkable 1.x
_,out,_ = ssh.exec_command("cat /sys/devices/soc0/machine")
rmv = out.read().decode("utf-8")
version = re.fullmatch(r"reMarkable (\d+)\..*\n", rmv)
@ -358,48 +371,63 @@ class rMViewApp(QApplication):
icon = QPixmap(":/assets/dead.svg")
icon.setDevicePixelRatio(self.devicePixelRatio())
mbox.setIconPixmap(icon)
mbox.addButton("Settings...", QMessageBox.ResetRole)
mbox.addButton(QMessageBox.Cancel)
if isinstance(e, BadHostKeyException):
mbox.setDetailedText(str(e))
mbox.setInformativeText(
"<big>The host at %s has the wrong key.<br>"
"This usually happens just after a software update on the tablet.</big><br><br>"
"You have three options to fix this problem:"
"You have three options to fix this permanently:"
"<ol><li>"
"Press Update to replace the old key with the new."
"<br></li><li>"
"Change your <code>~/.ssh/known_hosts</code> file to match the new fingerprint.<br>"
"The easiest way to do this is connecting manually via ssh and follow the instructions."
"<br></li><li>"
"Set <code>\"insecure_auto_add_host\": true</code> to rmView\'s settings.<br>"
"Set <code>\"host_key_policy\": \"ignore_new\"</code> in the <code>ssh</code> section of rmView\'s settings.<br>"
"This is not recommended unless you are in a trusted network."
"<br></li><li>"
"Connect using username/password."
"<br></li><ol>" % (self.config.get('ssh').get('address'))
"<br></li><ol>" % (e.hostname)
)
elif isinstance(e, SSHException) and str(e).endswith('known_hosts'):
mbox.addButton("Ignore", QMessageBox.NoRole)
mbox.addButton("Update", QMessageBox.YesRole)
elif isinstance(e, UnknownHostKeyException):
mbox.setDetailedText(str(e))
mbox.setInformativeText(
"<big>The host at %s is unknown.<br>"
"This usually happens if this is the first time you use ssh with your tablet.</big><br><br>"
"You have three options to fix this problem:"
"You have three options to fix this permanently:"
"<ol><li>"
"Press Add to add the key to the known hosts."
"<br></li><li>"
"Change your <code>~/.ssh/known_hosts</code> file to match the new fingerprint.<br>"
"The easiest way to do this is connecting manually via ssh and follow the instructions."
"<br></li><li>"
"Set <code>\"insecure_auto_add_host\": true</code> to rmView\'s settings.<br>"
"Set <code>\"host_key_policy\": \"ignore_new\"</code> in the <code>ssh</code> section of rmView\'s settings.<br>"
"This is not recommended unless you are in a trusted network."
"<br></li><li>"
"Connect using username/password."
"<br></li><ol>" % (self.config.get('ssh').get('address'))
"<br></li><ol>" % (e.hostname)
)
mbox.addButton("Ignore", QMessageBox.NoRole)
mbox.addButton("Add", QMessageBox.YesRole)
else:
mbox.setInformativeText("I could not connect to the reMarkable at %s:\n%s." % (self.config.get('ssh').get('address'), e))
mbox.addButton(QMessageBox.Cancel)
mbox.addButton("Settings...", QMessageBox.ResetRole)
mbox.addButton(QMessageBox.Retry)
mbox.setDefaultButton(QMessageBox.Retry)
mbox.addButton(QMessageBox.Retry)
mbox.setDefaultButton(QMessageBox.Retry)
answer = mbox.exec()
if answer == QMessageBox.Retry:
self.requestConnect()
elif answer == QMessageBox.Cancel:
self.quit()
elif answer == 1: # Ignore
self.requestConnect(host_key_policy="ignore_all")
elif answer == 2: # Add/Update
if not os.path.isfile(self.LOCAL_KNOWN_HOSTS):
open(self.LOCAL_KNOWN_HOSTS, 'a').close()
hk = HostKeys(self.LOCAL_KNOWN_HOSTS)
hk.add(e.hostname, e.key.get_name(), e.key)
hk.save(self.LOCAL_KNOWN_HOSTS)
log.info("Saved host key in %s", self.LOCAL_KNOWN_HOSTS)
self.requestConnect()
else:
self.openSettings(prompt=False)
self.quit()