From 3c3e82f72ec247e5cae3b1a184fa22910ac5d131 Mon Sep 17 00:00:00 2001 From: Mohammadreza Khellat Date: Thu, 12 Nov 2026 12:23:49 +0330 Subject: [PATCH] fix(crypto): a slow moment is not a wrong passphrase Found on macOS CI (PR #18's run, macOS Py 3.10): unlocking a key made a moment earlier failed with pyrage's "Excessive work parameter for passphrase", which codebud reported as "wrong passphrase". age sets the passphrase's scrypt work factor from the machine's speed when it seals the key, and on every unlock measures the speed again and refuses a work factor far above it. On a busy or throttled moment -- a loaded runner, a laptop on battery -- a correct passphrase is refused, and codebud told the user it was wrong: a lockout that sends them looking for a typo, or giving their data up for lost. pyrage offers no control over the work factor, so codebud now retries that specific refusal (age measures again each time) three times, then says what happened -- the machine was slower than when the key was sealed, the passphrase may well be right -- instead of "wrong passphrase". A genuinely wrong passphrase is still reported as such, and not retried. The manual's Troubleshooting chapter gains "Key Refused on a Slow Moment", including the persistent case of a key moved to a much slower machine. --- docs/chapters/troubleshooting.texi | 30 +++++++++++++++ src/codebud/crypto.py | 24 ++++++++++-- tests/test_crypto.py | 61 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/docs/chapters/troubleshooting.texi b/docs/chapters/troubleshooting.texi index ab3f597..29e017e 100644 --- a/docs/chapters/troubleshooting.texi +++ b/docs/chapters/troubleshooting.texi @@ -14,6 +14,7 @@ their root causes, and how to fix them. * Safety Rejection:: A plan step is blocked by the safety engine. * Invalid Plan:: Steps miss arguments their tools need. * Index Built With Another Model:: EMBED_MODEL changed since indexing. +* Key Refused on a Slow Moment:: A right passphrase, refused while busy. * Web Search Returns No Results:: The DuckDuckGo scraper finds nothing. * Tests Cannot Import codebud:: The package is not on the path. @end menu @@ -316,6 +317,35 @@ codebud index --rebuild @end example +@node Key Refused on a Slow Moment +@section Key Refused on a Slow Moment +@cindex passphrase refused +@cindex excessive work parameter + +@example +codebud: this machine was slower just now than when the key was sealed, +and age refuses a passphrase it estimates would take too long to check +here; the passphrase may well be right -- try again, ideally with the +machine less busy +@end example + +@noindent +@strong{What happened:} age, which seals your encryption key, sets the +passphrase's work factor (how long checking it takes) from the +machine's speed when the key was made, and on every unlock measures +the speed again and refuses a work factor far above it. On a busy +or throttled moment --- a heavy build, a laptop on battery --- a correct +passphrase can be refused. Codebud tries three times before saying +so, and never reports it as a wrong passphrase. + +@strong{Fix:} try again when the machine is less busy. A key sealed on +a much faster machine and moved to a slow one may be refused every +time, and @command{codebud key passwd} cannot help, since it seals the +key again for the speed of the machine it runs on. Use the key on a +machine at least as fast as the one it was made on; or create a new key +on the slow machine with @command{codebud key init} --- data encrypted +to the old key stays readable only with the old key. + @node Web Search Returns No Results @section Web Search Returns No Results @cindex web search, no results diff --git a/src/codebud/crypto.py b/src/codebud/crypto.py index 7607c6d..13c2896 100644 --- a/src/codebud/crypto.py +++ b/src/codebud/crypto.py @@ -121,15 +121,31 @@ def recipient() -> str: return (key_dir() / RECIPIENT_FILE).read_text().strip() +_SLOW_RETRIES = 3 + + def _open_identity(passphrase: str): pyrage = _pyrage() _require_key() _harden_process() # the passphrase and the secret key are about to be in memory sealed = (key_dir() / IDENTITY_FILE).read_bytes() - try: - secret = pyrage.passphrase.decrypt(sealed, passphrase) - except pyrage.DecryptError as exc: - raise CryptoError("wrong passphrase (or a damaged key file)") from exc + # age sets the passphrase's work factor from this machine's speed when + # sealing, and refuses one far above its speed when opening, measured + # again each time: on a busy or throttled moment a correct passphrase is + # refused. Try again a few times, and never call that a wrong passphrase. + for _attempt in range(_SLOW_RETRIES): + try: + secret = pyrage.passphrase.decrypt(sealed, passphrase) + break + except pyrage.DecryptError as exc: + if "excessive work" not in str(exc).lower(): + raise CryptoError("wrong passphrase (or a damaged key file)") from exc + else: + raise CryptoError( + "this machine was slower just now than when the key was sealed, and age " + "refuses a passphrase it estimates would take too long to check here; the " + "passphrase may well be right -- try again, ideally with the machine less busy" + ) return pyrage.x25519.Identity.from_str(secret.decode()) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 1a784d2..f0eca26 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -161,3 +161,64 @@ def test_while_unlocked_writes_use_the_key_not_the_file(unlocked): (crypto.key_dir() / crypto.RECIPIENT_FILE).write_text(str(stranger.to_public()) + "\n") sealed = crypto.encrypt(b"mine") assert crypto.decrypt(sealed) == b"mine" # encrypted to my key, not the stranger's + + +# --- age refusing a correct passphrase on a slow moment --------------------------- + + +def _excessive_then(real, times): + """pyrage.passphrase.decrypt that refuses ``times`` times the way age + does when the machine is slower than when the key was sealed, then + works.""" + import pyrage + + calls = [] + + def decrypt(data, passphrase): + calls.append(1) + if len(calls) <= times: + raise pyrage.DecryptError("Excessive work parameter for passphrase.") + return real(data, passphrase) + + return decrypt, calls + + +def test_a_slow_moment_is_retried(monkeypatch): + """age calibrates its passphrase work factor to the machine's speed when + sealing, and refuses one far above the current speed when opening: on a + loaded machine a correct passphrase was reported as wrong (seen on + macOS CI).""" + import pyrage + + crypto.create_key("correct horse") + crypto.lock() + decrypt, calls = _excessive_then(pyrage.passphrase.decrypt, 2) + monkeypatch.setattr(pyrage.passphrase, "decrypt", decrypt) + crypto.unlock("correct horse") + assert len(calls) == 3 + + +def test_a_persistent_refusal_is_not_called_a_wrong_passphrase(monkeypatch): + import pyrage + + crypto.create_key("correct horse") + crypto.lock() + decrypt, _ = _excessive_then(pyrage.passphrase.decrypt, 99) + monkeypatch.setattr(pyrage.passphrase, "decrypt", decrypt) + with pytest.raises(crypto.CryptoError) as exc: + crypto.unlock("correct horse") + assert "wrong passphrase" not in str(exc.value) + assert "slower" in str(exc.value) + + +def test_a_wrong_passphrase_is_still_wrong_and_not_retried(monkeypatch): + import pyrage + + crypto.create_key("correct horse") + crypto.lock() + calls = [] + real = pyrage.passphrase.decrypt + monkeypatch.setattr(pyrage.passphrase, "decrypt", lambda d, p: calls.append(1) or real(d, p)) + with pytest.raises(crypto.CryptoError, match="wrong passphrase"): + crypto.unlock("wrong horse") + assert len(calls) == 1