Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement framework for flexible 2FA #373

Closed
wants to merge 0 commits into from

Conversation

BryanJacobs
Copy link

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.

The implementation here has support for passwords (primitively - no brute-force resistance), key files, and FIDO2 authenticators. It contains documentation about the changes to the KeePass file format, and tests covering the basic functionality.

It adds a dependency on python-fido2 to perform actual FIDO2 operations with connected PC/SC or USB-HID authenticators.

.gitignore Outdated
@@ -9,3 +9,4 @@ Pipfile.lock
*.kdbx
*.kdbx.out
.idea
.venv
Copy link
Author

@BryanJacobs BryanJacobs Jan 15, 2024

Choose a reason for hiding this comment

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

Not using an isolated Python environment rapidly leads to a mess. I've marked the demi-standard .venv directory as ignored to make it easier to keep a virtual environment inside the project folder.

Copy link
Author

Choose a reason for hiding this comment

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

This file isn't just for pykeepass. I'm going to implement support for this in other projects next, and figured it would be good to have a reference, since the whole KeePass file format isn't centrally owned.


from .version import __version__

__all__ = ["PyKeePass", "create_database", "__version__"]
__all__ = ["PyKeePass", "create_database", "__version__", 'FactorInfo', 'FactorGroup', 'FIDO2Factor', 'KeyFileFactor']
Copy link
Author

Choose a reason for hiding this comment

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

As these classes are necessary to make a database start using multifactor auth, I figured it made sense to expose them at the top level.

Copy link
Author

@BryanJacobs BryanJacobs Jan 15, 2024

Choose a reason for hiding this comment

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

This file - and only this file - contains code for talking to FIDO2 authenticators.

The two operations we perform are, of course, make-credential and get-assertion.

@@ -105,7 +104,49 @@ def aes_kdf(key, rounds, key_composite):
return hashlib.sha256(transformed_key).digest()


def compute_key_composite(password=None, keyfile=None):
def compute_keyfile_part_of_composite(keyfile):
Copy link
Author

Choose a reason for hiding this comment

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

Factored out of compute_key_composite

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


def populate_custom_data(kdbx, d):
Copy link
Author

@BryanJacobs BryanJacobs Jan 15, 2024

Choose a reason for hiding this comment

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

I found working with construct extremely difficult.

@@ -152,14 +176,19 @@ def save(self, filename=None, transformed_key=None):
if not filename:
filename = self.filename

if hasattr(self.kdbx.header, 'data'):
Copy link
Author

Choose a reason for hiding this comment

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

I noticed that if you called save() after changing anything in the header it didn't really save because the binary representation was already there...


keepass_instance.save(transformed_key)
keepass_instance.save(transformed_key=transformed_key)
Copy link
Author

Choose a reason for hiding this comment

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

I think this was bugged before? If I understand correctly it would pass transformed_key as the filename. So I guess nobody really used this function that way?

pyproject.toml Outdated
@@ -14,6 +14,7 @@ dependencies = [
"argon2_cffi",
"pycryptodomex>=3.6.2",
"lxml",
"fido2[pcsc]"
Copy link
Author

Choose a reason for hiding this comment

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

New dependency

requirements.txt Outdated
@@ -1,5 +1,6 @@
lxml==4.8.0
lxml==5.1.0
Copy link
Author

Choose a reason for hiding this comment

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

Version bump to work with fido2. Doesn't seem to break things.

Copy link
Author

Choose a reason for hiding this comment

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

This file contains most of the implementation heavy lift.

digest = hmac.new(decrypted_key, self.group.validation_in, 'SHA-512').digest()
else:
# Can't verify, we don't know how
pass
Copy link
Author

Choose a reason for hiding this comment

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

In the event it's wrong we'll just end up failing to open the database later, as the constructed composite key won't pass the header checksum.


next_challenge = random.randbytes(32)

if len(fido2_factors) > 0:
Copy link
Author

Choose a reason for hiding this comment

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

Rather important (and very inconvenient to implement...) feature here. We make one FIDO2 call per authenticator per group.

If there are ten Factor entries, each one of which is a FIDO2 authenticator... that means for each authenticator one would succeed and nine would fail.

If there's one authenticator plugged into the system it would not be great for the user to have to type their PIN ten times.

So instead we gather all the credentials in the group and send them in one call. The authenticator will accept the credential it created, and ignore the others.

],
extensions={
"hmacCreateSecret": True,
"credentialProtectionPolicy": CredProtectExtension.POLICY.REQUIRED,
Copy link
Author

Choose a reason for hiding this comment

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

This forces the user to provide "verification" (a PIN, a fingerprint, etc - not just a touch) every time the credential is used.

This is important because the hmac-secret extension says that the keys used are silently different for UV (user verification) vs non-UV scenarios. It would not be great if the key were created with non-UV, and then later the authenticator is set to always-UV mode and so it becomes impossible to get the original key again.

PublicKeyCredentialDescriptor(
type=PublicKeyCredentialType.PUBLIC_KEY,
id=credential_id
) for credential_id in already_enrolled_credentials
Copy link
Author

Choose a reason for hiding this comment

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

Passing the credentials already in the group here prevents adding the same authenticator to the same group twice.

You can still add the same authenticator to two different groups, and of course to two different files. But I don't think it makes sense to have two credentials from the same authenticator in one group...

@Evidlo
Copy link
Member

Evidlo commented Feb 29, 2024

Closes #311

This looks good so far. I'll ask to move the contents of multifactor_auth.rst into a new section in the README as that's where our examples currently live.

I'll hold off on merging until the thread over at keepassxc is mostly resolved.

@BryanJacobs
Copy link
Author

Sorry, accidentally auto-closed as I updated my branch. I will reopen this momentarily with your requested changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants