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
17 changes: 17 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
17 changes: 17 additions & 0 deletions tests/test_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions warpedpinball/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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; "
Expand Down
4 changes: 3 additions & 1 deletion warpedpinball/transports/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading