SPEC v0.20: D-119 APK download rate limit; owner answers to the security review; HANDOFF, prompts

D-119 (landed 96e4cbd, security review L-3): E-6 at most 10 requests per
minute per client IP in its own window, not counted against the 60 of the
other unauthenticated endpoints; SPEC §8.6, §10.6, E-6. M-1 closed by the
owner as an accepted risk (§8.1, §8.7); the D-114 limits confirmed (§11.4).
HANDOFF: main and origin at 96e4cbd, NPM rework (D-120) in flight, owner
steps and questions rewritten, numbering next D-121 and v0.21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-19 19:35:24 +02:00
parent 96e4cbdf4e
commit 481889a9dc
4 changed files with 44 additions and 48 deletions
+9 -6
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.19** (2026-10-18). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.20** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
@@ -546,7 +546,7 @@ Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3
### 8.1 Threat model
Two trusted users. The server is on the public internet on port 8443. Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi.
Two trusted users. The server is on the public internet on port 8443. Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi. Accepted risk (owner, 2026-10-19, security review M-1): the Drive backups stay unencrypted and the rclone token keeps the full `drive` scope, so a compromised Google account or `rclone.conf` exposes every backup (§8.7, §10.9).
### 8.2 Device tokens
@@ -586,7 +586,8 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
### 8.6 Rate limiting and input limits
* Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds.
* Unauthenticated endpoints other than E-6 (and unknown paths): at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds.
* APK downloads (E-6, `GET /apk/files/{file_name}`): at most 10 requests per minute per client IP, then 429 `rate_limited` with `Retry-After`. Own fixed window, not counted against the 60; every request counts, a 404 included (D-119, security review L-3).
* Authenticated endpoints: at most 600 requests per minute per device.
* Keying (D-032, revised by D-115): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A token is accepted only when a response to it is 2xx. A request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back (a newly paired device costs its IP nothing); a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries, accepted or not, are pruned once their window expires; every 401 counts against the IP exactly once (security review H-1: before, a random token answered 426 without `X-Kvitt-Protocol` became accepted for good, bypassed the IP limit and was never pruned). All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::<SocketAddr>()` (added by M4 in `15056dc`, D-032 fulfilled).
* `GET /health` from the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted.
@@ -602,7 +603,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
* Caddy's access log and its error log replace the `/i/<code>` path segment and every query string (the inviter's name in `?from=`) with `REDACTED` through a `request>uri` filter; the `Authorization` header is redacted by Caddy's default (§10.5.2, D-117).
* The push sender logs one info line `push sent` per message with aggregates only: `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line is at debug (D-113).
* FCM payloads carry ids and types only (§14.6).
* Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. Whether to encrypt them with an rclone `crypt` remote and narrow the token to the `drive.file` scope is an open owner decision (security review M-1, `docs/validation/security-review.md`).
* Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. The owner decided on 2026-10-19 not to encrypt them with an rclone `crypt` remote and to keep the token's `drive` scope (security review M-1 closed as an accepted risk, §8.1, `docs/validation/security-review.md`).
### 8.8 Phone hardening
@@ -1044,6 +1045,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R
```
* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`.
* Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints.
* Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103).
### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
@@ -1282,7 +1284,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
Reports in `docs/validation/` are deliverables (`CLAUDE.md`); they carry aggregates only and record the commit they were taken at. The release reports `first-release.md` and `restore-drill.md` (M10, AC-18.2) are still to be written.
* `docs/validation/ac-coverage.md`, the AC-coverage audit taken at `956908a` (`ed1b130` before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from `/health` (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-18.1 and AC-20.1 observed on the Pi, AC-16.3 the real PackageInstaller install (all M2c-pi or M10), and a `KvittWorkerFactory` test.
* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner; M-1 (backup encryption) and L-3 (APK download rate) wait for the owner. H-1 must be fixed before port 8443 is opened to the internet.
* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner. Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 must be fixed before port 8443 is opened to the internet.
---
@@ -2220,7 +2222,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
**E-5 `GET /apk/latest`** (none). 200 `latest.json` content (§10.6). Errors: `not_found` (nothing published).
**E-6 `GET /apk/files/{file_name}`** (none). 200 binary with `Content-Length`. Errors: `not_found`.
**E-6 `GET /apk/files/{file_name}`** (none). 200 binary with `Content-Length`. Errors: `not_found`; 429 at 10 per minute per IP (§8.6).
**E-7 `GET /me`** (device). 200:
```json
@@ -2832,3 +2834,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.17 | 2026-10-18 | The M2c-pi bundle (`2476667`), M8e (`a59abeb`), M8f (`956908a`), the chips cleanup (`1328afe`) and the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`). D-103: the `infra/pi/` bundle (`compose.yaml`, was `infra/compose.yaml`; `.env.example`, was `infra/env.example`; `Caddyfile`; `install.sh`, `deploy.sh <tag> [--skip-backup]` with a backup before every deploy and `/opt/kvitt/previous-tag`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`), the APK path `infra/publish-apk.sh` → `infra/pi/out/apk/` → `/opt/kvitt/apk/` → `install-apk.sh` into the `data` volume, mappings outside the volume, `.env` keys `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN`, `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`, gate `docker compose -f infra/pi/compose.yaml config`; fulfils D-100 (§10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2, §14.7). D-104: images `${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2`, the Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, `v1.0.N` plus `latest`, git tag pushed, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as the Mac fallback, supercronic instead of busybox crond, `openssl` in cert-sync, pinned versions and Docker Engine from Docker's Debian arm64 repository (§10.1, §10.2, §10.3, §12 OQ-1). D-105: `backup` runs as uid 10001 on the Pi, `rclone.conf` 0600 owned 10001:10001, a temporary copy for refreshed tokens, volume owners set once by `install.sh`; revises D-023 for `rclone.conf` (§10.2). D-106: cert-sync always requests the `tar` stream (both `authorized_keys` variants), refuses a mismatched key or an earlier-expiring certificate, an unreachable admin API at first start is no failure; backup uploads every pending file, confirms each by name and size, deletes and writes `last-success` only after all are confirmed (§10.5.1, §10.9). D-107: S-19's account, payer and split as three fixed `EditorOptionRows` (Konto, Betalt av, Delning), extending D-102 (§13.3, design-spec §3.7, S-19). D-108: navigation is instant, no enter, exit, pop or size transition on the app's `NavHost` (§13.2, design-spec §2.11). Without a decision id: the Drive token, the cert-sync key and the Gitea secret `kvitt_cicd` exist; `server/Dockerfile` honours `CARGO_BUILD_JOBS` (§10.3); the unused `AccountChip`, `PayerChip`, `SplitChip` removed (design-spec §3.7); Caddy's `Authorization` redaction verified (§10.5.2); APKs about 11 MB (§10.10); §11.1 graph. |
| 0.18 | 2026-10-18 | M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`), the server gap pass (`8660b7f`), the server overhaul (`8103dae` to `b985ce9`), the infra overhaul (`2fac1ec` to `e70fe0d`) and infra security fixes (`7fb86f5`), the AC-coverage audit and the security review. D-109: S-16 shows a rejected change's lost version read-only, `Rejection.lost: RejectedVersion?` with `RejectedVersion(row, accountName, payer, mode, shares, notes, deleted)` from `rejected_payload`, null when undecodable or a user or the category is missing; dialog "Din version", "Du tog bort den här", "Visa nuvarande version"; ledger rows take a null `onClick` (read-only); four `home_sync_lost_*` strings (§13.3 S-16, §14.2.4, §14.4.6, design-spec S-16). D-110: `AboutState.schemaVersion`; S-22 reads `/health` when it opens online and shows "<version> (schema <n>)" (`settings_about_server_version_schema`), header fallback offline (§13.3 S-22, §14.2.4, E-1, design-spec S-22). D-111: `SettlementRepository.start` and `recompute` return `SettleState?` (null without a session or partner), `save` returns `Invalid(payer, validation_failed)`; no throws (§14.2.4, AC-5.1). D-112: a failed E-17 send is retried after each successful sync until accepted, a newer FID replaces it (AC-13.7). D-113: `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin) and the `push sent` info line (§8.7, §10.4); `tools/contracts-check.py` 134 checks. D-116: notifications `VISIBILITY_PRIVATE` with the generic public version (new AC-13.9); L-6 kept as built, S-1's server row expanded for a non-default server, the confirm step listed as OQ-6 (AC-2.6, AC-2.8, §12). D-117: Caddy log redaction of `/i/<code>` and query strings, `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (§8.7, §10.5.2, which now quotes `infra/pi/Caddyfile` at `7fb86f5`); Caddy as uid 10002 with group 1000, read-only, `cap_drop ALL` plus `NET_BIND_SERVICE`, tmpfs; `/certs` 0750 1000:1000, `caddy_data` 10002, extending D-105 (§8.9, §10.2); the development stack copies only `fcm-service-account.json` into `infra/dev/secrets/` (§10 intro); `/opt/kvitt/deployed-digests` with a WARNING on a changed digest and a workflow digest step; `kvitt_cicd` with `write:package` only (§10.3, §10.10); the plain root key (variant B) removed (§10.5.1); the `remote_ip` check (§10.10). D-114: pictures at most 16 000 000 pixels and 64 MB decoded, `image_invalid` (AC-4.4); `fcm_token` 1 to 512 characters, else `invalid_request` (§9.6, E-17). D-115: a token is accepted only after a 2xx, an unaccepted token counts against its IP, a 401 withdraws acceptance, entries pruned when their window expires; revises D-032 (§8.6). Without a decision id: the server overhaul (`8103dae` to `b985ce9`: shared row reads and inserts, invite creation and CLI helpers, one test harness `tests/common/`, clippy pedantic wins, synchronous `tasks::start`, `not_implemented` no longer produced; 268 server tests; all crates at their latest versions) (§6.3, §14.3.2, §14.7). Without a decision id: `install.sh` keeps the deployed `KVITT_TAG`, `--provenance=false`, the shared `tools/gradle-lock.sh`, `RCLONE_REMOTE` dropped from the development env (§10 intro, §10.3, §11.3); `strings.csv` 577 to 562 rows (§13.4); `localeFilters` en, sv (AC-19.1); `work-testing` and the screenshot harness fix (§6.2); release APK 10.0 MB (§10.6, §10.10); M12f row in §11.2 (941 tests); §11.4 validation reports (`docs/validation/ac-coverage.md`, `docs/validation/security-review.md`); the history rewrite and first push to `origin` (`18c527f`): every commit hash cited in this file is the rewritten one; `7c453d8` (the oxipng pass) no longer exists as a commit, its PNGs are in `18c527f`. |
| 0.19 | 2026-10-18 | The Android overhaul (`e4e6e5d` to `c582363`). D-118: shared test support in `:core:design`'s test fixtures (`se.hagstedt.kvitt.design.testing`: `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with `Root`/`Screen`/`ScreenWhenIdle`, `MainDispatcherRule`, `withMain`) through `testImplementation(testFixtures(project(":core:design")))`, not a module, the architecture check rejects test fixtures outside test configurations; the convention plugin `kvitt.android.screenshots`; `format/Failures.kt`, `format/Limits.kt` and `savedSplitLabel` in `:core:design`; `KvittMotion` only the seesaw spring, `KvittIcon.Description` and unused dimens and shapes removed; four catalog entries removed; one `CopyCategoryKeywords` task (§6.2 `:core:design` row and Screenshot tests, §11.1, §11.3; design-spec §2.11 and the icon table). Without a decision id: overhaul outcomes in §11.3 (Android `c582363`, server `b985ce9`, infra `e70fe0d`); 941 tests, no golden changed, release APK 10.46 MB; pushed to `origin` at `c582363`; `infra/README.md` APK size about 10 MB; pre-rewrite hashes in `PLAN-*.md` replaced by the rewritten ones. |
| 0.20 | 2026-10-19 | APK download rate limit (`96e4cbd`) and owner answers to the security review. D-119: E-6 `GET /apk/files/{file_name}` at most 10 requests per minute per client IP in its own fixed window, not counted against the 60 per minute of the other unauthenticated endpoints, every request counted (a 404 included), 429 `rate_limited` with `Retry-After`, client IP as in D-032, in memory; E-5 and E-4 stay on the 60 (§8.6, §10.6, E-6). Without a decision id: security review M-1 closed by the owner (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in §8.1 and §8.7); L-3 fixed; the D-114 limits (16 megapixels, 512-character `fcm_token`) confirmed by the owner (§11.4). |