From f21515ade47ec3e6a614c4fc0c5cac2776d87d61 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 09:25:43 -0800 Subject: [PATCH 01/13] U2F login for SSH --- privacyidea_pam.py | 101 +++++++++++++++++++++++++++++++++++++++++---- ssh-u2f.py | 57 +++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 9 deletions(-) create mode 100755 ssh-u2f.py diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 0725d23..a2a36f3 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -33,6 +33,7 @@ The code is tested in test_pam_module.py """ +import json import requests import syslog import sqlite3 @@ -75,6 +76,18 @@ def __init__(self, pamh, config): self.debug = config.get("debug") self.sqlfile = config.get("sqlfile", "/etc/privacyidea/pam.sqlite") + def make_request(self, data): + response = requests.post(self.URL + "/validate/check", data=data, + verify=self.sslverify) + + json_response = response.json + if callable(json_response): + syslog.syslog(syslog.LOG_DEBUG, "requests > 1.0") + json_response = json_response() + + return json_response + + def authenticate(self, password): rval = self.pamh.PAM_SYSTEM_ERR # First we try to authenticate against the sqlitedb @@ -91,13 +104,8 @@ def authenticate(self, password): "pass": password} if self.realm: data["realm"] = self.realm - response = requests.post(self.URL + "/validate/check", data=data, - verify=self.sslverify) - json_response = response.json - if callable(json_response): - syslog.syslog(syslog.LOG_DEBUG, "requests > 1.0") - json_response = json_response() + json_response = self.make_request(data) result = json_response.get("result") auth_item = json_response.get("auth_items") @@ -105,8 +113,10 @@ def authenticate(self, password): serial = detail.get("serial", "T%s" % time.time()) tokentype = detail.get("type", "unknown") if self.debug: - syslog.syslog(syslog.LOG_DEBUG, "%s: result: %s" % (__name__, - result)) + syslog.syslog(syslog.LOG_DEBUG, + "%s: result: %s" % (__name__, result)) + syslog.syslog(syslog.LOG_DEBUG, + "%s: detail: %s" % (__name__, detail)) if result.get("status"): if result.get("value"): @@ -114,7 +124,41 @@ def authenticate(self, password): save_auth_item(self.sqlfile, self.user, serial, tokentype, auth_item) else: - rval = self.pamh.PAM_AUTH_ERR + transaction_id = detail.get("transaction_id") + challenge = None + + if "attributes" in detail: + attributes = detail.get("attributes") + if "u2fSignRequest" in attributes: + syslog.syslog(syslog.LOG_DEBUG, + "Prompting for U2F authentication") +# In case of U2F the $attributes looks like this: +# { +# "img": "static/css/FIDO-U2F-Security-Key-444x444.png#012", +# "hideResponseInput" "1", +# "u2fSignRequest": { +# "challenge": "yji-PL1V0QELilDL3m6Lc-1yahpKZiU-z6ye5Zz2mp8", +# "version": "U2F_V2", +# "keyHandle": "fxDKTr6o8EEGWPyEyRVDvnoeA0c6v-dgvbN-6Mxc6XBmEItsw", +# "appId": "https://172.16.200.138" +# } +# } + challenge = """ +----- BEGIN U2F CHALLENGE ----- +%s +%s +----- END U2F CHALLENGE -----""" % (self.URL, json.dumps(attributes["u2fSignRequest"])) + if transaction_id: + if challenge: + rval = self.challenge_response(transaction_id, + challenge) + else: + syslog.syslog(syslog.LOG_ERR, + "%s: unsupported challenge" % + __name__) + + else: + rval = self.pamh.PAM_AUTH_ERR else: syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, @@ -122,6 +166,45 @@ def authenticate(self, password): return rval + def challenge_response(self, transaction_id, challenge): + rval = self.pamh.PAM_SYSTEM_ERR + + message = self.pamh.Message(self.pamh.PAM_PROMPT_ECHO_OFF, challenge) + response = self.pamh.conversation(message) + chal_response = json.loads(response.resp) + + data = {"user": self.user, + "transaction_id": transaction_id, + "pass": self.pamh.authtok, + "signaturedata": chal_response.get("signatureData"), + "clientdata": chal_response.get("clientData")} + if self.realm: + data["realm"] = self.realm + + json_response = self.make_request(data) + + result = json_response.get("result") + detail = json_response.get("detail") + + if self.debug: + syslog.syslog(syslog.LOG_DEBUG, + "%s: result: %s" % (__name__, result)) + syslog.syslog(syslog.LOG_DEBUG, + "%s: detail: %s" % (__name__, detail)) + + if result.get("status"): + if result.get("value"): + rval = self.pamh.PAM_SUCCESS + else: + rval = self.pamh.PAM_AUTH_ERR + else: + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, + result.get("error").get("message"))) + + return rval + + def pam_sm_authenticate(pamh, flags, argv): config = _get_config(argv) diff --git a/ssh-u2f.py b/ssh-u2f.py new file mode 100755 index 0000000..5425398 --- /dev/null +++ b/ssh-u2f.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +from __future__ import print_function + +import getpass,os,re,signal,subprocess,sys +import pexpect + +ssh = None + +def handler(signum, frame): + if ssh: + ssh.kill(signum) + sys.exit(signum) + +signal.signal(signal.SIGQUIT, handler) +signal.signal(signal.SIGTERM, handler) +signal.signal(signal.SIGINT, handler) + +def winch_handler(signum, frame): + if ssh: + rows, cols = os.popen('stty size', 'r').read().split() + ssh.setwinsize(int(rows), int(cols)) + +signal.signal(signal.SIGWINCH, winch_handler) + +ssh = pexpect.spawn("ssh", sys.argv[1:]) +winch_handler(None, None) + +index = -1 +pattern_list = ssh.compile_pattern_list([ + "Enter additional factors:.*", + "----- BEGIN U2F CHALLENGE -----\r?\n([^\r\n]*)\r?\n(.*)\r?\n----- END U2F CHALLENGE -----", + "Welcome.*", + pexpect.EOF +]) +while True: + index = ssh.expect_list(pattern_list) + if index == 0: + try: + pin = getpass.getpass(ssh.match.group()) + except EOFError: + pin = "" + ssh.sendline(pin) + elif index == 1: + p = subprocess.Popen(["/usr/local/bin/u2f-host", "-aauthenticate", + "-o", ssh.match.group(1)], + env={"LD_LIBRARY_PATH": "/usr/local/lib"}, + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + out, err = p.communicate(ssh.match.group(2)) + p.stdin.close() + p.wait() + ssh.sendline(out.strip()) + else: + break +if index == 3: + sys.exit(0) +sys.stdout.write(ssh.match.group()) +ssh.interact() From 4d4fc71c1d789a44cf3069a5d9f8a791e919d492 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:00:14 -0800 Subject: [PATCH 02/13] Specify global for function using global --- ssh-u2f.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ssh-u2f.py b/ssh-u2f.py index 5425398..ed435b1 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -7,6 +7,7 @@ ssh = None def handler(signum, frame): + global ssh if ssh: ssh.kill(signum) sys.exit(signum) @@ -16,6 +17,7 @@ def handler(signum, frame): signal.signal(signal.SIGINT, handler) def winch_handler(signum, frame): + global ssh if ssh: rows, cols = os.popen('stty size', 'r').read().split() ssh.setwinsize(int(rows), int(cols)) From cb8448388d37ff67aa7a19fb7edffb971e07293b Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:14:34 -0800 Subject: [PATCH 03/13] Support other ssh-type commands via symlinking --- ssh-u2f.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index ed435b1..7e52fd8 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -24,7 +24,12 @@ def winch_handler(signum, frame): signal.signal(signal.SIGWINCH, winch_handler) -ssh = pexpect.spawn("ssh", sys.argv[1:]) +try: + command = os.path.splitext(os.path.basename(__file__))[0].split("-")[0] +except: + command = None + +ssh = pexpect.spawn(command or "ssh", sys.argv[1:]) winch_handler(None, None) index = -1 From f088d204c5707916f0771d236c0e4775e999393d Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:15:08 -0800 Subject: [PATCH 04/13] Remove extra newline --- ssh-u2f.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index 7e52fd8..a152e7d 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -46,7 +46,7 @@ def winch_handler(signum, frame): pin = getpass.getpass(ssh.match.group()) except EOFError: pin = "" - ssh.sendline(pin) + ssh.sendline(pin.strip()) elif index == 1: p = subprocess.Popen(["/usr/local/bin/u2f-host", "-aauthenticate", "-o", ssh.match.group(1)], From eae6c040e31e9447694ebb2d4d4ce1ba674e860f Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:17:05 -0800 Subject: [PATCH 05/13] Remove extraneous call --- ssh-u2f.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index a152e7d..4844a9f 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -53,7 +53,6 @@ def winch_handler(signum, frame): env={"LD_LIBRARY_PATH": "/usr/local/lib"}, stdin=subprocess.PIPE, stdout=subprocess.PIPE) out, err = p.communicate(ssh.match.group(2)) - p.stdin.close() p.wait() ssh.sendline(out.strip()) else: From df572d3151094401acdaeac5faf08f0f63c2bda5 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:18:36 -0800 Subject: [PATCH 06/13] Remove some hacky crap --- ssh-u2f.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index 4844a9f..75f64db 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -48,9 +48,8 @@ def winch_handler(signum, frame): pin = "" ssh.sendline(pin.strip()) elif index == 1: - p = subprocess.Popen(["/usr/local/bin/u2f-host", "-aauthenticate", + p = subprocess.Popen(["u2f-host", "-aauthenticate", "-o", ssh.match.group(1)], - env={"LD_LIBRARY_PATH": "/usr/local/lib"}, stdin=subprocess.PIPE, stdout=subprocess.PIPE) out, err = p.communicate(ssh.match.group(2)) p.wait() From 3cfa8da658291738b9e742d5db222c04650185a4 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:20:11 -0800 Subject: [PATCH 07/13] Refactor expecting to allow other commands to work --- ssh-u2f.py | 55 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index 75f64db..3510661 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -32,31 +32,52 @@ def winch_handler(signum, frame): ssh = pexpect.spawn(command or "ssh", sys.argv[1:]) winch_handler(None, None) -index = -1 -pattern_list = ssh.compile_pattern_list([ - "Enter additional factors:.*", - "----- BEGIN U2F CHALLENGE -----\r?\n([^\r\n]*)\r?\n(.*)\r?\n----- END U2F CHALLENGE -----", - "Welcome.*", - pexpect.EOF -]) +def passthrough(): + print() + sys.stdout.write(ssh.match.group()) + try: + ssh.interact() + except UnboundLocalError: + # Work around bug in pexpect 3.1 + pass + sys.exit(0) + +index = ssh.expect(["Authenticated with partial success.", + "[^ \r\n]+", + pexpect.EOF]) + +if index == 0: + print(ssh.match.group()) +elif index == 1: + passthrough() +elif index == 2: + sys.exit(0) + while True: - index = ssh.expect_list(pattern_list) + index = ssh.expect(["Enter additional factors: ", + "----- BEGIN U2F CHALLENGE -----\r\n", + "[^ \r\n]+", + pexpect.EOF]) + if index == 0: try: pin = getpass.getpass(ssh.match.group()) except EOFError: pin = "" ssh.sendline(pin.strip()) + elif index == 1: - p = subprocess.Popen(["u2f-host", "-aauthenticate", - "-o", ssh.match.group(1)], + u2f_origin = ssh.readline().strip() + u2f_challenge = ssh.readline().strip() + ssh.expect("----- END U2F CHALLENGE -----") + p = subprocess.Popen(["u2f-host", "-aauthenticate", "-o", u2f_origin], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - out, err = p.communicate(ssh.match.group(2)) + out, err = p.communicate(u2f_challenge) p.wait() ssh.sendline(out.strip()) - else: - break -if index == 3: - sys.exit(0) -sys.stdout.write(ssh.match.group()) -ssh.interact() + + elif index == 2: + passthrough() + + elif index == 3: + sys.exit(0) From eb1e743e2be4512b53170ae4df47154bbe450e52 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Wed, 2 Mar 2016 13:20:29 -0800 Subject: [PATCH 08/13] Print a message about interacting with U2F token --- privacyidea_pam.py | 5 ++++- ssh-u2f.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index a2a36f3..24eccd0 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -147,7 +147,10 @@ def authenticate(self, password): ----- BEGIN U2F CHALLENGE ----- %s %s ------ END U2F CHALLENGE -----""" % (self.URL, json.dumps(attributes["u2fSignRequest"])) +%s +----- END U2F CHALLENGE -----""" % (self.URL, + json.dumps(attributes["u2fSignRequest"]), + str(detail.get("message", ""))) if transaction_id: if challenge: rval = self.challenge_response(transaction_id, diff --git a/ssh-u2f.py b/ssh-u2f.py index 3510661..3a3a52c 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -69,7 +69,9 @@ def passthrough(): elif index == 1: u2f_origin = ssh.readline().strip() u2f_challenge = ssh.readline().strip() - ssh.expect("----- END U2F CHALLENGE -----") + ssh.expect("(.*)----- END U2F CHALLENGE -----") + message = ssh.match.group(1).strip() + print(message or "Interact with your U2F token.") p = subprocess.Popen(["u2f-host", "-aauthenticate", "-o", u2f_origin], stdin=subprocess.PIPE, stdout=subprocess.PIPE) out, err = p.communicate(u2f_challenge) From bce754b178685c43ebc9059270b13bd87ed8d8fd Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Thu, 3 Mar 2016 09:31:34 -0800 Subject: [PATCH 09/13] Make u2f logic more friendly to other challenge/response token types --- privacyidea_pam.py | 54 +++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 24eccd0..00cb256 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -125,14 +125,33 @@ def authenticate(self, password): auth_item) else: transaction_id = detail.get("transaction_id") - challenge = None - if "attributes" in detail: - attributes = detail.get("attributes") + if transaction_id: + attributes = detail.get("attributes", {}) if "u2fSignRequest" in attributes: - syslog.syslog(syslog.LOG_DEBUG, - "Prompting for U2F authentication") -# In case of U2F the $attributes looks like this: + rval = self.u2f_challenge_response( + transaction_id, detail.get("message"), + attributes) + else: + syslog.syslog(syslog.LOG_ERR, + "%s: unsupported challenge" % + __name__) + + else: + rval = self.pamh.PAM_AUTH_ERR + else: + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, + result.get("error").get("message"))) + + return rval + + def u2f_challenge_response(self, transaction_id, message, attributes): + rval = self.pamh.PAM_SYSTEM_ERR + + syslog.syslog(syslog.LOG_DEBUG, "Prompting for U2F authentication") + +# In case of U2F "attributes" looks like this: # { # "img": "static/css/FIDO-U2F-Security-Key-444x444.png#012", # "hideResponseInput" "1", @@ -143,34 +162,15 @@ def authenticate(self, password): # "appId": "https://172.16.200.138" # } # } - challenge = """ + challenge = """ ----- BEGIN U2F CHALLENGE ----- %s %s %s ----- END U2F CHALLENGE -----""" % (self.URL, json.dumps(attributes["u2fSignRequest"]), - str(detail.get("message", ""))) - if transaction_id: - if challenge: - rval = self.challenge_response(transaction_id, - challenge) - else: - syslog.syslog(syslog.LOG_ERR, - "%s: unsupported challenge" % - __name__) - - else: - rval = self.pamh.PAM_AUTH_ERR - else: - syslog.syslog(syslog.LOG_ERR, - "%s: %s" % (__name__, - result.get("error").get("message"))) - - return rval + str(message or "")) - def challenge_response(self, transaction_id, challenge): - rval = self.pamh.PAM_SYSTEM_ERR message = self.pamh.Message(self.pamh.PAM_PROMPT_ECHO_OFF, challenge) response = self.pamh.conversation(message) From 7618c6c6b71533c9fe2c28ac212dfc0100319c93 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Thu, 3 Mar 2016 09:31:56 -0800 Subject: [PATCH 10/13] Respect hideResponseInput attribute --- privacyidea_pam.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 00cb256..7654135 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -171,8 +171,12 @@ def u2f_challenge_response(self, transaction_id, message, attributes): json.dumps(attributes["u2fSignRequest"]), str(message or "")) + if bool(attributes.get("hideResponseInput", True)): + prompt_type = self.pamh.PAM_PROMPT_ECHO_OFF + else: + prompt_type = self.pamh.PAM_PROMPT_ECHO_ON - message = self.pamh.Message(self.pamh.PAM_PROMPT_ECHO_OFF, challenge) + message = self.pamh.Message(prompt_type, challenge) response = self.pamh.conversation(message) chal_response = json.loads(response.resp) From e25b7f4483cba1672dc4a6183fdbd89f95b78339 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Thu, 3 Mar 2016 13:46:53 -0800 Subject: [PATCH 11/13] Update changelogs/docs --- privacyidea_pam.py | 2 ++ ssh-u2f.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 7654135..af94bdf 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- # +# 2016-03-03 Brandon Smith +# Add U2F challenge/response support # 2015-11-06 Cornelius Kölbel # Avoid SQL injections. # 2015-10-17 Cornelius Kölbel diff --git a/ssh-u2f.py b/ssh-u2f.py index 3a3a52c..0e8834b 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -1,9 +1,40 @@ #!/usr/bin/env python +# +# -*- coding: utf-8 -*- +# +# 2016-03-03 Brandon Smith +# Initial Creation +# +# (c) Brandon Smith +# Info: http://www.privacyidea.org +# +# This code is free software; you can redistribute it and/or +# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +# License as published by the Free Software Foundation; either +# version 3 of the License, or any later version. +# +# This code is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU AFFERO GENERAL PUBLIC LICENSE for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see . +# from __future__ import print_function import getpass,os,re,signal,subprocess,sys import pexpect +__doc__ = """This is an ssh (and ssh-like) wrapper that uses pexpect to +interact with privacyIDEA's pam_python module for u2f challenge/response. + +Usage: + Make executable + Symlink ssh-u2f, scp-u2f, sftp-u2f, mosh-u2f, etc. into your PATH + Call just like ssh, eg. "ssh-u2f name@example.com" +""" + ssh = None def handler(signum, frame): From 333b89eba773f3cf1b6a0beba426bccf117d6a97 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Thu, 3 Mar 2016 13:47:02 -0800 Subject: [PATCH 12/13] Support privacyIDEA as primary auth --- ssh-u2f.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index 0e8834b..745a7e9 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -73,31 +73,24 @@ def passthrough(): pass sys.exit(0) -index = ssh.expect(["Authenticated with partial success.", - "[^ \r\n]+", - pexpect.EOF]) - -if index == 0: - print(ssh.match.group()) -elif index == 1: - passthrough() -elif index == 2: - sys.exit(0) - while True: - index = ssh.expect(["Enter additional factors: ", + index = ssh.expect(["Authenticated with partial success.", + "Enter additional factors: ", "----- BEGIN U2F CHALLENGE -----\r\n", "[^ \r\n]+", pexpect.EOF]) if index == 0: + print(ssh.match.group()) + + if index == 1: try: pin = getpass.getpass(ssh.match.group()) except EOFError: pin = "" ssh.sendline(pin.strip()) - elif index == 1: + elif index == 2: u2f_origin = ssh.readline().strip() u2f_challenge = ssh.readline().strip() ssh.expect("(.*)----- END U2F CHALLENGE -----") @@ -109,8 +102,8 @@ def passthrough(): p.wait() ssh.sendline(out.strip()) - elif index == 2: + elif index == 3: passthrough() - elif index == 3: + elif index == 4: sys.exit(0) From 0e9961a96ca6bfbb7ad1014d671f6cbfbefaa962 Mon Sep 17 00:00:00 2001 From: Brandon Smith Date: Thu, 3 Mar 2016 17:49:16 -0800 Subject: [PATCH 13/13] Support password auth --- ssh-u2f.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ssh-u2f.py b/ssh-u2f.py index 745a7e9..0d9215e 100755 --- a/ssh-u2f.py +++ b/ssh-u2f.py @@ -75,7 +75,7 @@ def passthrough(): while True: index = ssh.expect(["Authenticated with partial success.", - "Enter additional factors: ", + "([Pp]assword[^:\r\n]*|Enter additional factors): ?", "----- BEGIN U2F CHALLENGE -----\r\n", "[^ \r\n]+", pexpect.EOF])