The first account can only be made in a 30-minute setup window
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s

Family idea #5105, practice 8 (Scribe #5113), the operator's choice of
setup window over a setup code.

Before this, whoever reached /register first on an empty server became
its admin. On a fresh server at a public address, that could be a
stranger, and a new DNS name is found within minutes.

Now the first registration is refused once 30 minutes have passed since
the server started (create_app records STARTED_AT). A restart opens the
window again. It is a constant rather than a Setting, because there is no
admin yet to change one. Once an account exists it no longer matters, so
existing servers are unaffected. public-hosting.md says so, and two
integration tests cover both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:08:29 -04:00
co-authored by Claude Opus 5.5
parent 4c350838b1
commit 5d08d8a7a6
4 changed files with 52 additions and 3 deletions
+6
View File
@@ -14,6 +14,12 @@ itself: the first account created becomes the admin *and* closes registration be
it, so there is no window between "my account exists" and "I remembered to turn it it, so there is no window between "my account exists" and "I remembered to turn it
off". A brand-new instance is never locked out of itself, and never left open either. off". A brand-new instance is never locked out of itself, and never left open either.
**The first account has to be made within 30 minutes of the server starting.** After
that, `/register` refuses the first account. Otherwise a fresh server on a public
address would belong to whoever found it first. If you miss the window, restart the
container and register straight away. Once an account exists, the window no longer
matters.
**Instances that predate this still need one manual flip.** The close fires when the **Instances that predate this still need one manual flip.** The close fires when the
first account is created, so a server whose admin already existed keeps whatever first account is created, so a server whose admin already existed keeps whatever
`allow_registration` was set to — which was **on** by default. Check **Settings → `allow_registration` was set to — which was **on** by default. Check **Settings →
+3
View File
@@ -5,6 +5,7 @@ import logging
import mimetypes import mimetypes
import os import os
import secrets import secrets
import time
from contextlib import suppress from contextlib import suppress
from datetime import timedelta from datetime import timedelta
@@ -64,6 +65,8 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
def create_app() -> Quart: def create_app() -> Quart:
# static_folder=None: the SPA catch-all below owns static serving. # static_folder=None: the SPA catch-all below owns static serving.
app = Quart(__name__, static_folder=None) app = Quart(__name__, static_folder=None)
# When this server started, for the first-account setup window (auth.py).
app.config["STARTED_AT"] = time.monotonic()
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a # Ephemeral/env secret so the app (and DB-free unit tests) construct without a
# database. before_serving swaps in the real, DB-persisted key before serving. # database. before_serving swaps in the real, DB-persisted key before serving.
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48) app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
+21 -3
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import functools import functools
import logging import logging
import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request, session from quart import Blueprint, current_app, g, jsonify, request, session
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from .common import iso from .common import iso
@@ -45,6 +46,20 @@ SESSION_KEY = "user_id"
# no key and reads as 0, the epoch every account started at. # no key and reads as 0, the epoch every account started at.
EPOCH_KEY = "epoch" EPOCH_KEY = "epoch"
MIN_PASSWORD_LEN = 8 MIN_PASSWORD_LEN = 8
# The first account can only be created this long after the server starts (family
# idea #5105, practice 8). Without it, a fresh server on a public address belongs to
# whoever reaches /register first, and a new DNS name is found within minutes.
#
# A constant, not a Setting: there is no admin yet to change one. Anyone who misses
# the window restarts the server, which opens it again; once an account exists the
# window no longer matters. Measured from `create_app` (STARTED_AT), so each start
# counts afresh.
SETUP_WINDOW_S = 30 * 60
SETUP_CLOSED = (
"this server's setup window has closed. Restart the server, then create the "
"first account within 30 minutes"
)
DEVICE_NAME_CAP = 100 DEVICE_NAME_CAP = 100
@@ -228,8 +243,11 @@ async def register():
async with session_scope() as db: async with session_scope() as db:
user_count = await db.scalar(select(func.count()).select_from(User)) or 0 user_count = await db.scalar(select(func.count()).select_from(User)) or 0
is_first = user_count == 0 is_first = user_count == 0
# The first account bootstraps the admin and is always allowed, even when if is_first and time.monotonic() - current_app.config["STARTED_AT"] > SETUP_WINDOW_S:
# registration is otherwise closed. After that, an invite lets one person in logger.warning("registration refused (setup window closed) email=%s from=%s", email, client_address())
return json_error(SETUP_CLOSED, 403)
# The first account bootstraps the admin and is allowed, inside the setup
# window above, even when registration is otherwise closed. After that, an invite lets one person in
# while it is closed (#5172). One that was offered is redeemed even while # while it is closed (#5172). One that was offered is redeemed even while
# registration is open, so the list still says who used it, and one that # registration is open, so the list still says who used it, and one that
# doesn't hold is refused rather than ignored. # doesn't hold is refused rather than ignored.
+22
View File
@@ -958,6 +958,28 @@ async def _admin_with_invite(app_client, **body) -> str:
return (await resp.get_json())["token"] return (await resp.get_json())["token"]
async def test_the_first_account_is_refused_once_the_setup_window_has_closed(db):
"""A fresh server on a public address must not belong to whoever finds it first
(family idea #5105, practice 8). Past the window, nobody becomes admin."""
from inkwell.auth import SETUP_WINDOW_S
ratelimit.reset_all()
app = create_app()
app.config["STARTED_AT"] -= SETUP_WINDOW_S + 1
resp = await app.test_client().post(
"/api/auth/register", json={"email": "late@example.test", "password": _PASSWORD}
)
assert resp.status_code == 403
assert "setup window" in (await resp.get_json())["error"]
assert await db.scalar(select(func.count()).select_from(User)) == 0
async def test_the_first_account_is_allowed_inside_the_setup_window(app_client, db):
resp = await app_client.post("/api/auth/register", json={"email": "prompt@example.test", "password": _PASSWORD})
assert resp.status_code == 201
assert (await resp.get_json())["is_admin"] is True
async def _register(email: str, invite: str | None = None): async def _register(email: str, invite: str | None = None):
"""Register from a separate client, so the admin's session stays where it is.""" """Register from a separate client, so the admin's session stays where it is."""
body = {"email": email, "password": _PASSWORD} body = {"email": email, "password": _PASSWORD}