From 55a26c77371781d3ae2c784ede27845c7be9361c Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sun, 8 Nov 2026 01:09:26 +0200 Subject: [PATCH] 3.0 phase 1a: account cryptography (not used by the app yet) account_crypto.py holds the PassMan 3.0 key design (E:\CLAUDE\PassMan\ DESIGN-3.0.md): Argon2id (cryptography>=44, or argon2-cffi = Ubuntu's python3-argon2) + a 128-bit Secret Key -> HKDF -> Account Unlock Key and a separate Login Key; a keyset sealed with AES-GCM under an authenticated header; X25519 sealed-box wrapping of vault keys bound to vault id + member; fingerprints; the PMV3 vault container; the 12-word BIP39 recovery phrase (official word list, hash-checked); create / unlock / recover / reseal. Pure functions, no files, nothing in the 2.x app calls them, so 2.x behaviour is unchanged and there is no version bump. Tests pin the RFC 9106 Argon2id vector and the BIP39 reference vectors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 3 +- deb/build_deb.py | 3 + deb/tests/test_build_deb.py | 3 +- requirements-dev.txt | 1 + requirements.txt | 1 + sourceFiles/account_crypto.py | 530 +++++++++++++++++++++++ sourceFiles/bip39_words.py | 264 +++++++++++ sourceFiles/tests/test_account_crypto.py | 378 ++++++++++++++++ 8 files changed, 1181 insertions(+), 2 deletions(-) create mode 100644 sourceFiles/account_crypto.py create mode 100644 sourceFiles/bip39_words.py create mode 100644 sourceFiles/tests/test_account_crypto.py diff --git a/README.md b/README.md index 5bb8e97..b28f636 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ cd PassMan-Client-Linux python3 deb/build_deb.py # writes build/passman__all.deb ``` -Then install it — apt pulls in Tk, cryptography and Pillow from Ubuntu: +Then install it — apt pulls in Tk, cryptography, Argon2 (`python3-argon2`, in +the *universe* section, on by default on Ubuntu desktops) and Pillow from Ubuntu: ```bash sudo apt install ./build/passman_*_all.deb diff --git a/deb/build_deb.py b/deb/build_deb.py index e031764..7590e76 100644 --- a/deb/build_deb.py +++ b/deb/build_deb.py @@ -53,6 +53,9 @@ "python3 (>= 3.10)", "python3-tk", "python3-cryptography", + # Argon2id for 3.0 accounts: Ubuntu's python3-cryptography (3.4 on + # 22.04, 41 on 24.04) predates it. In universe on both. + "python3-argon2", "python3-pil", "python3-pil.imagetk", "fonts-dejavu-core", diff --git a/deb/tests/test_build_deb.py b/deb/tests/test_build_deb.py index 76074f2..69bd942 100644 --- a/deb/tests/test_build_deb.py +++ b/deb/tests/test_build_deb.py @@ -86,7 +86,8 @@ def test_control_file(tree): assert fields["Version"] == "9.8.7" assert fields["Architecture"] == "all" assert int(fields["Installed-Size"]) > 0 - for dep in ("python3-tk", "python3-cryptography", "python3-pil.imagetk"): + for dep in ("python3-tk", "python3-cryptography", "python3-argon2", + "python3-pil.imagetk"): assert dep in fields["Depends"] assert text.endswith("\n") diff --git a/requirements-dev.txt b/requirements-dev.txt index 16c6d64..8761ac2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ # Development / test dependencies (the app itself needs only requirements.txt) -r requirements.txt pytest>=8 +argon2-cffi>=21.1 # what Ubuntu's python3-argon2 is; the tests check it agrees with cryptography's Argon2id diff --git a/requirements.txt b/requirements.txt index 594506d..ab1553c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # PassMan dependencies. Also needs Tk from the system: sudo apt install python3-tk cryptography>=42 # AES-GCM + PBKDF2 vault encryption +argon2-cffi>=21.1 # Argon2id for 3.0 accounts (Ubuntu: python3-argon2; or cryptography>=44) customtkinter>=5.2 # modern UI toolkit Pillow>=10 # logo image rendering on the login screen diff --git a/sourceFiles/account_crypto.py b/sourceFiles/account_crypto.py new file mode 100644 index 0000000..c073596 --- /dev/null +++ b/sourceFiles/account_crypto.py @@ -0,0 +1,530 @@ +""" +The cryptography of PassMan 3.0 accounts (design: E:\\CLAUDE\\PassMan\\DESIGN-3.0.md). + +Nothing in the 2.x app calls this yet: phase 1 builds the local account on it. +Every function is pure -- bytes and dicts in, bytes and dicts out, no files -- +so the format can be pinned by tests before any screen depends on it. + +The pieces, from what the user types to what opens a vault:: + + master password --Argon2id(salt)--+ + +--HKDF--> AUK (Account Unlock Key) + Secret Key (128 bits) ------------+ \\----> Login Key (for the server) + + AUK --AES-GCM--> keyset {X25519 private key, personal vault key} + X25519 key --unwraps--> a shared vault's key + vault key --AES-GCM--> PMV3: the vault's entries + +Recovery (personal accounts): a 12-word BIP39 phrase carries 128 random bits; +they make a Recovery Key that seals a second copy of the keyset. + +Libraries: ``cryptography`` for AES-GCM, HKDF and X25519 (all present in the +old 3.4 that Ubuntu 22.04 ships); Argon2id from ``cryptography`` >= 44 where it +exists (Windows), else from argon2-cffi (Ubuntu's python3-argon2). Both give +the same bytes for the same inputs -- the tests hold them to it. +""" + +import base64 +import hashlib +import hmac +import json +import os +import struct +import unicodedata + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import (X25519PrivateKey, + X25519PublicKey) +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from bip39_words import WORDS + +KEY_LEN = 32 +SALT_LEN = 16 +NONCE_LEN = 12 +SECRET_KEY_LEN = 16 +VAULT_ID_LEN = 16 + +#: Argon2id cost for a new account: 64 MiB, 3 passes, 1 lane -- 0.12 s on the +#: development PC (2026), a few times that on an old laptop. Memory is what +#: makes guessing on graphics cards expensive. Stored in each keyset header, so it +#: can be raised later without breaking older files. +ARGON2_MEMORY_KIB = 64 * 1024 +ARGON2_PASSES = 3 +ARGON2_LANES = 1 +#: Refuse headers outside these: a damaged or hostile file must not make the +#: app allocate gigabytes or spin for minutes before saying "wrong password". +ARGON2_MAX_MEMORY_KIB = 1024 * 1024 +ARGON2_MAX_PASSES = 20 +ARGON2_MAX_LANES = 16 + + +class CryptoError(ValueError): + """A sentence the user can be shown: wrong password, damaged file, typo.""" + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def _unb64(text, what: str) -> bytes: + try: + return base64.b64decode(text, validate=True) + except Exception: + raise CryptoError(f"The {what} is damaged (not valid base64).") from None + + +def _hkdf(ikm: bytes, salt: bytes, info: bytes, length: int = KEY_LEN) -> bytes: + return HKDF(algorithm=hashes.SHA256(), length=length, salt=salt, + info=info).derive(ikm) + + +# =========================================================================== +# Argon2id +# =========================================================================== +def _argon2_cryptography(secret, salt, passes, memory_kib, lanes, length): + from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + return Argon2id(salt=salt, length=length, iterations=passes, lanes=lanes, + memory_cost=memory_kib).derive(secret) + + +def _argon2_cffi(secret, salt, passes, memory_kib, lanes, length): + from argon2.low_level import Type, hash_secret_raw + return hash_secret_raw(secret=secret, salt=salt, time_cost=passes, + memory_cost=memory_kib, parallelism=lanes, + hash_len=length, type=Type.ID) + + +def argon2_backends() -> dict: + """The Argon2id implementations importable here, by name.""" + found = {} + try: + from cryptography.hazmat.primitives.kdf.argon2 import Argon2id # noqa: F401 + found["cryptography"] = _argon2_cryptography + except ImportError: + pass + try: + from argon2.low_level import hash_secret_raw # noqa: F401 + found["argon2-cffi"] = _argon2_cffi + except ImportError: + pass + return found + + +def argon2id(secret: bytes, salt: bytes, passes: int = ARGON2_PASSES, + memory_kib: int = ARGON2_MEMORY_KIB, lanes: int = ARGON2_LANES, + length: int = KEY_LEN) -> bytes: + backends = argon2_backends() + if not backends: + raise CryptoError("Argon2id is not available: install cryptography 44 " + "or newer, or argon2-cffi (python3-argon2).") + return next(iter(backends.values()))(secret, salt, passes, memory_kib, + lanes, length) + + +def _check_cost(passes, memory_kib, lanes): + if not (1 <= passes <= ARGON2_MAX_PASSES + and 8 * lanes <= memory_kib <= ARGON2_MAX_MEMORY_KIB + and 1 <= lanes <= ARGON2_MAX_LANES): + raise CryptoError("The account file asks for an unreasonable key " + "derivation cost; it is damaged or not PassMan's.") + + +# =========================================================================== +# Secret Key +# =========================================================================== +SECRET_KEY_PREFIX = "A3" +_B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + +def _secret_check_char(raw: bytes) -> str: + return _B32[hashlib.sha256(b"PassMan3 secret key" + raw).digest()[0] % 32] + + +def new_secret_key() -> bytes: + return os.urandom(SECRET_KEY_LEN) + + +def format_secret_key(raw: bytes) -> str: + """ + ``A3-XXXXX-XXXXX-XXXXX-XXXXXX-XXXXXX``: 26 base32 characters for the 128 + bits plus one check character, so a mistyped character is caught as a + typo instead of reported as a wrong password. + """ + if len(raw) != SECRET_KEY_LEN: + raise ValueError("a Secret Key is 16 bytes") + body = base64.b32encode(raw).decode("ascii").rstrip("=") + _secret_check_char(raw) + groups = [body[0:5], body[5:10], body[10:15], body[15:21], body[21:27]] + return SECRET_KEY_PREFIX + "-" + "-".join(groups) + + +def parse_secret_key(text: str) -> bytes: + """The 16 bytes of a typed Secret Key, or CryptoError saying what is wrong.""" + cleaned = "".join((text or "").split()).replace("-", "").upper() + if not cleaned: + raise CryptoError("Enter your Secret Key (it starts with A3-).") + if not cleaned.startswith(SECRET_KEY_PREFIX): + raise CryptoError("A Secret Key starts with A3-; check your Emergency Kit.") + body = cleaned[len(SECRET_KEY_PREFIX):] + if len(body) != 27: + raise CryptoError(f"A Secret Key has 27 characters after A3-; this one " + f"has {len(body)}.") + bad = sorted({c for c in body if c not in _B32}) + if bad: + hint = " (0 and 1 are never used: did you mean O or I?)" \ + if set(bad) & set("01") else "" + raise CryptoError("A Secret Key uses only A-Z and 2-7; this one also " + "has " + " ".join(bad) + hint + ".") + try: + raw = base64.b32decode(body[:26] + "=" * 6) + except Exception: + raise CryptoError("That Secret Key is not valid; check it against your " + "Emergency Kit.") from None + # The 26th character carries only 3 of the 128 bits; decoding ignores the + # rest, so compare against the canonical spelling to catch a typo there. + canonical = base64.b32encode(raw).decode("ascii")[:26] + if canonical != body[:26] or _secret_check_char(raw) != body[26]: + raise CryptoError("That Secret Key has a typo; check it against your " + "Emergency Kit.") + return raw + + +# =========================================================================== +# Master password + Secret Key -> AUK and Login Key +# =========================================================================== +def _normalise(password: str) -> bytes: + # The same password typed on Windows and on Linux (or pasted) can arrive + # in different Unicode forms; NFKC makes them one. + return unicodedata.normalize("NFKC", password).encode("utf-8") + + +def derive_account_keys(master_password: str, secret_key: bytes, username: str, + salt: bytes, passes: int = ARGON2_PASSES, + memory_kib: int = ARGON2_MEMORY_KIB, + lanes: int = ARGON2_LANES): + """ + (AUK, Login Key). Both need the password AND the Secret Key; the HKDF + labels keep them independent, so a server that sees the Login Key learns + nothing about the AUK. The username is mixed in so two accounts with the + same password and (impossibly) the same salt still differ. + """ + if not master_password: + raise CryptoError("Enter the master password.") + if len(secret_key) != SECRET_KEY_LEN or len(salt) != SALT_LEN: + raise ValueError("bad Secret Key or salt length") + _check_cost(passes, memory_kib, lanes) + stretched = argon2id(_normalise(master_password), salt, passes, memory_kib, + lanes) + who = unicodedata.normalize("NFKC", username).encode("utf-8") + auk = _hkdf(stretched, secret_key, b"PassMan3 account unlock key|" + who) + login = _hkdf(stretched, secret_key, b"PassMan3 login key|" + who) + return auk, login + + +# =========================================================================== +# Sealed containers (keyset, recovery copy) +# =========================================================================== +KEYSET_MAGIC = b"PMK3" +KEYSET_VERSION = 1 +KDF_ARGON2ID = 1 # sealed under the AUK (master password + Secret Key) +KDF_NONE = 0 # sealed under a full-strength key (the Recovery Key) +_KEYSET_HEADER = struct.Struct(">4sBBIBB16s") # magic ver kdf mem passes lanes salt + + +def seal_keyset(key: bytes, keyset: dict, salt: bytes, kdf: int = KDF_ARGON2ID, + passes: int = ARGON2_PASSES, memory_kib: int = ARGON2_MEMORY_KIB, + lanes: int = ARGON2_LANES) -> str: + """ + The keyset as base64 text: a header (how to get the key back from the + password: KDF, cost, salt) authenticated as AES-GCM associated data, so + lowering the cost or swapping the salt in a file breaks decryption. + """ + header = _KEYSET_HEADER.pack(KEYSET_MAGIC, KEYSET_VERSION, kdf, memory_kib, + passes, lanes, salt) + nonce = os.urandom(NONCE_LEN) + body = json.dumps(keyset, separators=(",", ":"), sort_keys=True).encode() + return _b64(header + nonce + AESGCM(key).encrypt(nonce, body, header)) + + +def keyset_header(blob: str) -> dict: + """What a sealed keyset says about its own key derivation -- read before + the password is stretched, so the stored cost is the one used.""" + raw = _unb64(blob, "account file") + if len(raw) < _KEYSET_HEADER.size + NONCE_LEN + 16: + raise CryptoError("The account file is too short to be PassMan's.") + magic, version, kdf, memory_kib, passes, lanes, salt = \ + _KEYSET_HEADER.unpack(raw[:_KEYSET_HEADER.size]) + if magic != KEYSET_MAGIC: + raise CryptoError("That is not a PassMan 3 account file.") + if version != KEYSET_VERSION: + raise CryptoError("The account file was written by a newer PassMan; " + "update this one.") + if kdf not in (KDF_ARGON2ID, KDF_NONE): + raise CryptoError("The account file uses an unknown key derivation.") + if kdf == KDF_ARGON2ID: + _check_cost(passes, memory_kib, lanes) + return {"kdf": kdf, "memory_kib": memory_kib, "passes": passes, + "lanes": lanes, "salt": salt} + + +def open_keyset(key: bytes, blob: str) -> dict: + keyset_header(blob) # format errors before key errors + raw = _unb64(blob, "account file") + header = raw[:_KEYSET_HEADER.size] + nonce = raw[_KEYSET_HEADER.size:_KEYSET_HEADER.size + NONCE_LEN] + try: + body = AESGCM(key).decrypt(nonce, raw[_KEYSET_HEADER.size + NONCE_LEN:], + header) + except Exception: + raise CryptoError("Wrong master password or Secret Key.") from None + return json.loads(body) + + +def new_keyset() -> dict: + """A fresh account: an X25519 key pair and the personal vault's key.""" + private = X25519PrivateKey.generate() + return {"x25519_private": _b64(private.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption())), + "personal_vault_key": _b64(os.urandom(KEY_LEN))} + + +def public_key_of(keyset: dict) -> bytes: + private = X25519PrivateKey.from_private_bytes( + _unb64(keyset["x25519_private"], "account key")) + return private.public_key().public_bytes(serialization.Encoding.Raw, + serialization.PublicFormat.Raw) + + +# =========================================================================== +# Wrapping a vault key to a member (sealed box) +# =========================================================================== +WRAP_VERSION = 1 + + +def _wrap_context(vault_id: bytes, member: str) -> bytes: + return (b"PassMan3 wrap|" + vault_id + b"|" + + unicodedata.normalize("NFKC", member).encode("utf-8")) + + +def wrap_key(recipient_public: bytes, vault_key: bytes, vault_id: bytes, + member: str) -> str: + """ + ``vault_key`` sealed so only ``recipient_public``'s owner can open it: + a one-off X25519 key, HKDF over the shared secret, AES-GCM. The vault id + and member name are associated data, so a wrapped key cannot be passed + off as another vault's or another person's. + """ + ephemeral = X25519PrivateKey.generate() + eph_pub = ephemeral.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + shared = ephemeral.exchange(X25519PublicKey.from_public_bytes(recipient_public)) + key = _hkdf(shared, eph_pub + recipient_public, b"PassMan3 wrap key") + nonce = os.urandom(NONCE_LEN) + ct = AESGCM(key).encrypt(nonce, vault_key, _wrap_context(vault_id, member)) + return _b64(bytes([WRAP_VERSION]) + eph_pub + nonce + ct) + + +def unwrap_key(keyset: dict, wrapped: str, vault_id: bytes, member: str) -> bytes: + raw = _unb64(wrapped, "shared vault key") + if len(raw) != 1 + 32 + NONCE_LEN + KEY_LEN + 16 or raw[0] != WRAP_VERSION: + raise CryptoError("A shared vault key is damaged or from a newer PassMan.") + eph_pub, nonce, ct = raw[1:33], raw[33:33 + NONCE_LEN], raw[33 + NONCE_LEN:] + private = X25519PrivateKey.from_private_bytes( + _unb64(keyset["x25519_private"], "account key")) + shared = private.exchange(X25519PublicKey.from_public_bytes(eph_pub)) + key = _hkdf(shared, eph_pub + public_key_of(keyset), b"PassMan3 wrap key") + try: + return AESGCM(key).decrypt(nonce, ct, _wrap_context(vault_id, member)) + except Exception: + raise CryptoError("This shared vault key was not made for this account " + "and vault.") from None + + +def fingerprint(public_key: bytes) -> str: + """Six groups of four digits, read aloud before the first share.""" + digest = hashlib.sha256(b"PassMan3 fingerprint" + public_key).digest() + digits = str(int.from_bytes(digest[:12], "big") % 10 ** 24).zfill(24) + return " ".join(digits[i:i + 4] for i in range(0, 24, 4)) + + +# =========================================================================== +# PMV3: a vault's entries under its own key +# =========================================================================== +VAULT_MAGIC = b"PMV3" +VAULT_VERSION = 1 +_VAULT_HEADER = struct.Struct(">4sB16sI") # magic ver vault-id key-generation + + +def new_vault_id() -> bytes: + return os.urandom(VAULT_ID_LEN) + + +def encrypt_vault(vault_key: bytes, vault_id: bytes, key_generation: int, + data: dict) -> str: + header = _VAULT_HEADER.pack(VAULT_MAGIC, VAULT_VERSION, vault_id, + key_generation) + nonce = os.urandom(NONCE_LEN) + body = json.dumps(data, separators=(",", ":")).encode("utf-8") + return _b64(header + nonce + AESGCM(vault_key).encrypt(nonce, body, header)) + + +def vault_header(blob: str) -> dict: + raw = _unb64(blob, "vault") + if len(raw) < _VAULT_HEADER.size + NONCE_LEN + 16: + raise CryptoError("The vault file is too short to be a PassMan 3 vault.") + magic, version, vault_id, generation = _VAULT_HEADER.unpack( + raw[:_VAULT_HEADER.size]) + if magic != VAULT_MAGIC: + raise CryptoError("That is not a PassMan 3 vault.") + if version != VAULT_VERSION: + raise CryptoError("The vault was written by a newer PassMan; update " + "this one.") + return {"vault_id": vault_id, "key_generation": generation} + + +def decrypt_vault(vault_key: bytes, blob: str, expect_vault_id: bytes = None) -> dict: + """ + The vault's data. ``expect_vault_id`` refuses a file that decrypts but + belongs to another vault -- the header is authenticated, so it cannot be + edited to pass. + """ + head = vault_header(blob) + if expect_vault_id is not None and not hmac.compare_digest( + head["vault_id"], expect_vault_id): + raise CryptoError("That file belongs to a different vault.") + raw = _unb64(blob, "vault") + header = raw[:_VAULT_HEADER.size] + nonce = raw[_VAULT_HEADER.size:_VAULT_HEADER.size + NONCE_LEN] + try: + body = AESGCM(vault_key).decrypt( + nonce, raw[_VAULT_HEADER.size + NONCE_LEN:], header) + except Exception: + raise CryptoError("The vault could not be decrypted: wrong key, or the " + "file is damaged.") from None + return json.loads(body) + + +# =========================================================================== +# The 12-word recovery phrase (BIP39, English) +# =========================================================================== +PHRASE_WORDS = 12 +_INDEX = {w: i for i, w in enumerate(WORDS)} + + +def entropy_to_phrase(entropy: bytes) -> str: + """BIP39: 128 bits + the first 4 bits of their SHA-256 -> 12 words.""" + if len(entropy) != 16: + raise ValueError("a 12-word phrase holds 16 bytes") + bits = int.from_bytes(entropy, "big") << 4 | hashlib.sha256(entropy).digest()[0] >> 4 + return " ".join(WORDS[(bits >> (11 * (PHRASE_WORDS - 1 - i))) & 0x7FF] + for i in range(PHRASE_WORDS)) + + +def new_recovery_phrase() -> str: + return entropy_to_phrase(os.urandom(16)) + + +def phrase_to_entropy(phrase: str) -> bytes: + """The 16 bytes behind a typed phrase, or CryptoError naming the problem.""" + words = (phrase or "").lower().split() + if len(words) != PHRASE_WORDS: + raise CryptoError(f"The recovery phrase has {PHRASE_WORDS} words; " + f"{len(words)} were entered.") + unknown = [f"{n}. \u201c{w}\u201d" for n, w in enumerate(words, 1) + if w not in _INDEX] + if unknown: + raise CryptoError("Not in the word list: " + ", ".join(unknown) + + ". Check the spelling against your paper.") + bits = 0 + for w in words: + bits = bits << 11 | _INDEX[w] + entropy = (bits >> 4).to_bytes(16, "big") + if hashlib.sha256(entropy).digest()[0] >> 4 != bits & 0xF: + raise CryptoError("Those words are all in the list, but not in this " + "order: two may be swapped or one replaced.") + return entropy + + +def recovery_key(phrase: str, username: str) -> bytes: + """ + The key that seals the recovery copy of the keyset. No slow KDF: the + phrase is 128 random bits, already beyond guessing. + """ + who = unicodedata.normalize("NFKC", username).encode("utf-8") + return _hkdf(phrase_to_entropy(phrase), b"PassMan3 recovery", + b"PassMan3 recovery key|" + who) + + +# =========================================================================== +# One account, end to end +# =========================================================================== +def create_account(username: str, master_password: str, + passes: int = ARGON2_PASSES, + memory_kib: int = ARGON2_MEMORY_KIB, + lanes: int = ARGON2_LANES) -> dict: + """ + Everything a new account needs, made on this device. The caller shows + ``secret_key_text`` (Emergency Kit) and ``recovery_phrase`` once, stores + the sealed blobs, and keeps ``login_key`` only to register with a server. + """ + secret = new_secret_key() + salt = os.urandom(SALT_LEN) + auk, login = derive_account_keys(master_password, secret, username, salt, + passes, memory_kib, lanes) + keyset = new_keyset() + phrase = new_recovery_phrase() + return { + "keyset": keyset, + "public_key": public_key_of(keyset), + "secret_key": secret, + "secret_key_text": format_secret_key(secret), + "recovery_phrase": phrase, + "login_key": login, + "sealed_keyset": seal_keyset(auk, keyset, salt, KDF_ARGON2ID, passes, + memory_kib, lanes), + "sealed_recovery": seal_keyset(recovery_key(phrase, username), keyset, + bytes(SALT_LEN), KDF_NONE, 0, 0, 0), + } + + +def unlock_account(username: str, master_password: str, secret_key: bytes, + sealed_keyset: str): + """(keyset, login key) from the master password and the Secret Key.""" + head = keyset_header(sealed_keyset) + if head["kdf"] != KDF_ARGON2ID: + raise CryptoError("That is a recovery copy, not the account file.") + auk, login = derive_account_keys(master_password, secret_key, username, + head["salt"], head["passes"], + head["memory_kib"], head["lanes"]) + return open_keyset(auk, sealed_keyset), login + + +def recover_account(username: str, phrase: str, sealed_recovery: str) -> dict: + """The keyset from the recovery phrase alone (personal accounts).""" + if keyset_header(sealed_recovery)["kdf"] != KDF_NONE: + raise CryptoError("That is the account file, not its recovery copy.") + try: + return open_keyset(recovery_key(phrase, username), sealed_recovery) + except CryptoError as e: + if "Wrong master password" in str(e): + raise CryptoError("That recovery phrase is not the one for this " + "account.") from None + raise + + +def reseal_with_new_password(username: str, keyset: dict, new_password: str, + secret_key: bytes, passes: int = ARGON2_PASSES, + memory_kib: int = ARGON2_MEMORY_KIB, + lanes: int = ARGON2_LANES): + """ + After a recovery or a password change: the same keyset (so every wrapped + vault key still opens) under a new password, with a fresh salt. + Returns (sealed keyset, new login key). + """ + salt = os.urandom(SALT_LEN) + auk, login = derive_account_keys(new_password, secret_key, username, salt, + passes, memory_kib, lanes) + return seal_keyset(auk, keyset, salt, KDF_ARGON2ID, passes, memory_kib, + lanes), login diff --git a/sourceFiles/bip39_words.py b/sourceFiles/bip39_words.py new file mode 100644 index 0000000..4788c88 --- /dev/null +++ b/sourceFiles/bip39_words.py @@ -0,0 +1,264 @@ +""" +The BIP39 English word list (2048 words), for the 12-word recovery phrase. + +Copied from https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt +(SHA-256 of that file: 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda; +tests/test_account_crypto.py checks it). A Python module rather than a data +file so the frozen app needs no extra bundling step. +""" + +WORDS = ( + "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", + "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", + "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", + "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", + "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", + "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", + "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", + "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", + "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", + "animal", "ankle", "announce", "annual", "another", "answer", "antenna", + "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", + "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", + "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", + "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", + "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", + "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", + "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", + "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", + "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", + "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", + "become", "beef", "before", "begin", "behave", "behind", "believe", "below", + "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", + "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", + "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", + "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", + "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", + "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", + "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", + "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", + "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", + "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", + "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", + "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", + "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", + "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", + "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", + "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", + "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", + "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", + "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", + "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", + "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", + "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", + "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", + "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", + "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", + "comic", "common", "company", "concert", "conduct", "confirm", "congress", + "connect", "consider", "control", "convince", "cook", "cool", "copper", + "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", + "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", + "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", + "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", + "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", + "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", + "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", + "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", + "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", + "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", + "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", + "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", + "destroy", "detail", "detect", "develop", "device", "devote", "diagram", + "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", + "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", + "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", + "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", + "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", + "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", + "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", + "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", + "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", + "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", + "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", + "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", + "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", + "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", + "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", + "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", + "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", + "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", + "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", + "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", + "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", + "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", + "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", + "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", + "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", + "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", + "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", + "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", + "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", + "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", + "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", + "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", + "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", + "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", + "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", + "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", + "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", + "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", + "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", + "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", + "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", + "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", + "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", + "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", + "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", + "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", + "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", + "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", + "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", + "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", + "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", + "image", "imitate", "immense", "immune", "impact", "impose", "improve", + "impulse", "inch", "include", "income", "increase", "index", "indicate", + "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", + "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", + "insane", "insect", "inside", "inspire", "install", "intact", "interest", + "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", + "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", + "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", + "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", + "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", + "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", + "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", + "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", + "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", + "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", + "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", + "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", + "list", "little", "live", "lizard", "load", "loan", "lobster", "local", + "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", + "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", + "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", + "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", + "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", + "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", + "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", + "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", + "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", + "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", + "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", + "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", + "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", + "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", + "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", + "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", + "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", + "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", + "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", + "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", + "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", + "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", + "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", + "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", + "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", + "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", + "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", + "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", + "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", + "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", + "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", + "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", + "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", + "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", + "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", + "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", + "pool", "popular", "portion", "position", "possible", "post", "potato", + "pottery", "poverty", "powder", "power", "practice", "praise", "predict", + "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", + "print", "priority", "prison", "private", "prize", "problem", "process", + "produce", "profit", "program", "project", "promote", "proof", "property", + "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", + "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", + "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", + "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", + "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", + "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", + "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", + "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", + "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", + "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", + "repeat", "replace", "report", "require", "rescue", "resemble", "resist", + "resource", "response", "result", "retire", "retreat", "return", "reunion", + "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", + "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", + "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", + "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", + "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", + "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", + "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", + "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", + "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", + "screen", "script", "scrub", "sea", "search", "season", "seat", "second", + "secret", "section", "security", "seed", "seek", "segment", "select", "sell", + "seminar", "senior", "sense", "sentence", "series", "service", "session", + "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", + "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", + "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", + "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", + "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", + "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", + "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", + "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", + "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", + "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", + "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", + "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", + "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", + "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", + "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", + "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", + "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", + "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", + "strong", "struggle", "student", "stuff", "stumble", "style", "subject", + "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", + "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", + "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", + "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", + "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", + "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", + "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", + "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", + "theory", "there", "they", "thing", "this", "thought", "three", "thrive", + "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", + "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", + "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", + "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", + "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", + "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", + "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", + "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", + "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", + "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", + "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", + "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", + "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", + "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", + "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", + "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", + "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", + "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", + "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", + "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", + "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", + "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", + "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", + "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", + "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", + "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", + "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", + "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", + "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", + "you", "young", "youth", "zebra", "zero", "zone", "zoo", +) diff --git a/sourceFiles/tests/test_account_crypto.py b/sourceFiles/tests/test_account_crypto.py new file mode 100644 index 0000000..ba1b924 --- /dev/null +++ b/sourceFiles/tests/test_account_crypto.py @@ -0,0 +1,378 @@ +"""PassMan 3.0 account cryptography: published vectors first, then the format.""" + +import base64 +import hashlib +import unicodedata + +import pytest + +import account_crypto as ac +import bip39_words + +# Cheap Argon2id settings: the format and the logic are what is under test, +# not the cost (the real cost is pinned separately below). +FAST = dict(passes=1, memory_kib=64, lanes=1) + + +# --------------------------------------------------------------------------- +# Argon2id +# --------------------------------------------------------------------------- +def test_rfc_9106_argon2id_vector(): + """RFC 9106 section 5.3, through the library the app uses on Windows.""" + argon2 = pytest.importorskip("cryptography.hazmat.primitives.kdf.argon2") + tag = argon2.Argon2id(salt=b"\x02" * 16, length=32, iterations=3, lanes=4, + memory_cost=32, ad=b"\x04" * 12, + secret=b"\x03" * 8).derive(b"\x01" * 32) + assert tag.hex() == ("0d640df58d78766c08c037a34a8b53c9" + "d01ef0452d75b65eb52520e96b01e659") + + +def test_the_wrapper_is_plain_argon2id(): + argon2 = pytest.importorskip("cryptography.hazmat.primitives.kdf.argon2") + direct = argon2.Argon2id(salt=b"s" * 16, length=32, iterations=2, lanes=1, + memory_cost=64).derive(b"pw") + assert ac.argon2id(b"pw", b"s" * 16, 2, 64, 1) == direct + + +def test_both_backends_give_the_same_key(): + """Windows (cryptography) and Ubuntu (python3-argon2) must agree, or a + vault made on one could not be opened on the other.""" + backends = ac.argon2_backends() + if len(backends) < 2: + pytest.skip("needs both cryptography>=44 and argon2-cffi") + outs = {name: fn(b"pass\xc3\xa9", b"salt" * 4, 2, 128, 2, 32) + for name, fn in backends.items()} + assert len(set(outs.values())) == 1, outs + + +def test_new_accounts_cost_64_mib_and_3_passes(): + assert (ac.ARGON2_MEMORY_KIB, ac.ARGON2_PASSES, ac.ARGON2_LANES) == \ + (65536, 3, 1) + + +# --------------------------------------------------------------------------- +# Secret Key +# --------------------------------------------------------------------------- +def test_secret_key_round_trip_and_shape(): + for _ in range(50): + raw = ac.new_secret_key() + text = ac.format_secret_key(raw) + assert text.startswith("A3-") and len(text.replace("-", "")) == 29 + assert [len(g) for g in text.split("-")] == [2, 5, 5, 5, 6, 6] + assert ac.parse_secret_key(text) == raw + + +def test_secret_key_typed_loosely_is_accepted(): + raw = bytes(range(16)) + text = ac.format_secret_key(raw) + assert ac.parse_secret_key(" " + text.lower().replace("-", " ") + "\n") == raw + + +def test_typos_read_as_typos_almost_always(): + """ + A typo should say "typo", not "wrong password". The check character + catches a random one-character typo 31 times in 32 (the rest end as + "Wrong master password or Secret Key" -- annoying, never unsafe), and a + typo in the check character itself every time. + """ + raw = bytes(range(16)) + body = ac.format_secret_key(raw).replace("-", "")[2:] + tried = missed = 0 + for i, original in enumerate(body): + for c in ac._B32: + if c == original: + continue + tried += 1 + try: + ac.parse_secret_key("A3" + body[:i] + c + body[i + 1:]) + except ac.CryptoError: + continue + missed += 1 + assert i < 26, "a typo in the check character slipped through" + assert missed < tried / 16, (missed, tried) + + +@pytest.mark.parametrize("text,words", [ + ("", "Enter your Secret Key"), + ("B3-AAAAA", "starts with A3"), + ("A3-AAAAA-AAAAA", "27 characters"), + ("A3-AAAA0-AAAAA-AAAAA-AAAAAA-AAAAAA", "never used"), +]) +def test_secret_key_mistakes_say_what_is_wrong(text, words): + with pytest.raises(ac.CryptoError, match=words): + ac.parse_secret_key(text) + + +# --------------------------------------------------------------------------- +# Account keys +# --------------------------------------------------------------------------- +SECRET, SALT = b"k" * 16, b"s" * 16 + + +def keys(pw="correct horse", secret=SECRET, user="alex", salt=SALT): + return ac.derive_account_keys(pw, secret, user, salt, **FAST) + + +def test_keys_are_deterministic_and_independent(): + auk, login = keys() + assert (auk, login) == keys() + assert auk != login and len(auk) == len(login) == 32 + + +@pytest.mark.parametrize("change", [ + dict(pw="correct horsf"), dict(secret=b"K" * 16), dict(user="Alex"), + dict(salt=b"S" * 16)]) +def test_every_input_matters(change): + assert keys(**change)[0] != keys()[0] + + +def test_the_same_password_in_two_unicode_forms_is_one_password(): + composed = unicodedata.normalize("NFC", "café-Zürich") + decomposed = unicodedata.normalize("NFD", "café-Zürich") + assert composed != decomposed + assert keys(pw=composed) == keys(pw=decomposed) + + +def test_an_empty_password_is_refused(): + with pytest.raises(ac.CryptoError, match="master password"): + keys(pw="") + + +# --------------------------------------------------------------------------- +# Sealed keyset +# --------------------------------------------------------------------------- +def sealed(key=b"a" * 32, **cost): + cost = cost or FAST + return ac.seal_keyset(key, {"x": 1}, SALT, ac.KDF_ARGON2ID, **cost) + + +def test_keyset_round_trip_and_header(): + blob = sealed() + assert ac.open_keyset(b"a" * 32, blob) == {"x": 1} + head = ac.keyset_header(blob) + assert head == {"kdf": ac.KDF_ARGON2ID, "memory_kib": 64, "passes": 1, + "lanes": 1, "salt": SALT} + + +def test_wrong_key_reads_as_wrong_password_or_secret_key(): + with pytest.raises(ac.CryptoError, match="Wrong master password or Secret Key"): + ac.open_keyset(b"b" * 32, sealed()) + + +def test_a_lowered_cost_in_the_header_breaks_the_file(): + """The header is associated data: an attacker cannot make the next unlock + cheaper (or swap the salt) without the file failing to open.""" + raw = bytearray(base64.b64decode(sealed(memory_kib=128, passes=2, lanes=1))) + raw[10] = 1 # passes 2 -> 1 + with pytest.raises(ac.CryptoError, match="Wrong"): + ac.open_keyset(b"a" * 32, base64.b64encode(bytes(raw)).decode()) + + +@pytest.mark.parametrize("cost", [dict(passes=99, memory_kib=64, lanes=1), + dict(passes=1, memory_kib=4 * 1024 * 1024, lanes=1), + dict(passes=1, memory_kib=64, lanes=0)]) +def test_an_absurd_cost_is_refused_before_any_work(cost): + blob = ac.seal_keyset(b"a" * 32, {}, SALT, ac.KDF_ARGON2ID, **cost) + with pytest.raises(ac.CryptoError, match="unreasonable"): + ac.keyset_header(blob) + + +@pytest.mark.parametrize("mangle,words", [ + (lambda r: b"PMV2" + r[4:], "not a PassMan 3 account"), + (lambda r: r[:4] + b"\x09" + r[5:], "newer PassMan"), + (lambda r: r[:30], "too short"), +]) +def test_a_wrong_file_says_so(mangle, words): + raw = base64.b64decode(sealed()) + with pytest.raises(ac.CryptoError, match=words): + ac.open_keyset(b"a" * 32, base64.b64encode(mangle(raw)).decode()) + + +# --------------------------------------------------------------------------- +# Wrapped vault keys and fingerprints +# --------------------------------------------------------------------------- +VID = b"v" * 16 + + +def test_a_wrapped_key_opens_only_for_its_member_and_vault(): + alice, bob = ac.new_keyset(), ac.new_keyset() + vault_key = b"V" * 32 + wrapped = ac.wrap_key(ac.public_key_of(alice), vault_key, VID, "alice") + assert ac.unwrap_key(alice, wrapped, VID, "alice") == vault_key + for keyset, vid, member in ((bob, VID, "alice"), (alice, b"w" * 16, "alice"), + (alice, VID, "bob")): + with pytest.raises(ac.CryptoError, match="not made for"): + ac.unwrap_key(keyset, wrapped, vid, member) + + +def test_wrapping_twice_gives_different_bytes(): + alice = ac.new_keyset() + pub = ac.public_key_of(alice) + assert ac.wrap_key(pub, b"V" * 32, VID, "a") != ac.wrap_key(pub, b"V" * 32, VID, "a") + + +def test_fingerprint_is_six_groups_of_four_and_stable(): + pub = ac.public_key_of(ac.new_keyset()) + fp = ac.fingerprint(pub) + assert fp == ac.fingerprint(pub) + groups = fp.split(" ") + assert len(groups) == 6 and all(len(g) == 4 and g.isdigit() for g in groups) + assert fp != ac.fingerprint(ac.public_key_of(ac.new_keyset())) + + +# --------------------------------------------------------------------------- +# PMV3 +# --------------------------------------------------------------------------- +DATA = {"accounts": [{"id": "1", "site": "Router", "password": "pässwörd"}]} + + +def test_vault_round_trip_and_header(): + blob = ac.encrypt_vault(b"V" * 32, VID, 3, DATA) + assert ac.decrypt_vault(b"V" * 32, blob, VID) == DATA + assert ac.vault_header(blob) == {"vault_id": VID, "key_generation": 3} + + +def test_a_vault_from_another_vault_is_refused(): + blob = ac.encrypt_vault(b"V" * 32, VID, 0, DATA) + with pytest.raises(ac.CryptoError, match="different vault"): + ac.decrypt_vault(b"V" * 32, blob, b"w" * 16) + + +def test_an_edited_vault_header_breaks_decryption(): + raw = bytearray(base64.b64decode(ac.encrypt_vault(b"V" * 32, VID, 0, DATA))) + raw[24] ^= 1 # key generation + with pytest.raises(ac.CryptoError, match="could not be decrypted"): + ac.decrypt_vault(b"V" * 32, base64.b64encode(bytes(raw)).decode()) + + +def test_wrong_vault_key(): + with pytest.raises(ac.CryptoError, match="could not be decrypted"): + ac.decrypt_vault(b"W" * 32, ac.encrypt_vault(b"V" * 32, VID, 0, DATA)) + + +def test_a_2x_vault_is_not_mistaken_for_pmv3(): + import crypto_utils + blob = crypto_utils.encrypt("pw", DATA, iterations=1000) + blob = blob.decode() if isinstance(blob, bytes) else blob + with pytest.raises(ac.CryptoError, match="not a PassMan 3 vault"): + ac.vault_header(blob) + + +# --------------------------------------------------------------------------- +# Recovery phrase +# --------------------------------------------------------------------------- +def test_the_word_list_is_the_official_one(): + text = "\n".join(bip39_words.WORDS) + "\n" + assert hashlib.sha256(text.encode()).hexdigest() == \ + "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda" + + +@pytest.mark.parametrize("entropy,phrase", [ + ("00" * 16, "abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon about"), + ("7f" * 16, "legal winner thank year wave sausage worth useful legal " + "winner thank yellow"), + ("80" * 16, "letter advice cage absurd amount doctor acoustic avoid " + "letter advice cage above"), + ("ff" * 16, "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"), + ("9e885d952ad362caeb4efe34a8e91bd2", + "ozone drill grab fiber curtain grace pudding thank cruise elder eight " + "picnic"), +]) +def test_bip39_reference_vectors(entropy, phrase): + assert ac.entropy_to_phrase(bytes.fromhex(entropy)) == phrase + assert ac.phrase_to_entropy(phrase) == bytes.fromhex(entropy) + + +def test_a_phrase_typed_in_capitals_with_extra_spaces(): + phrase = ac.new_recovery_phrase() + assert ac.phrase_to_entropy(" " + phrase.upper().replace(" ", " ")) == \ + ac.phrase_to_entropy(phrase) + + +def test_swapped_words_are_caught_by_the_checksum(): + words = ("legal winner thank year wave sausage worth useful legal winner " + "thank yellow").split() + words[2], words[3] = words[3], words[2] + with pytest.raises(ac.CryptoError, match="not in this order"): + ac.phrase_to_entropy(" ".join(words)) + + +def test_an_unknown_word_is_named_with_its_position(): + with pytest.raises(ac.CryptoError, match="3. “thenk”"): + ac.phrase_to_entropy("legal winner thenk year wave sausage worth useful " + "legal winner thank yellow") + + +def test_wrong_word_count(): + with pytest.raises(ac.CryptoError, match="12 words; 11"): + ac.phrase_to_entropy("zoo " * 11) + + +# --------------------------------------------------------------------------- +# An account, end to end +# --------------------------------------------------------------------------- +@pytest.fixture +def account(): + return ac.create_account("alex", "correct horse", **FAST) + + +def test_create_then_unlock(account): + keyset, login = ac.unlock_account("alex", "correct horse", + account["secret_key"], + account["sealed_keyset"]) + assert keyset == account["keyset"] and login == account["login_key"] + assert ac.parse_secret_key(account["secret_key_text"]) == account["secret_key"] + assert ac.public_key_of(keyset) == account["public_key"] + + +def test_password_alone_or_secret_key_alone_is_not_enough(account): + with pytest.raises(ac.CryptoError, match="Wrong"): + ac.unlock_account("alex", "correct horse", b"x" * 16, + account["sealed_keyset"]) + with pytest.raises(ac.CryptoError, match="Wrong"): + ac.unlock_account("alex", "wrong horse", account["secret_key"], + account["sealed_keyset"]) + + +def test_the_phrase_alone_recovers_the_same_keyset(account): + keyset = ac.recover_account("alex", account["recovery_phrase"], + account["sealed_recovery"]) + assert keyset == account["keyset"] + + +def test_another_phrase_does_not(account): + with pytest.raises(ac.CryptoError, match="not the one for this account"): + ac.recover_account("alex", ac.new_recovery_phrase(), + account["sealed_recovery"]) + + +def test_the_two_files_cannot_be_used_for_each_other(account): + with pytest.raises(ac.CryptoError, match="recovery copy"): + ac.unlock_account("alex", "correct horse", account["secret_key"], + account["sealed_recovery"]) + with pytest.raises(ac.CryptoError, match="not its recovery copy"): + ac.recover_account("alex", account["recovery_phrase"], + account["sealed_keyset"]) + + +def test_after_recovery_a_new_password_keeps_every_vault_key(account): + keyset = ac.recover_account("alex", account["recovery_phrase"], + account["sealed_recovery"]) + wrapped = ac.wrap_key(account["public_key"], b"V" * 32, VID, "alex") + resealed, login = ac.reseal_with_new_password( + "alex", keyset, "new password 2026", account["secret_key"], **FAST) + opened, login2 = ac.unlock_account("alex", "new password 2026", + account["secret_key"], resealed) + assert ac.unwrap_key(opened, wrapped, VID, "alex") == b"V" * 32 + assert login == login2 != account["login_key"] + with pytest.raises(ac.CryptoError, match="Wrong"): + ac.unlock_account("alex", "correct horse", account["secret_key"], resealed) + + +def test_nothing_secret_is_in_the_sealed_files(account): + for blob in (account["sealed_keyset"], account["sealed_recovery"]): + raw = base64.b64decode(blob) + assert account["secret_key"] not in raw + assert b"correct horse" not in raw + assert base64.b64decode(account["keyset"]["x25519_private"]) not in raw