From 7c798d62a52ca891dd5f36eb21084587c8e5f0f5 Mon Sep 17 00:00:00 2001 From: mullinmax Date: Sat, 18 Jul 2026 20:17:40 +0000 Subject: [PATCH] Treat an empty password as valid, distinct from unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some Vector boards have no password configured, which the firmware authenticates by signing with an empty-string HMAC key. The client conflated an empty password with "no password set": the Machine.password property coerced "" to the $VECTOR_PASSWORD fallback, and both the auth preflight and the HTTP transport rejected a falsy password outright, so an empty password could never be used. Distinguish None (unset — still falls back to the environment and raises AuthenticationRequiredError when nothing is configured) from "" (a valid empty password that is signed as such): - Machine.password returns an explicitly-set empty string instead of the env fallback; only None falls back. - Machine._preflight_auth and HttpTransport._send guard on `is None` rather than falsiness. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014Y49XvgGpmTHrYLDeTvmbA --- tests/test_http.py | 17 +++++++++++++++++ tests/test_machine.py | 17 +++++++++++++++++ warpedpinball/machine.py | 14 +++++++++++--- warpedpinball/transports/http.py | 4 +++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/test_http.py b/tests/test_http.py index 7ed63b2..0c93ab6 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -77,6 +77,23 @@ def test_no_password_raises_before_any_request(): assert session.get_calls == [] # not even a challenge fetch +def test_empty_password_is_valid_and_signs(): + # A board with no password configured signs with an empty HMAC key; an + # empty string is a real password, not "no password". + session = FakeSession(challenge=CHALLENGE) + t = make_transport(session, password="") + body = {"id": 0} + t.request("/api/player/update", body=body, authenticated=True) + + assert session.get_calls == ["http://192.168.1.42" + auth.CHALLENGE_PATH] + req = session.requests[0] + body_str = json.dumps(body, separators=(",", ":")) + assert req["headers"][auth.CHALLENGE_HEADER] == CHALLENGE + assert req["headers"][auth.HMAC_HEADER] == auth.sign( + "", CHALLENGE, "/api/player/update", body_str + ) + + def test_expired_challenge_retried_once_with_fresh_challenge(): session = FakeSession() session.next_challenges = ["11" * 32, "22" * 32] diff --git a/tests/test_machine.py b/tests/test_machine.py index 4b50b9f..47db633 100644 --- a/tests/test_machine.py +++ b/tests/test_machine.py @@ -107,6 +107,23 @@ def test_no_preflight_error_when_transport_needs_no_password(): assert transport.calls == [("/api/settings/reboot", None, True)] +def test_empty_password_passes_preflight(): + # An empty string is a valid (empty) password, so authenticated routes work + # rather than raising AuthenticationRequiredError. + machine, transport = make_machine(password="") + machine.reboot() + assert transport.calls == [("/api/settings/reboot", None, True)] + assert transport.password == "" + + +def test_empty_password_not_overridden_by_env(monkeypatch): + # An explicit empty password wins over $VECTOR_PASSWORD; only an unset + # (None) password falls back to the environment. + monkeypatch.setenv("VECTOR_PASSWORD", "from-env") + machine, _ = make_machine(password="") + assert machine.password == "" + + def test_env_var_password_fallback(monkeypatch): monkeypatch.setenv("VECTOR_PASSWORD", "from-env") machine, transport = make_machine(password=None) diff --git a/warpedpinball/machine.py b/warpedpinball/machine.py index 60d0c67..8c113a0 100644 --- a/warpedpinball/machine.py +++ b/warpedpinball/machine.py @@ -83,8 +83,16 @@ def __repr__(self) -> str: @property def password(self) -> Optional[str]: - """Password for HMAC auth; falls back to $VECTOR_PASSWORD.""" - return self._password or os.environ.get(PASSWORD_ENV_VAR) + """Password for HMAC auth; falls back to $VECTOR_PASSWORD. + + An empty string is a valid password (some boards have no password + configured, which the firmware signs with an empty HMAC key) and is + kept distinct from ``None``, which means no password has been set and + triggers the ``$VECTOR_PASSWORD`` fallback. + """ + if self._password is not None: + return self._password + return os.environ.get(PASSWORD_ENV_VAR) @password.setter def password(self, value: Optional[str]) -> None: @@ -136,7 +144,7 @@ def _preflight_auth(self, path: str, authenticated: bool) -> None: if ( authenticated and self.transport.requires_password - and not self.password + and self.password is None ): raise AuthenticationRequiredError( f"Route {path!r} requires authentication but no password is set; " diff --git a/warpedpinball/transports/http.py b/warpedpinball/transports/http.py index beef20d..3806bd3 100644 --- a/warpedpinball/transports/http.py +++ b/warpedpinball/transports/http.py @@ -129,7 +129,9 @@ def _send( if body_str is not None: headers["Content-Type"] = "application/json" if authenticated: - if not self.password: + # An empty string is a valid (empty) password and is signed as + # such; only ``None`` means no password has been set. + if self.password is None: raise AuthenticationRequiredError( f"Route {path!r} requires authentication but no password is set" )