Harden the container: non-root, pinned images and dependencies, the key in a file (B-91, SEC-51, SEC-52, SEC-53; closes B-71, B-72)
- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it and closes it to others (works on an older root-owned install with no host sudo); files are created 0600 (umask 077) - python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from requirements.lock with --require-hashes - the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose secret), out of every environment variable and of a ./data backup - setup.md no longer presents a second way to run the server (B-72) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40ac490826
commit
c1b192f6d1
23 files changed
+1500
-183
No files matched your search
@@ -11,3 +11,4 @@ data/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
tests/
|
tests/
|
||||||
.claude/
|
.claude/
|
||||||
|
secrets/
|
||||||
+8
-3
@@ -30,9 +30,14 @@ ELECTRUM_CERT_PIN=
|
|||||||
# NEVER true in production.
|
# NEVER true in production.
|
||||||
ALLOW_NO_QUORUM=false
|
ALLOW_NO_QUORUM=false
|
||||||
|
|
||||||
# Fernet key protecting the master xprv at rest. Generate with:
|
# File holding the Fernet key that protects the master xprv at rest (B-91): in a file, not in
|
||||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# an environment variable, so `docker inspect` cannot show it and a backup of ./data does not
|
||||||
XPRV_ENCRYPTION_KEY=
|
# carry both. Inside the container docker-compose.yml mounts it as a secret and overrides
|
||||||
|
# this path. Create it once:
|
||||||
|
# mkdir -p secrets && chmod 700 secrets
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key
|
||||||
|
# chmod 600 secrets/xprv_encryption.key
|
||||||
|
XPRV_ENCRYPTION_KEY_FILE=./secrets/xprv_encryption.key
|
||||||
|
|
||||||
# Random secret for JWT session signing. Generate with:
|
# Random secret for JWT session signing. Generate with:
|
||||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ master.xprv.enc
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
logs/
|
logs/
|
||||||
data/
|
data/
|
||||||
|
secrets/
|
||||||
@@ -1,77 +1,10 @@
|
|||||||
# BUGS.md — open findings
|
# BUGS.md — open findings
|
||||||
|
|
||||||
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
|
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
|
||||||
2026-09-28 specification review (B-73 … B-98, at the end of the file).
|
(B-92, B-93, B-94, B-96). A finding is removed from this file once it is fixed, and
|
||||||
|
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
|
||||||
## Audit of 2026-08-03
|
convention each entry gets its own commit with its own regression test, and the `B-nn`
|
||||||
|
marker goes in a comment next to the fix.
|
||||||
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
|
|
||||||
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
|
|
||||||
fixed finding, B-51.
|
|
||||||
|
|
||||||
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
|
|
||||||
removed from this file once it is fixed, and is not listed here afterwards. Per
|
|
||||||
CLAUDE.md's convention each entry gets its own commit with its own regression test,
|
|
||||||
and the `B-nn` marker goes in a comment next to the fix, so
|
|
||||||
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
|
|
||||||
|
|
||||||
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
|
|
||||||
still empty; withdrawal and the RBF bump path still never live-broadcast.
|
|
||||||
|
|
||||||
Verified as *not* broken while looking for these: i18n key parity (146 identical
|
|
||||||
keys across all 7 languages), HTML escaping of every user-controlled value in
|
|
||||||
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
|
|
||||||
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
|
|
||||||
secrets/DB/logs (nothing sensitive is tracked in git).
|
|
||||||
|
|
||||||
Severity is about consequence, not likelihood:
|
|
||||||
**critical** = money stuck or lost, or the lottery stops;
|
|
||||||
**high** = a security control that does not hold;
|
|
||||||
**medium** = wrong behaviour with a bounded blast radius;
|
|
||||||
**low** = drift between documentation and code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical
|
|
||||||
|
|
||||||
### (not new) `drawing` does not resume after a restart
|
|
||||||
|
|
||||||
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
|
|
||||||
here. Worth restating in context: with `restart: unless-stopped` on the app
|
|
||||||
container, this is the one state that gets stuck with money in play, and it
|
|
||||||
remains the last prerequisite for running unattended.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Low — documentation and consistency drift
|
|
||||||
|
|
||||||
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
|
|
||||||
|
|
||||||
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
|
|
||||||
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
|
|
||||||
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
|
|
||||||
working tree (both gitignored). They were verified during this audit to decrypt to
|
|
||||||
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
|
|
||||||
reads a different file from the one the container uses, and a future
|
|
||||||
`generate_master_key.py --overwrite` would split them silently, with an ops
|
|
||||||
recovery path that then reports the wrong key.
|
|
||||||
|
|
||||||
The same duplication exists for the database (`./plm_lottery.db` next to
|
|
||||||
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
|
|
||||||
|
|
||||||
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
|
|
||||||
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
|
|
||||||
|
|
||||||
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
|
|
||||||
|
|
||||||
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
|
|
||||||
and describes the master-key step as local *or* Docker, while B-44 made Docker the
|
|
||||||
only supported way to run the server (`docs/running-the-server.md` and README were
|
|
||||||
updated, this file was not). The venv genuinely is needed for tests, migrations and
|
|
||||||
the key scripts — the wording just needs to say that instead of implying a second
|
|
||||||
way to run the server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Audit of 2026-09-28 — specification review (B-73 … B-98)
|
# Audit of 2026-09-28 — specification review (B-73 … B-98)
|
||||||
|
|
||||||
@@ -100,19 +33,6 @@ decisions, see `docs/limiti-noti.md`):
|
|||||||
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
|
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
|
||||||
on deep ones, and pay out only what is verifiably on chain.
|
on deep ones, and pay out only what is verifiably on chain.
|
||||||
|
|
||||||
## High
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### B-91 — Host and supply-chain hardening
|
|
||||||
|
|
||||||
The container runs as root, `.env` and key files are world-readable, dependencies are
|
|
||||||
unpinned `>=` and base images float, secrets sit in env vars visible to
|
|
||||||
`docker inspect`.
|
|
||||||
|
|
||||||
Fix: non-root `USER`, `0600`/`0700` permissions, a hash-pinned lock file,
|
|
||||||
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
|
|
||||||
|
|
||||||
## Medium
|
## Medium
|
||||||
|
|
||||||
### B-92 — No health, readiness or real audit browsing
|
### B-92 — No health, readiness or real audit browsing
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
|||||||
|
|
||||||
## Project status
|
## Project status
|
||||||
|
|
||||||
All 10 stages of the original build order are code-complete and unit-tested — 719 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
All 10 stages of the original build order are code-complete and unit-tested — 728 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||||
|
|
||||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||||
|
|
||||||
@@ -44,23 +44,23 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
|
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
|
||||||
|
|
||||||
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env)
|
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs the key file `XPRV_ENCRYPTION_KEY_FILE` points at)
|
||||||
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
|
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
|
||||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||||
|
|
||||||
python -m pytest # all 719 tests
|
python -m pytest # all 728 tests
|
||||||
python -m pytest tests/unit/test_hd.py # one file
|
python -m pytest tests/unit/test_hd.py # one file
|
||||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||||
```
|
```
|
||||||
|
|
||||||
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
|
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
|
||||||
|
|
||||||
`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
|
`.env` (gitignored) holds the real secrets (except the Fernet key, which is `secrets/xprv_encryption.key`); `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
|
||||||
|
|
||||||
## Deployment (Docker + Caddy)
|
## Deployment (Docker + Caddy)
|
||||||
|
|
||||||
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
|
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Three services: a one-shot `data-permissions` (root, chowns `./data` to uid 1000 and closes it to others, B-91), `app` (non-root uid 1000; this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/db data/keys data/logs # one-time
|
mkdir -p data/db data/keys data/logs # one-time
|
||||||
@@ -78,7 +78,7 @@ The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Option
|
|||||||
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
|
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
|
||||||
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
|
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
|
||||||
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
||||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
- Secrets: master xprv Fernet-encrypted at rest, encryption key in its own file, `./secrets/xprv_encryption.key` (`XPRV_ENCRYPTION_KEY_FILE`; a compose secret in the container, never in the DB, git or an environment variable — B-91). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or the encryption key file is missing or empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||||
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
||||||
|
|
||||||
## PLM network parameters (mainnet)
|
## PLM network parameters (mainnet)
|
||||||
|
|||||||
+13
-3
@@ -1,15 +1,25 @@
|
|||||||
FROM python:3.12-slim
|
# B-91 (SEC-51, SEC-52): base image pinned by digest, dependencies installed from a
|
||||||
|
# hash-checked lock file, process running as a non-root user.
|
||||||
|
# To update: `docker buildx imagetools inspect python:3.12-slim` for the digest, and
|
||||||
|
# `pip-compile --generate-hashes --strip-extras -o requirements.lock pyproject.toml` for the lock.
|
||||||
|
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.lock ./
|
||||||
|
RUN pip install --no-cache-dir --require-hashes --no-deps -r requirements.lock
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir --no-deps . \
|
||||||
|
&& useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin app
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
EXPOSE 8123
|
EXPOSE 8123
|
||||||
|
|
||||||
CMD ["/bin/sh", "-c", "test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
|
# umask 077: the database, its WAL files and the logs are created readable by the app user only.
|
||||||
|
CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
|
||||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
|||||||
python -m pytest tests/unit/test_hd.py # one file
|
python -m pytest tests/unit/test_hd.py # one file
|
||||||
```
|
```
|
||||||
|
|
||||||
719 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
728 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||||
automated integration tests against a live Electrum connection — mainnet
|
automated integration tests against a live Electrum connection — mainnet
|
||||||
|
|||||||
+18
-1
@@ -19,6 +19,14 @@ logger = logging.getLogger(__name__)
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
if self.xprv_encryption_key_file and not self.xprv_encryption_key:
|
||||||
|
try:
|
||||||
|
with open(self.xprv_encryption_key_file) as f:
|
||||||
|
self.xprv_encryption_key = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass # validate_runtime_secrets reports it, naming the file
|
||||||
|
|
||||||
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
|
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
|
||||||
|
|
||||||
electrum_host: str = "santantonio.sytes.net"
|
electrum_host: str = "santantonio.sytes.net"
|
||||||
@@ -45,6 +53,10 @@ class Settings(BaseSettings):
|
|||||||
start_paused: bool = False
|
start_paused: bool = False
|
||||||
|
|
||||||
xprv_encryption_key: str = ""
|
xprv_encryption_key: str = ""
|
||||||
|
# B-91 (SEC-53): the key as a file (a compose secret in the container), so it is in no
|
||||||
|
# environment variable and `docker inspect` cannot show it. When set it is read into
|
||||||
|
# `xprv_encryption_key` at load, which is what the rest of the code uses.
|
||||||
|
xprv_encryption_key_file: str = ""
|
||||||
master_key_path: str = "./master.xprv.enc"
|
master_key_path: str = "./master.xprv.enc"
|
||||||
jwt_secret: str = ""
|
jwt_secret: str = ""
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
@@ -95,8 +107,13 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
|
|||||||
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
|
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
|
||||||
)
|
)
|
||||||
if not config.xprv_encryption_key.strip():
|
if not config.xprv_encryption_key.strip():
|
||||||
|
where = (
|
||||||
|
f"XPRV_ENCRYPTION_KEY_FILE ({config.xprv_encryption_key_file}) is missing or empty"
|
||||||
|
if config.xprv_encryption_key_file
|
||||||
|
else "XPRV_ENCRYPTION_KEY_FILE must point to a file holding the key"
|
||||||
|
)
|
||||||
problems.append(
|
problems.append(
|
||||||
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
|
where + " (generate: python -c "
|
||||||
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
||||||
)
|
)
|
||||||
if problems:
|
if problems:
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def verify_master_key() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): "
|
f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): "
|
||||||
"check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY"
|
"check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY_FILE"
|
||||||
) from exc
|
) from exc
|
||||||
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
|
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
|
||||||
if problems:
|
if problems:
|
||||||
|
|||||||
+26
-1
@@ -1,20 +1,39 @@
|
|||||||
services:
|
services:
|
||||||
|
# B-91 (SEC-51): one-shot, as root, so the bind-mounted ./data belongs to the non-root
|
||||||
|
# user the app runs as (uid 1000) and is closed to everyone else — also the first time
|
||||||
|
# after an older root-owned install, with no sudo on the host.
|
||||||
|
data-permissions:
|
||||||
|
build: .
|
||||||
|
image: plm-lottery-app:local
|
||||||
|
user: "0:0"
|
||||||
|
entrypoint: ["/bin/sh", "-c", "chown -R 1000:1000 /data && chmod -R go-rwx /data"]
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
image: plm-lottery-app:local
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
|
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
|
||||||
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
|
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
|
||||||
|
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
|
||||||
|
secrets:
|
||||||
|
- xprv_encryption_key
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/db:/app/db_data
|
- ./data/db:/app/db_data
|
||||||
- ./data/keys:/app/key_data
|
- ./data/keys:/app/key_data
|
||||||
- ./data/logs:/app/logs
|
- ./data/logs:/app/logs
|
||||||
expose:
|
expose:
|
||||||
- "8123"
|
- "8123"
|
||||||
|
depends_on:
|
||||||
|
data-permissions:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
@@ -28,6 +47,12 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
|
# The Fernet key that protects the master key lives in its own file, outside ./data (a backup of
|
||||||
|
# the data does not carry both) and in no environment variable (SEC-53).
|
||||||
|
secrets:
|
||||||
|
xprv_encryption_key:
|
||||||
|
file: ./secrets/xprv_encryption.key
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
|
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
|
||||||
|
|
||||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
- **Stato:** Accettata
|
||||||
- **Area:** Custodia, chiavi e indirizzi
|
- **Area:** Custodia, chiavi e indirizzi
|
||||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||||
|
|
||||||
@@ -23,5 +23,3 @@ git. L'utente non controlla nulla finché non preleva.
|
|||||||
|
|
||||||
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
|
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
|
||||||
*Test:* `tests/unit/test_hd.py`
|
*Test:* `tests/unit/test_hd.py`
|
||||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la
|
|
||||||
variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-11 — Il server rifiuta di partire con segreti inadeguati
|
# ADR-11 — Il server rifiuta di partire con segreti inadeguati
|
||||||
|
|
||||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
- **Stato:** Accettata
|
||||||
- **Area:** Configurazione e ambiente
|
- **Area:** Configurazione e ambiente
|
||||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||||
|
|
||||||
@@ -24,8 +24,6 @@ chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
|
|||||||
|
|
||||||
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
|
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
|
||||||
*Test:* `tests/unit/test_config.py`
|
*Test:* `tests/unit/test_config.py`
|
||||||
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
|
La chiave Fernet arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||||
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
|
|
||||||
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
|
||||||
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
|
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
|
||||||
mancante o vuoto.
|
mancante o vuoto.
|
||||||
+6
-10
@@ -55,9 +55,8 @@ flowchart LR
|
|||||||
installate da un file di lock con gli hash
|
installate da un file di lock con gli hash
|
||||||
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
|
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
|
||||||
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
|
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
|
||||||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
|
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` · `requirements.lock` ·
|
||||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
|
*Test:* `tests/unit/test_container_hardening.py`
|
||||||
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
|
|
||||||
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
|
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
|
||||||
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
|
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
|
||||||
443. Su questo si basa la lettura dell'IP del client (vedi
|
443. Su questo si basa la lettura dell'IP del client (vedi
|
||||||
@@ -79,9 +78,9 @@ flowchart LR
|
|||||||
rilascio, così un rollback sa quale immagine rimettere
|
rilascio, così un rollback sa quale immagine rimettere
|
||||||
([operativita.md](operativita.md) (B-93)).
|
([operativita.md](operativita.md) (B-93)).
|
||||||
*Codice:* `docker-compose.yml`
|
*Codice:* `docker-compose.yml`
|
||||||
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
|
*Test:* `tests/unit/test_container_hardening.py`
|
||||||
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
|
*Stato:* da implementare (B-93), per l'etichetta per rilascio · *Oggi:*
|
||||||
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
|
l'immagine di `app` ha l'etichetta fissa `plm-lottery-app:local`.
|
||||||
|
|
||||||
- **[ARC-61]** **Salute del container.** Il container `app` ha un
|
- **[ARC-61]** **Salute del container.** Il container `app` ha un
|
||||||
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
|
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
|
||||||
@@ -415,8 +414,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
|||||||
veri.
|
veri.
|
||||||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||||||
*Test:* `tests/unit/test_config.py`
|
*Test:* `tests/unit/test_config.py`
|
||||||
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
|
|
||||||
`XPRV_ENCRYPTION_KEY` non sia vuota.
|
|
||||||
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
|
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
|
||||||
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
|
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
|
||||||
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
|
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
|
||||||
@@ -598,8 +595,7 @@ La configurazione è divisa in due posti con regole diverse.
|
|||||||
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
|
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
|
||||||
|
|
||||||
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
|
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
|
||||||
*Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE` · *Oggi:* la
|
|
||||||
chiave Fernet è la variabile `XPRV_ENCRYPTION_KEY`.
|
|
||||||
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
|
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
|
||||||
durata e pausa del round, animazione, commissione di rete, timeout RBF,
|
durata e pausa del round, animazione, commissione di rete, timeout RBF,
|
||||||
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
|
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
|
||||||
|
|||||||
@@ -581,7 +581,7 @@ restano nella tabella come storico.
|
|||||||
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
||||||
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
||||||
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
|
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
|
||||||
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di
|
che cifra la master xprv (il file di cifratura,
|
||||||
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
|
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
|
||||||
mai scritto nel database prima della rivelazione: `server_seed` resta
|
mai scritto nel database prima della rivelazione: `server_seed` resta
|
||||||
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
||||||
|
|||||||
+5
-5
@@ -44,9 +44,9 @@ flowchart LR
|
|||||||
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
|
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
|
||||||
*Codice:* `docker-compose.yml`, `Caddyfile`
|
*Codice:* `docker-compose.yml`, `Caddyfile`
|
||||||
|
|
||||||
Oggi il container gira come root, e i file che scrive in `./data` (database,
|
Il container gira come utente non root (uid 1000, quello dell'utente dell'host:
|
||||||
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91
|
un servizio `data-permissions`, a ogni avvio, riporta `./data` a quel
|
||||||
gira come utente non root, con `./data` in `0700` e i file di chiave in
|
proprietario), con `./data` in `0700` e i file di chiave in
|
||||||
`0600`, e la chiave di cifratura sta in un file a parte
|
`0600`, e la chiave di cifratura sta in un file a parte
|
||||||
([architettura.md](architettura.md) ARC-03, ARC-05;
|
([architettura.md](architettura.md) ARC-03, ARC-05;
|
||||||
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
|
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
|
||||||
@@ -106,7 +106,7 @@ Il file da salvare è `./data/keys/master.xprv.enc`, quello che il container
|
|||||||
legge. Su questa installazione esiste anche una seconda copia,
|
legge. Su questa installazione esiste anche una seconda copia,
|
||||||
`./master.xprv.enc` nella radice del progetto, a cui punta il
|
`./master.xprv.enc` nella radice del progetto, a cui punta il
|
||||||
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
|
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
|
||||||
il file dell'app. Dopo B-91 la chiave di cifratura non sta più in `.env` ma
|
il file dell'app. La chiave di cifratura non sta più in `.env` ma
|
||||||
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
|
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
|
||||||
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
|
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
|
||||||
oggi la variabile, separata dalla master key.
|
oggi la variabile, separata dalla master key.
|
||||||
@@ -750,7 +750,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
|
|||||||
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
|
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
|
||||||
Gli script scrivono `0600` e rifiutano una xprv i cui
|
Gli script scrivono `0600` e rifiutano una xprv i cui
|
||||||
indirizzi non coincidono con quelli del database
|
indirizzi non coincidono con quelli del database
|
||||||
([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non
|
([SEC-50](sicurezza.md#sec-50)); la chiave sta in un file e non
|
||||||
in `.env` ([SEC-53](sicurezza.md#sec-53)).
|
in `.env` ([SEC-53](sicurezza.md#sec-53)).
|
||||||
|
|
||||||
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
|
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
|
||||||
|
|||||||
+16
-18
@@ -1,13 +1,13 @@
|
|||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
|
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
|
||||||
poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
|
poterla avviare. Il server gira sempre in Docker; il venv serve solo a test, migrazioni da generare e script una tantum. Per come avviarla poi ogni volta, vedi
|
||||||
[running-the-server.md](running-the-server.md).
|
[running-the-server.md](running-the-server.md).
|
||||||
|
|
||||||
## 1. Prerequisiti
|
## 1. Prerequisiti
|
||||||
|
|
||||||
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker)
|
- Docker + Docker Compose (il server gira solo così)
|
||||||
- Docker + Docker Compose (serve solo per il workflow a container)
|
- Python 3.12+ (solo per gli strumenti locali: test, `alembic revision` e gli script delle chiavi)
|
||||||
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
|
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
|
||||||
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
|
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
|
||||||
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
|
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
|
||||||
@@ -31,7 +31,7 @@ cp .env.example .env
|
|||||||
|
|
||||||
| Variabile | Scopo | Come generarla |
|
| Variabile | Scopo | Come generarla |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `XPRV_ENCRYPTION_KEY` | Chiave simmetrica che cifra a riposo la master xprv del server. **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
|
| `XPRV_ENCRYPTION_KEY_FILE` | Percorso del file che contiene la chiave simmetrica che cifra a riposo la master xprv del server (`./secrets/xprv_encryption.key`, cartella `0700`, file `0600`; nel container è montato come secret). **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `mkdir -p secrets && chmod 700 secrets && python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key && chmod 600 secrets/xprv_encryption.key` |
|
||||||
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||||
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ certificato del primario. Attenzione: un valore scritto male **blocca
|
|||||||
l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
|
l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
|
||||||
disservizio in cui il fallback serve davvero.
|
disservizio in cui il fallback serve davvero.
|
||||||
|
|
||||||
`JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti
|
`JWT_SECRET` e la chiave in `XPRV_ENCRYPTION_KEY_FILE` vengono verificati all'avvio: se sono vuoti
|
||||||
(o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con
|
(o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con
|
||||||
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
|
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
|
||||||
contiene solo segreti e configurazione di infrastruttura — i parametri di
|
contiene solo segreti e configurazione di infrastruttura — i parametri di
|
||||||
@@ -74,15 +74,13 @@ admin dopo l'avvio, non qui — vedi [guida-admin.md](guida-admin.md).
|
|||||||
|
|
||||||
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
|
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
|
||||||
un'unica master xprv, generata una volta e cifrata a riposo con
|
un'unica master xprv, generata una volta e cifrata a riposo con
|
||||||
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per
|
la chiave in `XPRV_ENCRYPTION_KEY_FILE`. Questo passaggio va eseguito esattamente una volta per
|
||||||
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`:
|
ogni deployment, dopo aver creato quel file e impostato `XPRV_ENCRYPTION_KEY_FILE` in `.env`:
|
||||||
|
|
||||||
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py`
|
Dal venv: `PYTHONPATH=. python scripts/generate_master_key.py`, con `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, così lo script scrive esattamente il file che legge il container.
|
||||||
- **Docker**: `docker compose run --rm app python scripts/generate_master_key.py`
|
|
||||||
|
|
||||||
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in
|
Questo scrive un file cifrato (`MASTER_KEY_PATH`, in Docker `./data/keys/master.xprv.enc`). **Fai il backup di questo
|
||||||
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo
|
file insieme a `secrets/xprv_encryption.key`** — uno dei due da solo è inutile, ma
|
||||||
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
|
|
||||||
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
|
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
|
||||||
possibilità di recupero.
|
possibilità di recupero.
|
||||||
|
|
||||||
@@ -107,10 +105,10 @@ Entrambi vanno eseguiti localmente (o dentro il container via
|
|||||||
pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di
|
pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di
|
||||||
tutti gli utenti e del pool.
|
tutti gli utenti e del pool.
|
||||||
|
|
||||||
## 4. Installare le dipendenze (solo workflow locale/venv)
|
## 4. Installare gli strumenti locali (venv)
|
||||||
|
|
||||||
Salta questo passaggio se usi solo Docker — l'immagine installa le proprie
|
Servono per i test e per gli script delle chiavi; il server non ne ha bisogno,
|
||||||
dipendenze durante la build.
|
perché l'immagine installa le proprie dipendenze (da `requirements.lock`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
@@ -120,9 +118,9 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
## 5. Applicare le migrazioni del database
|
## 5. Applicare le migrazioni del database
|
||||||
|
|
||||||
- **Locale/venv**: `alembic upgrade head`
|
Le migrazioni vengono eseguite automaticamente all'avvio del container (vedi il
|
||||||
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del
|
`CMD` del `Dockerfile`) — nessun passaggio manuale, e `alembic upgrade head` non
|
||||||
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale.
|
si lancia mai a mano.
|
||||||
|
|
||||||
## 6. Impostare l'indirizzo delle fee
|
## 6. Impostare l'indirizzo delle fee
|
||||||
|
|
||||||
|
|||||||
+4
-26
@@ -104,11 +104,7 @@ Restano **accettati**, e descritti in
|
|||||||
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
|
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
|
||||||
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
|
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
|
||||||
|
|
||||||
*Oggi* (B-91): la chiave di cifratura è la variabile d'ambiente
|
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente (B-91): le
|
||||||
`XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect`
|
|
||||||
sul container; `.env` e i file di chiave hanno i permessi predefiniti del
|
|
||||||
sistema (di norma `0644`, leggibili da tutti).
|
|
||||||
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le
|
|
||||||
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
|
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
|
||||||
l'host.
|
l'host.
|
||||||
|
|
||||||
@@ -118,10 +114,6 @@ l'host.
|
|||||||
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
|
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
|
||||||
git sia dall'immagine Docker.
|
git sia dall'immagine Docker.
|
||||||
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
|
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
|
||||||
*Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di
|
|
||||||
`secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env`
|
|
||||||
(`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono
|
|
||||||
`secrets/`.
|
|
||||||
|
|
||||||
- **[SEC-03]** Tutti i parametri operativi stanno nel database
|
- **[SEC-03]** Tutti i parametri operativi stanno nel database
|
||||||
(`round_config`); in `.env` restano solo segreti, collegamenti
|
(`round_config`); in `.env` restano solo segreti, collegamenti
|
||||||
@@ -131,7 +123,6 @@ l'host.
|
|||||||
riavvio. L'elenco completo è in
|
riavvio. L'elenco completo è in
|
||||||
[architettura.md](architettura.md#configurazione).
|
[architettura.md](architettura.md#configurazione).
|
||||||
*Codice:* `app/config.py:Settings`
|
*Codice:* `app/config.py:Settings`
|
||||||
*Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE`.
|
|
||||||
|
|
||||||
`.env.example` documenta le chiavi necessarie e il comando per generarle;
|
`.env.example` documenta le chiavi necessarie e il comando per generarle;
|
||||||
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
|
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
|
||||||
@@ -204,10 +195,7 @@ sì che un backup dei dati non contenga entrambe.
|
|||||||
container, quindi `docker inspect` non la mostra, e non finisce in un
|
container, quindi `docker inspect` non la mostra, e non finisce in un
|
||||||
backup di `./data/`.
|
backup di `./data/`.
|
||||||
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
|
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
|
||||||
`docker-compose.yml` · *Test:* —
|
`docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`, `tests/unit/test_config.py`
|
||||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile
|
|
||||||
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
|
|
||||||
visibile con `docker inspect`.
|
|
||||||
|
|
||||||
### Derivazione
|
### Derivazione
|
||||||
|
|
||||||
@@ -265,8 +253,6 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
|
|||||||
reali.
|
reali.
|
||||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
||||||
*Test:* `tests/unit/test_config.py`
|
*Test:* `tests/unit/test_config.py`
|
||||||
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
|
|
||||||
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
|
|
||||||
|
|
||||||
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
||||||
risulta semplicemente chiuso (vedi [SEC-24]).
|
risulta semplicemente chiuso (vedi [SEC-24]).
|
||||||
@@ -628,11 +614,7 @@ entra nell'immagine.
|
|||||||
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
|
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
|
||||||
`0600`; tutti appartengono all'utente con cui gira il container. I file che
|
`0600`; tutti appartengono all'utente con cui gira il container. I file che
|
||||||
l'app crea (database, backup, log) nascono con permessi `0600`.
|
l'app crea (database, backup, log) nascono con permessi `0600`.
|
||||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* —
|
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`
|
||||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i
|
|
||||||
file che scrive in `./data/` appartengono a root sull'host, e `.env` e i
|
|
||||||
file di chiave hanno i permessi predefiniti del sistema (di norma `0644`,
|
|
||||||
leggibili da ogni utente dell'host).
|
|
||||||
|
|
||||||
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
|
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
|
||||||
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
|
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
|
||||||
@@ -640,11 +622,7 @@ entra nell'immagine.
|
|||||||
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
|
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
|
||||||
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
|
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
|
||||||
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
|
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
|
||||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* —
|
*Codice:* `Dockerfile`, `docker-compose.yml`, `requirements.lock` · *Test:* `tests/unit/test_container_hardening.py`
|
||||||
*Stato:* da implementare (B-91) · *Oggi:* le immagini di base sono
|
|
||||||
indicate per etichetta mobile e le dipendenze con `>=`: ogni build può
|
|
||||||
installare versioni diverse, comprese versioni compromesse pubblicate dopo
|
|
||||||
l'ultima build.
|
|
||||||
|
|
||||||
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
|
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
|
||||||
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
|
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
|
||||||
|
|||||||
+7
-8
@@ -193,8 +193,8 @@ limiti accettati: lo stato `drawing` non riprende dopo un riavvio (vedi
|
|||||||
| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC |
|
| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC |
|
||||||
| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC |
|
| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC |
|
||||||
| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR |
|
| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR |
|
||||||
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | **aperto** | — | OPS, SEC |
|
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | corretto con l'allineamento dei file e B-89 | `test_master_key_check.py` | OPS, SEC |
|
||||||
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS |
|
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | *da registrare* | — (documentazione) | OPS |
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
|||||||
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
|
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
|
||||||
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
|
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
|
||||||
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
|
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
|
||||||
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS |
|
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS |
|
||||||
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
|
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
|
||||||
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
|
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
|
||||||
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
|
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
|
||||||
@@ -252,11 +252,10 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
|||||||
|
|
||||||
## Stato attuale
|
## Stato attuale
|
||||||
|
|
||||||
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
|
- **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
|
||||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-95, B-98, B-99, B-100.
|
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-95, B-98, B-99, B-100.
|
||||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93, B-94 e B-96 (0 critici,
|
||||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici,
|
0 alti, 4 medi), da implementare nell'ordine di
|
||||||
3 alti, 5 medi), da implementare nell'ordine di
|
|
||||||
[piano-correzioni.md](piano-correzioni.md).
|
[piano-correzioni.md](piano-correzioni.md).
|
||||||
- **Prossimo numero libero:** `B-101`.
|
- **Prossimo numero libero:** `B-101`.
|
||||||
+1301
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,7 @@ locally, only when you actually need it (e.g. disaster-recovery backup).
|
|||||||
Usage:
|
Usage:
|
||||||
python scripts/decrypt_master_key.py
|
python scripts/decrypt_master_key.py
|
||||||
|
|
||||||
Requires XPRV_ENCRYPTION_KEY to already be set (.env), same as the running
|
Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file, same as the running
|
||||||
server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path
|
server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path
|
||||||
doesn't exist, falls back to ./data/keys/master.xprv.enc, the location
|
doesn't exist, falls back to ./data/keys/master.xprv.enc, the location
|
||||||
docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc)
|
docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc)
|
||||||
@@ -34,7 +34,7 @@ def _resolve_key_path() -> str:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not settings.xprv_encryption_key:
|
if not settings.xprv_encryption_key:
|
||||||
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
|
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
|
||||||
|
|
||||||
key_path = _resolve_key_path()
|
key_path = _resolve_key_path()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""One-time ops bootstrap: encrypt an externally-generated master xprv and
|
"""One-time ops bootstrap: encrypt an externally-generated master xprv and
|
||||||
write it to disk, using the exact same scheme generate_master_key.py uses
|
write it to disk, using the exact same scheme generate_master_key.py uses
|
||||||
(Fernet, XPRV_ENCRYPTION_KEY). Use this instead of generate_master_key.py
|
(Fernet, the key in XPRV_ENCRYPTION_KEY_FILE). Use this instead of generate_master_key.py
|
||||||
when you already have an xprv from elsewhere (e.g. generated offline/air-
|
when you already have an xprv from elsewhere (e.g. generated offline/air-
|
||||||
gapped) and want to bring your own instead of letting the server create one.
|
gapped) and want to bring your own instead of letting the server create one.
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ Usage:
|
|||||||
python scripts/encrypt_master_key.py
|
python scripts/encrypt_master_key.py
|
||||||
python scripts/encrypt_master_key.py --overwrite # replace an existing key
|
python scripts/encrypt_master_key.py --overwrite # replace an existing key
|
||||||
|
|
||||||
Requires XPRV_ENCRYPTION_KEY to already be set (.env). Writes to
|
Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file. Writes to
|
||||||
MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the
|
MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the
|
||||||
docker-compose.yml bind-mount location) if that directory exists and .env's
|
docker-compose.yml bind-mount location) if that directory exists and .env's
|
||||||
default path doesn't, same fallback as decrypt_master_key.py.
|
default path doesn't, same fallback as decrypt_master_key.py.
|
||||||
@@ -48,7 +48,7 @@ if __name__ == "__main__":
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not settings.xprv_encryption_key:
|
if not settings.xprv_encryption_key:
|
||||||
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
|
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
|
||||||
|
|
||||||
key_path = _resolve_key_path()
|
key_path = _resolve_key_path()
|
||||||
if os.path.exists(key_path) and not args.overwrite:
|
if os.path.exists(key_path) and not args.overwrite:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
# put that value in XPRV_ENCRYPTION_KEY (.env), then:
|
# write it to the file XPRV_ENCRYPTION_KEY_FILE (.env) points at, then:
|
||||||
python scripts/generate_master_key.py
|
python scripts/generate_master_key.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from app.wallet.verify import load_sample
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not settings.xprv_encryption_key:
|
if not settings.xprv_encryption_key:
|
||||||
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
|
raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
|
||||||
sample = load_sample()
|
sample = load_sample()
|
||||||
if sample.users or sample.rounds or sample.seeds:
|
if sample.users or sample.rounds or sample.seeds:
|
||||||
# SEC-50: a new key cannot reproduce the addresses the database already holds.
|
# SEC-50: a new key cannot reproduce the addresses the database already holds.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""B-91 (SEC-51, SEC-52, SEC-53): non-root, pinned images and dependencies, the key in a file."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import ConfigError, Settings, validate_runtime_secrets
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name):
|
||||||
|
return (ROOT / name).read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dockerfile_pins_its_base_image_and_runs_as_a_non_root_user():
|
||||||
|
dockerfile = _read("Dockerfile")
|
||||||
|
assert re.search(r"^FROM python:3\.12-slim@sha256:[0-9a-f]{64}$", dockerfile, re.M)
|
||||||
|
assert re.search(r"^USER 1000:1000$", dockerfile, re.M)
|
||||||
|
assert "--require-hashes" in dockerfile and "umask 077" in dockerfile
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_locked_requirement_carries_a_hash():
|
||||||
|
lock = _read("requirements.lock")
|
||||||
|
pinned = re.findall(r"^([A-Za-z0-9_.\-]+==\S+) \\$", lock, re.M)
|
||||||
|
assert len(pinned) > 20
|
||||||
|
blocks = re.split(r"\n(?=[A-Za-z0-9_.\-]+==)", lock)[1:]
|
||||||
|
assert all("--hash=sha256:" in block for block in blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_pins_caddy_and_keeps_the_key_out_of_the_environment():
|
||||||
|
compose = _read("docker-compose.yml")
|
||||||
|
assert re.search(r"image: caddy:2-alpine@sha256:[0-9a-f]{64}", compose)
|
||||||
|
assert "XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key" in compose
|
||||||
|
assert "file: ./secrets/xprv_encryption.key" in compose
|
||||||
|
assert not re.search(r"XPRV_ENCRYPTION_KEY=", compose)
|
||||||
|
assert "data-permissions" in compose and "service_completed_successfully" in compose
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_example_env_points_at_a_file_not_a_key():
|
||||||
|
example = _read(".env.example")
|
||||||
|
assert re.search(r"^XPRV_ENCRYPTION_KEY_FILE=", example, re.M)
|
||||||
|
assert not re.search(r"^XPRV_ENCRYPTION_KEY=", example, re.M)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ignore", [".gitignore", ".dockerignore"])
|
||||||
|
def test_the_secrets_folder_is_kept_out_of_git_and_the_image(ignore):
|
||||||
|
assert "secrets/" in _read(ignore).split()
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides):
|
||||||
|
return Settings(_env_file=None, jwt_secret="x" * 32, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_key_is_read_from_its_file(tmp_path):
|
||||||
|
key_file = tmp_path / "k"
|
||||||
|
key_file.write_text("a-fernet-key\n")
|
||||||
|
config = _settings(xprv_encryption_key_file=str(key_file))
|
||||||
|
assert config.xprv_encryption_key == "a-fernet-key"
|
||||||
|
validate_runtime_secrets(config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", [None, " \n"])
|
||||||
|
def test_a_missing_or_empty_key_file_stops_the_server_naming_the_file(tmp_path, content):
|
||||||
|
key_file = tmp_path / "k"
|
||||||
|
if content is not None:
|
||||||
|
key_file.write_text(content)
|
||||||
|
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY_FILE .*is missing or empty"):
|
||||||
|
validate_runtime_secrets(_settings(xprv_encryption_key_file=str(key_file)))
|
||||||
Reference in new issue
Block a user