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
2 changes: 1 addition & 1 deletion hullwork/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Hullwork — from production errors to reviewable pull requests, on your own infrastructure."""

__version__ = "0.1.0a6"
__version__ = "0.1.0a7"

__all__ = ["__version__"]
94 changes: 55 additions & 39 deletions hullwork/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import argparse
import getpass
import json
import logging
import os
Expand Down Expand Up @@ -1201,44 +1202,54 @@ def _cmd_page_token(
return 0


def _cmd_operator_key(
def _cmd_password(
args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
) -> int:
"""Mint the credential that **acts** on the read-only page. Item 166.
"""Set the password that unlocks the two buttons on the page. Item 168.

**A second credential rather than a promotion of the first**, and the difference is the whole
security model: the page token is a bearer string in a URL — a saved page, a screenshot of the
address bar, a link mailed to a colleague — so it reads everything and may never spend money.
This one is pasted into a form once and exchanged for a session cookie, so it never lands
anywhere a URL lands.
**Read from a prompt, not from an argument.** A password on a command line is in shell history,
in `ps`, and in whatever collects either. `--stdin` exists for a provisioning script, which has
the same problem and has usually already solved it.

Refuses to replace an existing key without `--rotate`, for the reason `page-token` does: a
second person running this to "get in" would lock out the first, and the failure would read as
the buttons being broken rather than as a key having changed underneath them.
Third design in three items, and the two before were secure and unusable: a stored key to paste,
then a one-time link that meant a trip to the host every twelve hours. This is what every
self-hosted tool does, for a mechanical reason — a browser's password manager fills it in.
"""
existing = operator.configured(session)
if existing and not args.rotate:
if args.end_sessions:
ended = operator.end_every_session(session)
print(f"Ended {ended} session(s). The password is unchanged.", file=out)
return 0

if args.stdin:
chosen = sys.stdin.readline().rstrip("\n")
else:
chosen = getpass.getpass("New password: ")
if chosen != getpass.getpass("Again: "):
raise CommandError("the two did not match; nothing was changed")

least = 12
if len(chosen) < least:
raise CommandError(
"this instance already has an operator key, and it cannot be shown again — it was "
"printed once and only its hash is stored.\n"
" To replace it: hullwork operator-key --rotate. Every session open right now ends "
"the moment you do."
f"that is {len(chosen)} character(s); this wants at least {least}.\n"
" It is the only thing between a stranger who found the page URL and your budget,\n"
" and the browser will remember it for you — so length is nearly free here."
)

key = operator.issue_key(session)
print("Rotated. Every session that was open has ended." if existing else
"The page can now be acted on.", file=out)
print("\n This key is shown once and cannot be recovered:\n", file=out)
print(f" {key}\n", file=out)
existing = operator.configured(session)
operator.set_password(session, chosen)
print("Password changed. Every session that was open has ended." if existing else
"Password set. The page can now be signed in to.", file=out)
print(
" Paste it into the page's login, once per browser. Unlike the page URL it is **not** a\n"
" link and must never become one: it is the difference between somebody reading this\n"
" instance and somebody spending its budget.\n"
f"\n Open the page and sign in once per browser; the session lasts "
f"{operator.LIFETIME.days} days and renews while you use it.\n"
"\n"
" What a session may do: approve one item waiting for a decision, or hand one to a\n"
" human. Nothing else on the page changes anything, and there is no approve-everything.\n"
"\n"
" What a session may then do: approve one amber item, or hand one to a human. Nothing\n"
" else on the page changes anything, and there is no approve-everything.\n"
" The page's own URL is unaffected: it still only reads, so it is still safe to hand to\n"
" somebody who should see this instance without being able to spend its budget.\n"
"\n"
" Sessions last 12 hours. To end them all at once, rotate.",
" To end every session without changing the password: hullwork password --end-sessions",
file=out,
)
return 0
Expand Down Expand Up @@ -3069,25 +3080,30 @@ def build_parser() -> argparse.ArgumentParser:
)
page_token.set_defaults(func=_cmd_page_token)

