diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b2c1da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + + + +## v0.1.0 (2024-01-25) + +### Feature + +* Cli tweaks and more pytests ([`01f8574`](https://github.com/robinvandernoord/2fas-python/commit/01f8574e527a60025e4e7b7bf0416a4e344fde2e)) +* Started basic cli functionality ([`f15bbbf`](https://github.com/robinvandernoord/2fas-python/commit/f15bbbfe1d4e79ba644775dd1e4eb638e394dc81)) +* TwoFactorStorage is now a recursive data type when using .find() ([`1f4847f`](https://github.com/robinvandernoord/2fas-python/commit/1f4847fa07eecd9c85623e5cb799a34ab3a8714d)) +* Added tests and more general functionality ([`be86df5`](https://github.com/robinvandernoord/2fas-python/commit/be86df54cc4616541c6e636e882a1fa444af9d3a)) + +### Fix + +* Improved settings menu + mypy typing + refactor ([`5d08f62`](https://github.com/robinvandernoord/2fas-python/commit/5d08f62daba7873e84766562c07370fa22018868)) +* Improved settings tui ([`c0275b5`](https://github.com/robinvandernoord/2fas-python/commit/c0275b5d5e1b77fba77f65f3efdb5d117d9f5715)) +* Include rapidfuzz in dependencies (previously only collected via 'dev' extra) ([`d2016e0`](https://github.com/robinvandernoord/2fas-python/commit/d2016e033ff00392032492525a3c4eb14a4432b3)) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae2e8a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Robin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4119d7 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# lib2fas Python + +Unofficial implementation of 2fas for Python (as a library). + +## Installation + +To install this project, use pip: + +```bash +pip install lib2fas +# or to also install the cli tool: +pip install 2fas +``` + +## Usage + +After installing the package, you can import it in your Python scripts as follows: + +```python +import lib2fas + +services = lib2fas.load_services("/path/to/file.2fas", passphrase="optional") # -> TwoFactorStorage + +services.generate() # generate all TOTP keys +github = services.find("githbu") # fuzzy match should find GitHub, returns a new TwoFactorStorage. + +for label, services in github.items(): + # one label can have multiple services! + for service in services: + print(label, service.name, service.generate_int()) + +``` + +## License + +This project is licensed under the MIT License. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..05cde14 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,184 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lib2fas" +dynamic = ["version"] +description = 'Unofficial implementation of 2fas for Python (as a library)' +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = [] +authors = [ + { name = "Robin van der Noord", email = "robinvandernoord@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "cryptography", + "configuraptor>=1.25", + "PyOTP", + "keyring", + "typer[all]", + "questionary", + "rapidfuzz", +] + +[template.plugins.default] +src-layout = true + +[tool.setuptools.package-data] +"lib2fas" = ["py.typed"] + +[project.optional-dependencies] +dev = [ + "hatch", + "python-semantic-release<8", + "su6[all]", + "pytest-mypy-testing", + "edwh", +] + + +[project.urls] +Documentation = "https://github.com/robinvandernoord/lib2fas-python#readme" +Issues = "https://github.com/robinvandernoord/lib2fas-python/issues" +Source = "https://github.com/robinvandernoord/lib2fas-python" + +[tool.hatch.version] +path = "src/lib2fas/__about__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/lib2fas"] + +[tool.semantic_release] +branch = "master" +version_variable = "src/lib2fas/__about__.py:__version__" +change_log = "CHANGELOG.md" +upload_to_repository = false +upload_to_release = false +build_command = "hatch build" + +### required in every su6 pyproject: ### +[tool.su6] +# every checker: +directory = "src" +# 'all' and 'fix': +include = [] +exclude = [] +# 'all': +stop-after-first-failure = true +# pytest: +coverage = 100 +badge = true +# --format json indent +json-indent = 2 + + +[tool.black] +target-version = ["py310"] +line-length = 120 +# 'extend-exclude' excludes files or directories in addition to the defaults +extend-exclude = ''' +# A regex preceded with ^/ will apply only to files and directories +# in the root of the project. +( + ^.*\.bak\/.+ # ignore every .bak directory + ^.*venv.+\/.+ # ignore every venv directory + venv.+|.+\.bak # idk why it suddenly works, let's not touch it +) +''' + +[tool.mypy] +python_version = "3.11" + +# `some: int = None` looks nicer than `some: int | None = None` and pycharm still understands it +no_implicit_optional = false # I guess 'strict_optional' should be true, but disable this one because it's double! +# same as above (thrown if no_implicit_optional = False) +# ACTUALLY: not the same! Setting strict_optional = false may miss some type errors like +# 'Item "None" of "Optional" has no attribute "lower"' +# 'strict_optional' complains more for class properties and 'no_implicit_optional' for function arguments +# strict_optional = false +# 3rd party packages may not be typed, that's not my fault! +ignore_missing_imports = true +# kinda hypocritical to disable Optional and still enable strict, but I do agree with some other strict rules. +strict = true +# fixes defs with clear return var (doesn't seem to work for __init__ which is the most obvious case) +# check_untyped_defs = True + +exclude = ["venv", ".bak"] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +select = [ + "F", # pyflake error + "E", # pycodestyle error + "W", # pycodestyle warning + "Q", # quotes + "A", # builtins + # "C4", # comprehensions - NO: doesn't allow dict() + # "RET", # return - NO: annoying + "SIM", # simplify + "ARG", # unused arguments + # "COM", # comma's - NO: annoying + # "PTH", # use pathlib - NO: annoying + "RUF", # ruff rules +] +unfixable = [ + # Don't touch unused imports + "F401", +] +extend-exclude = ["*.bak/", "venv*/"] + +ignore = [ + "RUF013" # implicit Optional +] + +[tool.bandit] +# bandit -c pyproject.toml -r . +exclude_dirs = [".bak", "venv"] +skips = [ + "B108" # hard coded /tmp/... files are fine for me tbh +] + +[tool.isort] +profile = "black" +extend_skip_glob = ["*.bak/*"] + +[tool.pydocstyle] +convention = "google" +match-dir = '(?!venv)[^\.].*' +add_select = [ + "D213", # = Multi-line docstring summary should start at the second line + "D416", # = Google-style section name checks. + "D417", # = Missing argument descriptions in the docstring +] +add_ignore = [ + "D200", # = One-line docstring should fit on one line with quotes + "D212", # = Multi-line docstring summary should start at the first line +] + +### and if it's a project and NOT a package, add this to make it not look for anything buildable: ### +# make this a meta package: not a library but simply allow me to run `pip install .[dev]` +#[build-system] +#build-backend = "setuptools.build_meta" +#requires = ["setuptools"] +# +#[tool.setuptools.packages.find] +## look nowhere for any code to 'build' since this is just used to manage (dev) dependencies +#where = [] + +[tool.pytest.ini_options] +pythonpath = [ + "src", +] diff --git a/src/lib2fas/__about__.py b/src/lib2fas/__about__.py new file mode 100644 index 0000000..e436a68 --- /dev/null +++ b/src/lib2fas/__about__.py @@ -0,0 +1,5 @@ +""" +This file stores the module version. +""" + +__version__ = "0.0.0" diff --git a/src/lib2fas/__init__.py b/src/lib2fas/__init__.py new file mode 100644 index 0000000..6588b05 --- /dev/null +++ b/src/lib2fas/__init__.py @@ -0,0 +1,7 @@ +""" +This file exposes the most important element to the global lib2fas namespace. +""" + +from .core import load_services + +__all__ = ["load_services"] diff --git a/src/lib2fas/_security.py b/src/lib2fas/_security.py new file mode 100644 index 0000000..6ea2027 --- /dev/null +++ b/src/lib2fas/_security.py @@ -0,0 +1,168 @@ +""" +This file deals with the 2fas encryption and keyring integration. +""" + +import base64 +import getpass +import hashlib +import json +import logging +import time +import warnings +from pathlib import Path +from typing import Any, Optional + +import cryptography.exceptions +import keyring +import keyring.backends.SecretService +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from keyring.backend import KeyringBackend + +from ._types import AnyDict, TwoFactorAuthDetails, into_class + +# Suppress keyring warnings +keyring_logger = logging.getLogger("keyring") +keyring_logger.setLevel(logging.ERROR) # Set the logging level to ERROR for keyring logger + + +def _decrypt(encrypted: str, passphrase: str) -> list[AnyDict]: + # thanks https://github.com/wodny/decrypt-2fas-backup/blob/master/decrypt-2fas-backup.py + credentials_enc, pbkdf2_salt, nonce = map(base64.b64decode, encrypted.split(":")) + kdf = PBKDF2HMAC(algorithm=SHA256(), length=32, salt=pbkdf2_salt, iterations=10000) + key = kdf.derive(passphrase.encode()) + aesgcm = AESGCM(key) + credentials_dec = aesgcm.decrypt(nonce, credentials_enc, None) + dec = json.loads(credentials_dec) # type: list[AnyDict] + if not isinstance(dec, list): # pragma: no cover + raise TypeError("Unexpected data structure in input file.") + return dec + + +def decrypt(encrypted: str, passphrase: str) -> list[TwoFactorAuthDetails]: + """ + Decrypt the 'servicesEncrypted' block with a passphrase into a list of TwoFactorAuthDetails instances. + + Raises: + PermissionError + """ + try: + dicts = _decrypt(encrypted, passphrase) + return into_class(dicts, TwoFactorAuthDetails) + except cryptography.exceptions.InvalidTag as e: + # wrong passphrase! + raise PermissionError("Invalid passphrase for file.") from e + + +def hash_string(data: Any) -> str: + """ + Hashes a string using SHA-256. + """ + sha256 = hashlib.sha256() + sha256.update(str(data).encode()) + return sha256.hexdigest() + + +PREFIX = "2fas:" + + +class KeyringManager: + """ + Makes working with the keyring a bit easier. + + Stores passphrases for encrypted .2fas files in the keyring. + When the user logs out, the keyring item is invalidated and the user is asked for the passphrase again. + While the user stays logged in, the passphrase is then 'remembered'. + """ + + appname: str = "" + tmp_file = Path("/tmp") / ".2fas" + + def __init__(self) -> None: + """ + See _init. + """ + self._init() + + def _init(self) -> None: + """ + Setup for a new instance. + + This is used instead of __init__ so you can call init again to set active appname (for pytest) + """ + tmp_file = self.tmp_file + # APPNAME is session specific but with global prefix for easy clean up + + if tmp_file.exists() and (session := tmp_file.read_text()) and session.startswith(PREFIX): + # existing session + self.appname = session + else: + # new session! + session = hash_string((time.time())) # random enough for this purpose + self.appname = f"{PREFIX}{session}" + tmp_file.write_text(self.appname) + + @classmethod + def _retrieve_credentials(cls, filename: str, appname: str) -> Optional[str]: + return keyring.get_password(appname, hash_string(filename)) + + def retrieve_credentials(self, filename: str) -> Optional[str]: + """ + Get the saved passphrase for a specific file. + """ + return self._retrieve_credentials(filename, self.appname) + + @classmethod + def _save_credentials(cls, filename: str, passphrase: str, appname: str) -> None: + keyring.set_password(appname, hash_string(filename), passphrase) + + def save_credentials(self, filename: str) -> str: + """ + Query the user for a passphrase and store it in the keyring. + """ + passphrase = getpass.getpass(f"Passphrase for '{filename}'? ") + self._save_credentials(filename, passphrase, self.appname) + + return passphrase + + @classmethod + def _delete_credentials(cls, filename: str, appname: str) -> None: + keyring.delete_password(appname, hash_string(filename)) + + def delete_credentials(self, filename: str) -> None: + """ + Remove a stored passphrase for a file. + """ + self._delete_credentials(filename, self.appname) + + @classmethod + def _cleanup_keyring(cls, appname: str) -> int: + kr: keyring.backends.SecretService.Keyring | KeyringBackend = keyring.get_keyring() + if not hasattr(kr, "get_preferred_collection"): # pragma: no cover + warnings.warn(f"Can't clean up this keyring backend! {type(kr)}", category=RuntimeWarning) + return -1 + + collection = kr.get_preferred_collection() + + # get old 2fas: keyring items: + return len( + [ + item + for item in collection.get_all_items() + if ( + service := item.get_attributes().get("service", "") + ) # must have a 'service' attribute, otherwise it's unrelated + and service.startswith(PREFIX) # must be a 2fas: service, otherwise it's unrelated + and service != appname # must not be the currently active session + ] + ) + + def cleanup_keyring(self) -> None: + """ + Remove all old items from the keyring. + """ + self._cleanup_keyring(self.appname) + + +keyring_manager = KeyringManager() diff --git a/src/lib2fas/_types.py b/src/lib2fas/_types.py new file mode 100644 index 0000000..ce2d506 --- /dev/null +++ b/src/lib2fas/_types.py @@ -0,0 +1,124 @@ +""" +This file holds reusable types. +""" + +import typing +from typing import Optional + +from configuraptor import TypedConfig, asdict, asjson +from pyotp import TOTP + +AnyDict = dict[str, typing.Any] + + +class OtpDetails(TypedConfig): + """ + Fields under the 'otp' key of the 2fas file. + """ + + link: str + tokenType: str + source: str + label: Optional[str] = None + account: Optional[str] = None + digits: Optional[int] = None + period: Optional[int] = None + + +class OrderDetails(TypedConfig): + """ + Fields under the 'order' key of the 2fas file. + """ + + position: int + + +class IconCollectionDetails(TypedConfig): + """ + Fields under the 'icon.iconCollection' key of the 2fas file. + """ + + id: str + + +class IconDetails(TypedConfig): + """ + Fields under the 'icon' key of the 2fas file. + """ + + selected: str + iconCollection: IconCollectionDetails + + +class TwoFactorAuthDetails(TypedConfig): + """ + Fields of a service in a 2fas file. + """ + + name: str + secret: str + updatedAt: int + serviceTypeID: Optional[str] + otp: OtpDetails + order: OrderDetails + icon: IconDetails + groupId: Optional[str] = None # todo: groups are currently not supported! + + _topt: Optional[TOTP] = None # lazily loaded when calling .totp or .generate() + + @property + def totp(self) -> TOTP: + """ + Get a TOTP instance for this service. + """ + if not self._topt: + self._topt = TOTP(self.secret) + return self._topt + + def generate(self) -> str: + """ + Generate the current TOTP code. + """ + return self.totp.now() + + def generate_int(self) -> int: + """ + Generate the current TOTP code, as a number instead of string. + + !!! usually not prefered, because this drops leading zeroes!! + """ + return int(self.totp.now()) + + def as_dict(self) -> AnyDict: + """ + Dump this object as a dictionary. + """ + return asdict(self, with_top_level_key=False, exclude_internals=2) + + def as_json(self) -> str: + """ + Dump this object as a JSON string. + """ + return asjson(self, with_top_level_key=False, indent=2, exclude_internals=2) + + def __str__(self) -> str: + """ + Magic method for str() - simple representation. + """ + return f"<2fas '{self.name}'>" + + def __repr__(self) -> str: + """ + Magic method for repr() - representation in JSON. + """ + return self.as_json() + + +T_TypedConfig = typing.TypeVar("T_TypedConfig", bound=TypedConfig) + + +def into_class(entries: list[AnyDict], klass: typing.Type[T_TypedConfig]) -> list[T_TypedConfig]: + """ + Helper to load a list of dicts into a list of Typed Config instances. + """ + return [klass.load(d) for d in entries] diff --git a/src/lib2fas/core.py b/src/lib2fas/core.py new file mode 100644 index 0000000..3b7664d --- /dev/null +++ b/src/lib2fas/core.py @@ -0,0 +1,198 @@ +""" +This file contains the core functionality. +""" + +import json +import sys +import typing +from collections import defaultdict +from pathlib import Path +from typing import Optional + +from ._security import decrypt, keyring_manager +from ._types import TwoFactorAuthDetails, into_class +from .utils import flatten, fuzzy_match + +T_TwoFactorAuthDetails = typing.TypeVar("T_TwoFactorAuthDetails", bound=TwoFactorAuthDetails) + + +class TwoFactorStorage(typing.Generic[T_TwoFactorAuthDetails]): + """ + Container to make working with a collection of 2fas services easier. + """ + + _multidict: defaultdict[str, list[T_TwoFactorAuthDetails]] + count: int + + def __init__(self, _klass: typing.Type[T_TwoFactorAuthDetails] = None) -> None: + """ + Create a new instance, usually done by `new_auth_storage()`. + + Args: + _klass: _klass is purely for annotation atm + """ + self._multidict = defaultdict(list) # one name can map to multiple keys + self.count = 0 + + def __len__(self) -> int: + """ + The length of the storage is the amount of items in it. + """ + return self.count + + def __bool__(self) -> bool: + """ + The storage is truthy if it has any items. + """ + return self.count > 0 + + def add(self, entries: list[T_TwoFactorAuthDetails]) -> None: + """ + Extend the storage with new items. + """ + for entry in entries: + name = (entry.name or "").lower() + self._multidict[name].append(entry) + + self.count += len(entries) + + def __getitem__(self, item: str) -> "list[T_TwoFactorAuthDetails]": + """ + Get a service via the class[property] syntax. + """ + # + return self._multidict[item.lower()] + + def keys(self) -> list[str]: + """ + Return a list of services in this storage. + + Usage: + storage.keys() + """ + return list(self._multidict.keys()) + + def items(self) -> typing.Generator[tuple[str, list[T_TwoFactorAuthDetails]], None, None]: + """ + Loop through tuples of key and values. + + Usage: + for key, value in storage.items(): ... + # (like dict.items()) + """ + yield from self._multidict.items() + + def _fuzzy_find(self, find: typing.Optional[str], fuzz_threshold: int) -> list[T_TwoFactorAuthDetails]: + if not find: + # don't loop + return list(self) + + all_items = self._multidict.items() + + find = find.lower() + # if nothing found exactly, try again but fuzzy (could be slower) + # search in key: + fuzzy = [ + # search in key + v + for k, v in all_items + if fuzzy_match(k.lower(), find) > fuzz_threshold + ] + if fuzzy and (flat := flatten(fuzzy)): + return flat + + # search in value: + # str is short, repr is json + return [ + # search in value instead + v + for v in list(self) + if fuzzy_match(repr(v).lower(), find) > fuzz_threshold + ] + + def generate(self) -> list[tuple[str, str]]: + """ + Create TOTP codes for all services in this storage. + """ + return [(_.name, _.generate()) for _ in self] + + def find( + self, target: Optional[str] = None, fuzz_threshold: int = 75 + ) -> "TwoFactorStorage[T_TwoFactorAuthDetails]": + """ + Create a new storage object with a subset of items in this storage, filtered by the search query in 'target'. + + First, an exact search is tried and if that fails, fuzzy matching is applied. + """ + target = (target or "").lower() + # first try exact match: + if items := self._multidict.get(target): + return new_auth_storage(items) + # else: fuzzy match: + return new_auth_storage(self._fuzzy_find(target, fuzz_threshold)) + + def all(self) -> list[T_TwoFactorAuthDetails]: + """ + Return a list of services. + """ + return list(self) + + def __iter__(self) -> typing.Generator[T_TwoFactorAuthDetails, None, None]: + """ + Allows for-looping through this storage. + """ + for entries in self._multidict.values(): + yield from entries + + def __repr__(self) -> str: + """ + Representation for repr(). + """ + return f"" + + +def new_auth_storage(initial_items: list[T_TwoFactorAuthDetails] = None) -> TwoFactorStorage[T_TwoFactorAuthDetails]: + """ + Create an instance of TwoFactorStorage and maybe load some items into it. + """ + storage: TwoFactorStorage[T_TwoFactorAuthDetails] = TwoFactorStorage() + + if initial_items: + storage.add(initial_items) + + return storage + + +def load_services(filename: str, _max_retries: int = 0) -> TwoFactorStorage[TwoFactorAuthDetails]: + """ + Given a 2fas file, try to decrypt it (via stored password in keyring or by querying user) \ + and load into a TwoFactorStorage object. + """ + filepath = Path(filename).expanduser() + with filepath.open() as f: + data_raw = f.read() + data = json.loads(data_raw) + + storage: TwoFactorStorage[TwoFactorAuthDetails] = new_auth_storage() + + if decrypted := data["services"]: + services = into_class(decrypted, TwoFactorAuthDetails) + storage.add(services) + return storage + + encrypted = data["servicesEncrypted"] + + retries = 0 + while True: + password = keyring_manager.retrieve_credentials(filename) or keyring_manager.save_credentials(filename) + try: + entries = decrypt(encrypted, password) + storage.add(entries) + return storage + except PermissionError as e: + retries += 1 # only really useful for pytest + print(e, file=sys.stderr) + keyring_manager.delete_credentials(filename) + + if _max_retries and retries > _max_retries: + raise e diff --git a/src/lib2fas/py.typed b/src/lib2fas/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/lib2fas/utils.py b/src/lib2fas/utils.py new file mode 100644 index 0000000..3d7ad47 --- /dev/null +++ b/src/lib2fas/utils.py @@ -0,0 +1,24 @@ +""" +This file contains helper functionality. +""" + +import typing + +from more_itertools import flatten as _flatten +from rapidfuzz import fuzz + +T = typing.TypeVar("T") + + +def flatten(data: list[list[T]]) -> list[T]: + """ + Flatten a 2D list into a 1D list. + """ + return list(_flatten(data)) + + +def fuzzy_match(val1: str, val2: str) -> float: + """ + Wrapper around `fuzz.partial_ratio` with a slighly more friendly name. + """ + return fuzz.partial_ratio(val1, val2) diff --git a/tests/2fas-demo-nopass.2fas b/tests/2fas-demo-nopass.2fas new file mode 100644 index 0000000..f1733bd --- /dev/null +++ b/tests/2fas-demo-nopass.2fas @@ -0,0 +1,122 @@ +{ + "services": [ + { + "name": "Example 1", + "secret": "N5LVC5JZNVVDSUZPJFIWUZSHGFDGMZJU", + "updatedAt": 1705504078693, + "otp": { + "link": "otpauth://totp/Ledgy:Elon Must?secret=N5LVC5JZNVVDSUZPJFIWUZSHGFDGMZJU&issuer=Ledgy", + "label": "Additional Info", + "account": "Additional Info", + "issuer": "Ledgy", + "tokenType": "TOTP", + "source": "Link" + }, + "order": { + "position": 0 + }, + "icon": { + "selected": "IconCollection", + "label": { + "text": "LE", + "backgroundColor": "Orange" + }, + "iconCollection": { + "id": "4d15f4f2-0c7e-41b1-a9af-be07644246c1" + } + } + }, + { + "name": "Example 1", + "secret": "JBSWY3DPEHPK3PXP", + "updatedAt": 1705504116783, + "otp": { + "link": "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + "label": "Other Additional Info", + "account": "Other Additional Info", + "issuer": "Example", + "tokenType": "TOTP", + "source": "Link" + }, + "order": { + "position": 1 + }, + "icon": { + "selected": "IconCollection", + "label": { + "text": "EX", + "backgroundColor": "Red" + }, + "iconCollection": { + "id": "66190b0f-9600-4a6f-b06b-33254b5316ad" + } + } + }, + { + "name": "Example 2", + "secret": "JBSWY3DPEHPK3PXW", + "updatedAt": 1705504161885, + "otp": { + "link": "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXW&issuer=Example", + "label": "alice@google", + "account": "alice@google", + "issuer": "Example", + "tokenType": "TOTP", + "source": "Link" + }, + "order": { + "position": 2 + }, + "icon": { + "selected": "IconCollection", + "label": { + "text": "EX", + "backgroundColor": "Purple" + }, + "iconCollection": { + "id": "7d7c381b-7feb-4155-991f-074ae53e6bd9" + } + } + }, + { + "name": "Example 3", + "secret": "XBSWY3DPEHPK3PXW", + "updatedAt": 1705504197926, + "otp": { + "link": "otpauth://totp/Example 3:Example in Folder?secret=XBSWY3DPEHPK3PXW&issuer=Example 3", + "label": "Example in Folder", + "account": "Example in Folder", + "issuer": "Example 3", + "tokenType": "TOTP", + "source": "Link" + }, + "order": { + "position": 3 + }, + "icon": { + "selected": "Label", + "label": { + "text": "EX", + "backgroundColor": "Pink" + }, + "iconCollection": { + "id": "a5b3fb65-4ec5-43e6-8ec1-49e24ca9e7ad" + } + }, + "groupId": "8ea6c261-e88e-4cb8-951c-001786d144bc" + } + ], + "groups": [ + { + "id": "8ea6c261-e88e-4cb8-951c-001786d144bc", + "name": "Folder 1", + "isExpanded": true, + "updatedAt": 1705504169176 + } + ], + "updatedAt": 1705504226186, + "schemaVersion": 4, + "appVersionCode": 5000012, + "appVersionName": "5.2.0", + "appOrigin": "android" +} \ No newline at end of file diff --git a/tests/2fas-demo-pass.2fas b/tests/2fas-demo-pass.2fas new file mode 100644 index 0000000..7fdf4f9 --- /dev/null +++ b/tests/2fas-demo-pass.2fas @@ -0,0 +1 @@ +{"services":[],"groups":[{"id":"8ea6c261-e88e-4cb8-951c-001786d144bc","name":"Folder 1","isExpanded":true,"updatedAt":1705504169176}],"updatedAt":1705504241031,"schemaVersion":4,"appVersionCode":5000012,"appVersionName":"5.2.0","appOrigin":"android","servicesEncrypted":"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:I90AMhbdg/2kEGuE/dkcp0O19XiavoPiy2HXsO/AAywDZMMLSPD1U+3pD8bckd4SlPwjmXvU+gjLd4m4a7WL7Uqb1fCawRQ74Lvp4WDjN5g7SmJhyLpvXjsRjLZj9q6HQOAsBeyxggGW7sAwCYTkpv7Vel77aSoHqx4fN7cDrLQ0/hOc+3WmgcxOXRTeex49F4gOsyWn5dunz0MhPIHnGFAYxMLyqXxMajalRqk8tJL7eCz4Umlm3tSrrI39aYpO1lGHPRoZMTx04aJuHJwrFVlqcAcJkGs2pzCQrXHtN8p7f/lMHrdpNEnc93wjWYs3XHPdiGdA8zPdU/AQRItFTQ==:n3R+O+IfdN6iHvfL","reference":"NtGP2JfaqREfQcfe9gKllN3pd1+GkfJHrUSFEFZMRHMprFDJHB9Q7afMe00vNjTu4Jy4usCiu/RGU6rRN1gyJaTCqcOrM6xupVgX1AA/idp4faO2Gexcsg3mcqN8wWPJnc38YuP3V1Z0o6iu65ASsQnY7fm0fzB+CVzVgYTmzHbFU/un3xrmyUlxuzFL0PRlGgzoiDtfrG5CxPRvp1pO+WAoXNzgudABGcNUO/X0g6RKcetyaFwoko2190n+yr/tmV8KTDCfwxjm/BuGuTYXksT72AX5xB7scPtnrTQKOJnwYXxyEELdbyS0FKU/yajqOAlqx3zr59KCxlvreiYUfHRNiylTucI2mdh+VzK8RuI=:I90AMhbdg/2kEGuE/dkcp0O19XiavoPiy2HXsO/AAywDZMMLSPD1U+3pD8bckd4SlPwjmXvU+gjLd4m4a7WL7Uqb1fCawRQ74Lvp4WDjN5g7SmJhyLpvXjsRjLZj9q6HQOAsBeyxggGW7sAwCYTkpv7Vel77aSoHqx4fN7cDrLQ0/hOc+3WmgcxOXRTeex49F4gOsyWn5dunz0MhPIHnGFAYxMLyqXxMajalRqk8tJL7eCz4Umlm3tSrrI39aYpO1lGHPRoZMTx04aJuHJwrFVlqcAcJkGs2pzCQrXHtN8p7f/lMHrdpNEnc93wjWYs3XHPdiGdA8zPdU/AQRItFTQ==:p+HspW0nvUhneUmE"} \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_shared.py b/tests/_shared.py new file mode 100644 index 0000000..3941001 --- /dev/null +++ b/tests/_shared.py @@ -0,0 +1,3 @@ +from pathlib import Path + +CWD = Path(__file__).parent diff --git a/tests/test_decrypted.py b/tests/test_decrypted.py new file mode 100644 index 0000000..2f8be9a --- /dev/null +++ b/tests/test_decrypted.py @@ -0,0 +1,105 @@ +import json + +import pytest + +from src.lib2fas import load_services +from src.lib2fas._types import TwoFactorAuthDetails +from src.lib2fas.core import TwoFactorStorage + +from ._shared import CWD + +FILENAME = str(CWD / "2fas-demo-nopass.2fas") + + +def test_empty(): + storage = TwoFactorStorage() + assert not storage + + +@pytest.fixture +def services(): + yield load_services(FILENAME) + + +def test_load(services): + assert services + + assert len(list(services)) == services.count == 4 + + assert len(services.keys()) == 3 # 1 2 and 3 + + for name, detail_list in services.items(): + assert isinstance(name, str) + assert isinstance(detail_list, list) + assert detail_list # why would it return an empty list? + assert isinstance(detail_list[0], TwoFactorAuthDetails) + + service = next(iter(services)) + assert repr(service) == service.as_json() == json.dumps(service.as_dict(), indent=2) + + assert str(service).startswith("<2fas") + + assert "3" in repr(services) and "4" in repr(services) + + example_1 = services["Example 1"] + example_1a, example_1b = example_1 # type: TwoFactorAuthDetails + + totp_1a = example_1a.generate() + totp_1b = example_1b.generate_int() + + assert totp_1a + assert totp_1b + + assert totp_1a != str(totp_1b) + assert int(totp_1a) != totp_1b + + example_2 = services["Example 2"] + assert len(example_2) == 1 + + example_2 = example_2[0] + + assert str(example_2.generate_int()).rjust(6, "0") == example_2.generate() + assert example_2.generate_int() == int(example_2.generate()) + + +def test_search_exact(services): + found = services.find("Example 1") + assert len(found) == 2 + assert list(found) == services["Example 1"] + + +def test_search_fuzzy(services): + print(list(services.find())) + print(services.all()) + + assert list(services.find()) == services.all() + + found = services.find("Example") + assert len(found) == 4 + + found = services.find("1") + assert len(found) == 2 + + found = services.find("2") + assert len(found) == 1 + + found = services.find("___") + assert len(found) == 0 + + # search in value + + found = services.find("@google") + assert len(found) == 2 + + found = services.find("Additional inof") # fuzzy with typo + assert len(found) == 2 + + # test nested search: + found = services.find("1").find("other") + assert len(found) == 1 + + assert ( + [_[1] for _ in services.generate()] + == [_[1] for _ in services.find().generate()] + == [_.generate() for _ in services] + ) # generate all diff --git a/tests/test_encrypted.py b/tests/test_encrypted.py new file mode 100644 index 0000000..63f9640 --- /dev/null +++ b/tests/test_encrypted.py @@ -0,0 +1,62 @@ +import pytest + +from src.lib2fas import load_services +from src.lib2fas._security import KeyringManager, keyring_manager + +from ._shared import CWD + +FILENAME = str(CWD / "2fas-demo-pass.2fas") +PASSWORD = "test" + + +@pytest.fixture() +def clean_keyring(): + # manager classmethods without an instance will clear even active entries. + KeyringManager.tmp_file.unlink(missing_ok=True) + KeyringManager._cleanup_keyring("") + + keyring_manager._init() + # and also the active manager (idk why but seems necessary): + keyring_manager.cleanup_keyring() + + +@pytest.fixture +def getpass_wrong(clean_keyring, monkeypatch): + """Did the file we wrote actually become json.""" + monkeypatch.setattr("getpass.getpass", lambda _: "***") + + +@pytest.fixture +def getpass_correct(clean_keyring, monkeypatch): + """Did the file we wrote actually become json.""" + monkeypatch.setattr("getpass.getpass", lambda _: PASSWORD) + + +@pytest.fixture +def getpass_empty(monkeypatch): + """Did the file we wrote actually become json.""" + monkeypatch.setattr("getpass.getpass", lambda _: "") + + +def test_wrong_pass(getpass_wrong): + with pytest.raises(PermissionError): + assert not load_services(FILENAME, _max_retries=1) + + +def test_right_pass(getpass_correct): + services = load_services(FILENAME) + assert services + + +def test_from_keyring(getpass_empty): + # note: `test_right_pass` MUST be executed before! + services = load_services(FILENAME, _max_retries=1) + assert services + + +def test_reload_keyring(): + # must also be executed after `test_right_pass` and/or `test_from_keyring` + new_manager = KeyringManager() + + assert new_manager != keyring_manager + assert new_manager.appname == keyring_manager.appname diff --git a/tests/test_other.py b/tests/test_other.py new file mode 100644 index 0000000..d84273e --- /dev/null +++ b/tests/test_other.py @@ -0,0 +1,6 @@ +from src.lib2fas.__about__ import __version__ + + +def test_version(): + assert __version__ + assert isinstance(__version__, str)