Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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.)

Expand Down
11 changes: 11 additions & 0 deletions sourceFiles/crypto_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 44 additions & 8 deletions sourceFiles/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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("<Return>", lambda e: go())
top.bind("<Escape>", 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}”",
Expand All @@ -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)
Expand Down
93 changes: 89 additions & 4 deletions sourceFiles/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion sourceFiles/tests/test_clipboard_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
10 changes: 10 additions & 0 deletions sourceFiles/tests/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ✓")
Loading
Loading