Skip to content

Commit 8391f91

Browse files
committed
Implement framework for flexible 2FA
This adds support for using the hmac-secret FIDO extension to contribute keying material for a KeePass 4 file. It does this by storing an additional XML statekeeping blob in the outer ("public") header. This blob is designed to hold a variety of different authentication factors, such as passwords, key files, and Yubikey challenge-response devices.
1 parent 9459b06 commit 8391f91

12 files changed

+1456
-64
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ Pipfile.lock
99
*.kdbx
1010
*.kdbx.out
1111
.idea
12+
.venv

multifactor_auth.rst

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
multifactor authentication
2+
==========================
3+
4+
PyKeePass supports securing a database using an arbitrary combination of "authentication factors".
5+
A single factor could be something like a password, a file, or a hardware authenticator.
6+
7+
Factors are arranged into "factor groups". In order to open the database, *one* factor from
8+
each group must be provided.
9+
10+
Example using a single FIDO2 authenticator to unlock a database:
11+
12+
.. code:: python
13+
14+
# Import things
15+
>>> from pykeepass import PyKeePass, FactorInfo, FactorGroup, FIDO2Factor, create_database
16+
# Create new DB
17+
>>> db = create_database()
18+
# Create a FIDO2 factor
19+
>>> fido2_factor = FIDO2Factor(name="MyCoolFIDO")
20+
# Create a single factor group with that one factor in it
21+
>>> group = FactorGroup(factors=[fido2_factor])
22+
# Set PIN to use for the FIDO2 credential
23+
>>> factor_data = {"fido2_pin": "my_pin"}
24+
# Declare the one factor group is the only contributor to the database composite key
25+
>>> factor_info = FactorInfo(comprehensive=True, factor_groups=[group])
26+
# Save database
27+
>>> db.factor_data = factor_data
28+
>>> db.factor_info = factor_info
29+
>>> db.save()
30+
# Reopen database easily later - factor_info is stored inside it
31+
>>> kp = PyKeePass(filename, factor_data=factor_data)
32+
33+
Example using a password and one of two different keyfiles:
34+
35+
.. code:: python
36+
37+
>>> from pykeepass import PyKeePass, FactorInfo, FactorGroup, PasswordFactor, KeyFileFactor, create_database
38+
# Password factor
39+
>>> password_factor = PasswordFactor(name="MyCoolFIDO")
40+
# Keyfile factor
41+
>>> kf_factor_1 = KeyFileFactor(name="First KF")
42+
>>> kf_factor_2 = KeyFileFactor(name="Second KF")
43+
# First factor group, password only
44+
>>> group_1 = FactorGroup(factors=[password_factor])
45+
# Second factor group, either of two key files
46+
>>> group_2 = FactorGroup(factors=[kf_factor_1, kf_factor_2])
47+
>>> factor_data = {"password": "my_pass", "keyfile": {"First KF": "/kf1", "Second KF": "/kf2"}}
48+
>>> factor_info = FactorInfo(comprehensive=True, factor_groups=[group_1, group_2])
49+
50+
It's okay to mix factors of different types within a group.

pykeepass/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import absolute_import
22
from .pykeepass import PyKeePass, create_database
3+
from .kdbx_parsing.factorinfo import FactorInfo, FactorGroup, FIDO2Factor, PasswordFactor, KeyFileFactor
34

45
from .version import __version__
56

6-
__all__ = ["PyKeePass", "create_database", "__version__"]
7+
__all__ = ["PyKeePass", "create_database", "__version__", 'FactorInfo', 'FactorGroup', 'FIDO2Factor', 'KeyFileFactor']