operator_key = subparsers.add_parser(
"operator-key",
help="mint the credential that acts on the read-only page",
password = subparsers.add_parser(
"password",
help="set the password that unlocks the buttons on the page",
description=(
"The page reads with a token in its URL, which is why it may not act: a URL is a thing "
"that gets saved, screenshotted and forwarded. This mints a second credential that "
"never appears in a URL — pasted into a login once per browser, exchanged for a "
"session cookie — and it is what the two buttons on an amber item require.\n\n"
"Until this command runs there are no buttons, and every route that would change "
"that gets saved, screenshotted and forwarded. This sets a second credential that "
"never appears in a URL — typed into the page's login once per browser, which is "
"where a browser's password manager takes over.\n\n"
"Until this runs there is no login and no buttons, and every route that would change "
"something answers 404 the way an unknown path does.\n\n"
"Shown once, stored as a hash."
"Read from a prompt: a password on a command line is in shell history and in `ps`."
),
)
operator_key.add_argument(
"--rotate",
password.add_argument(
"--stdin",
action="store_true",
help="read it from standard input instead of prompting, for a provisioning script",
)
password.add_argument(
"--end-sessions",
action="store_true",
help="replace the existing key, ending every session that is open right now",
help="end every open session without changing the password",
)
operator_key.set_defaults(func=_cmd_operator_key)
password.set_defaults(func=_cmd_password)

pruning = subparsers.add_parser(
"prune", help="forget the raw bodies of old deliveries, keeping every row"
Expand Down
23 changes: 12 additions & 11 deletions hullwork/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,11 @@ def _acting(session: Session, request: Request) -> page.Acting:
the answer and never the cookie, so a view cannot accidentally treat a *read* token as
authority.
"""
locked = operator.locked_for(session)
return page.Acting(
csrf=operator.acting(session, request.cookies.get(operator.COOKIE)),
offered=operator.configured(session),
locked_minutes=None if locked is None else max(1, int(locked.total_seconds() // 60)),
)


Expand All @@ -474,23 +476,22 @@ async def page_login(
request: Request,
session: Annotated[Session, Depends(_readiness_session)],
) -> RedirectResponse:
"""Exchange the operator key for a session cookie. Item 166.
"""Exchange the password for a session cookie. Item 168.

**The one route that accepts a secret in a body, and it answers the same either way.** A wrong
key redirects to the page exactly as a right one does: an attacker with the read link learns
nothing from the response about whether a key was right, and the operator finds out by whether
the buttons are there. No error page, because an error page is an oracle.

`Secure` is read off the request rather than hardcoded. Hardcoding it on would silently break
the plain-HTTP tailnet deployment this runs on — the cookie would never be sent and the login
would look broken; hardcoding it off would be wrong the day a TLS proxy is put in front. On the
tailnet the transport is encrypted by WireGuard even without it.
password redirects to the page exactly as a right one does: somebody with the read link learns
nothing from the response, and the operator finds out by whether the buttons are there. No error
page, because an error page is an oracle.

`Secure` is read off the request rather than hardcoded. Hardcoding it on would silently break a
deployment served over plain HTTP behind a VPN — the cookie would never be sent and the login
would look broken; hardcoding it off would be wrong the day a TLS proxy is put in front.
"""
if not page.opens(session, token):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")

key = await _field(request, "key")
issued = operator.log_in(session, key) if key else None
supplied = await _field(request, "password")
issued = operator.sign_in(session, supplied) if supplied else None
redirect = _to_page(token)
if issued is not None:
cookie, _csrf = issued
Expand Down
66 changes: 43 additions & 23 deletions hullwork/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,34 +657,52 @@ class PageAccess(Base):
created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)


class OperatorKey(Base):
"""The credential that **acts**. Item 166. One row, id 1, or none at all.

**Separate from `PageAccess` on purpose, and that separation is the whole security model.** The
page token is a bearer credential that lives in a URL — a saved page, a screenshot of the
address bar, a link mailed to a colleague — so it reads everything and may never spend money.
This one never appears in a URL: it is pasted into a form once, exchanged for a session, and
after that only the session cookie travels.

**None at all is the default, and it means the buttons do not exist.** An instance that upgrades
into this item is byte-identical to the one before it until somebody runs
`hullwork operator-key`.

Generated, never chosen. 32 random bytes hashed with SHA-256, for the reason already written
beside the page token: against 32 random bytes a KDF buys nothing. A human-chosen password would
need scrypt or argon2, a new dependency, and a guessing-rate story — three problems this does
not have.
class OperatorPassword(Base):
"""The password that unlocks the two buttons. Item 168. One row, id 1, or none at all.

**Third design in three items, and the two before it failed the same test: could an operator
actually use it.** Item 166 stored 32 random bytes and asked for them to be pasted into a form.
Item 167 replaced that with a one-time link from the CLI — which removed the paste and added a
trip to the host: open the page, ssh, run a command, copy a link, open it, come back, reload.
Eight steps, twice a day, against the one command it was supposed to improve on.

A password is what every self-hosted tool does, and the reason is not convention: **a browser's
password manager fills it in.** One visit to the host, ever; after that the operator opens the
page and clicks.

**The reason I gave for rejecting it was wrong**, and worth recording because it decided a
milestone: I said a chosen password needs scrypt or argon2 and therefore a new dependency in the
half of Hullwork that listens on the network. `hashlib.scrypt` is in the standard library.
Measured at `n=2**14`: 37 ms per attempt, which is both the work factor and most of the answer
to online guessing.
"""

__tablename__ = "operator_key"
__tablename__ = "operator_password"

id: Mapped[int] = mapped_column(Integer, primary_key=True)

#: SHA-256, compared in constant time. See `security.hash_token`.
key_hash: Mapped[str] = mapped_column(String(64))
#: 16 random bytes, hex. Per-instance, so two deployments with the same password store different
#: hashes and one leaked table says nothing about the other.
salt: Mapped[str] = mapped_column(String(32))

#: `hashlib.scrypt(...).hex()`. Compared with `hmac.compare_digest`, never with `==`.
key: Mapped[str] = mapped_column(String(64))

#: The cost, stored rather than assumed. A password set in 2026 must keep verifying after the
#: default is raised, and a row that does not carry its own parameters cannot be re-hashed on a
#: later sign-in without locking the operator out first.
n: Mapped[int] = mapped_column(Integer)
r: Mapped[int] = mapped_column(Integer)
p: Mapped[int] = mapped_column(Integer)

created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)

#: Consecutive failures, and when they stop counting. **On this row rather than per address**:
#: an instance has one operator, so a lockout is about the credential and not about who is
#: asking — and a per-address counter is defeated by changing address, which is free.
failures: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[datetime | None] = mapped_column(UtcDateTime(), default=None)


class OperatorSession(Base):
"""One browser that has proved it holds the operator key. Item 166.
Expand Down Expand Up @@ -712,9 +730,11 @@ class OperatorSession(Base):

created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)

#: When this stops being accepted. An absolute expiry rather than an idle timeout: an idle
#: timeout has to be written on every request, which turns a read of the page into a write to
#: the database — and the receiver's sweep already contends for that lock.
#: When this stops being accepted. **Extended on use, but only past the halfway mark** (item
#: 168): a true idle timeout writes on every request, which turns reading the page into a write
#: and the receiver's sweep already contends for that lock. Renewing at the halfway point is one
#: write every fifteen days and gets the same result — an operator who opens the page most weeks
#: never signs in twice.
expires_at: Mapped[datetime] = mapped_column(UtcDateTime())


Expand Down
Loading
Loading