From 14948aff1dfdce17e65e832afdcb0f8243fa5846 Mon Sep 17 00:00:00 2001 From: ZProger Date: Thu, 10 Sep 2026 21:34:40 +0300 Subject: [PATCH] =?UTF-8?q?security:=20TLS,=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20Host,=20=D1=88=D0=B8=D1=84=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20=D0=B8=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82-?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TLS через подключаемый overlay docker-compose.tls.yml + Caddyfile: Caddy сам получает и продлевает сертификат Let's Encrypt, HSTS добавляется на уровне прокси — намеренно там, а не в nginx.conf, иначе установки без TLS заперли бы сами себя; - проверка заголовка Host против DNS rebinding: AURUM_ALLOWED_HOSTS, по умолчанию localhost и 127.0.0.1, чужой Host получает 421 с подсказкой, какую переменную поправить; - «запомнить меня на 7 дней» вернулось в безопасном виде: пароль шифруется AES-GCM ключом с extractable: false в IndexedDB, вместо прежнего base64 в localStorage, который разворачивался обратно в пароль одним atob. Требует secure context, поэтому по обычному HTTP к LAN-адресу чекбокс не показывается, а не притворяется рабочим; - аудит-лог необратимых операций (восстановление бэкапа, массовое создание транзакций, удаление счёта) в лог контейнера. Не в БД сознательно: таблица аудита исчезла бы ровно в том событии, которое должна объяснять; - README: раздел про TLS и таблица двух переменных, решающих, кто может достучаться до инстанса. --- .env.example | 11 ++ Caddyfile | 23 +++ README.md | 30 +++- backend/app/api/routes/accounts.py | 4 + backend/app/api/routes/backup.py | 12 ++ backend/app/api/routes/transactions.py | 4 + backend/app/core/audit.py | 32 +++++ docker-compose.tls.yml | 50 +++++++ docker-compose.yml | 3 + frontend/Dockerfile | 11 +- .../docker-entrypoint.d/25-allowed-hosts.sh | 45 ++++++ frontend/nginx.conf | 5 + frontend/src/components/auth/LoginGate.tsx | 23 +-- frontend/src/components/auth/LoginScreen.tsx | 31 ++-- frontend/src/lib/auth.ts | 103 +++++++------ frontend/src/lib/credentialVault.ts | 135 ++++++++++++++++++ frontend/src/lib/i18n.ts | 2 + 17 files changed, 454 insertions(+), 70 deletions(-) create mode 100644 Caddyfile create mode 100644 backend/app/core/audit.py create mode 100644 docker-compose.tls.yml create mode 100644 frontend/docker-entrypoint.d/25-allowed-hosts.sh create mode 100644 frontend/src/lib/credentialVault.ts diff --git a/.env.example b/.env.example index bdc9fa7..4320fde 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,14 @@ AURUM_WEB_PORT=3000 # proxy), but set the basic auth variables above first: without them the app # has no password, and this is the line that decides who can knock. AURUM_BIND_ADDRESS=127.0.0.1 + +# Hostnames Aurum will answer to, space- or comma-separated. Anything else +# gets a 421 with a message telling you to edit this line. It exists to block +# DNS rebinding: a site you're visiting can point its own domain at +# 127.0.0.1 and then read your instance through your own browser — the Host +# header is what gives it away. +# +# If you opened AURUM_BIND_ADDRESS above, add whatever you type in the +# address bar here too: the LAN IP (192.168.1.50), your domain +# (aurum.example.com), a .local name. Set it to * to turn the check off. +AURUM_ALLOWED_HOSTS=localhost 127.0.0.1 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..e216050 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,23 @@ +# Used only by the TLS overlay (docker-compose.tls.yml). The hostname comes +# from AURUM_DOMAIN in .env — Caddy requests a Let's Encrypt certificate for +# it on first start and renews it on its own from then on. +# +# For a local trial without a public domain, set AURUM_DOMAIN=localhost and +# uncomment "tls internal" below: Caddy then issues its own certificate from +# a local CA. The browser won't trust it until you install that CA, so this +# is for checking the plumbing, not for daily use. +{$AURUM_DOMAIN} { + # tls internal + + encode zstd gzip + + # Aurum's own nginx already sets CSP, nosniff, Referrer-Policy and the + # rest — they pass through untouched. HSTS is added here instead of + # there on purpose: it tells browsers to refuse plain HTTP to this host + # for a year, which is only safe to promise once TLS actually terminates + # in front of the app. Setting it in nginx.conf would apply it to + # plain-HTTP installs too, and lock them out of their own instance. + header Strict-Transport-Security "max-age=31536000; includeSubDomains" + + reverse_proxy web:80 +} diff --git a/README.md b/README.md index 81af933..57f619e 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,35 @@ or open `/api/docs` on your running instance for interactive Swagger docs. **Aurum has no built-in login system.** It's built for one person to self-host one private instance of their own financial data — not as a multi-tenant service with per-user accounts. That's a deliberate trade-off, not an oversight, but it means: -- If you leave `AURUM_BASIC_AUTH_USER` / `AURUM_BASIC_AUTH_PASSWORD` unset in `.env`, **anyone who can reach the container can read, edit, and delete all of it — no password prompt at all.** This is fine if Aurum is only reachable from `localhost` or your own private network. +- If you leave `AURUM_BASIC_AUTH_USER` / `AURUM_BASIC_AUTH_PASSWORD` unset in `.env`, **anyone who can reach the container can read, edit, and delete all of it — no password prompt at all.** The app itself now says so on first load, with a warning you have to dismiss. This is fine if Aurum is only reachable from `localhost`. - Set both variables before exposing your instance beyond your own machine (a VPS, a subdomain, a Tailscale/VPN endpoint someone else might share). This turns on an HTTP Basic Auth prompt in front of the entire app, UI and API alike. -- For anything beyond that — a reverse proxy with TLS (Caddy, Traefik, nginx + Let's Encrypt) is on you; Aurum doesn't terminate HTTPS itself. + +### Two settings that decide who can reach you + +Both default to "this machine only", so opening Aurum up is a deliberate act rather than something that happens by accident: + +| Variable | Default | What it does | +|---|---|---| +| `AURUM_BIND_ADDRESS` | `127.0.0.1` | Which host interface the port is published on. The default means nothing else on your network can connect, even on the same Wi-Fi. Set `0.0.0.0` to open it. | +| `AURUM_ALLOWED_HOSTS` | `localhost 127.0.0.1` | Hostnames Aurum answers to; anything else gets a `421`. This blocks DNS rebinding, where a site you're browsing points its own domain at `127.0.0.1` and reads your instance through your own browser. | + +If you open the bind address, add whatever you type in the address bar to `AURUM_ALLOWED_HOSTS` too — the LAN IP, your domain, a `.local` name. Forgetting the second one is the usual cause of a `421` on an otherwise working setup. + +### Serving it over HTTPS + +Aurum speaks plain HTTP by design — certificates belong to a dedicated terminator, not to the app's own nginx. Basic Auth sends your password on **every** request, so without TLS anyone who can see the traffic has it. + +A ready-to-use Caddy setup ships with the repo (`docker-compose.tls.yml` + `Caddyfile`). It gets a Let's Encrypt certificate on first start and renews it by itself: + +```bash +# in .env — a real domain pointing at this host +AURUM_DOMAIN=aurum.example.com +AURUM_ALLOWED_HOSTS=aurum.example.com + +docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build +``` + +Caddy then owns ports 80 and 443 (80 stays open for the ACME challenge and the HTTPS redirect), the `web` container stops publishing a port of its own, and HSTS is added at the proxy — deliberately there and not in `nginx.conf`, since promising browsers "never use plain HTTP for this host again" is only safe once TLS is actually in front. If you find a security issue, please open a private report via GitHub's Security tab rather than a public issue. diff --git a/backend/app/api/routes/accounts.py b/backend/app/api/routes/accounts.py index 0940c2d..0694c02 100644 --- a/backend/app/api/routes/accounts.py +++ b/backend/app/api/routes/accounts.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session +from app.core.audit import log_destructive from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance from app.services.account_service import create_account, delete_account, list_accounts, update_account @@ -31,4 +32,7 @@ async def update_account_route( @router.delete("/{account_id}", status_code=204) async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None: + # Takes every transaction on the account with it, so it's worth a line in + # the log even though the UI asks for confirmation first. await delete_account(session, account_id) + log_destructive("account.deleted", account_id=account_id) diff --git a/backend/app/api/routes/backup.py b/backend/app/api/routes/backup.py index d5582fb..b278c8d 100644 --- a/backend/app/api/routes/backup.py +++ b/backend/app/api/routes/backup.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session +from app.core.audit import log_destructive from app.schemas.backup import BackupPayload from app.services.backup_service import build_backup, restore_backup @@ -15,5 +16,16 @@ async def export_backup(session: AsyncSession = Depends(get_session)) -> BackupP @router.post("/import") async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]: + # Logged before the fact as well as after: if the restore dies partway + # (it rolls back, but the process could also be killed outright), the + # "started" line is the only evidence it was ever attempted. + log_destructive( + "backup.restore.started", + exported_at=payload.exported_at, + source_app_version=payload.app_version, + accounts=len(payload.accounts), + transactions=len(payload.transactions), + ) await restore_backup(session, payload) + log_destructive("backup.restore.completed", transactions=len(payload.transactions)) return {"status": "ok"} diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index 39bdb08..858f8e3 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.api.deps import get_session +from app.core.audit import log_destructive from app.models.category import Category from app.models.enums import CategoryKind, TransactionType from app.models.tag import Tag @@ -228,6 +229,9 @@ async def bulk_create_transactions( session.add_all(transactions) await session.commit() + # One request can add thousands of rows (CSV import) — worth a line so an + # unexpected pile of transactions can be dated later. + log_destructive("transactions.bulk_created", count=len(transactions)) return TransactionBulkCreateResult(created=len(transactions)) diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py new file mode 100644 index 0000000..36b5b99 --- /dev/null +++ b/backend/app/core/audit.py @@ -0,0 +1,32 @@ +"""Audit trail for the handful of operations that destroy or wholesale +replace data. + +Aurum is single-user, so this isn't about "who did it" — there's only one +person. It's about *what happened and when*, after the fact. The operations +recorded here are the ones that can't be undone from the UI: restoring a +backup wipes every table before writing the new rows, deleting an account +takes its transactions with it, and a CSV import can add thousands of rows in +one request. When someone opens the app and finds it empty, the container log +is the only place that can say whether a restore ran at 02:14 or whether +something else went wrong. + +Deliberately just the standard logger, no table of its own: an audit trail +that lives in the same database it's auditing disappears in exactly the event +it exists to explain (a restore truncates it too). Read it with +`docker compose logs backend`. +""" +import logging + +logger = logging.getLogger("aurum.audit") + + +def log_destructive(action: str, **details: object) -> None: + """Record one irreversible operation. `details` are rendered as + key=value; keep them to counts and identifiers — never transaction + descriptions, amounts or anything else that would spill the user's + finances into a log file that gets shared when debugging.""" + if details: + rendered = " ".join(f"{key}={value}" for key, value in details.items()) + logger.warning("%s %s", action, rendered) + else: + logger.warning("%s", action) diff --git a/docker-compose.tls.yml b/docker-compose.tls.yml new file mode 100644 index 0000000..212ad3e --- /dev/null +++ b/docker-compose.tls.yml @@ -0,0 +1,50 @@ +# TLS overlay — opt in, not part of the default stack. +# +# docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d +# +# Aurum itself speaks plain HTTP and stays that way: certificate handling is +# a job for a dedicated terminator, not for the app's own nginx. Caddy is +# used here because it obtains and renews a Let's Encrypt certificate on its +# own, with no cron job and no certbot invocation to forget. +# +# What this changes: +# - Caddy takes ports 80 and 443 on the host. 80 has to stay open: it's +# both the ACME HTTP challenge and the redirect to HTTPS. +# - The web container stops publishing a port of its own — it's reachable +# only from inside the compose network, through Caddy. +# +# Required before the first run (see .env): +# AURUM_DOMAIN=aurum.example.com — a real name resolving to this host +# AURUM_ALLOWED_HOSTS=aurum.example.com +# ^ easy to miss: the app's own Host check (25-allowed-hosts.sh) still +# applies behind the proxy, and Caddy forwards the original Host, so +# leaving this at the default gets you a 421 through a perfectly good +# certificate. +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + environment: + AURUM_DOMAIN: ${AURUM_DOMAIN:?set AURUM_DOMAIN in .env to the hostname this instance answers on} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Certificates and ACME account keys. Keep this volume — losing it + # means re-issuing certificates, and Let's Encrypt rate-limits that. + - caddy_data:/data + - caddy_config:/config + depends_on: + - web + + web: + # Reachable only through Caddy now. The "!reset" is what actually removes + # the base file's published port instead of adding to it — a plain empty + # list would be merged, not substituted, and the port would stay open. + ports: !reset [] + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index 983b316..1e28e2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,9 @@ services: # Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh. AURUM_BASIC_AUTH_USER: ${AURUM_BASIC_AUTH_USER:-} AURUM_BASIC_AUTH_PASSWORD: ${AURUM_BASIC_AUTH_PASSWORD:-} + # Hostnames this instance answers to — anything else gets a 421. + # See frontend/docker-entrypoint.d/25-allowed-hosts.sh. + AURUM_ALLOWED_HOSTS: ${AURUM_ALLOWED_HOSTS:-localhost 127.0.0.1} depends_on: - backend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1cddc7a..10b0ac0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,13 +12,16 @@ RUN apk add --no-cache openssl COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf COPY docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/20-basic-auth.sh +COPY docker-entrypoint.d/25-allowed-hosts.sh /docker-entrypoint.d/25-allowed-hosts.sh # sed strips any stray CR: cloned on Windows with core.autocrlf=true the -# script arrives as CRLF, the kernel reads the shebang as "/bin/sh\r", and +# scripts arrive as CRLF, the kernel reads the shebang as "/bin/sh\r", and # nginx dies on boot with "not found" (exit 127). .gitattributes fixes this # at the repository level; this line covers checkouts that predate it. -RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh \ - && chmod +x /docker-entrypoint.d/20-basic-auth.sh \ - && touch /etc/nginx/basic-auth.conf +# The empty .conf files matter: nginx.conf includes both unconditionally, and +# an include of a missing file is a startup error. +RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/25-allowed-hosts.sh \ + && chmod +x /docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/25-allowed-hosts.sh \ + && touch /etc/nginx/basic-auth.conf /etc/nginx/allowed-hosts.conf EXPOSE 80 diff --git a/frontend/docker-entrypoint.d/25-allowed-hosts.sh b/frontend/docker-entrypoint.d/25-allowed-hosts.sh new file mode 100644 index 0000000..1cba6bb --- /dev/null +++ b/frontend/docker-entrypoint.d/25-allowed-hosts.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Runs before nginx starts, same convention as 20-basic-auth.sh next door. +# +# Writes the Host allow-list that nginx.conf includes. This is what stops DNS +# rebinding: a page on the open internet can point its own hostname at +# 127.0.0.1, wait for the DNS TTL to expire, and then have the victim's own +# browser issue same-origin requests to whatever is listening there. The +# browser's origin checks don't help — as far as it's concerned the request +# really is same-origin — but the Host header still carries the attacker's +# domain, so refusing hostnames we don't expect closes it. +# +# AURUM_ALLOWED_HOSTS is a space- or comma-separated list. Default covers +# local access only; add your own hostname when you serve Aurum under one +# (see .env.example). "*" disables the check entirely. +set -eu + +FRAGMENT=/etc/nginx/allowed-hosts.conf +HOSTS="${AURUM_ALLOWED_HOSTS:-localhost 127.0.0.1}" + +if [ "$HOSTS" = "*" ]; then + : > "$FRAGMENT" + echo "[aurum] Host header check disabled (AURUM_ALLOWED_HOSTS=*)." >&2 + return 0 2>/dev/null || exit 0 +fi + +# "a,b c" -> "a|b|c", with regex metacharacters in each entry escaped so an +# IP's dots can't match any character. +PATTERN="$(printf '%s' "$HOSTS" \ + | tr ',' ' ' \ + | tr -s ' ' '\n' \ + | sed '/^$/d' \ + | sed 's/[].[^$*\/+?(){}|\\]/\\&/g' \ + | paste -sd '|' -)" + +cat > "$FRAGMENT" <&2 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6eea216..faf13a0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -17,6 +17,11 @@ server { # vulnerable to X" into a lookup instead of a probe. server_tokens off; + # Rejects requests whose Host header isn't one we expect (DNS rebinding — + # see docker-entrypoint.d/25-allowed-hosts.sh, which generates this and + # explains the attack). Empty file when the check is switched off. + include /etc/nginx/allowed-hosts.conf; + # Security headers, applied at server level so every location inherits # them. Note for whoever edits this next: adding an add_header inside a # location block drops ALL of these for that location — nginx does not diff --git a/frontend/src/components/auth/LoginGate.tsx b/frontend/src/components/auth/LoginGate.tsx index ed14796..72fa9b6 100644 --- a/frontend/src/components/auth/LoginGate.tsx +++ b/frontend/src/components/auth/LoginGate.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useEffect, useState } from "react"; import { Logo } from "@/components/layout/Logo"; import { LoginScreen } from "@/components/auth/LoginScreen"; import { NoAuthModal } from "@/components/auth/NoAuthModal"; -import { checkCredentials, useAuthHeader } from "@/lib/auth"; +import { checkCredentials, useAuthHeader, whenAuthRestored } from "@/lib/auth"; // "not-configured" and "authenticated" both render `children` the same way, // but they must stay distinct: only "not-configured" means this instance has @@ -30,13 +30,20 @@ export function LoginGate({ children }: { children: ReactNode }) { return; } let cancelled = false; - checkCredentials(null).then((result) => { - if (cancelled) return; - // A network/backend error here isn't an auth problem — don't block - // the user behind a login screen for an unrelated outage, let the - // app's own per-page error states (e.g. dashboard.errorLoading) explain it. - setProbe(result === "unauthorized" ? "required" : "not-configured"); - }); + // Wait for the encrypted "remember me" credential to be read back before + // concluding anything: it arrives asynchronously (IndexedDB + WebCrypto), + // and probing first would show the login screen for a moment to someone + // who is, in fact, already logged in. When it does arrive it sets the + // header, which re-runs this effect down the authenticated path above. + void whenAuthRestored() + .then(() => (cancelled ? null : checkCredentials(null))) + .then((result) => { + if (cancelled || result === null) return; + // A network/backend error here isn't an auth problem — don't block + // the user behind a login screen for an unrelated outage, let the + // app's own per-page error states (e.g. dashboard.errorLoading) explain it. + setProbe(result === "unauthorized" ? "required" : "not-configured"); + }); return () => { cancelled = true; }; diff --git a/frontend/src/components/auth/LoginScreen.tsx b/frontend/src/components/auth/LoginScreen.tsx index f503358..8c1b541 100644 --- a/frontend/src/components/auth/LoginScreen.tsx +++ b/frontend/src/components/auth/LoginScreen.tsx @@ -3,7 +3,7 @@ import { Logo } from "@/components/layout/Logo"; import { Button } from "@/components/ui/Button"; import { Card, CardContent } from "@/components/ui/Card"; import { Input, Label } from "@/components/ui/Input"; -import { buildBasicAuthHeader, checkCredentials, setCredentials } from "@/lib/auth"; +import { buildBasicAuthHeader, checkCredentials, isRememberSupported, setCredentials } from "@/lib/auth"; import { useTranslation } from "@/lib/i18n"; type Status = "idle" | "submitting" | "invalid" | "unreachable"; @@ -19,13 +19,18 @@ export function LoginScreen() { const [password, setPassword] = useState(""); const [remember, setRemember] = useState(false); const [status, setStatus] = useState("idle"); + // Remembering encrypts the credential with WebCrypto, which browsers only + // expose in a secure context — over plain HTTP to a LAN address there's no + // safe way to persist it, so the option is hidden rather than offered and + // quietly ignored. See lib/credentialVault.ts. + const canRemember = isRememberSupported(); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setStatus("submitting"); const result = await checkCredentials(buildBasicAuthHeader(username, password)); if (result === "ok") { - setCredentials(username, password, remember); + setCredentials(username, password, canRemember && remember); return; } setStatus(result === "unauthorized" ? "invalid" : "unreachable"); @@ -67,15 +72,19 @@ export function LoginScreen() { /> - + {canRemember ? ( + + ) : ( +

