diff --git a/cmdline.c b/cmdline.c index 0c7ef4c9..327a585a 100644 --- a/cmdline.c +++ b/cmdline.c @@ -236,6 +236,21 @@ int cmdline_process_param(char *p, char *value, int need_save, Conf *conf) SAVEABLE(0); conf_set_str(conf, CONF_loghost, value); } + if (!strcmp(p, "-hostkey")) { + char *dup; + RETURN(2); + UNAVAILABLE_IN(TOOLTYPE_NONNETWORK); + SAVEABLE(0); + dup = dupstr(value); + if (!validate_manual_hostkey(dup)) { + cmdline_error("'%s' is not a valid format for a manual host " + "key specification", value); + sfree(dup); + return ret; + } + conf_set_str_str(conf, CONF_ssh_manual_hostkeys, dup, ""); + sfree(dup); + } if ((!strcmp(p, "-L") || !strcmp(p, "-R") || !strcmp(p, "-D"))) { char type, *q, *qq, *key, *val; RETURN(2); diff --git a/config.c b/config.c index e4cfabf8..bfa9071b 100644 --- a/config.c +++ b/config.c @@ -1225,6 +1225,73 @@ static void portfwd_handler(union control *ctrl, void *dlg, } } +struct manual_hostkey_data { + union control *addbutton, *rembutton, *listbox, *keybox; +}; + +static void manual_hostkey_handler(union control *ctrl, void *dlg, + void *data, int event) +{ + Conf *conf = (Conf *)data; + struct manual_hostkey_data *mh = + (struct manual_hostkey_data *)ctrl->generic.context.p; + + if (event == EVENT_REFRESH) { + if (ctrl == mh->listbox) { + char *key, *val; + dlg_update_start(ctrl, dlg); + dlg_listbox_clear(ctrl, dlg); + for (val = conf_get_str_strs(conf, CONF_ssh_manual_hostkeys, + NULL, &key); + val != NULL; + val = conf_get_str_strs(conf, CONF_ssh_manual_hostkeys, + key, &key)) { + dlg_listbox_add(ctrl, dlg, key); + } + dlg_update_done(ctrl, dlg); + } + } else if (event == EVENT_ACTION) { + if (ctrl == mh->addbutton) { + char *key; + + key = dlg_editbox_get(mh->keybox, dlg); + if (!*key) { + dlg_error_msg(dlg, "You need to specify a host key or " + "fingerprint"); + sfree(key); + return; + } + + if (!validate_manual_hostkey(key)) { + dlg_error_msg(dlg, "Host key is not in a valid format"); + } else if (conf_get_str_str_opt(conf, CONF_ssh_manual_hostkeys, + key)) { + dlg_error_msg(dlg, "Specified host key is already listed"); + } else { + conf_set_str_str(conf, CONF_ssh_manual_hostkeys, key, ""); + } + + sfree(key); + dlg_refresh(mh->listbox, dlg); + } else if (ctrl == mh->rembutton) { + int i = dlg_listbox_index(mh->listbox, dlg); + if (i < 0) { + dlg_beep(dlg); + } else { + char *key; + + key = conf_get_str_nthstrkey(conf, CONF_ssh_manual_hostkeys, i); + if (key) { + dlg_editbox_set(mh->keybox, dlg, key); + /* And delete it */ + conf_del_str_str(conf, CONF_ssh_manual_hostkeys, key); + } + } + dlg_refresh(mh->listbox, dlg); + } + } +} + void setup_config_box(struct controlbox *b, int midsession, int protocol, int protcfginfo) { @@ -1235,6 +1302,7 @@ void setup_config_box(struct controlbox *b, int midsession, struct ttymodes_data *td; struct environ_data *ed; struct portfwd_data *pfd; + struct manual_hostkey_data *mh; union control *c; char *str; @@ -2163,6 +2231,42 @@ void setup_config_box(struct controlbox *b, int midsession, I(16)); ctrl_text(s, "(Use 1M for 1 megabyte, 1G for 1 gigabyte etc)", HELPCTX(ssh_kex_repeat)); + + s = ctrl_getset(b, "Connection/SSH/Kex", "hostkeys", + "Manually configure host keys for this connection"); + + ctrl_columns(s, 2, 75, 25); + c = ctrl_text(s, "Host keys or fingerprints to accept:", + HELPCTX(ssh_kex_manual_hostkeys)); + c->generic.column = 0; + /* You want to select from the list, _then_ hit Remove. So + * tab order should be that way round. */ + mh = (struct manual_hostkey_data *) + ctrl_alloc(b,sizeof(struct manual_hostkey_data)); + mh->rembutton = ctrl_pushbutton(s, "Remove", 'r', + HELPCTX(ssh_kex_manual_hostkeys), + manual_hostkey_handler, P(mh)); + mh->rembutton->generic.column = 1; + mh->rembutton->generic.tabdelay = 1; + mh->listbox = ctrl_listbox(s, NULL, NO_SHORTCUT, + HELPCTX(ssh_kex_manual_hostkeys), + manual_hostkey_handler, P(mh)); + /* This list box can't be very tall, because there's not + * much room in the pane on Windows at least. This makes + * it become really unhelpful if a horizontal scrollbar + * appears, so we suppress that. */ + mh->listbox->listbox.height = 2; + mh->listbox->listbox.hscroll = FALSE; + ctrl_tabdelay(s, mh->rembutton); + mh->keybox = ctrl_editbox(s, "Key", 'k', 80, + HELPCTX(ssh_kex_manual_hostkeys), + manual_hostkey_handler, P(mh), P(NULL)); + mh->keybox->generic.column = 0; + mh->addbutton = ctrl_pushbutton(s, "Add key", 'y', + HELPCTX(ssh_kex_manual_hostkeys), + manual_hostkey_handler, P(mh)); + mh->addbutton->generic.column = 1; + ctrl_columns(s, 1, 100); } if (!midsession || protcfginfo != 1) { diff --git a/doc/config.but b/doc/config.but index f355a489..7d93402c 100644 --- a/doc/config.but +++ b/doc/config.but @@ -2466,6 +2466,56 @@ when the SSH connection is idle, so they shouldn't cause the same problems. The SSH-1 protocol, incidentally, has even weaker integrity protection than SSH-2 without rekeys. +\S{config-ssh-kex-manual-hostkeys} \ii{Manually configuring host keys} + +\cfg{winhelp-topic}{ssh.kex.manualhostkeys} + +In some situations, if PuTTY's automated host key management is not +doing what you need, you might need to manually configure PuTTY to +accept a specific host key, or one of a specific set of host keys. + +One reason why you might want to do this is because the host name +PuTTY is connecting to is using round-robin DNS to return one of +multiple actual servers, and they all have different host keys. In +that situation, you might need to configure PuTTY to accept any of a +list of host keys for the possible servers, while still rejecting any +key not in that list. + +Another reason is if PuTTY's automated host key management is +completely unavailable, e.g. because PuTTY (or Plink or PSFTP, etc) is +running in a Windows environment without access to the Registry. In +that situation, you will probably want to use the \cw{-hostkey} +command-line option to configure the expected host key(s); see FIXME. + +To configure manual host keys via the GUI, enter some text describing +the host key into the edit box in the \q{Manually configure host keys +for this connection} container, and press the \q{Add} button. The text +will appear in the {q Host keys or fingerprints to accept} list box. +You can remove keys again with the \q{Remove} button. + +The text describing a host key can be in one of the following formats: + +\b An MD5-based host key fingerprint of the form displayed in PuTTY's +Event Log and host key dialog boxes, i.e. sixteen 2-digit hex numbers +separated by colons. + +\b A base64-encoded blob describing an SSH-2 public key in the +standard way. This can be found in OpenSSH's one-line public key +format, or by concatenating all the lines of the public key section in +one of PuTTY's \cw{.ppk} files. Alternatively, you can load a key into +PuTTYgen, and paste out the OpenSSH-format public key line it +displays. + +If this box contains at least one host key or fingerprint when PuTTY +makes an SSH connection, then PuTTY's automated host key management is +completely bypassed: the connection will be permitted if and only if +the host key presented by the server is one of the keys listed in this +box, and the host key store in the Registry will be neither read +\e{nor written}. + +If the box is empty (as it usually is), then PuTTY's automated host +key management will work as normal. + \H{config-ssh-encryption} The Cipher panel \cfg{winhelp-topic}{ssh.ciphers} diff --git a/doc/faq.but b/doc/faq.but index a34973bd..b8650df9 100644 --- a/doc/faq.but +++ b/doc/faq.but @@ -151,13 +151,14 @@ military-strength cipher. That insignificant host key prompt really does make \e{that} much difference. If you're having a specific problem with host key checking - perhaps -you want an automated batch job to make use of PSCP or Plink, and -the interactive host key prompt is hanging the batch process - then -the right way to fix it is to add the correct host key to the -Registry in advance. That way, you retain the \e{important} feature -of host key checking: the right key will be accepted and the wrong -ones will not. Adding an option to turn host key checking off -completely is the wrong solution and we will not do it. +you want an automated batch job to make use of PSCP or Plink, and the +interactive host key prompt is hanging the batch process - then the +right way to fix it is to add the correct host key to the Registry in +advance, or if the Registry is not available, to use the \cw{-hostkey} +command-line option. That way, you retain the \e{important} feature of +host key checking: the right key will be accepted and the wrong ones +will not. Adding an option to turn host key checking off completely is +the wrong solution and we will not do it. If you have host keys available in the common \i\c{known_hosts} format, we have a script called diff --git a/doc/using.but b/doc/using.but index 27a1c2a8..fec03d32 100644 --- a/doc/using.but +++ b/doc/using.but @@ -913,6 +913,19 @@ connecting to). It can be a plain host name, or a host name followed by a colon and a port number. See \k{config-loghost} for more detail on this. +\S2{using-cmdline-hostkey} \i\c{-hostkey}: \I{manually configuring +host keys}manually specify an expected host key + +This option overrides PuTTY's normal SSH host key caching policy by +telling it exactly what host key to expect, which can be useful if the +normal automatic host key store in the Registry is unavailable. The +argument to this option should be either a host key fingerprint, or an +SSH-2 public key blob. See \k{config-ssh-kex-manual-hostkeys} for more +information. + +You can specify this option more than once if you want to configure +more than one key to be accepted. + \S2{using-cmdline-pgpfp} \i\c{-pgpfp}: display \i{PGP key fingerprint}s This option causes the PuTTY tools not to run as normal, but instead diff --git a/misc.c b/misc.c index fb7cc8c2..24e42eba 100644 --- a/misc.c +++ b/misc.c @@ -922,3 +922,100 @@ void smemclr(void *b, size_t n) { } } #endif + +/* + * Validate a manual host key specification (either entered in the + * GUI, or via -hostkey). If valid, we return TRUE, and update 'key' + * to contain a canonicalised version of the key string in 'key' + * (which is guaranteed to take up at most as much space as the + * original version), suitable for putting into the Conf. If not + * valid, we return FALSE. + */ +int validate_manual_hostkey(char *key) +{ + char *p, *q, *r, *s; + + /* + * Step through the string word by word, looking for a word that's + * in one of the formats we like. + */ + p = key; + while ((p += strspn(p, " \t"))[0]) { + q = p; + p += strcspn(p, " \t"); + if (p) *p++ = '\0'; + + /* + * Now q is our word. + */ + + if (strlen(q) == 16*3 - 1 && + q[strspn(q, "0123456789abcdefABCDEF:")] == 0) { + /* + * Might be a key fingerprint. Check the colons are in the + * right places, and if so, return the same fingerprint + * canonicalised into lowercase. + */ + int i; + for (i = 0; i < 16; i++) + if (q[3*i] == ':' || q[3*i+1] == ':') + goto not_fingerprint; /* sorry */ + for (i = 0; i < 15; i++) + if (q[3*i+2] != ':') + goto not_fingerprint; /* sorry */ + for (i = 0; i < 16*3 - 1; i++) + key[i] = tolower(q[i]); + key[16*3 - 1] = '\0'; + return TRUE; + } + not_fingerprint:; + + /* + * Before we check for a public-key blob, trim newlines out of + * the middle of the word, in case someone's managed to paste + * in a public-key blob _with_ them. + */ + for (r = s = q; *r; r++) + if (*r != '\n' && *r != '\r') + *s++ = *r; + *s = '\0'; + + if (strlen(q) % 4 == 0 && strlen(q) > 2*4 && + q[strspn(q, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz+/=")] == 0) { + /* + * Might be a base64-encoded SSH-2 public key blob. Check + * that it starts with a sensible algorithm string. No + * canonicalisation is necessary for this string type. + * + * The algorithm string must be at most 64 characters long + * (RFC 4251 section 6). + */ + unsigned char decoded[6]; + unsigned alglen; + int minlen; + int len = 0; + + len += base64_decode_atom(q, decoded+len); + if (len < 3) + goto not_ssh2_blob; /* sorry */ + len += base64_decode_atom(q+4, decoded+len); + if (len < 4) + goto not_ssh2_blob; /* sorry */ + + alglen = GET_32BIT_MSB_FIRST(decoded); + if (alglen > 64) + goto not_ssh2_blob; /* sorry */ + + minlen = ((alglen + 4) + 2) / 3; + if (strlen(q) < minlen) + goto not_ssh2_blob; /* sorry */ + + strcpy(key, q); + return TRUE; + } + not_ssh2_blob:; + } + + return FALSE; +} diff --git a/misc.h b/misc.h index 306b4a1e..a361d706 100644 --- a/misc.h +++ b/misc.h @@ -60,6 +60,8 @@ void bufchain_prefix(bufchain *ch, void **data, int *len); void bufchain_consume(bufchain *ch, int len); void bufchain_fetch(bufchain *ch, void *data, int len); +int validate_manual_hostkey(char *key); + struct tm ltime(void); void smemclr(void *b, size_t len); diff --git a/putty.h b/putty.h index a6578ae0..ff0f3156 100644 --- a/putty.h +++ b/putty.h @@ -848,6 +848,12 @@ void cleanup_exit(int); X(INT, NONE, ssh_connection_sharing) \ X(INT, NONE, ssh_connection_sharing_upstream) \ X(INT, NONE, ssh_connection_sharing_downstream) \ + /* + * ssh_manual_hostkeys is conceptually a set rather than a + * dictionary: the string subkeys are the important thing, and the + * actual values to which those subkeys map are all "". + */ \ + X(STR, STR, ssh_manual_hostkeys) \ /* Options for pterm. Should split out into platform-dependent part. */ \ X(INT, NONE, stamp_utmp) \ X(INT, NONE, login_shell) \ diff --git a/settings.c b/settings.c index aff6bf64..898c0dab 100644 --- a/settings.c +++ b/settings.c @@ -650,6 +650,7 @@ void save_open_settings(void *sesskey, Conf *conf) write_setting_i(sesskey, "ConnectionSharing", conf_get_int(conf, CONF_ssh_connection_sharing)); write_setting_i(sesskey, "ConnectionSharingUpstream", conf_get_int(conf, CONF_ssh_connection_sharing_upstream)); write_setting_i(sesskey, "ConnectionSharingDownstream", conf_get_int(conf, CONF_ssh_connection_sharing_downstream)); + wmap(sesskey, "SSHManualHostKeys", conf, CONF_ssh_manual_hostkeys, FALSE); } void load_settings(char *section, Conf *conf) @@ -996,6 +997,7 @@ void load_open_settings(void *sesskey, Conf *conf) gppi(sesskey, "ConnectionSharing", 0, conf, CONF_ssh_connection_sharing); gppi(sesskey, "ConnectionSharingUpstream", 1, conf, CONF_ssh_connection_sharing_upstream); gppi(sesskey, "ConnectionSharingDownstream", 1, conf, CONF_ssh_connection_sharing_downstream); + gppmap(sesskey, "SSHManualHostKeys", conf, CONF_ssh_manual_hostkeys); } void do_defaults(char *session, Conf *conf) diff --git a/ssh.c b/ssh.c index 893321e2..57ee2e8a 100644 --- a/ssh.c +++ b/ssh.c @@ -3677,6 +3677,59 @@ static void ssh_disconnect(Ssh ssh, char *client_reason, char *wire_reason, sfree(error); } +int verify_ssh_manual_host_key(Ssh ssh, const char *fingerprint, + const struct ssh_signkey *ssh2keytype, + void *ssh2keydata) +{ + if (!conf_get_str_nthstrkey(ssh->conf, CONF_ssh_manual_hostkeys, 0)) { + return -1; /* no manual keys configured */ + } + + if (fingerprint) { + /* + * The fingerprint string we've been given will have things + * like 'ssh-rsa 2048' at the front of it. Strip those off and + * narrow down to just the colon-separated hex block at the + * end of the string. + */ + const char *p = strrchr(fingerprint, ' '); + fingerprint = p ? p+1 : fingerprint; + /* Quick sanity checks, including making sure it's in lowercase */ + assert(strlen(fingerprint) == 16*3 - 1); + assert(fingerprint[2] == ':'); + assert(fingerprint[strspn(fingerprint, "0123456789abcdef:")] == 0); + + if (conf_get_str_str_opt(ssh->conf, CONF_ssh_manual_hostkeys, + fingerprint)) + return 1; /* success */ + } + + if (ssh2keydata) { + /* + * Construct the base64-encoded public key blob and see if + * that's listed. + */ + unsigned char *binblob; + char *base64blob; + int binlen, atoms, i; + binblob = ssh2keytype->public_blob(ssh2keydata, &binlen); + atoms = (binlen + 2) / 3; + base64blob = snewn(atoms * 4 + 1, char); + for (i = 0; i < atoms; i++) + base64_encode_atom(binblob + 3*i, binlen - 3*i, base64blob + 4*i); + base64blob[atoms * 4] = '\0'; + sfree(binblob); + if (conf_get_str_str_opt(ssh->conf, CONF_ssh_manual_hostkeys, + base64blob)) { + sfree(base64blob); + return 1; /* success */ + } + sfree(base64blob); + } + + return 0; +} + /* * Handle the key exchange and user authentication phases. */ @@ -3800,29 +3853,36 @@ static int do_ssh1_login(Ssh ssh, unsigned char *in, int inlen, rsastr_fmt(keystr, &s->hostkey); rsa_fingerprint(fingerprint, sizeof(fingerprint), &s->hostkey); - ssh_set_frozen(ssh, 1); - s->dlgret = verify_ssh_host_key(ssh->frontend, - ssh->savedhost, ssh->savedport, - "rsa", keystr, fingerprint, - ssh_dialog_callback, ssh); - sfree(keystr); - if (s->dlgret < 0) { - do { - crReturn(0); - if (pktin) { - bombout(("Unexpected data from server while waiting" - " for user host key response")); - crStop(0); - } - } while (pktin || inlen > 0); - s->dlgret = ssh->user_response; - } - ssh_set_frozen(ssh, 0); + /* First check against manually configured host keys. */ + s->dlgret = verify_ssh_manual_host_key(ssh, fingerprint, NULL, NULL); + if (s->dlgret == 0) { /* did not match */ + bombout(("Host key did not appear in manually configured list")); + crStop(0); + } else if (s->dlgret < 0) { /* none configured; use standard handling */ + ssh_set_frozen(ssh, 1); + s->dlgret = verify_ssh_host_key(ssh->frontend, + ssh->savedhost, ssh->savedport, + "rsa", keystr, fingerprint, + ssh_dialog_callback, ssh); + sfree(keystr); + if (s->dlgret < 0) { + do { + crReturn(0); + if (pktin) { + bombout(("Unexpected data from server while waiting" + " for user host key response")); + crStop(0); + } + } while (pktin || inlen > 0); + s->dlgret = ssh->user_response; + } + ssh_set_frozen(ssh, 0); - if (s->dlgret == 0) { - ssh_disconnect(ssh, "User aborted at host key verification", - NULL, 0, TRUE); - crStop(0); + if (s->dlgret == 0) { + ssh_disconnect(ssh, "User aborted at host key verification", + NULL, 0, TRUE); + crStop(0); + } } } @@ -6721,31 +6781,39 @@ static void do_ssh2_transport(Ssh ssh, void *vin, int inlen, * checked the signature of the exchange hash.) */ s->fingerprint = ssh->hostkey->fingerprint(s->hkey); - ssh_set_frozen(ssh, 1); - s->dlgret = verify_ssh_host_key(ssh->frontend, - ssh->savedhost, ssh->savedport, - ssh->hostkey->keytype, s->keystr, - s->fingerprint, - ssh_dialog_callback, ssh); - if (s->dlgret < 0) { - do { - crReturnV; - if (pktin) { - bombout(("Unexpected data from server while waiting" - " for user host key response")); - crStopV; - } - } while (pktin || inlen > 0); - s->dlgret = ssh->user_response; - } - ssh_set_frozen(ssh, 0); - if (s->dlgret == 0) { - ssh_disconnect(ssh, "User aborted at host key verification", NULL, - 0, TRUE); - crStopV; - } logevent("Host key fingerprint is:"); logevent(s->fingerprint); + /* First check against manually configured host keys. */ + s->dlgret = verify_ssh_manual_host_key(ssh, s->fingerprint, + ssh->hostkey, s->hkey); + if (s->dlgret == 0) { /* did not match */ + bombout(("Host key did not appear in manually configured list")); + crStopV; + } else if (s->dlgret < 0) { /* none configured; use standard handling */ + ssh_set_frozen(ssh, 1); + s->dlgret = verify_ssh_host_key(ssh->frontend, + ssh->savedhost, ssh->savedport, + ssh->hostkey->keytype, s->keystr, + s->fingerprint, + ssh_dialog_callback, ssh); + if (s->dlgret < 0) { + do { + crReturnV; + if (pktin) { + bombout(("Unexpected data from server while waiting" + " for user host key response")); + crStopV; + } + } while (pktin || inlen > 0); + s->dlgret = ssh->user_response; + } + ssh_set_frozen(ssh, 0); + if (s->dlgret == 0) { + ssh_disconnect(ssh, "Aborted at host key verification", NULL, + 0, TRUE); + crStopV; + } + } sfree(s->fingerprint); /* * Save this host key, to check against the one presented in diff --git a/windows/winhelp.h b/windows/winhelp.h index dabd8b82..fc10a8f4 100644 --- a/windows/winhelp.h +++ b/windows/winhelp.h @@ -102,6 +102,7 @@ #define WINHELP_CTX_ssh_share "ssh.sharing:config-ssh-sharing" #define WINHELP_CTX_ssh_kexlist "ssh.kex.order:config-ssh-kex-order" #define WINHELP_CTX_ssh_kex_repeat "ssh.kex.repeat:config-ssh-kex-rekey" +#define WINHELP_CTX_ssh_kex_manual_hostkeys "ssh.kex.manualhostkeys:config-ssh-kex-manual-hostkeys" #define WINHELP_CTX_ssh_auth_bypass "ssh.auth.bypass:config-ssh-noauth" #define WINHELP_CTX_ssh_auth_banner "ssh.auth.banner:config-ssh-banner" #define WINHELP_CTX_ssh_auth_privkey "ssh.auth.privkey:config-ssh-privkey"