mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
security: TLS, проверка Host, шифрованное запоминание входа и аудит-лог
- 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 и таблица двух переменных, решающих, кто может достучаться до инстанса.
This commit is contained in:
11
.env.example
11
.env.example
@@ -61,3 +61,14 @@ AURUM_WEB_PORT=3000
|
|||||||
# proxy), but set the basic auth variables above first: without them the app
|
# 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.
|
# has no password, and this is the line that decides who can knock.
|
||||||
AURUM_BIND_ADDRESS=127.0.0.1
|
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
|
||||||
|
|||||||
23
Caddyfile
Normal file
23
Caddyfile
Normal file
@@ -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
|
||||||
|
}
|
||||||
30
README.md
30
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:
|
**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.
|
- 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.
|
If you find a security issue, please open a private report via GitHub's Security tab rather than a public issue.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
|
from app.core.audit import log_destructive
|
||||||
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
||||||
from app.services.account_service import create_account, delete_account, list_accounts, update_account
|
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)
|
@router.delete("/{account_id}", status_code=204)
|
||||||
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
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)
|
await delete_account(session, account_id)
|
||||||
|
log_destructive("account.deleted", account_id=account_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
|
from app.core.audit import log_destructive
|
||||||
from app.schemas.backup import BackupPayload
|
from app.schemas.backup import BackupPayload
|
||||||
from app.services.backup_service import build_backup, restore_backup
|
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")
|
@router.post("/import")
|
||||||
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
|
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)
|
await restore_backup(session, payload)
|
||||||
|
log_destructive("backup.restore.completed", transactions=len(payload.transactions))
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
|
from app.core.audit import log_destructive
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
@@ -228,6 +229,9 @@ async def bulk_create_transactions(
|
|||||||
|
|
||||||
session.add_all(transactions)
|
session.add_all(transactions)
|
||||||
await session.commit()
|
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))
|
return TransactionBulkCreateResult(created=len(transactions))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
32
backend/app/core/audit.py
Normal file
32
backend/app/core/audit.py
Normal file
@@ -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)
|
||||||
50
docker-compose.tls.yml
Normal file
50
docker-compose.tls.yml
Normal file
@@ -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:
|
||||||
@@ -68,6 +68,9 @@ services:
|
|||||||
# Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh.
|
# 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_USER: ${AURUM_BASIC_AUTH_USER:-}
|
||||||
AURUM_BASIC_AUTH_PASSWORD: ${AURUM_BASIC_AUTH_PASSWORD:-}
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ RUN apk add --no-cache openssl
|
|||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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/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
|
# 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
|
# nginx dies on boot with "not found" (exit 127). .gitattributes fixes this
|
||||||
# at the repository level; this line covers checkouts that predate it.
|
# at the repository level; this line covers checkouts that predate it.
|
||||||
RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh \
|
# The empty .conf files matter: nginx.conf includes both unconditionally, and
|
||||||
&& chmod +x /docker-entrypoint.d/20-basic-auth.sh \
|
# an include of a missing file is a startup error.
|
||||||
&& touch /etc/nginx/basic-auth.conf
|
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
|
EXPOSE 80
|
||||||
|
|
||||||
|
|||||||
45
frontend/docker-entrypoint.d/25-allowed-hosts.sh
Normal file
45
frontend/docker-entrypoint.d/25-allowed-hosts.sh
Normal file
@@ -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" <<EOF
|
||||||
|
# Generated at container start by docker-entrypoint.d/25-allowed-hosts.sh.
|
||||||
|
# \$host is the Host header without the port, so entries here are bare
|
||||||
|
# hostnames. "if" is normally best avoided in nginx, but a bare "return"
|
||||||
|
# inside it is one of the two documented-safe uses.
|
||||||
|
if (\$host !~* '^(${PATTERN})\$') {
|
||||||
|
return 421 "Aurum: unexpected Host header '\$host'. Add it to AURUM_ALLOWED_HOSTS in .env (see .env.example), or set that variable to * to disable this check.\n";
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[aurum] Host header allow-list: ${HOSTS}" >&2
|
||||||
@@ -17,6 +17,11 @@ server {
|
|||||||
# vulnerable to X" into a lookup instead of a probe.
|
# vulnerable to X" into a lookup instead of a probe.
|
||||||
server_tokens off;
|
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
|
# Security headers, applied at server level so every location inherits
|
||||||
# them. Note for whoever edits this next: adding an add_header inside a
|
# 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
|
# location block drops ALL of these for that location — nginx does not
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type ReactNode, useEffect, useState } from "react";
|
|||||||
import { Logo } from "@/components/layout/Logo";
|
import { Logo } from "@/components/layout/Logo";
|
||||||
import { LoginScreen } from "@/components/auth/LoginScreen";
|
import { LoginScreen } from "@/components/auth/LoginScreen";
|
||||||
import { NoAuthModal } from "@/components/auth/NoAuthModal";
|
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,
|
// "not-configured" and "authenticated" both render `children` the same way,
|
||||||
// but they must stay distinct: only "not-configured" means this instance has
|
// but they must stay distinct: only "not-configured" means this instance has
|
||||||
@@ -30,13 +30,20 @@ export function LoginGate({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
checkCredentials(null).then((result) => {
|
// Wait for the encrypted "remember me" credential to be read back before
|
||||||
if (cancelled) return;
|
// concluding anything: it arrives asynchronously (IndexedDB + WebCrypto),
|
||||||
// A network/backend error here isn't an auth problem — don't block
|
// and probing first would show the login screen for a moment to someone
|
||||||
// the user behind a login screen for an unrelated outage, let the
|
// who is, in fact, already logged in. When it does arrive it sets the
|
||||||
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
|
// header, which re-runs this effect down the authenticated path above.
|
||||||
setProbe(result === "unauthorized" ? "required" : "not-configured");
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Logo } from "@/components/layout/Logo";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardContent } from "@/components/ui/Card";
|
import { Card, CardContent } from "@/components/ui/Card";
|
||||||
import { Input, Label } from "@/components/ui/Input";
|
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";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
type Status = "idle" | "submitting" | "invalid" | "unreachable";
|
type Status = "idle" | "submitting" | "invalid" | "unreachable";
|
||||||
@@ -19,13 +19,18 @@ export function LoginScreen() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [remember, setRemember] = useState(false);
|
const [remember, setRemember] = useState(false);
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
const [status, setStatus] = useState<Status>("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) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setStatus("submitting");
|
setStatus("submitting");
|
||||||
const result = await checkCredentials(buildBasicAuthHeader(username, password));
|
const result = await checkCredentials(buildBasicAuthHeader(username, password));
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
setCredentials(username, password, remember);
|
setCredentials(username, password, canRemember && remember);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
|
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
|
||||||
@@ -67,15 +72,19 @@ export function LoginScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
{canRemember ? (
|
||||||
<input
|
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={remember}
|
type="checkbox"
|
||||||
onChange={(event) => setRemember(event.target.checked)}
|
checked={remember}
|
||||||
className="h-3.5 w-3.5 accent-text-primary"
|
onChange={(event) => setRemember(event.target.checked)}
|
||||||
/>
|
className="h-3.5 w-3.5 accent-text-primary"
|
||||||
{t("auth.rememberMe")}
|
/>
|
||||||
</label>
|
{t("auth.rememberMe")}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-muted">{t("auth.sessionOnlyHint")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
|
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
|
||||||
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
|
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
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
|
* Client-side mirror of the HTTP Basic Auth credentials Aurum's own login
|
||||||
* screen collects (see components/auth/LoginScreen.tsx), so every fetch can
|
* 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
|
* this only avoids ever triggering that native prompt, by never letting an
|
||||||
* unauthenticated request happen without us attaching the header ourselves.
|
* unauthenticated request happen without us attaching the header ourselves.
|
||||||
*
|
*
|
||||||
* Two storage tiers, chosen at login time by the "remember me" checkbox:
|
* Two tiers, chosen by the "remember me" checkbox:
|
||||||
* - sessionStorage (default): gone as soon as the tab closes.
|
* - sessionStorage (default): plain, but gone the moment the browser closes.
|
||||||
* - localStorage, with an explicit expiry stamped into the stored value:
|
* - the encrypted vault (lib/credentialVault.ts): survives a restart for
|
||||||
* survives closing the tab/browser, but only for REMEMBER_DAYS — an
|
* REMEMBER_DAYS. It used to be plain localStorage, which meant the
|
||||||
* unbounded "stay logged in forever" is too much for a finance app.
|
* instance password sat on disk recoverable with a single atob() — the
|
||||||
* The expiry is only checked when this module loads (i.e. on page load/
|
* vault encrypts it under a key the browser refuses to hand back.
|
||||||
* reload) — a tab left open across the expiry moment without reloading
|
*
|
||||||
* keeps working until its next reload or a 401 forces a fresh check.
|
* 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 SESSION_KEY = "aurum:basicAuth";
|
||||||
const REMEMBER_KEY = "aurum:basicAuth:remember";
|
// Where the pre-vault "remember me" tier kept its plaintext copy. Read never,
|
||||||
// 7, not 30: what's stored is the Basic Auth header, i.e. the instance
|
// deleted always: an install upgrading past that version would otherwise
|
||||||
// password recoverable in plaintext by anyone who can read localStorage (an
|
// leave the password sitting on disk for up to a week with nothing left to
|
||||||
// XSS, a browser extension, someone with the machine). It can't be revoked
|
// clear it.
|
||||||
// server-side, so the only lever on that exposure is how long it sits there.
|
const LEGACY_REMEMBER_KEY = "aurum:basicAuth:remember";
|
||||||
const REMEMBER_DAYS = 7;
|
const REMEMBER_DAYS = 7;
|
||||||
const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000;
|
const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
interface RememberedEntry {
|
function forgetLegacyRememberedCredentials(): void {
|
||||||
header: string;
|
|
||||||
expiresAt: number; // epoch ms
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRemembered(): string | null {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(REMEMBER_KEY);
|
localStorage.removeItem(LEGACY_REMEMBER_KEY);
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as Partial<RememberedEntry>;
|
|
||||||
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;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// storage unavailable — nothing was stored there either
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +47,13 @@ function readSession(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStored(): string | null {
|
export function isRememberSupported(): boolean {
|
||||||
return readRemembered() ?? readSession();
|
return isVaultAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentHeader: string | null = readStored();
|
forgetLegacyRememberedCredentials();
|
||||||
|
|
||||||
|
let currentHeader: string | null = readSession();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function notify(): void {
|
function notify(): void {
|
||||||
@@ -74,6 +64,25 @@ export function getAuthHeader(): string | null {
|
|||||||
return currentHeader;
|
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<void> = (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<void> {
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
// btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic
|
// btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic
|
||||||
// password has to survive this, not just ASCII ones.
|
// password has to survive this, not just ASCII ones.
|
||||||
function encodeUtf8Base64(value: string): string {
|
function encodeUtf8Base64(value: string): string {
|
||||||
@@ -89,22 +98,22 @@ export function buildBasicAuthHeader(username: string, password: string): string
|
|||||||
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
|
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);
|
currentHeader = buildBasicAuthHeader(username, password);
|
||||||
try {
|
try {
|
||||||
if (remember) {
|
sessionStorage.setItem(SESSION_KEY, currentHeader);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// storage unavailable (private browsing, storage disabled) — the header
|
// storage unavailable (private browsing, storage disabled) — the header
|
||||||
// still works for the rest of this tab's life via the in-memory
|
// still works for the rest of this tab's life via the in-memory
|
||||||
// variable above, it just won't survive a refresh.
|
// 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();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,10 +125,14 @@ export function clearCredentials(): void {
|
|||||||
currentHeader = null;
|
currentHeader = null;
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(SESSION_KEY);
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
localStorage.removeItem(REMEMBER_KEY);
|
forgetLegacyRememberedCredentials();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — nothing to clean up if storage was never usable
|
// 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();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
135
frontend/src/lib/credentialVault.ts
Normal file
135
frontend/src/lib/credentialVault.ts
Normal file
@@ -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<IDBDatabase> {
|
||||||
|
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<T>(store: IDBObjectStore, request: IDBRequest<T>): Promise<T> {
|
||||||
|
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<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => Promise<T>): Promise<T> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
const existing = await withStore("readonly", (store) => runRequest<CryptoKey | undefined>(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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
if (!isVaultAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const blob = await withStore("readonly", (store) => runRequest<StoredBlob | undefined>(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<void> {
|
||||||
|
if (typeof indexedDB === "undefined") return;
|
||||||
|
try {
|
||||||
|
await withStore("readwrite", (store) => runRequest(store, store.delete(BLOB_ID)));
|
||||||
|
} catch {
|
||||||
|
// ignore — nothing usable to clean up
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ const ru = {
|
|||||||
"auth.usernameLabel": "Логин",
|
"auth.usernameLabel": "Логин",
|
||||||
"auth.passwordLabel": "Пароль",
|
"auth.passwordLabel": "Пароль",
|
||||||
"auth.rememberMe": "Запомнить меня на 7 дней",
|
"auth.rememberMe": "Запомнить меня на 7 дней",
|
||||||
|
"auth.sessionOnlyHint": "Вход сохраняется до закрытия браузера. Запоминание надолго требует HTTPS или localhost.",
|
||||||
"auth.submitButton": "Войти",
|
"auth.submitButton": "Войти",
|
||||||
"auth.submitting": "Вход…",
|
"auth.submitting": "Вход…",
|
||||||
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
||||||
@@ -568,6 +569,7 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"auth.usernameLabel": "Username",
|
"auth.usernameLabel": "Username",
|
||||||
"auth.passwordLabel": "Password",
|
"auth.passwordLabel": "Password",
|
||||||
"auth.rememberMe": "Remember me for 7 days",
|
"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.submitButton": "Sign in",
|
||||||
"auth.submitting": "Signing in…",
|
"auth.submitting": "Signing in…",
|
||||||
"auth.errorInvalidCredentials": "Incorrect username or password",
|
"auth.errorInvalidCredentials": "Incorrect username or password",
|
||||||
|
|||||||
Reference in New Issue
Block a user