248 lines
8.9 KiB
Python
Executable File
248 lines
8.9 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# Copyright (c) 2011, Yubico AB
|
|
# See the file COPYING for licence statement.
|
|
#
|
|
"""
|
|
Demonstrate rolling challenges.
|
|
|
|
This is a scheme for generating "one time" HMAC-SHA1 challenges, which
|
|
works by being able to access the HMAC-SHA1 key on the host computer every
|
|
time the correct response is provided.
|
|
|
|
GPGME would've been used to encrypt the HMAC-SHA1 with the next expected
|
|
response, but none of the two Python bindings to gpgme I have available
|
|
at the moment supports symmetric encryption, so for demo purposes AES CBC
|
|
is used instead.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import hmac
|
|
import argparse
|
|
import hashlib
|
|
import binascii
|
|
|
|
import yubico
|
|
import six
|
|
|
|
from Crypto.Cipher import AES
|
|
|
|
def parse_args():
|
|
"""
|
|
Parse the command line arguments
|
|
"""
|
|
parser = argparse.ArgumentParser(description = "Demonstrate rolling challenges",
|
|
add_help=True
|
|
)
|
|
parser.add_argument('-v', '--verbose',
|
|
dest='verbose',
|
|
action='store_true', default=False,
|
|
help='Enable verbose operation.'
|
|
)
|
|
parser.add_argument('--debug',
|
|
dest='debug',
|
|
action='store_true', default=False,
|
|
help='Enable debug operation.'
|
|
)
|
|
parser.add_argument('--init',
|
|
dest='init',
|
|
action='store_true', default=False,
|
|
help='Initialize demo.'
|
|
)
|
|
parser.add_argument('-F', '--filename',
|
|
dest='filename',
|
|
required=True,
|
|
help='State filename.'
|
|
)
|
|
parser.add_argument('--challenge-length',
|
|
dest='challenge_length',
|
|
type = int, default = 32,
|
|
help='Length of challenges generated, in bytes.'
|
|
)
|
|
parser.add_argument('--slot',
|
|
dest='slot',
|
|
type = int, default = 2,
|
|
help='YubiKey slot to send challenge to.'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
return args
|
|
|
|
def init_demo(args):
|
|
""" Initializes the demo by asking a few questions and creating a new stat file. """
|
|
hmac_key = six.moves.input("Enter HMAC-SHA1 key as 40 chars of hex (or press enter for random key) : ")
|
|
if hmac_key:
|
|
try:
|
|
hmac_key = binascii.unhexlify(hmac_key)
|
|
except:
|
|
sys.stderr.write("Could not decode HMAC-SHA1 key. Please enter 40 hex-chars.\n")
|
|
sys.exit(1)
|
|
else:
|
|
hmac_key = os.urandom(20)
|
|
if len(hmac_key) != 20:
|
|
sys.stderr.write("Decoded HMAC-SHA1 key is %i bytes, expected 20.\n" %( len(hmac_key)))
|
|
sys.exit(1)
|
|
|
|
print("To program a YubiKey >= 2.2 for challenge-response with this key, use :")
|
|
print("")
|
|
print(" $ ykpersonalize -%i -ochal-resp -ochal-hmac -ohmac-lt64 -a %s" % (args.slot, binascii.hexlify(hmac_key).decode('ascii')))
|
|
print("")
|
|
|
|
passphrase = six.moves.input("Enter the secret passphrase to protect with the rolling challenges : ")
|
|
|
|
secret_dict = {"count": 0,
|
|
"passphrase": passphrase,
|
|
}
|
|
roll_next_challenge(args, hmac_key, secret_dict)
|
|
|
|
def do_challenge(args):
|
|
""" Send a challenge to the YubiKey and use the result to decrypt the state file. """
|
|
outer_j = load_state_file(args)
|
|
challenge = outer_j["challenge"]
|
|
print("Challenge : %s" % (challenge))
|
|
response = get_yubikey_response(args, binascii.unhexlify(outer_j["challenge"]))
|
|
if args.debug or args.verbose:
|
|
print("\nGot %i bytes response %s\n" % (len(response), binascii.hexlify(response)))
|
|
else:
|
|
print("Response : %s" % binascii.hexlify(response))
|
|
inner_j = decrypt_with_response(args, outer_j["inner"], response)
|
|
if args.verbose or args.debug:
|
|
print("\nDecrypted 'inner' :\n%s\n" % (inner_j))
|
|
|
|
secret_dict = {}
|
|
try:
|
|
secret_dict = json.loads(inner_j.decode('ascii'))
|
|
except ValueError:
|
|
sys.stderr.write("\nCould not parse decoded data as JSON, you probably did not produce the right response.\n")
|
|
sys.exit(1)
|
|
|
|
secret_dict["count"] += 1
|
|
|
|
print("\nThe passphrase protected using rolling challenges is :\n")
|
|
print("\t%s\n\nAccessed %i times.\n" % (secret_dict["passphrase"], secret_dict["count"]))
|
|
roll_next_challenge(args, binascii.unhexlify(secret_dict["hmac_key"]), secret_dict)
|
|
|
|
def get_yubikey_response(args, challenge):
|
|
"""
|
|
Do challenge-response with the YubiKey if one is found. Otherwise prompt user to fake a response. """
|
|
try:
|
|
YK = yubico.find_yubikey(debug = args.debug)
|
|
response = YK.challenge_response(challenge.ljust(64, b'\0'), slot = args.slot)
|
|
return response
|
|
except yubico.yubico_exception.YubicoError as e:
|
|
print("YubiKey challenge-response failed (%s)" % e.reason)
|
|
print("")
|
|
response = six.moves.input("Assuming you do not have a YubiKey. Enter repsonse manually (hex encoded) : ")
|
|
return binascii.unhexlify(response)
|
|
|
|
def roll_next_challenge(args, hmac_key, inner_dict):
|
|
"""
|
|
When we have the HMAC-SHA1 key in clear, generate a random challenge and compute the
|
|
expected response for that challenge.
|
|
|
|
hmac_key is a 20-byte bytestring
|
|
"""
|
|
if len(hmac_key) != 20 or not isinstance(hmac_key, bytes):
|
|
hmac_key = binascii.unhexlify(hmac_key)
|
|
|
|
challenge = os.urandom(args.challenge_length)
|
|
response = get_response(hmac_key, challenge)
|
|
|
|
print("Generated challenge : %s" % binascii.hexlify(challenge).decode('ascii'))
|
|
print("Expected response : %s (sssh, don't tell anyone)" % binascii.hexlify(response).decode('ascii'))
|
|
print("")
|
|
if args.debug or args.verbose or args.init:
|
|
print("To manually verify that your YubiKey produces this response, use :")
|
|
print("")
|
|
print(" $ ykchalresp -%i -x %s" % (args.slot, binascii.hexlify(challenge).decode('ascii')))
|
|
print("")
|
|
|
|
inner_dict["hmac_key"] = binascii.hexlify(hmac_key).decode('ascii')
|
|
inner_j = json.dumps(inner_dict, indent = 4)
|
|
if args.verbose or args.debug:
|
|
print("Inner JSON :\n%s\n" % (inner_j))
|
|
inner_ciphertext = encrypt_with_response(args, inner_j, response)
|
|
outer_dict = {"challenge": binascii.hexlify(challenge).decode('ascii'),
|
|
"inner": inner_ciphertext.decode('ascii'),
|
|
}
|
|
outer_j = json.dumps(outer_dict, indent = 4)
|
|
if args.verbose or args.debug:
|
|
print("\nOuter JSON :\n%s\n" % (outer_j))
|
|
|
|
print("Saving 'outer' JSON to file '%s'" % (args.filename))
|
|
write_state_file(args, outer_j)
|
|
|
|
def get_response(hmac_key, challenge):
|
|
""" Compute the expected response for `challenge', as hexadecimal string """
|
|
print(binascii.hexlify(hmac_key), binascii.hexlify(challenge), hashlib.sha1)
|
|
h = hmac.new(hmac_key, challenge, hashlib.sha1)
|
|
return h.digest()
|
|
|
|
def encrypt_with_response(args, data, key):
|
|
"""
|
|
Encrypt our secret inner data with the response we expect the next time.
|
|
|
|
NOTE: The use of AES CBC has not been validated as cryptographically sound
|
|
in this application.
|
|
|
|
I would have done this with GPGme if it weren't for the fact that neither
|
|
of the two versions for Python available in Ubuntu 10.10 have support for
|
|
symmetric encrypt/decrypt (LP: #295918).
|
|
"""
|
|
# pad data to multiple of 16 bytes for AES CBC
|
|
pad = len(data) % 16
|
|
data += ' ' * (16 - pad)
|
|
|
|
# need to pad key as well
|
|
aes_key = key
|
|
aes_key += b'\0' * (32 - len(aes_key))
|
|
if args.debug:
|
|
print(("AES-CBC encrypting 'inner' with key (%i bytes) : %s" % (len(aes_key), binascii.hexlify(aes_key))))
|
|
|
|
obj = AES.new(aes_key, AES.MODE_CBC, b'\0' * 16)
|
|
ciphertext = obj.encrypt(data)
|
|
return binascii.hexlify(ciphertext)
|
|
|
|
def decrypt_with_response(args, data, key):
|
|
"""
|
|
Try to decrypt the secret inner data with the response we got to this challenge.
|
|
"""
|
|
aes_key = key
|
|
try:
|
|
aes_key = binascii.unhexlify(key)
|
|
except (TypeError, binascii.Error):
|
|
# was not hex encoded
|
|
pass
|
|
# need to pad key
|
|
aes_key += b'\0' * (32 - len(aes_key))
|
|
if args.debug:
|
|
print(("AES-CBC decrypting 'inner' using key (%i bytes) : %s" % (len(aes_key), binascii.hexlify(aes_key))))
|
|
|
|
obj = AES.new(aes_key, AES.MODE_CBC, b'\0' * 16)
|
|
plaintext = obj.decrypt(binascii.unhexlify(data))
|
|
return plaintext
|
|
|
|
def write_state_file(args, data):
|
|
""" Save state to file. """
|
|
f = open(args.filename, 'w')
|
|
f.write(data)
|
|
f.close()
|
|
|
|
def load_state_file(args):
|
|
""" Load (and parse) the state file. """
|
|
return json.loads(open(args.filename).read())
|
|
|
|
def main():
|
|
args = parse_args()
|
|
if args.init:
|
|
init_demo(args)
|
|
else:
|
|
do_challenge(args)
|
|
|
|
print("\nDone\n")
|
|
|
|
if __name__ == '__main__':
|
|
main()
|