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