Skip to content
This repository was archived by the owner on Mar 8, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 101 additions & 9 deletions privacyidea_pam.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
#
# 2016-03-03 Brandon Smith <freedom@reardencode.com>
# Add U2F challenge/response support
# 2015-11-06 Cornelius Kölbel <cornelius.koelbel@netknights.it>
# Avoid SQL injections.
# 2015-10-17 Cornelius Kölbel <cornelius.koelbel@netknights.it>
Expand Down Expand Up @@ -33,6 +35,7 @@
The code is tested in test_pam_module.py
"""

import json
import requests
import syslog
import sqlite3
Expand Down Expand Up @@ -75,6 +78,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
Expand All @@ -91,37 +106,114 @@ 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")
detail = json_response.get("detail") or {}
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"):
rval = self.pamh.PAM_SUCCESS
save_auth_item(self.sqlfile, self.user, serial, tokentype,
auth_item)
else:
rval = self.pamh.PAM_AUTH_ERR
transaction_id = detail.get("transaction_id")

if transaction_id:
attributes = detail.get("attributes", {})
if "u2fSignRequest" in attributes:
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",
# "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
%s
----- END U2F CHALLENGE -----""" % (self.URL,
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(prompt_type, challenge)
response = self.pamh.conversation(message)
chal_response = json.loads(response.resp)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to run it but I break at this point. The ssh-u2f.py client seems to return a wrong response object from the conversation.

response.resp == "u2f".

Mar  3 18:27:43 puckel sshd: 
Mar  3 18:27:43 puckel sshd: u2f
Mar  3 18:27:43 puckel sshd: Traceback (most recent call last):
#012  File "/home/cornelius/src/pam_python_u2f/privacyidea_pam.py", line 236, in pam_sm_authenticate
#012    rval = Auth.authenticate(pamh.authtok)
#012  File "/home/cornelius/src/pam_python_u2f/privacyidea_pam.py", line 157, in authenticate
#012    challenge)
#012  File "/home/cornelius/src/pam_python_u2f/privacyidea_pam.py", line 181, in challenge_response
#012    chal_response = json.loads(response.resp)
#012  File "/usr/lib/python2.7/json/__init__.py", line 338, in loads
#012    return _default_decoder.decode(s)
#012  File "/usr/lib/python2.7/json/decoder.py", line 366, in decode
#012    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
#012  File "/usr/lib/python2.7/json/decoder.py", line 384, in raw_decode
#012    raise ValueError("No JSON object could be decoded")
#012ValueError: No JSON object could be decoded

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, what version of libu2f-host are you running?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.1.0-1ppa1trusty1

Am Donnerstag, den 03.03.2016, 10:29 -0800 schrieb Brandon Smith:

In privacyidea_pam.py:

@@ -122,6 +169,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)
    

Hmm, what version of libu2f-host are you running?


Reply to this email directly or view it on GitHub.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.1.0-1ppa1trusty1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weird -- my team have it working on Mac with libu2f-host from homebrew, on linux mint with libu2f-host from git and on ubuntu with u2f-host from the PPA.

If you send a challenge manually to your u2f-host command, does it output some text prior to the response?


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)
Expand Down
109 changes: 109 additions & 0 deletions ssh-u2f.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, this is the wrapper for the SSH client to handle the U2F stuff.
I like this a lot!
At first I only saw solutions with copying the challenge to another external command. -> I did not like this!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I put myself in a situation where I was motivated to solve the problem because I'd already bought U2F tokens for my team :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you buy the blue u2f yubikeys or did you buy daplug?
The yubikey edge also work as a normal otp token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually went cheap and got HyperFIDO which are U2F only

#
# -*- coding: utf-8 -*-
#
# 2016-03-03 Brandon Smith <freedom@reardencode.com>
# 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 <http://www.gnu.org/licenses/>.
#
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):
global ssh
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):
global ssh
if ssh:
rows, cols = os.popen('stty size', 'r').read().split()
ssh.setwinsize(int(rows), int(cols))

signal.signal(signal.SIGWINCH, winch_handler)

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)

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)

while True:
index = ssh.expect(["Authenticated with partial success.",
"([Pp]assword[^:\r\n]*|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 == 2:
u2f_origin = ssh.readline().strip()
u2f_challenge = ssh.readline().strip()
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)
p.wait()
ssh.sendline(out.strip())

elif index == 3:
passthrough()

elif index == 4:
sys.exit(0)