diff --git a/README.md b/README.md index 5b1ae24..5bb8e97 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ encrypted file. There is no account and no service behind it: it works entirely offline, and if you want two PCs in step you run the sync server yourself, on your own hardware. -**Version 0.7.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or +**Version 0.8.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or newer, or runs from source. It has so far been tested in CI only, on a virtual display, not yet on a real desktop. @@ -19,8 +19,8 @@ virtual display, not yet on a real desktop. | [PassMan-Server-Linux](https://github.com/usermaneGermane/PassMan-Server-Linux) | the sync server for Linux, run by systemd | **Same vaults as on Windows.** The vault file, the sync protocol and the -features match PassMan for Windows 2.9.0, including its black-and-green -window and dialogs (see the version table under *What is different from +features match PassMan for Windows 2.10.0, including its black-and-green +window and dialogs and 2FA codes (see the version table under *What is different from Windows*). A vault moves between a Windows PC and a Linux PC unchanged, and both sync with either server. @@ -121,8 +121,8 @@ screen if you sync. from a copy run from source — copy the `vault` folder into `~/.local/share/passman` yourself. -Everything else — syncing, the archive, revision history, importing from -other password managers, and the security notes and limits — works and +Everything else — syncing, the archive, revision history, 2FA codes, +importing from other password managers, and the security notes and limits — works and reads exactly as in [PassMan for Windows's README](https://github.com/usermaneGermane/PassMan-Client-Windows#syncing-two-pcs). Where that README names a Windows version, read the Linux one beside it: @@ -136,6 +136,7 @@ Where that README names a Windows version, read the Linux one beside it: | 2.7.0 | 0.7.0 | the redesigned window | | 2.8.0 | 0.6.0 | locking no longer clears a copied password | | 2.9.0 | 0.7.0 | the dialogs redesigned to match | +| 2.10.0 | 0.8.0 | 2FA codes, behind the master password | (0.2.0 added the `.deb` package, which has no Windows counterpart.) diff --git a/sourceFiles/crypto_utils.py b/sourceFiles/crypto_utils.py index 9cdefaa..7d451f8 100644 --- a/sourceFiles/crypto_utils.py +++ b/sourceFiles/crypto_utils.py @@ -139,6 +139,17 @@ def decrypt(master_password: str, encrypted_data: bytes) -> dict: raise ValueError("Decryption failed. Wrong password or corrupted data.") from e +def password_matches(typed: str, actual: str) -> bool: + """ + Compare a typed master password with the one in memory, in constant + time, so how long a wrong guess takes says nothing about how much of it + was right. + """ + import hmac + return hmac.compare_digest((typed or "").encode("utf-8"), + (actual or "").encode("utf-8")) + + def blob_iterations(encrypted_data: bytes): """ Iteration count a stored blob was written with, or ``None`` if it cannot diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 3288eb8..96cc015 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -18,6 +18,7 @@ import appconfig import importers import sync_client +import totp import vault from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, INPUT_BG, BORDER, CARD_HOVER, ON_ACCENT, MONO, tile_colors) @@ -27,7 +28,7 @@ find_duplicate) from widgets import (dialog_shell, section, setting_row, option_menu, note, error_label, check, field_label, entry, danger_button) -from crypto_utils import decrypt +from crypto_utils import decrypt, password_matches from password_generator import generate_password from version import long_version_string @@ -250,8 +251,8 @@ def account_dialog(app, acc): bar, label, update_strength = app._strength_widget(wrap) totp_entry = app._field( - wrap, "TOTP secret (optional)", - placeholder="stored for safekeeping — codes are not generated") + wrap, "2FA secret (optional)", + placeholder="the secret or otpauth:// link the site shows") field_label(wrap, "Notes (optional)") notes_box = ctk.CTkTextbox(wrap, height=76, corner_radius=12, @@ -301,7 +302,11 @@ def save(): if not password: err.configure(text="Password is required."); return notes = notes_box.get("1.0", "end").strip() - totp = totp_entry.get().strip() + totp_secret = totp_entry.get().strip() + problem = totp.check(totp_secret) + if problem: + # Refused here rather than discovered when a code is needed. + err.configure(text="2FA secret: " + problem); return username = user_entry.get().strip() # Ask before the same login is stored twice -- on add, or when an # edit would turn this entry into a copy of another one. @@ -319,10 +324,10 @@ def save(): # nothing. It also stamps updated_at -- shown in the conflict # dialog, never used by the merge, which goes by the sync base. update_entry(acc, site=site, username=username, - password=password, notes=notes, totp=totp) + password=password, notes=notes, totp=totp_secret) else: add_account(app.vault, site, username, - password, notes=notes, totp=totp) + password, notes=notes, totp=totp_secret) app._save() top.destroy() app.refresh_account_list() @@ -590,7 +595,7 @@ def _change_master_dialog(app, parent_top): err = error_label(wrap, shell.text_width) def change(): - if cur_entry.get() != app.master_password: + if not password_matches(cur_entry.get(), app.master_password): err.configure(text="Current master password is incorrect."); return new = new_entry.get() if not new: @@ -641,6 +646,37 @@ def change(): cur_entry.focus() +def ask_master_password(app, title, subtitle, action="Continue"): + """ + Ask for the master password again while the vault is open -- before a + 2FA code is shown, every time. True once it is typed right; False if the + dialog is closed. + """ + top = app._modal(title, 460, 300) + shell = dialog_shell(top, title, subtitle) + pw = app._field(shell.body, "Master password", show="•") + err = error_label(shell.body, shell.text_width) + outcome = {"ok": False} + + def go(): + if not password_matches(pw.get(), app.master_password): + pw.delete(0, "end") + err.configure(text="Incorrect master password.") + return + outcome["ok"] = True + top.destroy() + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + app._accent_button(shell.buttons, action, go, + width=150).pack(side="right") + pw.bind("", lambda e: go()) + top.bind("", lambda e: top.destroy()) + top.after(200, pw.focus) + app.root.wait_window(top) + return outcome["ok"] + + def _delete_user_flow(app, parent_top): top = app._modal("Delete user", 440, 300) shell = dialog_shell(top, f"Delete “{app.current_user}”", @@ -650,7 +686,7 @@ def _delete_user_flow(app, parent_top): err = error_label(shell.body, shell.text_width) def do_delete(): - if pw.get() != app.master_password: + if not password_matches(pw.get(), app.master_password): err.configure(text="Incorrect master password."); return try: delete_user(app.current_user, app.master_password) diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 60f7f04..df92180 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -23,6 +23,7 @@ import history_ui import lockout import sync_ui +import totp from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, ON_ACCENT, WARN, MONO, @@ -117,6 +118,8 @@ def __init__(self, root): # being rebuilt after an edit, a revert or a sync. self._selected_id = None self._reveal_job = None + # The ticking 2FA code, while one is shown. + self._totp_job = None self._login_user = "" self.search_entry = None @@ -905,7 +908,18 @@ def _selected_entry(self): return acc return None + def _cancel_totp(self): + if self._totp_job: + try: + self.root.after_cancel(self._totp_job) + except Exception: + pass + self._totp_job = None + def _cancel_reveal(self): + # A shown 2FA code goes with it: leaving the entry hides the code, + # and the next look asks for the master password again. + self._cancel_totp() if self._reveal_job: try: self.root.after_cancel(self._reveal_job) @@ -971,10 +985,7 @@ def _show_detail(self): self._strength_row(scroll, acc.get("password", "")) if acc.get("totp"): - ctk.CTkLabel(scroll, text=" 2FA seed stored with this entry ", - height=28, corner_radius=14, fg_color=INPUT_BG, - text_color=MUTED, font=F(12, "bold")).pack( - anchor="w", padx=8, pady=(14, 0)) + self._totp_box(scroll, acc).pack(fill="x", padx=8, pady=(14, 0)) self._section_title(scroll, "Notes") ctk.CTkLabel(scroll, text=acc.get("notes") or "No notes.", @@ -1053,6 +1064,79 @@ def toggle(): "manager may keep its own copy") return box + def _totp_box(self, parent, acc): + """ + The entry's 2FA code: hidden until asked for, and every Show asks + for the master password (owner, 2026-11-06). Once shown it ticks + over with the period until the entry is left or the vault locks. + """ + box = self._field_box(parent, "2FA code") + problem = totp.check(acc.get("totp", "")) + if problem: + ctk.CTkLabel(box, text="No codes from this secret: " + problem, + font=F(12), text_color=WARN, wraplength=440, + justify="left", anchor="w").grid( + row=1, column=0, columnspan=2, sticky="w", padx=16, + pady=(2, 12)) + return box + + params = totp.parse(acc["totp"]) + masked = totp.spaced("•" * params.digits) + hint = "Hidden — Show asks for your master password" + shown = ctk.CTkLabel(box, text=masked, anchor="w", + font=ctk.CTkFont(family=MONO, size=20, + weight="bold"), + text_color=TEXT) + shown.grid(row=1, column=0, sticky="w", padx=16, pady=(2, 0)) + status = ctk.CTkLabel(box, text=hint, font=F(11), text_color=MUTED, + anchor="w") + status.grid(row=2, column=0, sticky="w", padx=16, pady=(0, 12)) + buttons = ctk.CTkFrame(box, fg_color="transparent") + buttons.grid(row=0, column=1, rowspan=3, padx=(4, 12)) + + def tick(): + now = time.time() + left = totp.seconds_left(params, now) + shown.configure(text=totp.spaced(totp.code(params, now))) + status.configure(text=f"Changes in {left} s", + text_color=GREEN if left > 5 else WARN) + # Wake just after the next whole second, so the count is exact. + self._totp_job = self.root.after( + max(1, 1000 - int((now % 1) * 1000)), tick) + + def hide(): + self._cancel_totp() + shown.configure(text=masked) + status.configure(text=hint, text_color=MUTED) + show.configure(text="Show") + copy.pack_forget() + + def toggle(): + if show.cget("text") == "Hide": + return hide() + if not dialogs.ask_master_password( + self, "Show 2FA code", + f"Enter your master password to see the code for " + f"“{_clip(acc['site'], 40)}”. PassMan asks every time.", + action="Show code"): + return + if not shown.winfo_exists(): + return # the entry changed meanwhile + show.configure(text="Hide") + copy.pack(side="left", padx=(6, 0)) + tick() + + def copy_code(): + if self._totp_job is not None: # only while shown + self._copy(totp.code(params), "2FA code copied", + sensitive=True) + + show = self._small_button(buttons, "Show", toggle) + show.pack(side="left") + Tooltip(show, "Asks for your master password each time") + copy = self._small_button(buttons, "Copy", copy_code, accent=True) + return box + def _strength_row(self, parent, password): name, frac, color = estimate_strength(password) row = ctk.CTkFrame(parent, fg_color="transparent") @@ -1137,6 +1221,7 @@ def sync_now(self, **options): # ================================================================== def lock_vault(self, auto=False): self._cancel_idle_timer() + self._cancel_totp() if self.master_password and self.current_user: self._save() # A copied password outlives the lock: the usual auto-clear timer diff --git a/sourceFiles/tests/test_clipboard_lock.py b/sourceFiles/tests/test_clipboard_lock.py index 1a9b706..6522b5e 100644 --- a/sourceFiles/tests/test_clipboard_lock.py +++ b/sourceFiles/tests/test_clipboard_lock.py @@ -31,11 +31,14 @@ def __init__(self): self._clip_text = "s3cret" self._clip_job = "clear-job" self.cleared = [] - self._history_open = set() + self.totp_hidden = False def _cancel_idle_timer(self): pass + def _cancel_totp(self): + self.totp_hidden = True + def show_login_screen(self): pass @@ -56,3 +59,11 @@ def test_lock_keeps_the_copied_password_and_its_timer(auto): assert app.root.cancelled == [] assert app._clip_job == "clear-job" assert app._clip_text == "s3cret" + + +def test_lock_hides_a_shown_2fa_code(): + app = FakeApp() + + app.lock_vault() + + assert app.totp_hidden diff --git a/sourceFiles/tests/test_crypto.py b/sourceFiles/tests/test_crypto.py index 8d3fce7..3908c54 100644 --- a/sourceFiles/tests/test_crypto.py +++ b/sourceFiles/tests/test_crypto.py @@ -86,3 +86,13 @@ def test_ciphertext_tampering_is_detected(): def test_garbage_input_raises_valueerror_not_indexerror(junk): with pytest.raises(ValueError): C.decrypt("pw", junk) + + +def test_password_matches_only_the_exact_password(): + from crypto_utils import password_matches + assert password_matches("correct horse", "correct horse") + assert not password_matches("correct hors", "correct horse") + assert not password_matches("correct horse ", "correct horse") + assert not password_matches("", "correct horse") + assert not password_matches(None, "correct horse") + assert password_matches("pässwörd ✓", "pässwörd ✓") diff --git a/sourceFiles/tests/test_totp.py b/sourceFiles/tests/test_totp.py new file mode 100644 index 0000000..5e7827d --- /dev/null +++ b/sourceFiles/tests/test_totp.py @@ -0,0 +1,105 @@ +"""2FA codes: the RFC 6238 test vectors, and every form a secret is pasted in.""" + +import base64 + +import pytest + +import totp + +# RFC 6238, Appendix B: the seeds are these ASCII strings, codes have 8 digits. +SEEDS = {"SHA1": b"12345678901234567890", + "SHA256": b"12345678901234567890123456789012", + "SHA512": b"1234567890123456789012345678901234567890123456789012345678901234"} +VECTORS = [ + (59, "94287082", "46119246", "90693936"), + (1111111109, "07081804", "68084774", "25091201"), + (1111111111, "14050471", "67062674", "99943326"), + (1234567890, "89005924", "91819424", "93441116"), + (2000000000, "69279037", "90698825", "38618901"), + (20000000000, "65353130", "77737706", "47863826"), +] + + +def b32(raw: bytes) -> str: + return base64.b32encode(raw).decode().rstrip("=") + + +@pytest.mark.parametrize("at,sha1,sha256,sha512", VECTORS) +def test_rfc_6238_vectors(at, sha1, sha256, sha512): + for algorithm, expected in (("SHA1", sha1), ("SHA256", sha256), + ("SHA512", sha512)): + params = totp.parse(f"otpauth://totp/RFC?secret={b32(SEEDS[algorithm])}" + f"&digits=8&algorithm={algorithm}") + assert totp.code(params, at) == expected, algorithm + + +def test_plain_secret_is_six_digits_every_30_seconds_sha1(): + params = totp.parse(b32(SEEDS["SHA1"])) + assert params.digits == 6 and params.period == 30 + assert params.algorithm == "SHA1" + # The RFC's 8-digit SHA-1 code at t=59 ends in these six digits. + assert totp.code(params, 59) == "287082" + + +@pytest.mark.parametrize("pasted", [ + "GEZD GNBV GY3T QOJQ GEZD GNBV GY3T QOJQ", # as sites show it + "gezdgnbvgy3tqojqgezdgnbvgy3tqojq", # lower case + "GEZDG-NBVGY-3TQOJ-QGEZD-GNBVG-Y3TQO-JQ", # dashes + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ====", # padded + " GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n", # pasted with whitespace +]) +def test_secrets_are_accepted_as_people_paste_them(pasted): + assert totp.parse(pasted).key == SEEDS["SHA1"] + + +def test_otpauth_link_with_label_and_issuer(): + link = ("otpauth://totp/Google%3Aalex%40gmail.com?secret=" + + b32(SEEDS["SHA1"]) + "&issuer=Google&period=60&digits=7") + params = totp.parse(link) + assert (params.digits, params.period) == (7, 60) + assert len(totp.code(params, 0)) == 7 + + +@pytest.mark.parametrize("bad,words", [ + ("", "empty"), + ("NOT-A-SECRET-1!", "A-Z"), + ("ABC0DEF1", "A-Z"), # 0, 1 are not base32 + ("JBSWY3DP", "too short"), + ("otpauth://hotp/x?secret=" + b32(SEEDS["SHA1"]), "time-based"), + ("otpauth://totp/x?issuer=Google", "no secret"), + ("otpauth://totp/x?secret=" + b32(SEEDS["SHA1"]) + "&algorithm=MD5", + "not supported"), + ("otpauth://totp/x?secret=" + b32(SEEDS["SHA1"]) + "&digits=4", "6 to 8"), + ("otpauth://totp/x?secret=" + b32(SEEDS["SHA1"]) + "&period=abc", + "not a number"), +]) +def test_a_bad_secret_says_why(bad, words): + with pytest.raises(ValueError, match=words): + totp.parse(bad) + assert words.split()[0] in totp.check(bad) or bad == "" + + +def test_check_is_quiet_for_empty_and_good_secrets(): + assert totp.check("") == "" + assert totp.check(" ") == "" + assert totp.check(b32(SEEDS["SHA1"])) == "" + + +def test_seconds_left_counts_down_within_the_period(): + params = totp.parse(b32(SEEDS["SHA1"])) + assert totp.seconds_left(params, 0) == 30 + assert totp.seconds_left(params, 29.5) == 1 + assert totp.seconds_left(params, 30) == 30 + assert totp.seconds_left(params, 41) == 19 + + +def test_the_code_changes_only_at_the_period_boundary(): + params = totp.parse(b32(SEEDS["SHA1"])) + assert totp.code(params, 30) == totp.code(params, 59.9) + assert totp.code(params, 59.9) != totp.code(params, 60) + + +def test_spaced_for_reading(): + assert totp.spaced("123456") == "123 456" + assert totp.spaced("1234567") == "1234 567" + assert totp.spaced("12345678") == "1234 5678" diff --git a/sourceFiles/totp.py b/sourceFiles/totp.py new file mode 100644 index 0000000..a00290b --- /dev/null +++ b/sourceFiles/totp.py @@ -0,0 +1,119 @@ +""" +2FA codes (TOTP, RFC 6238) from the secret stored with an entry. + +Made here on this PC with the standard library -- no network, no new +dependency. The stored secret is accepted in the two forms people paste: + +- the plain secret a site shows under its QR code ("JBSW Y3DP EHPK 3PXP"): + base32, spaces, dashes and lower case allowed; 6 digits every 30 seconds + with SHA-1, which is what nearly every site uses; +- an ``otpauth://totp/...`` link, as exported by authenticator apps and + password managers, which may also set ``digits``, ``period`` and + ``algorithm``. + +Nothing here touches the vault. ``parse`` raises ValueError with a sentence +the user can act on; the dialogs show it as is. +""" + +import base64 +import hashlib +import hmac +import struct +import time +from collections import namedtuple +from urllib.parse import parse_qs, unquote, urlparse + +#: What a stored secret says about making codes. +Params = namedtuple("Params", "key digits period algorithm") + +ALGORITHMS = {"SHA1": hashlib.sha1, "SHA256": hashlib.sha256, + "SHA512": hashlib.sha512} +DEFAULT_DIGITS = 6 +DEFAULT_PERIOD = 30 + + +def _decode_secret(text: str) -> bytes: + cleaned = "".join(text.split()).replace("-", "").upper().rstrip("=") + if not cleaned: + raise ValueError("The 2FA secret is empty.") + bad = sorted({c for c in cleaned if c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"}) + if bad: + raise ValueError("A 2FA secret uses only the letters A-Z and the " + "digits 2-7; this one also has " + + " ".join(repr(c) for c in bad) + ".") + try: + key = base64.b32decode(cleaned + "=" * (-len(cleaned) % 8)) + except Exception: + raise ValueError("That 2FA secret is not complete: some characters " + "seem to be missing.") from None + if len(key) < 10: + raise ValueError("That 2FA secret is too short to be a real one " + "(fewer than 16 characters).") + return key + + +def parse(stored: str) -> Params: + """The code settings in a stored secret, or ValueError saying why not.""" + stored = (stored or "").strip() + if not stored.lower().startswith("otpauth://"): + return Params(_decode_secret(stored), DEFAULT_DIGITS, DEFAULT_PERIOD, + "SHA1") + + url = urlparse(stored) + if url.netloc.lower() != "totp": + raise ValueError("Only time-based codes (otpauth://totp/...) are " + "supported, not otpauth://" + url.netloc + ".") + query = {k.lower(): v[-1] for k, v in parse_qs(url.query).items()} + if "secret" not in query: + raise ValueError("That otpauth:// link has no secret in it.") + key = _decode_secret(unquote(query["secret"])) + + algorithm = query.get("algorithm", "SHA1").upper().replace("-", "") + if algorithm not in ALGORITHMS: + raise ValueError(f"The algorithm {algorithm} is not supported " + "(SHA1, SHA256 or SHA512).") + try: + digits = int(query.get("digits", DEFAULT_DIGITS)) + period = int(query.get("period", DEFAULT_PERIOD)) + except ValueError: + raise ValueError("The digits or period in that otpauth:// link is " + "not a number.") from None + if not 6 <= digits <= 8: + raise ValueError("Codes must have 6 to 8 digits.") + if not 1 <= period <= 300: + raise ValueError("The code period must be 1 to 300 seconds.") + return Params(key, digits, period, algorithm) + + +def check(stored: str) -> str: + """'' if codes can be made from ``stored`` (or it is empty), else why not.""" + if not (stored or "").strip(): + return "" + try: + parse(stored) + return "" + except ValueError as e: + return str(e) + + +def code(params: Params, at: float = None) -> str: + """The code for the moment ``at`` (Unix time; now by default).""" + at = time.time() if at is None else at + counter = int(at // params.period) + mac = hmac.new(params.key, struct.pack(">Q", counter), + ALGORITHMS[params.algorithm]).digest() + offset = mac[-1] & 0x0F + value = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF + return str(value % 10 ** params.digits).zfill(params.digits) + + +def seconds_left(params: Params, at: float = None) -> int: + """Whole seconds until the code changes (1 to period).""" + at = time.time() if at is None else at + return params.period - int(at % params.period) + + +def spaced(digits: str) -> str: + """'123456' -> '123 456', '12345678' -> '1234 5678', for reading aloud.""" + half = (len(digits) + 1) // 2 if len(digits) != 6 else 3 + return digits[:half] + " " + digits[half:] diff --git a/sourceFiles/version.py b/sourceFiles/version.py index f855cdb..1380884 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -6,7 +6,7 @@ format changes. """ -__version__ = "0.7.0" +__version__ = "0.8.0" #: Container version PassMan writes today (see crypto_utils.MAGIC). VAULT_FORMAT = "PMV2"