pykeepass/fido2.py

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import logging
2+
import random
3+
4+
from fido2.cose import ES256
5+
from fido2.ctap import CtapError
6+
from fido2.ctap2.extensions import HmacSecretExtension, CredProtectExtension
7+
from fido2.hid import CtapHidDevice
8+
from fido2.client import Fido2Client, UserInteraction
9+
from fido2.webauthn import PublicKeyCredentialCreationOptions, PublicKeyCredentialRpEntity, \
10+
PublicKeyCredentialUserEntity, PublicKeyCredentialParameters, PublicKeyCredentialType, \
11+
PublicKeyCredentialDescriptor, PublicKeyCredentialRequestOptions, UserVerificationRequirement
12+
13+
log = logging.getLogger(__name__)
14+
15+
try:
16+
from fido2.pcsc import CtapPcscDevice
17+
except ImportError:
18+
CtapPcscDevice = None
19+
20+
FIDO2_FACTOR_RPID = "fido2.keepass.nodomain"
21+
22+
23+
class NonInteractive(UserInteraction):
24+
25+
def __init__(self, fixed_pin):
26+
self.fixed_pin = fixed_pin
27+
28+
def request_pin(self, permissions, rp_id):
29+
return self.fixed_pin
30+
31+
32+
def _get_all_authenticators():
33+
for dev in CtapHidDevice.list_devices():
34+
yield dev
35+
if CtapPcscDevice:
36+
for dev in CtapPcscDevice.list_devices():
37+
yield dev
38+
39+
40+
def _get_suitable_clients(pin_data):
41+
for authenticator in _get_all_authenticators():
42+
authenticator_path_string = repr(authenticator)
43+
44+
if isinstance(pin_data, str):
45+
pin_to_use = pin_data
46+
else:
47+
pin_to_use = pin_data.get(authenticator_path_string, pin_data.get("*", None))
48+
49+
client = Fido2Client(
50+
authenticator,
51+
"https://{}".format(FIDO2_FACTOR_RPID),
52+
user_interaction=NonInteractive(pin_to_use),
53+
extension_types=[
54+
HmacSecretExtension,
55+
CredProtectExtension
56+
]
57+
)
58+
59+
if "hmac-secret" in client.info.extensions and "credProtect" in client.info.extensions:
60+
yield client
61+
62+
63+
class FIDOException(Exception):
64+
pass
65+
66+
67+
def fido2_enroll(pin_data, already_enrolled_credentials):
68+
log.info("Enrolling new FIDO2 authenticator")
69+
70+
# We don't care about the user ID
71+
# So long as it doesn't collide with another one for the same authenticator, it's all good
72+
user_id = random.randbytes(16)
73+
74+
chosen_client = next(_get_suitable_clients(pin_data), None)
75+
if chosen_client is None:
76+
raise FIDOException("Could not find an authenticator supporting the hmac-secret and credProtect extensions")
77+
78+
credential = chosen_client.make_credential(PublicKeyCredentialCreationOptions(
79+
rp=PublicKeyCredentialRpEntity(
80+
name="pykeepass",
81+
id=FIDO2_FACTOR_RPID
82+
),
83+
user=PublicKeyCredentialUserEntity(
84+
name="keepass",
85+
id=user_id,
86+
display_name="KeePass"
87+
),
88+
challenge=random.randbytes(32),
89+
pub_key_cred_params=[
90+
PublicKeyCredentialParameters(
91+
type=PublicKeyCredentialType.PUBLIC_KEY,
92+
alg=ES256.ALGORITHM
93+
)
94+
],
95+
exclude_credentials=[
96+
PublicKeyCredentialDescriptor(
97+
type=PublicKeyCredentialType.PUBLIC_KEY,
98+
id=credential_id
99+
) for credential_id in already_enrolled_credentials
100+
],
101+
extensions={
102+
"hmacCreateSecret": True,
103+
"credentialProtectionPolicy": CredProtectExtension.POLICY.REQUIRED,
104+
"enforceCredentialProtectionPolicy": True
105+
}
106+
))
107+
108+
if not credential.extension_results.get("hmacCreateSecret", False):
109+
raise FIDOException("Authenticator didn't create an HMAC secret!")
110+
111+
return credential.attestation_object.auth_data.credential_data.credential_id
112+
113+
114+
def fido2_get_key_material(pin_data, credential_ids, salt1, salt2, verify_user=True):
115+
log.info("Getting keying material from FIDO2 authenticator (with {} potential credentials)".format(len(credential_ids)))
116+
117+
user_verification = UserVerificationRequirement.REQUIRED if verify_user else UserVerificationRequirement.DISCOURAGED
118+
for client in _get_suitable_clients(pin_data):
119+
try:
120+
assertion_response = client.get_assertion(
121+
PublicKeyCredentialRequestOptions(
122+
challenge=random.randbytes(32),
123+
rp_id=FIDO2_FACTOR_RPID,
124+
allow_credentials=[
125+
PublicKeyCredentialDescriptor(
126+
type=PublicKeyCredentialType.PUBLIC_KEY,
127+
id=credential_id
128+
) for credential_id in credential_ids
129+
],
130+
user_verification=user_verification,
131+
extensions={
132+
"hmacGetSecret": {
133+
"salt1": salt1,
134+
"salt2": salt2
135+
}
136+
}
137+
)
138+
)
139+
assertion = assertion_response.get_response(0)
140+
hmac_response = assertion.extension_results.get("hmacGetSecret", None)
141+
if hmac_response is not None:
142+
return hmac_response.get("output1", None), hmac_response.get("output2", None)
143+
except CtapError as e:
144+
if e.code != CtapError.ERR.NO_CREDENTIALS:
145+
raise e
146+
147+
raise FIDOException("No authenticator provided key material")

pykeepass/kdbx_parsing/common.py

+75-43
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from copy import deepcopy
1010
import base64
1111
from binascii import Error as BinasciiError
12-
import unicodedata
1312
import zlib
1413
import re
1514
import codecs
@@ -105,7 +104,49 @@ def aes_kdf(key, rounds, key_composite):
105104
return hashlib.sha256(transformed_key).digest()
106105

107106