{t("auth.sessionOnlyHint")}

+ )} {status === "invalid" &&

{t("auth.errorInvalidCredentials")}

} {status === "unreachable" &&

{t("auth.errorUnreachable")}

} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index c953069..d3b6074 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,5 +1,7 @@ import { useSyncExternalStore } from "react"; +import { forgetCredential, isVaultAvailable, recallCredential, rememberCredential } from "@/lib/credentialVault"; + /** * Client-side mirror of the HTTP Basic Auth credentials Aurum's own login * screen collects (see components/auth/LoginScreen.tsx), so every fetch can @@ -9,45 +11,31 @@ import { useSyncExternalStore } from "react"; * this only avoids ever triggering that native prompt, by never letting an * unauthenticated request happen without us attaching the header ourselves. * - * Two storage tiers, chosen at login time by the "remember me" checkbox: - * - sessionStorage (default): gone as soon as the tab closes. - * - localStorage, with an explicit expiry stamped into the stored value: - * survives closing the tab/browser, but only for REMEMBER_DAYS — an - * unbounded "stay logged in forever" is too much for a finance app. - * The expiry is only checked when this module loads (i.e. on page load/ - * reload) — a tab left open across the expiry moment without reloading - * keeps working until its next reload or a 401 forces a fresh check. + * Two tiers, chosen by the "remember me" checkbox: + * - sessionStorage (default): plain, but gone the moment the browser closes. + * - the encrypted vault (lib/credentialVault.ts): survives a restart for + * REMEMBER_DAYS. It used to be plain localStorage, which meant the + * instance password sat on disk recoverable with a single atob() — the + * vault encrypts it under a key the browser refuses to hand back. + * + * The vault needs a secure context, so on plain HTTP to anything but + * localhost the remember tier is simply unavailable (isRememberSupported()) — + * the login screen hides the checkbox rather than silently not remembering. */ const SESSION_KEY = "aurum:basicAuth"; -const REMEMBER_KEY = "aurum:basicAuth:remember"; -// 7, not 30: what's stored is the Basic Auth header, i.e. the instance -// password recoverable in plaintext by anyone who can read localStorage (an -// XSS, a browser extension, someone with the machine). It can't be revoked -// server-side, so the only lever on that exposure is how long it sits there. +// Where the pre-vault "remember me" tier kept its plaintext copy. Read never, +// deleted always: an install upgrading past that version would otherwise +// leave the password sitting on disk for up to a week with nothing left to +// clear it. +const LEGACY_REMEMBER_KEY = "aurum:basicAuth:remember"; const REMEMBER_DAYS = 7; const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000; -interface RememberedEntry { - header: string; - expiresAt: number; // epoch ms -} - -function readRemembered(): string | null { +function forgetLegacyRememberedCredentials(): void { try { - const raw = localStorage.getItem(REMEMBER_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.header !== "string" || typeof parsed.expiresAt !== "number") { - localStorage.removeItem(REMEMBER_KEY); - return null; - } - if (Date.now() >= parsed.expiresAt) { - localStorage.removeItem(REMEMBER_KEY); - return null; - } - return parsed.header; + localStorage.removeItem(LEGACY_REMEMBER_KEY); } catch { - return null; + // storage unavailable — nothing was stored there either } } @@ -59,11 +47,13 @@ function readSession(): string | null { } } -function readStored(): string | null { - return readRemembered() ?? readSession(); +export function isRememberSupported(): boolean { + return isVaultAvailable(); } -let currentHeader: string | null = readStored(); +forgetLegacyRememberedCredentials(); + +let currentHeader: string | null = readSession(); const listeners = new Set<() => void>(); function notify(): void { @@ -74,6 +64,25 @@ export function getAuthHeader(): string | null { return currentHeader; } +/** Reading the vault is async (IndexedDB + WebCrypto), unlike the + * sessionStorage tier that's already resolved by the time this module + * finishes loading. LoginGate awaits this before deciding anything, so a + * remembered login doesn't flash the login screen on its way in. Started + * eagerly at module load rather than on demand, so the work overlaps with + * React mounting instead of following it. */ +const restored: Promise = (async () => { + if (currentHeader !== null) return; + const remembered = await recallCredential(); + // A login that happened while the vault was being read wins — it's newer. + if (remembered === null || currentHeader !== null) return; + currentHeader = remembered; + notify(); +})(); + +export function whenAuthRestored(): Promise { + return restored; +} + // btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic // password has to survive this, not just ASCII ones. function encodeUtf8Base64(value: string): string { @@ -89,22 +98,22 @@ export function buildBasicAuthHeader(username: string, password: string): string return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`; } -export function setCredentials(username: string, password: string, remember: boolean): void { +export function setCredentials(username: string, password: string, remember = false): void { currentHeader = buildBasicAuthHeader(username, password); try { - if (remember) { - const entry: RememberedEntry = { header: currentHeader, expiresAt: Date.now() + REMEMBER_MS }; - localStorage.setItem(REMEMBER_KEY, JSON.stringify(entry)); - sessionStorage.removeItem(SESSION_KEY); - } else { - sessionStorage.setItem(SESSION_KEY, currentHeader); - localStorage.removeItem(REMEMBER_KEY); - } + sessionStorage.setItem(SESSION_KEY, currentHeader); } catch { // storage unavailable (private browsing, storage disabled) — the header // still works for the rest of this tab's life via the in-memory // variable above, it just won't survive a refresh. } + // Fire-and-forget: nothing downstream waits on the write, and a vault that + // refuses to store only costs this login its persistence. + if (remember) { + void rememberCredential(currentHeader, REMEMBER_MS); + } else { + void forgetCredential(); + } notify(); } @@ -116,10 +125,14 @@ export function clearCredentials(): void { currentHeader = null; try { sessionStorage.removeItem(SESSION_KEY); - localStorage.removeItem(REMEMBER_KEY); + forgetLegacyRememberedCredentials(); } catch { // ignore — nothing to clean up if storage was never usable } + // A 401 means the stored credential is wrong or revoked, so the remembered + // copy is wrong too — leaving it would restore the same rejected header on + // the next reload and loop the user straight back to the login screen. + void forgetCredential(); notify(); } diff --git a/frontend/src/lib/credentialVault.ts b/frontend/src/lib/credentialVault.ts new file mode 100644 index 0000000..b08039b --- /dev/null +++ b/frontend/src/lib/credentialVault.ts @@ -0,0 +1,135 @@ +/** + * Encrypted at-rest storage for the Basic Auth header, so "remember me" can + * survive a browser restart without leaving the instance password readable on + * disk. + * + * How it works: an AES-GCM key is generated with `extractable: false` and + * kept in IndexedDB. The browser will happily store and hand back that + * CryptoKey, but `crypto.subtle.exportKey` refuses to reveal its bytes — so + * page code can decrypt with it and still cannot read it. Alongside it sits + * the ciphertext of the header plus an expiry. + * + * What this actually buys, stated honestly: + * - Someone who copies the profile directory, restores a disk backup, reads + * synced storage, or just opens DevTools gets ciphertext. Previously they + * got `Basic dXNlcjpwYXNz`, which is the password in one `atob()`. + * - It does NOT stop code executing *inside* the page: an XSS can simply + * call decrypt() the same way the app does. That vector is what the + * Content-Security-Policy in frontend/nginx.conf is there to close; + * this and that are two halves of the same answer. + * + * Requires a secure context — `crypto.subtle` is undefined on plain HTTP to + * anything but localhost. That's a real configuration here, not a hypothetical + * (AURUM_BIND_ADDRESS makes LAN access over http:// possible), so callers must + * check isVaultAvailable() and fall back to session-only storage. + */ +const DB_NAME = "aurum-auth"; +const DB_VERSION = 1; +const STORE = "vault"; +const KEY_ID = "wrapping-key"; +const BLOB_ID = "remembered-credential"; + +interface StoredBlob { + iv: Uint8Array; + ciphertext: ArrayBuffer; + expiresAt: number; // epoch ms +} + +export function isVaultAvailable(): boolean { + return ( + typeof indexedDB !== "undefined" && + typeof crypto !== "undefined" && + typeof crypto.subtle !== "undefined" && + typeof crypto.subtle.generateKey === "function" + ); +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE)) { + request.result.createObjectStore(STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function runRequest(store: IDBObjectStore, request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + store.transaction.onabort = () => reject(store.transaction.error); + }); +} + +async function withStore(mode: IDBTransactionMode, run: (store: IDBObjectStore) => Promise): Promise { + const db = await openDb(); + try { + return await run(db.transaction(STORE, mode).objectStore(STORE)); + } finally { + db.close(); + } +} + +/** Reuses the existing key when there is one: regenerating it on every login + * would strand any ciphertext already written under the old key. */ +async function getOrCreateKey(): Promise { + const existing = await withStore("readonly", (store) => runRequest(store, store.get(KEY_ID))); + if (existing) return existing; + + // extractable: false is the whole point — see the module comment. + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + await withStore("readwrite", (store) => runRequest(store, store.put(key, KEY_ID))); + return key; +} + +export async function rememberCredential(header: string, ttlMs: number): Promise { + if (!isVaultAvailable()) return; + try { + const key = await getOrCreateKey(); + // A fresh IV per encryption: reusing one with AES-GCM under the same key + // is what breaks the mode outright. + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(header)); + const blob: StoredBlob = { iv, ciphertext, expiresAt: Date.now() + ttlMs }; + await withStore("readwrite", (store) => runRequest(store, store.put(blob, BLOB_ID))); + } catch { + // Storage denied or unavailable (private window, quota, disabled) — + // login still works for this session, it just won't be remembered. + } +} + +export async function recallCredential(): Promise { + if (!isVaultAvailable()) return null; + try { + const blob = await withStore("readonly", (store) => runRequest(store, store.get(BLOB_ID))); + if (!blob) return null; + if (Date.now() >= blob.expiresAt) { + await forgetCredential(); + return null; + } + // Copied into a fresh view: structured clone hands the IV back typed over + // ArrayBufferLike, which doesn't satisfy WebCrypto's BufferSource. + const iv = new Uint8Array(blob.iv); + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, await getOrCreateKey(), blob.ciphertext); + return new TextDecoder().decode(plaintext); + } catch { + // Tampered ciphertext, a key that no longer matches, or storage trouble. + // Nothing recoverable either way — drop it and fall back to the login + // screen rather than leaving a blob around that can never be read. + await forgetCredential(); + return null; + } +} + +export async function forgetCredential(): Promise { + if (typeof indexedDB === "undefined") return; + try { + await withStore("readwrite", (store) => runRequest(store, store.delete(BLOB_ID))); + } catch { + // ignore — nothing usable to clean up + } +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index b9c6c00..67dfc97 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -50,6 +50,7 @@ const ru = { "auth.usernameLabel": "Логин", "auth.passwordLabel": "Пароль", "auth.rememberMe": "Запомнить меня на 7 дней", + "auth.sessionOnlyHint": "Вход сохраняется до закрытия браузера. Запоминание надолго требует HTTPS или localhost.", "auth.submitButton": "Войти", "auth.submitting": "Вход…", "auth.errorInvalidCredentials": "Неверный логин или пароль", @@ -568,6 +569,7 @@ const en: Record = { "auth.usernameLabel": "Username", "auth.passwordLabel": "Password", "auth.rememberMe": "Remember me for 7 days", + "auth.sessionOnlyHint": "You stay signed in until you close the browser. Staying signed in for longer needs HTTPS or localhost.", "auth.submitButton": "Sign in", "auth.submitting": "Signing in…", "auth.errorInvalidCredentials": "Incorrect username or password",