├── .gitignore ├── README.rst ├── mypy.ini ├── pindeps ├── pindeps.bat ├── pyproject.toml ├── requirements.txt ├── requirements.windows.txt └── src └── tokenring ├── __init__.py ├── agent ├── _admin_pipe.py ├── client.py └── common.py ├── cli.py ├── fidoclient.py ├── handles.py ├── interaction.py ├── local.py └── vault.py /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | *.tokenvault 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | --------- 2 | TokenRing 3 | --------- 4 | 5 | TokenRing is a back-end for the Python `keyring 6 | `_ module, which uses a `hard token 7 | `_ to encrypt your collection of 8 | passwords as a large `Fernet token 9 | `_, 10 | composed of individual password entries, each of which is separately encrypted 11 | as a smaller Fernet token of its own. 12 | 13 | --------------------------- 14 | Background and Threat Model 15 | --------------------------- 16 | 17 | The keyring module is a great starting point for managing confidential 18 | materials in your Python applications. Anything that needs to connect to a 19 | network-backed service for an account requires some kind of saved password or 20 | API token for nearly every operation. By using the keyring API, you give the 21 | user control over how that information is accessed, via configuration and 22 | plugins. 23 | 24 | However, using its default backend on every platform, Keyring provides silent 25 | access to your credentials. Only macOS even provides a mechanism that *could* 26 | require user interaction to access a credential; Windows provides literally 27 | nothing and desktop Linux provides only the ability to temporarily lock *all* 28 | secrets behind your login password. 29 | 30 | For a lot of applications, this is fine; “arbitrary code execution on your 31 | computer” is a pretty high bar for an attacker to achieve, and there's a lot of 32 | nasty stuff they can get up to if they get it; it would be annoying to have to 33 | log in once every 30 seconds so that background tasks could check your email or 34 | on every single ``git fetch``. 35 | 36 | But some operations are dangerous and infrequent enough that your computer 37 | should *really* not be able to do them without you noticing. Just for a couple 38 | of examples: uploading widely-used packages to PyPI that can execute code on 39 | millions of computers, or issuing bank transfers to your payment provider. 40 | 41 | This is where ``tokenring`` comes in. 42 | 43 | ------------ 44 | Requirements 45 | ------------ 46 | 47 | You need to have an NFC authenticator with the `hmac-secret 48 | `_ 49 | extension. In practice, in my limited experience, this means a YubiCo 50 | authenticator of some recent vintage. 51 | 52 | ----- 53 | Usage 54 | ----- 55 | 56 | Step 0: make sure the software that you're using uses ``keyring`` to fetch its secrets 57 | -------------------------------------------------------------------------------------- 58 | 59 | Many things which handle sensitive information do, but you might need to submit 60 | a patch first. 61 | 62 | Step 1: install ``tokenring`` into the environment where you're accessing extra-sensitive secrets 63 | --------------------------------------------------------------------------------------------------------- 64 | 65 | You probably don't want ``tokenring`` globally installed; or indeed installed 66 | in most of your Python environments, since it is a high-priority backend that 67 | will take over for all Keyring API calls by default, and therefore require your 68 | hard-token to access every time. 69 | 70 | For example, let's say you upload all your packages with twine. First, install 71 | twine itself with `pipx `_ so it gets its own 72 | dedicated virtual environment. Then, ``pipx inject twine --include-apps 73 | tokenring``; since this always injects ``keyring`` as well, ``twine`` will 74 | always use ``tokenring`` as a backend. 75 | 76 | Step 2: run the agent 77 | ---------------------- 78 | 79 | This is currently mandatory on Windows due to `this issue 80 | `_ unless you are running your 81 | application as an administrator. On other platforms, it'll fall back to local 82 | access within the requesting process, but you'll have to tap your authenticator 83 | one extra time per process in that case, to unlock the vault. 84 | 85 | ``pipx install tokenring``, and run ``tokenring agent path/to/your/tokenring.vault``. 86 | 87 | 88 | Step 3: call ``keyring.set_password`` and ``keyring.get_password`` in whatever application you'd like to use 89 | ------------------------------------------------------------------------------------------------------------- 90 | 91 | If the ``keyring`` command on your shell's ``PATH`` is in an environment with 92 | ``tokenring`` installed, you can just use ``keyring set`` and ``keyring get`` 93 | to test this, but as a convenience, to make sure you're inspecting 94 | ``tokenring`` directly, you can use the ``tokenring set`` and ``tokenring get`` 95 | commands, which behave similarly but will never use any other keyring backend. 96 | 97 | To use Twine with a secret stored in ``tokenring``, for example, the full 98 | workflow would be: 99 | 100 | 1. open a terminal and run ``tokenring agent my.vault`` 101 | 2. create a token at https://pypi.org/manage/account/token/ 102 | 3. open a terminal and run ``tokenring set https://upload.pypi.org/legacy/ 103 | __token__``, then paste your token when prompted 104 | 4. in whatever project you'd like to upload, ``TWINE_USERNAME=__token__ twine 105 | upload dist/*`` 106 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | -------------------------------------------------------------------------------- /pindeps: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pip-compile --upgrade --no-emit-index-url --extra=dev 4 | -------------------------------------------------------------------------------- /pindeps.bat: -------------------------------------------------------------------------------- 1 | pip-compile --upgrade --no-emit-index-url --extra=dev --output-file=requirements.windows.txt pyproject.toml 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tokenring" 3 | version = "2025.2.19" 4 | dependencies = [ 5 | "fido2>=1.2.0", 6 | "cryptography", 7 | "keyring", 8 | "pyuac", 9 | "click", 10 | "pywin32; os_name=='nt'", 11 | ] 12 | 13 | [metadata] 14 | author = "Glyph" 15 | author_email = "code@glyph.im" 16 | description = "A backend for the `keyring` module which uses a hardware token to require user presence for any secret access, by encrypting your vault and passwords as Fernet tokens." 17 | long_description = "file:README.rst" 18 | url = "https://github.com/glyph/tokenring" 19 | classifiers = [ 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Topic :: Security", 25 | "Topic :: Security :: Cryptography", 26 | ] 27 | 28 | [project.optional-dependencies] 29 | dev = [ 30 | "build", 31 | "mypy", 32 | "types-pywin32", 33 | ] 34 | 35 | [project.scripts] 36 | tokenring = "tokenring.cli:cli" 37 | 38 | [project.entry-points."keyring.backends"] 39 | tokenring_local = "tokenring.local" 40 | tokenring_agent = "tokenring.agent.client" 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --extra=dev --no-emit-index-url 6 | # 7 | build==1.2.2.post1 8 | # via tokenring (pyproject.toml) 9 | cffi==1.17.1 10 | # via cryptography 11 | click==8.1.8 12 | # via tokenring (pyproject.toml) 13 | cryptography==44.0.1 14 | # via 15 | # fido2 16 | # tokenring (pyproject.toml) 17 | decorator==5.1.1 18 | # via pyuac 19 | fido2==1.2.0 20 | # via tokenring (pyproject.toml) 21 | jaraco-classes==3.4.0 22 | # via keyring 23 | jaraco-context==6.0.1 24 | # via keyring 25 | jaraco-functools==4.1.0 26 | # via keyring 27 | keyring==25.6.0 28 | # via tokenring (pyproject.toml) 29 | more-itertools==10.6.0 30 | # via 31 | # jaraco-classes 32 | # jaraco-functools 33 | mypy==1.15.0 34 | # via tokenring (pyproject.toml) 35 | mypy-extensions==1.0.0 36 | # via mypy 37 | packaging==24.2 38 | # via build 39 | pycparser==2.22 40 | # via cffi 41 | pyproject-hooks==1.2.0 42 | # via build 43 | pyuac==0.0.3 44 | # via tokenring (pyproject.toml) 45 | tee==0.0.3 46 | # via pyuac 47 | types-pywin32==308.0.0.20250128 48 | # via tokenring (pyproject.toml) 49 | typing-extensions==4.12.2 50 | # via mypy 51 | -------------------------------------------------------------------------------- /requirements.windows.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile --extra=dev --no-emit-index-url --output-file=requirements.windows.txt pyproject.toml 6 | # 7 | backports-tarfile==1.2.0 8 | # via jaraco-context 9 | build==1.2.2.post1 10 | # via tokenring (pyproject.toml) 11 | cffi==1.17.1 12 | # via cryptography 13 | click==8.1.7 14 | # via tokenring (pyproject.toml) 15 | colorama==0.4.6 16 | # via 17 | # build 18 | # click 19 | cryptography==44.0.0 20 | # via 21 | # fido2 22 | # tokenring (pyproject.toml) 23 | decorator==5.1.1 24 | # via pyuac 25 | fido2==1.2.0 26 | # via tokenring (pyproject.toml) 27 | importlib-metadata==8.5.0 28 | # via keyring 29 | jaraco-classes==3.4.0 30 | # via keyring 31 | jaraco-context==6.0.1 32 | # via keyring 33 | jaraco-functools==4.1.0 34 | # via keyring 35 | keyring==25.5.0 36 | # via tokenring (pyproject.toml) 37 | more-itertools==10.5.0 38 | # via 39 | # jaraco-classes 40 | # jaraco-functools 41 | mypy==1.13.0 42 | # via tokenring (pyproject.toml) 43 | mypy-extensions==1.0.0 44 | # via mypy 45 | packaging==24.2 46 | # via build 47 | pycparser==2.22 48 | # via cffi 49 | pyproject-hooks==1.2.0 50 | # via build 51 | pyuac==0.0.3 52 | # via tokenring (pyproject.toml) 53 | pywin32==308 ; os_name == "nt" 54 | # via tokenring (pyproject.toml) 55 | pywin32-ctypes==0.2.3 56 | # via keyring 57 | tee==0.0.3 58 | # via pyuac 59 | tomli==2.2.1 60 | # via 61 | # build 62 | # mypy 63 | types-pywin32==308.0.0.20241128 64 | # via tokenring (pyproject.toml) 65 | typing-extensions==4.12.2 66 | # via mypy 67 | zipp==3.21.0 68 | # via importlib-metadata 69 | -------------------------------------------------------------------------------- /src/tokenring/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/tokenring/403e8ab16ca5ee81d81cd29e6a3ac2a222972089/src/tokenring/__init__.py -------------------------------------------------------------------------------- /src/tokenring/agent/_admin_pipe.py: -------------------------------------------------------------------------------- 1 | """ 2 | On Windows, L{multiprocessing} does not allow administrative helper processes 3 | that non-administrative helper processes connect to. 4 | """ 5 | 6 | # mypy can't see these because they're private; this is a gross monkey patch, 7 | # so no surprise. 8 | 9 | from multiprocessing.connection import ( # type:ignore[attr-defined] 10 | BUFSIZE, 11 | PipeListener, 12 | ) 13 | from typing import TYPE_CHECKING 14 | 15 | from win32file import FILE_FLAG_OVERLAPPED 16 | from win32pipe import ( 17 | CreateNamedPipe, 18 | FILE_FLAG_FIRST_PIPE_INSTANCE, 19 | NMPWAIT_WAIT_FOREVER, 20 | PIPE_ACCESS_DUPLEX, 21 | PIPE_READMODE_MESSAGE, 22 | PIPE_TYPE_MESSAGE, 23 | PIPE_UNLIMITED_INSTANCES, 24 | PIPE_WAIT, 25 | ) 26 | from win32security import ( 27 | ConvertStringSecurityDescriptorToSecurityDescriptor as CSSDTSD, 28 | SDDL_REVISION_1, 29 | SECURITY_ATTRIBUTES, 30 | ) 31 | 32 | 33 | if TYPE_CHECKING: 34 | 35 | class HANDLEType: 36 | # https://github.com/python/typeshed/pull/10032 37 | def Detach(self) -> None: 38 | ... 39 | 40 | def __int__(self) -> int: 41 | ... 42 | 43 | 44 | def _new_handle(self: PipeListener, first: bool = False) -> object: 45 | """ 46 | Replacement for internal pipe-allocation scheme in multiprocessing's 47 | internal PipeListener implementation which allows non-admins to connect to . 48 | """ 49 | flags = PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED 50 | if first: 51 | flags |= FILE_FLAG_FIRST_PIPE_INSTANCE 52 | attribs = SECURITY_ATTRIBUTES() 53 | # Thank you David Heffernan https://stackoverflow.com/a/14500073/13564 54 | attribs.SECURITY_DESCRIPTOR = CSSDTSD("D:(A;OICI;GRGW;;;AU)", SDDL_REVISION_1) 55 | not_int = CreateNamedPipe( 56 | self._address, 57 | flags, 58 | PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, 59 | PIPE_UNLIMITED_INSTANCES, 60 | BUFSIZE, 61 | BUFSIZE, 62 | NMPWAIT_WAIT_FOREVER, 63 | attribs, 64 | ) 65 | pipe_handle_obj: HANDLEType = not_int # type:ignore[assignment] 66 | pipe_handle_int = int(pipe_handle_obj) 67 | 68 | # pywin32 opened the handle, but it's going to be manipulated by the 69 | # stdlib's _win32api, so we need to convert it to an integer (which is the 70 | # type that the stdlib works in, much as it works with UNIX file 71 | # descriptors as ints). We therefore need to *not* have pywin32 *close* 72 | # the handle, which is why we Detach() it; otherwise it closes when 73 | # `pipe_handle_obj` gets finalized. 74 | pipe_handle_obj.Detach() 75 | return pipe_handle_int 76 | 77 | 78 | def _patch() -> None: 79 | """ 80 | Execute the monkeypatch. 81 | """ 82 | PipeListener._new_handle = _new_handle 83 | -------------------------------------------------------------------------------- /src/tokenring/agent/client.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import contextmanager 3 | from dataclasses import dataclass 4 | from multiprocessing.connection import Client, Connection 5 | from typing import Iterator 6 | 7 | from keyring.backend import KeyringBackend 8 | 9 | from .common import address, auth_key, family 10 | 11 | 12 | @contextmanager 13 | def show_waiting() -> Iterator[None]: 14 | print("Waiting for agent…", file=sys.stderr, end="", flush=True) 15 | try: 16 | yield 17 | finally: 18 | print("OK", file=sys.stderr, flush=True) 19 | 20 | 21 | @dataclass 22 | class BackgroundTokenRing(KeyringBackend): 23 | """ 24 | Keyring backend that connects to a L{tokenring.local.LocalTokenRing} 25 | running in a dedicated helper process, for two reasons: 26 | 27 | 1. to minimize the amount of code running in the UAC-elevated process 28 | until we address the issue described in L{tokenring._admin_pipe} 29 | 30 | 2. to reduce the number of user-presence checks when repeated 31 | authentications are require. Specifically, the vault itself needs a 32 | UP/PIN check for unlock, but then, each credential will also need a 33 | UP check. With a background helper, you can unlock the vault for a 34 | session and only touch the key once for each credential rather than 35 | twice. 36 | """ 37 | 38 | connection: Connection | None = None 39 | priority: int = 25 40 | 41 | try: 42 | connection = Client(address=address, family=family, authkey=auth_key) 43 | except FileNotFoundError: 44 | priority = 0 45 | 46 | def realize_connection(self) -> Connection: 47 | """ 48 | Create a connection if none is present. 49 | """ 50 | if self.connection is None: 51 | self.connection = Client(address=address, family=family, authkey=auth_key) 52 | return self.connection 53 | 54 | def multisend(self, words: list[str]) -> str | None: 55 | conn = self.realize_connection() 56 | for word in words: 57 | conn.send_bytes(word.encode("utf-8")) 58 | ok = conn.recv_bytes() 59 | if ok == b"y": 60 | return conn.recv_bytes().decode("utf-8") 61 | else: 62 | return None 63 | 64 | def get_password(self, servicename: str, username: str) -> str | None: 65 | with show_waiting(): 66 | return self.multisend(["get", servicename, username]) 67 | 68 | def set_password(self, servicename: str, username: str, password: str) -> None: 69 | with show_waiting(): 70 | self.multisend(["set", servicename, username, password]) 71 | -------------------------------------------------------------------------------- /src/tokenring/agent/common.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from platform import win32_edition 3 | from os import urandom 4 | 5 | from keyring.util.platform_ import data_root 6 | 7 | keyringdir = Path(data_root()).absolute() 8 | keyringdir.mkdir(parents=True, exist_ok=True) 9 | 10 | if win32_edition() is not None: 11 | from getpass import getuser 12 | 13 | family = "AF_PIPE" 14 | address = rf"\\.\pipe\{getuser()}-TokenVault" 15 | else: 16 | from getpass import getuser 17 | 18 | family = "AF_UNIX" 19 | address = (keyringdir / "tokenring.socket").as_posix() 20 | 21 | secret_path = keyringdir / "tokenring.socket-secret" 22 | if secret_path.is_file(): 23 | auth_key = secret_path.read_bytes() 24 | else: 25 | auth_key = urandom(32) 26 | secret_path.write_bytes(auth_key) 27 | -------------------------------------------------------------------------------- /src/tokenring/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from getpass import getpass 3 | from multiprocessing.connection import Listener 4 | from pathlib import Path 5 | 6 | import click 7 | 8 | from .agent.common import address, auth_key, family 9 | from .agent.client import BackgroundTokenRing 10 | 11 | if sys.platform == "win32": 12 | from .agent._admin_pipe import _patch 13 | 14 | _patch() 15 | 16 | 17 | from .local import LocalTokenRing 18 | 19 | # Windows requires administrator access in order to access the hmac-secret 20 | # extension on the hard token, to get direct USB HID access to the device, 21 | # because of this bug: 22 | 23 | # https://github.com/Yubico/python-fido2/issues/185 24 | 25 | def token_ring() -> BackgroundTokenRing | LocalTokenRing: 26 | if BackgroundTokenRing.connection is not None: 27 | return BackgroundTokenRing() 28 | else: 29 | return LocalTokenRing() 30 | 31 | @click.group() 32 | def cli(): 33 | ... 34 | 35 | @cli.command() 36 | @click.argument('servicename') 37 | @click.argument('username') 38 | def get(servicename: str, username: str) -> None: 39 | click.echo(token_ring().get_password(servicename, username)) 40 | 41 | @cli.command() 42 | @click.argument('servicename') 43 | @click.argument('username') 44 | def set(servicename: str, username: str) -> None: 45 | password = getpass(f"Password for '{username}' in '{servicename}': ") 46 | token_ring().set_password(servicename, username, password) 47 | 48 | 49 | click_path = click.Path(path_type=Path) # type:ignore[type-var] 50 | # type ignore here seems to be just a bug in types-click? 51 | 52 | from sys import argv 53 | real_argv = argv[:] 54 | @cli.command() 55 | @click.argument("vault_path", required=False, type=click_path) 56 | def agent(vault_path: Path | None) -> None: 57 | 58 | local_ring = ( 59 | LocalTokenRing(location=vault_path) 60 | if vault_path is not None 61 | else LocalTokenRing() 62 | ) 63 | 64 | vault = local_ring.realize_vault() 65 | with Listener(address=address, family=family, authkey=auth_key) as listener: 66 | while True: 67 | with listener.accept() as conn: 68 | while True: 69 | password = None 70 | try: 71 | command = conn.recv_bytes() 72 | except EOFError: 73 | break 74 | else: 75 | if command == b"get": 76 | servicename = conn.recv_bytes().decode("utf-8") 77 | username = conn.recv_bytes().decode("utf-8") 78 | password = vault.get_password(servicename, username) 79 | if password is None: 80 | conn.send_bytes(b"n") 81 | else: 82 | conn.send_bytes(b"y") 83 | conn.send_bytes(password.encode("utf-8")) 84 | password = None 85 | 86 | if command == b"set": 87 | servicename = conn.recv_bytes().decode("utf-8") 88 | username = conn.recv_bytes().decode("utf-8") 89 | password = conn.recv_bytes().decode("utf-8") 90 | vault.set_password(servicename, username, password) 91 | password = None 92 | conn.send_bytes(b"n") 93 | -------------------------------------------------------------------------------- /src/tokenring/fidoclient.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import ctypes 5 | from typing import TYPE_CHECKING, Callable, Iterable, Sequence 6 | 7 | from fido2.client import Fido2Client, UserInteraction, WindowsClient 8 | from fido2.ctap2.extensions import HmacSecretExtension 9 | from fido2.hid import CtapHidDevice 10 | 11 | try: 12 | from fido2.pcsc import CtapPcscDevice 13 | 14 | have_pcsc = True 15 | except ImportError: 16 | have_pcsc = False 17 | 18 | if TYPE_CHECKING: 19 | AnyCtapDevice = CtapHidDevice | CtapPcscDevice 20 | 21 | 22 | def enumerate_devices() -> Iterable[AnyCtapDevice]: 23 | yield from CtapHidDevice.list_devices() 24 | if have_pcsc: 25 | yield from CtapPcscDevice.list_devices() 26 | 27 | 28 | class NoAuthenticator(Exception): 29 | """ 30 | Could not find an authenticator. 31 | """ 32 | 33 | 34 | AnyFidoClient = Fido2Client | WindowsClient 35 | 36 | fake_url = "https://hardware.keychain.glyph.im" 37 | 38 | 39 | def enumerate_clients( 40 | interaction: UserInteraction, 41 | ) -> Iterable[tuple[AnyFidoClient, AnyCtapDevice | None]]: 42 | # Locate a device 43 | if WindowsClient.is_available(): 44 | is_admin: bool = ctypes.windll.shell32.IsUserAnAdmin() # type:ignore 45 | if not is_admin: 46 | yield (WindowsClient(fake_url, allow_hmac_secret=True), None) 47 | return 48 | for dev in enumerate_devices(): 49 | yield ( 50 | Fido2Client( 51 | dev, 52 | fake_url, 53 | user_interaction=interaction, 54 | extensions=[HmacSecretExtension(allow_hmac_secret=True)], 55 | ), 56 | dev, 57 | ) 58 | 59 | 60 | def extension_required(client: AnyFidoClient) -> bool: 61 | """ 62 | Client filter for clients that support the hmac-secret extension. 63 | """ 64 | if os.name == 'nt': 65 | # TODO: report this upstream; Windows (without administrator access, at 66 | # least) reports an empty extension list, even if your device can do 67 | # hmac-secret. 68 | return True 69 | has_extension = "hmac-secret" in client.info.extensions 70 | return has_extension 71 | 72 | 73 | def select_client( 74 | interaction: UserInteraction, 75 | filters: Sequence[Callable[[AnyFidoClient], bool]], 76 | choose: Callable[ 77 | [Sequence[tuple[AnyFidoClient, AnyCtapDevice | None]]], AnyFidoClient 78 | ], 79 | ) -> AnyFidoClient: 80 | """ 81 | Prompt the user to choose a device to authenticate with, if necessary. 82 | """ 83 | eligible = [] 84 | for client, device in enumerate_clients(interaction): 85 | if all(each(client) for each in filters): 86 | eligible.append((client, device)) 87 | if not eligible: 88 | raise NoAuthenticator("No eligible authenticators found.") 89 | if len(eligible) == 1: 90 | return eligible[0][0] 91 | return choose(eligible) 92 | -------------------------------------------------------------------------------- /src/tokenring/handles.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from base64 import urlsafe_b64encode as encode_fernet_key 5 | from dataclasses import dataclass 6 | from typing import ( 7 | Any, 8 | ClassVar, 9 | Sequence, 10 | TypedDict, 11 | ) 12 | 13 | from cryptography.fernet import Fernet 14 | from fido2.cose import ES256 15 | from fido2.webauthn import ( 16 | PublicKeyCredentialCreationOptions, 17 | PublicKeyCredentialDescriptor, 18 | PublicKeyCredentialParameters, 19 | PublicKeyCredentialRequestOptions, 20 | PublicKeyCredentialRpEntity, 21 | PublicKeyCredentialType, 22 | PublicKeyCredentialUserEntity, 23 | ) 24 | 25 | from .fidoclient import AnyFidoClient 26 | 27 | SerializedCredentialHandle = dict[str, str] 28 | 29 | def platform_specific_extract_extension_results(results: Any)->bytes: 30 | """ 31 | There's a bug in python-fido2 which reflects extension output values as 32 | literal dictionaries full of bytes on Windows (which is what it used to do 33 | everywhere) and magical dict-proxy-but-also-has-some-attributes objects on 34 | all other platforms, where the other platforms reflect the dict-ish values 35 | as base64-encoded strings and the extra attributes they provide (but do not 36 | provide type annotations for) are the original bytes. 37 | """ 38 | if os.name == 'nt': 39 | return results["hmacGetSecret"]["output1"] 40 | else: 41 | return results.hmacGetSecret.output1 42 | 43 | 44 | @dataclass 45 | class CredentialHandle: 46 | client: AnyFidoClient 47 | credential_id: bytes 48 | 49 | # Static parameters that have to be the same, but can have fairly arbitrary 50 | # values. 51 | rp: ClassVar[PublicKeyCredentialRpEntity] = PublicKeyCredentialRpEntity( 52 | id="hardware.keychain.glyph.im", name="Hardware Secret Keyring" 53 | ) 54 | user: ClassVar[PublicKeyCredentialUserEntity] = PublicKeyCredentialUserEntity( 55 | id=b"hardware_keyring_user", 56 | name="Hardware Keyring User", 57 | ) 58 | params: ClassVar[Sequence[PublicKeyCredentialParameters]] = [ 59 | PublicKeyCredentialParameters( 60 | type=PublicKeyCredentialType.PUBLIC_KEY, alg=ES256.ALGORITHM 61 | ) 62 | ] 63 | 64 | @classmethod 65 | def load(cls, client: AnyFidoClient, obj: dict[str, str]) -> CredentialHandle: 66 | """ 67 | Load a key handle from a JSON blob. 68 | """ 69 | assert obj["rp_id"] == cls.rp.id 70 | return CredentialHandle( 71 | client=client, 72 | credential_id=bytes.fromhex(obj["credential_id"]), 73 | ) 74 | 75 | @classmethod 76 | def new_credential(cls, client: AnyFidoClient) -> CredentialHandle: 77 | """ 78 | Create a new credential for generating keys on the device. 79 | """ 80 | options = PublicKeyCredentialCreationOptions( 81 | rp=cls.rp, 82 | user=cls.user, 83 | challenge=os.urandom(32), 84 | pub_key_cred_params=cls.params, 85 | extensions={"hmacCreateSecret": True}, 86 | ) 87 | 88 | # Create a credential with a HmacSecret 89 | result = client.make_credential(options) 90 | 91 | # Sanity-check response. 92 | assert result.extension_results is not None 93 | assert result.extension_results.get("hmacCreateSecret") is not None 94 | 95 | credential = result.attestation_object.auth_data.credential_data 96 | assert credential is not None 97 | return CredentialHandle(client=client, credential_id=credential.credential_id) 98 | 99 | def key_from_salt(self, salt: bytes) -> bytes: 100 | """ 101 | Get the actual secret key from the hardware. 102 | 103 | Note that this requires user verification. 104 | """ 105 | allow_list = [ 106 | PublicKeyCredentialDescriptor( 107 | type=PublicKeyCredentialType.PUBLIC_KEY, 108 | id=self.credential_id, 109 | ) 110 | ] 111 | challenge = os.urandom(32) 112 | options = PublicKeyCredentialRequestOptions( 113 | rp_id=self.rp.id, 114 | challenge=challenge, 115 | allow_credentials=allow_list, 116 | extensions={"hmacGetSecret": {"salt1": salt}}, 117 | ) 118 | # Only one cred in allowList, only one response. 119 | assertion_itself = self.client.get_assertion(options) 120 | assertion_result: Any = assertion_itself.get_response(0) 121 | assert assertion_result.extension_results is not None 122 | output1: bytes = platform_specific_extract_extension_results(assertion_result.extension_results) 123 | return output1 124 | 125 | def serialize(self) -> SerializedCredentialHandle: 126 | """ 127 | Serialize to JSON blob. 128 | """ 129 | assert self.rp.id is not None 130 | return { 131 | "rp_id": self.rp.id, 132 | "credential_id": self.credential_id.hex(), 133 | } 134 | 135 | @classmethod 136 | def deserialize( 137 | cls, 138 | client: AnyFidoClient, 139 | obj: SerializedCredentialHandle, 140 | ) -> CredentialHandle: 141 | """ 142 | Deserialize from JSON blob. 143 | """ 144 | # TODO: check client serial number. 145 | return CredentialHandle( 146 | client=client, credential_id=bytes.fromhex(obj["credential_id"]) 147 | ) 148 | 149 | 150 | class SerializedKeyHandle(TypedDict): 151 | salt: str 152 | credential: SerializedCredentialHandle 153 | 154 | 155 | @dataclass 156 | class KeyHandle: 157 | """ 158 | The combination of a L{CredentialHandle} to reference key material on the 159 | device, and a random salt. 160 | """ 161 | 162 | credential: CredentialHandle 163 | salt: bytes 164 | _saved_key: bytes | None = None 165 | 166 | @classmethod 167 | def new(cls, credential: CredentialHandle) -> KeyHandle: 168 | """ 169 | Create a new KeyHandle. 170 | """ 171 | return KeyHandle(credential, os.urandom(32)) 172 | 173 | def remember_key(self) -> None: 174 | """ 175 | Cache the bytes of the underlying key in memory so that we don't need 176 | to prompt the user repeatedly for subsequent authentications. 177 | """ 178 | self._saved_key = self.key_as_bytes() 179 | 180 | def key_as_bytes(self) -> bytes: 181 | """ 182 | Return 32 bytes suitable for use as an AES key. 183 | """ 184 | saved = self._saved_key 185 | if saved is not None: 186 | return saved 187 | return self.credential.key_from_salt(self.salt) 188 | 189 | def encrypt_bytes(self, plaintext: bytes) -> bytes: 190 | """ 191 | Encrypt some plaintext bytes. 192 | """ 193 | key_bytes: bytes = self.key_as_bytes() 194 | fernet_key = encode_fernet_key(key_bytes) 195 | fernet = Fernet(fernet_key) 196 | ciphertext = fernet.encrypt(plaintext) 197 | return ciphertext 198 | 199 | def decrypt_bytes(self, ciphertext: bytes) -> bytes: 200 | """ 201 | Decrypt some enciphered bytes. 202 | """ 203 | key_bytes: bytes = self.key_as_bytes() 204 | fernet_key = encode_fernet_key(key_bytes) 205 | fernet = Fernet(fernet_key) 206 | plaintext = fernet.decrypt(ciphertext) 207 | return plaintext 208 | 209 | def encrypt_text(self, plaintext: str) -> str: 210 | """ 211 | Encrypt some unicode text, returning text to represent it. 212 | """ 213 | encoded = plaintext.encode("utf-8") 214 | cipherbytes = self.encrypt_bytes(encoded) 215 | return cipherbytes.hex() 216 | 217 | def decrypt_text(self, ciphertext: str) -> str: 218 | """ 219 | Decrypt some hexlified bytes, returning the unicode text embedded in 220 | its plaintext. 221 | """ 222 | decoded = bytes.fromhex(ciphertext) 223 | return self.decrypt_bytes(decoded).decode("utf-8") 224 | 225 | def serialize(self) -> SerializedKeyHandle: 226 | """ 227 | Serialize to JSON-able data. 228 | """ 229 | return { 230 | "salt": self.salt.hex(), 231 | "credential": self.credential.serialize(), 232 | } 233 | 234 | @classmethod 235 | def deserialize(cls, client: AnyFidoClient, obj: SerializedKeyHandle) -> KeyHandle: 236 | """ """ 237 | return KeyHandle( 238 | credential=CredentialHandle.deserialize(client, obj["credential"]), 239 | salt=bytes.fromhex(obj["salt"]), 240 | ) 241 | -------------------------------------------------------------------------------- /src/tokenring/interaction.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from contextlib import contextmanager 5 | from dataclasses import dataclass 6 | from getpass import getpass 7 | from threading import Event, Thread 8 | from typing import ( 9 | Iterator, 10 | Optional, 11 | Sequence, 12 | TYPE_CHECKING, 13 | ) 14 | 15 | from fido2.client import ClientError, UserInteraction 16 | from fido2.ctap2.pin import ClientPin 17 | 18 | 19 | from .fidoclient import NoAuthenticator 20 | 21 | if TYPE_CHECKING: 22 | from .fidoclient import AnyCtapDevice, AnyFidoClient 23 | 24 | 25 | class UnknownPurpose(Exception): 26 | """ 27 | The authenticator requested user-presence for an unknown purpose. 28 | """ 29 | 30 | 31 | @dataclass 32 | class ConsoleInteraction(UserInteraction): 33 | _purpose: str | None = None 34 | 35 | @contextmanager 36 | def purpose(self, description: str, completed: str) -> Iterator[None]: 37 | """ 38 | Temporarily set the purpose of this interaction. Any prompts that 39 | occur without a purpose will raise an exception. 40 | """ 41 | was, self._purpose = self._purpose, description 42 | try: 43 | yield None 44 | finally: 45 | self._purpose = was 46 | print("OK:", completed, file=sys.stderr) 47 | 48 | def prompt_up(self) -> None: 49 | """ 50 | User-Presence Prompt. 51 | """ 52 | if self._purpose is None: 53 | raise UnknownPurpose() 54 | print(f"Touch your authenticator to {self._purpose}", file=sys.stderr) 55 | 56 | def request_pin( 57 | self, permissions: ClientPin.PERMISSION, rp_id: Optional[str] 58 | ) -> str: 59 | """ 60 | PIN entry required; return the PIN. 61 | """ 62 | return getpass(f"Enter PIN to {self._purpose}: ") 63 | 64 | def request_uv( 65 | self, permissions: ClientPin.PERMISSION, rp_id: Optional[str] 66 | ) -> bool: 67 | raise RuntimeError("User verification should not be required.") 68 | 69 | 70 | def console_chooser( 71 | clients: Sequence[tuple[AnyFidoClient, AnyCtapDevice | None]] 72 | ) -> AnyFidoClient: 73 | """ 74 | Select between different client devices we've discovered. 75 | """ 76 | for idx, (client, device) in enumerate(clients): 77 | print( 78 | f"{1+idx}) {getattr(device, 'product_name')} {getattr(device, 'serial_number')} {getattr(getattr(device, 'descriptor', None), 'path', None)}", 79 | file=sys.stderr, 80 | ) 81 | 82 | while True: 83 | value = input("> ") 84 | try: 85 | result = int(value) 86 | except ValueError: 87 | print("Please enter a number.", file=sys.stderr) 88 | else: 89 | try: 90 | return clients[result - 1][0] 91 | except IndexError: 92 | print("Please enter a number in range.", file=sys.stderr) 93 | 94 | 95 | def up_chooser(clients: Sequence[AnyFidoClient]) -> AnyFidoClient: 96 | """ 97 | Choose a client from the given list of clients by prompting for user 98 | presence on one of them. Does not work because of U{this bug 99 | }. 100 | """ 101 | cancel = Event() 102 | selected: AnyFidoClient | None = None 103 | 104 | def select(client: AnyFidoClient) -> None: 105 | nonlocal selected 106 | try: 107 | # type ignore - not available on windows, but not necessary on 108 | # windows 109 | client.selection(cancel) # type:ignore 110 | 111 | selected = client 112 | except ClientError as e: 113 | if e.code != ClientError.ERR.TIMEOUT: 114 | raise 115 | else: 116 | return 117 | cancel.set() 118 | 119 | threads = [] 120 | for client in clients: 121 | t = Thread(target=select, args=[client]) 122 | threads.append(t) 123 | t.start() 124 | for t in threads: 125 | t.join() 126 | if selected is None: 127 | raise NoAuthenticator("user did not choose an authenticator") 128 | return selected 129 | -------------------------------------------------------------------------------- /src/tokenring/local.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import ( 6 | TYPE_CHECKING, 7 | ) 8 | 9 | from keyring.backend import KeyringBackend 10 | from keyring.util.platform_ import data_root 11 | from .vault import Vault 12 | 13 | 14 | @dataclass 15 | class LocalTokenRing(KeyringBackend): 16 | """ 17 | Keyring backend implementation for L{Vault} that runs in-process with the 18 | requesting code. 19 | """ 20 | 21 | vault: Vault | None = None 22 | location: Path = Path(data_root()) / "keyring.tokenvault" 23 | priority = 20 24 | 25 | def realize_vault(self) -> Vault: 26 | """ 27 | Create or open a vault. 28 | """ 29 | if self.vault is not None: 30 | return self.vault 31 | # Ensure our location exists. 32 | self.location.parent.mkdir(parents=True, exist_ok=True) 33 | # XXX gotta choose the correct client 34 | if self.location.is_file(): 35 | self.vault = Vault.load(self.location) 36 | else: 37 | self.vault = Vault.create(self.location) 38 | return self.vault 39 | 40 | def get_password(self, servicename: str, username: str) -> str | None: 41 | return self.realize_vault().get_password(servicename, username) 42 | 43 | def set_password(self, servicename: str, username: str, password: str) -> None: 44 | self.realize_vault().set_password(servicename, username, password) 45 | 46 | 47 | if TYPE_CHECKING: 48 | LocalTokenRing() 49 | -------------------------------------------------------------------------------- /src/tokenring/vault.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from json import dump, dumps, load, loads 6 | from os import fsync 7 | from pathlib import Path 8 | from typing import ( 9 | TypedDict, 10 | ) 11 | from uuid import uuid4 12 | 13 | 14 | from .interaction import ConsoleInteraction 15 | from .fidoclient import AnyFidoClient, extension_required, select_client 16 | from .handles import CredentialHandle, KeyHandle, SerializedKeyHandle 17 | from .interaction import console_chooser 18 | from fido2.client import ClientError 19 | 20 | 21 | @dataclass 22 | class Vault: 23 | """ 24 | A vault where users may store multiple credentials. 25 | """ 26 | 27 | interaction: ConsoleInteraction 28 | client: AnyFidoClient 29 | vault_handle: KeyHandle 30 | handles: dict[tuple[str, str], tuple[KeyHandle, str]] 31 | storage_path: Path 32 | 33 | def serialize(self) -> SerializedVault: 34 | """ 35 | Serialize this vault. 36 | """ 37 | return { 38 | "key": self.vault_handle.serialize(), 39 | "data": self.vault_handle.encrypt_text( 40 | dumps( 41 | [ 42 | (service, user, handle.serialize(), ciphertext) 43 | for (service, user), ( 44 | handle, 45 | ciphertext, 46 | ) in self.handles.items() 47 | ] 48 | ) 49 | ), 50 | } 51 | 52 | @classmethod 53 | def deserialize( 54 | cls, 55 | interaction: ConsoleInteraction, 56 | client: AnyFidoClient, 57 | obj: SerializedVault, 58 | where: Path, 59 | ) -> Vault: 60 | """ 61 | Deserialize the given vault from a fido2client. 62 | """ 63 | vault_handle = KeyHandle.deserialize(client, obj["key"]) 64 | vault_handle.remember_key() 65 | unlocked = vault_handle.decrypt_text(obj["data"]) 66 | handlesobj = loads(unlocked) 67 | self = Vault( 68 | interaction, 69 | client, 70 | vault_handle, 71 | handles={ 72 | (service, user): (KeyHandle.deserialize(client, handleobj), ciphertext) 73 | for (service, user, handleobj, ciphertext) in handlesobj 74 | }, 75 | storage_path=where.absolute(), 76 | ) 77 | return self 78 | 79 | @classmethod 80 | def create(cls, where: Path) -> Vault: 81 | """ 82 | Create a new Vault and save it in the given IO. 83 | """ 84 | interaction = ConsoleInteraction() 85 | client = select_client(interaction, [extension_required], console_chooser) 86 | with interaction.purpose("create the vault", "vault created!"): 87 | cred = CredentialHandle.new_credential(client) 88 | vault_key = KeyHandle.new(cred) 89 | with interaction.purpose("open the vault we just created", "vault open!"): 90 | vault_key.remember_key() 91 | self = Vault(interaction, client, vault_key, {}, where.absolute()) 92 | self.save() 93 | return self 94 | 95 | @classmethod 96 | def load(cls, where: Path) -> Vault: 97 | """ 98 | Load an existing vault saved at a given path. 99 | """ 100 | where = where.absolute() 101 | with where.open("r") as f: 102 | contents = load(f) 103 | interaction = ConsoleInteraction() 104 | while True: 105 | client = select_client(interaction, [extension_required], console_chooser) 106 | dispath = where.as_posix() 107 | homepath = Path.home().as_posix() + "/" 108 | if dispath.startswith(homepath): 109 | dispath = "~/" + dispath[len(homepath):] 110 | try: 111 | with interaction.purpose( 112 | f"open the vault at {dispath}", "vault open!" 113 | ): 114 | return cls.deserialize(interaction, client, contents, where) 115 | 116 | except ClientError as ce: 117 | if ce.code != ClientError.ERR.DEVICE_INELIGIBLE: 118 | raise ce 119 | print( 120 | "This is the wrong authenticator. Try touching a different one.", 121 | file=sys.stderr, 122 | ) 123 | 124 | def save(self) -> None: 125 | """ 126 | Save the vault to secondary storage. 127 | """ 128 | # Be extra-careful about atomicity; we really do not want to have a 129 | # partial write happen here, as we'll lose the whole vault. 130 | temp = ( 131 | self.storage_path.parent 132 | / f".{self.storage_path.name}.{uuid4()}.atomic-temp" 133 | ) 134 | 135 | serialized = self.serialize() 136 | 137 | with temp.open("w") as f: 138 | dump(serialized, f) 139 | f.flush() 140 | fsync(f.fileno()) 141 | 142 | temp.replace(self.storage_path) 143 | 144 | def set_password(self, servicename: str, username: str, password: str) -> None: 145 | """ 146 | Encrypt and store a password for the given service and username. 147 | """ 148 | handle = KeyHandle.new(self.vault_handle.credential) 149 | with self.interaction.purpose( 150 | f"encrypt the password for {username!r} in {servicename!r}", 151 | "password encrypted and stored!", 152 | ): 153 | ciphertext = handle.encrypt_text(password) 154 | self.handles[servicename, username] = (handle, ciphertext) 155 | self.save() 156 | 157 | def get_password(self, servicename: str, username: str) -> str | None: 158 | """ 159 | Retrieve a password for the . 160 | """ 161 | key = (servicename, username) 162 | if key not in self.handles: 163 | print(f"No entry for {username!r} in {servicename!r}, not prompting", file=sys.stderr) 164 | return None 165 | handle, ciphertext = self.handles[key] 166 | try: 167 | with self.interaction.purpose( 168 | f"decrypt the password for {username!r} in {servicename!r}", 169 | "password decrypted!", 170 | ): 171 | plaintext = handle.decrypt_text(ciphertext) 172 | except ClientError as ce: 173 | if ce.code == ClientError.ERR.TIMEOUT: 174 | print("User presence check timed out.", file=sys.stderr) 175 | return None 176 | raise ce 177 | 178 | return plaintext 179 | 180 | def delete_password(self, servicename: str, username: str) -> None: 181 | """ 182 | Delete a password. 183 | """ 184 | del self.handles[servicename, username] 185 | self.save() 186 | 187 | 188 | class SerializedVault(TypedDict): 189 | """ 190 | Serialized form of a L{Vault} 191 | """ 192 | 193 | key: SerializedKeyHandle 194 | data: str 195 | --------------------------------------------------------------------------------