108-
def compute_key_composite(password=None, keyfile=None):
107+
def compute_keyfile_part_of_composite(keyfile):
108+
if hasattr(keyfile, "read"):
109+
keyfile_bytes = keyfile.read()
110+
else:
111+
with open(keyfile, 'rb') as f:
112+
keyfile_bytes = f.read()
113+
# try to read XML keyfile
114+
try:
115+
tree = etree.fromstring(keyfile_bytes)
116+
version = tree.find('Meta/Version').text
117+
data_element = tree.find('Key/Data')
118+
if version.startswith('1.0'):
119+
return base64.b64decode(data_element.text)
120+
elif version.startswith('2.0'):
121+
# read keyfile data and convert to bytes
122+
keyfile_composite = bytes.fromhex(data_element.text.strip())
123+
# validate bytes against hash
124+
hash = bytes.fromhex(data_element.attrib['Hash'])
125+
hash_computed = hashlib.sha256(keyfile_composite).digest()[:4]
126+
assert hash == hash_computed, "Keyfile has invalid hash"
127+
return keyfile_composite
128+
# otherwise, try to read plain keyfile
129+
except (etree.XMLSyntaxError, UnicodeDecodeError):
130+
try:
131+
try:
132+
int(keyfile_bytes, 16)
133+
is_hex = True
134+
except ValueError:
135+
is_hex = False
136+
# if the length is 32 bytes we assume it is the key
137+
if len(keyfile_bytes) == 32:
138+
return keyfile_bytes
139+
# if the length is 64 bytes we assume the key is hex encoded
140+
elif len(keyfile_bytes) == 64 and is_hex:
141+
return codecs.decode(keyfile_bytes, 'hex')
142+
# anything else may be a file to hash for the key
143+
else:
144+
return hashlib.sha256(keyfile_bytes).digest()
145+
except:
146+
raise IOError('Could not read keyfile')
147+
148+
149+
def compute_key_composite(password=None, keyfile=None, additional_parts=None):
109150
"""Compute composite key.
110151
Used in header verification and payload decryption."""
111152

@@ -116,50 +157,17 @@ def compute_key_composite(password=None, keyfile=None):
116157
password_composite = b''
117158
# hash the keyfile
118159
if keyfile:
119-
if hasattr(keyfile, "read"):
120-
keyfile_bytes = keyfile.read()
121-
else:
122-
with open(keyfile, 'rb') as f:
123-
keyfile_bytes = f.read()
124-
# try to read XML keyfile
125-
try:
126-
tree = etree.fromstring(keyfile_bytes)
127-
version = tree.find('Meta/Version').text
128-
data_element = tree.find('Key/Data')
129-
if version.startswith('1.0'):
130-
keyfile_composite = base64.b64decode(data_element.text)
131-
elif version.startswith('2.0'):
132-
# read keyfile data and convert to bytes
133-
keyfile_composite = bytes.fromhex(data_element.text.strip())
134-
# validate bytes against hash
135-
hash = bytes.fromhex(data_element.attrib['Hash'])
136-
hash_computed = hashlib.sha256(keyfile_composite).digest()[:4]
137-
assert hash == hash_computed, "Keyfile has invalid hash"
138-
# otherwise, try to read plain keyfile
139-
except (etree.XMLSyntaxError, UnicodeDecodeError):
140-
try:
141-
try:
142-
int(keyfile_bytes, 16)
143-
is_hex = True
144-
except ValueError:
145-
is_hex = False
146-
# if the length is 32 bytes we assume it is the key
147-
if len(keyfile_bytes) == 32:
148-
keyfile_composite = keyfile_bytes
149-
# if the length is 64 bytes we assume the key is hex encoded
150-
elif len(keyfile_bytes) == 64 and is_hex:
151-
keyfile_composite = codecs.decode(keyfile_bytes, 'hex')
152-
# anything else may be a file to hash for the key
153-
else:
154-
keyfile_composite = hashlib.sha256(keyfile_bytes).digest()
155-
except:
156-
raise IOError('Could not read keyfile')
157-
160+
keyfile_composite = compute_keyfile_part_of_composite(keyfile)
158161
else:
159162
keyfile_composite = b''
160163

161-
# create composite key from password and keyfile composites
162-
return hashlib.sha256(password_composite + keyfile_composite).digest()
164+
# create composite key from password, keyfile, and other composites
165+
overall_composite = password_composite + keyfile_composite
166+
if additional_parts is not None:
167+
for part in additional_parts:
168+
overall_composite += part
169+
170+
return hashlib.sha256(overall_composite).digest()
163171

164172

165173
def compute_master(context):
@@ -173,6 +181,30 @@ def compute_master(context):
173181
return master_key
174182

175183

184+
def populate_custom_data(kdbx, d):
185+
if len(d.keys()) > 0:
186+
vd = Container(
187+
version=b'\x00\x01',
188+
dict=d,
189+
)
190+
kdbx.header.value.dynamic_header.update(
191+
{
192+
"public_custom_data":
193+
Container(
194+
id='public_custom_data',
195+
data=vd,
196+
next_byte=0xFF,
197+
)
198+
}
199+
)
200+
else:
201+
# Removing header entirely
202+
if "public_custom_data" in kdbx.header.value.dynamic_header:
203+
del kdbx.header.value.dynamic_header["public_custom_data"]
204+
205+
kdbx.header.value.dynamic_header.move_to_end("end")
206+
207+
176208
# -------------------- XML Processing --------------------
177209

178210

0 commit comments

Comments
 (0)