SPEC v0.25: D-126 app gate in NPM; infra README step 4a
Outside the home LAN NPM closes every request without X-Kvitt-App: <key> (return 444); the LAN check reads $realip_remote_addr (NPM's X-Real-IP realip makes $remote_addr forgeable) and Let's Encrypt's challenge passes on the normalised $uri. Block rehearsed in NPM v2.16.0's openresty. README: step 4a (key, keyed APK, gate, acceptance test), health checks, stale CARGO_BUILD_JOBS text and owner decisions fixed. HANDOFF as of 2026-10-21. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.24** (2026-10-20). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.25** (2026-10-21). 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.
|
||||
|
||||
@@ -119,7 +119,7 @@ The server starts with an empty database. The household is created from the comm
|
||||
* AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone).
|
||||
* AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which.
|
||||
* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the kind of the accepted invite (`invite_kind`, D-086), the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050).
|
||||
* AC-2.5 `GET /i/<CODE>` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>` (followed by `&from=<urlencoded name>` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404".
|
||||
* AC-2.5 `GET /i/<CODE>` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>` (followed by `&from=<urlencoded name>` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". Since the app gate (§8.5, D-126) the page opens in a browser only on the home LAN; from outside, a browser gets a closed connection, while the app opens the link (AC-2.6, `autoVerify="false"`, so no App Links check depends on the page).
|
||||
* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://<kvitt.publicHost>/i/<CODE>[?from=…]` (the manifest placeholder `publicHost`, D-121; unverified link, the user may get a chooser; the intent filter names no port, so it matches every port of the host and an older `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port, `:443` for `https` and `:80` for `http`, D-124) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL` = `https://<kvitt.publicHost>`, D-121, D-124), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6).
|
||||
* AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `RELEASE_SERVER_URL` in release builds, D-121) and the code. Codes are normalised per §8.3 before sending.
|
||||
* AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with any `https://` server a link or the user names; what S-1 shows then is in AC-2.6 (D-116).
|
||||
@@ -550,7 +550,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 443, through NPM (D-120, D-124). 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).
|
||||
Two trusted users. The server is on the public internet on port 443, through NPM (D-120, D-124); outside the home LAN NPM passes only requests that carry the app key (§8.5, D-126). 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
|
||||
|
||||
@@ -584,6 +584,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM
|
||||
### 8.5 Transport
|
||||
|
||||
* TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi, with NPM's Let's Encrypt certificate for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), on the default HTTPS port 443 (D-124); the router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant; nothing is forwarded to the Kvitt Pi (§10.1, §10.5, D-120, D-124). TLS 1.2 minimum, NPM defaults otherwise. NPM forwards plain HTTP over the home LAN to the server's port 8080 on the Kvitt Pi (accepted by the owner, D-120).
|
||||
* App gate (D-126, owner decision 2026-10-21): NPM closes the connection (`return 444`, nothing sent) of every request that neither carries the header `X-Kvitt-App: <key>` with the app key nor comes from the home LAN `192.168.1.0/24`. Every route is covered, `/health`, `/setup/status`, `/invites/accept`, `/i/{code}` and `/apk/*` included; only Let's Encrypt's `/.well-known/acme-challenge/`, which NPM serves from its own folder, passes (§10.5.2). The LAN check reads nginx's `$realip_remote_addr`, the address that really connected, because NPM's `real_ip_header X-Real-IP` for private peers lets a client set `$remote_addr`. The release app sends `X-Kvitt-App` on every request when its build carries the key: the Gradle property `kvitt.appKey` (`-Pkvitt.appKey=…`, else the key `kvitt.appKey` in `android/local.properties`, else empty), 16 to 128 printable ASCII characters without white space or the build stops with a message giving the length, never the value; exposed as `BuildConfig.APP_KEY` and to `:data` as `AppBuildInfo.appKey` (whose `toString` hides it); added in `KvittApi`'s common headers (every API call: `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` for profile pictures from the paired server; an empty key sends no header (debug builds against the development stack need none). The key is generated by the owner (`openssl rand -hex 32`) and lives only in `android/local.properties` and NPM's Custom Nginx Configuration: never in git, never in `.env`, never printed in docs or logs. The Rust server neither sees nor checks the header and `contracts/openapi.yaml` is unchanged (§14.3.1). The per-device bearer token stays the authentication (§8.2); the gate is a perimeter that keeps scanners and anyone without the app from the server. On the home LAN the "secret" is the Wi-Fi password, and the owner controls the APK's distribution (he installs it on both phones). Rotation: a second `if` line with the new key next to the old in NPM, the new APK through the update path (FR-16), then the old line removed. Accepted: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; nothing at home protects against a bandwidth flood.
|
||||
* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the pinned-root switch is on, in which case a pinned root replaces the system anchors for one domain only (available but unprepared since D-120, §10.5.3). The pinned-root config is generated at build time (task `generatePinnedRootNetworkSecurityConfig`) from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost` (D-121), into the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082); the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (`038c603`).
|
||||
* Debug builds override the config in `android/app/src/debug` and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended (D-082).
|
||||
* The server trusts `X-Forwarded-For` only from NPM: `KVITT_TRUSTED_PROXY` is `HA_HOST`, NPM's LAN IPv4 address (an IP literal in the Pi's `.env`, D-125), and from that peer the server takes the last `X-Forwarded-For` entry as the client IP for rate limiting (D-120; the server is unchanged). A request from any other peer counts against the peer's own address.
|
||||
@@ -878,7 +879,7 @@ All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `i
|
||||
| Host | Role | Facts |
|
||||
|---|---|---|
|
||||
| Kvitt Pi | Runs the compose stack; publishes the server's port 8080 on the LAN for NPM (D-120) | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104; a prerequisite, already installed, usable without sudo by the owner's user `jacobhagstedt`, uid and gid 1000, D-125), static LAN address. Kvitt runs from `/home/jacobhagstedt/kvitt` (D-125). LAN address `<kvitt-pi-address>`, known to the owner (OQ-3, D-100; no `.env` key since `200c99f`); also NPM's Forward Hostname / IP; no agent and no Mac script reaches it over SSH |
|
||||
| Home Assistant Pi | Runs the owner's Nginx Proxy Manager (NPM), which owns ports 80/443: terminates TLS for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) with NPM's own Let's Encrypt certificate (HTTP-01 on port 80) and forwards plain HTTP to `<kvitt-pi-address>:8080` (§10.5, D-120) | Its LAN IPv4 address is `HA_HOST` in the Pi's `.env`, filled by the owner (OQ-3), and is the server's `KVITT_TRUSTED_PROXY`. NPM is also the proxy host for Home Assistant under `icehack.asuscomm.com`, on the same port 443, and selects the proxy host by name, so Kvitt has its own name (D-124). The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH |
|
||||
| Home Assistant Pi | Runs the owner's Nginx Proxy Manager (NPM), which owns ports 80/443: terminates TLS for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) with NPM's own Let's Encrypt certificate (HTTP-01 on port 80) and forwards plain HTTP to `<kvitt-pi-address>:8080` (§10.5, D-120); closes every request from outside the home LAN without the app key (§8.5, D-126) | Its LAN IPv4 address is `HA_HOST` in the Pi's `.env`, filled by the owner (OQ-3), and is the server's `KVITT_TRUSTED_PROXY`. NPM is also the proxy host for Home Assistant under `icehack.asuscomm.com`, on the same port 443, and selects the proxy host by name, so Kvitt has its own name (D-124). The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH |
|
||||
| Owner's M4 Mac | Builds release APKs (`infra/publish-apk.sh`); builds images only as the fallback of the Gitea workflow (`infra/build-images.sh`, D-104); runs the development server (D-001) | Apple Silicon, so `linux/arm64` builds run natively in Docker |
|
||||
| Router (ASUS) | `icehack.asuscomm.com` DDNS (Home Assistant's name); the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen; currently `kvitt.suorra.duckdns.org`, HANDOFF) points at the same public address (a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`) | External TCP 443 → Home Assistant Pi 443 (NPM), already in place for Home Assistant, serves Kvitt too: no new rule (D-124; the owner confirmed the 443 forward, 2026-10-19); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-120) |
|
||||
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path `gitea.lan/jacobh2` (D-104); Gitea Actions builds them (§10.3). Pushes use the Gitea secret `kvitt_cicd` (§10.3). The Kvitt packages are public, so the Pi pulls anonymously; were they private, the owner runs `docker login gitea.lan` once on the Pi with a `read:package` token (HUMAN, D-125). Agents never hold credentials (D-100) |
|
||||
@@ -953,7 +954,7 @@ Run them on the Pi in `~/kvitt` with `docker compose exec server kvitt-server <c
|
||||
|
||||
### 10.5 TLS
|
||||
|
||||
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 4 (step 6 before D-125); no agent and no script touches NPM.
|
||||
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 4 (step 6 before D-125); no agent and no script touches NPM. The proxy host carries the app gate of §8.5 (D-126) in its Custom Nginx Configuration (§10.5.2), set up by step 4a once both phones run an APK that carries the key; an APK without the key cannot reach the server from outside the LAN, its update download included.
|
||||
|
||||
#### 10.5.1 Nginx Proxy Manager
|
||||
|
||||
@@ -968,7 +969,7 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
|
||||
| Details | Cache Assets | off |
|
||||
| Details | Block Common Exploits | on |
|
||||
| Details | Websockets Support | off |
|
||||
| Details | Access List | Publicly Accessible |
|
||||
| Details | Access List | Publicly Accessible (the app gate is in the Advanced block, D-126: an NPM access list checks addresses or HTTP basic auth, not a header, and basic auth would take the `Authorization` header the bearer token needs) |
|
||||
| Custom Locations | (none) | left empty |
|
||||
| SSL | SSL Certificate | Request a new Certificate: Let's Encrypt, HTTP challenge (Use DNS Challenge off), the owner's email address, Terms of Service accepted where shown |
|
||||
| SSL | Force SSL | on |
|
||||
@@ -982,14 +983,24 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
|
||||
* Client IP: NPM's request to the server carries `X-Forwarded-For: <what the client sent>, <client address>`. The server takes the last entry only when the TCP peer is `KVITT_TRUSTED_PROXY`, which compose sets to `HA_HOST`, NPM's LAN IPv4 address (an IP literal in `.env`, because the container cannot resolve an mDNS name such as `homeassistant.local`; nothing checks it since D-125); the server is unchanged (D-032, D-115, D-119). The router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no SNAT, for traffic arriving on the LAN interface), so the per-IP limits see real addresses (checked once, §10.10). The server does not use `X-Forwarded-Proto` or `Host`: links are built from `KVITT_PUBLIC_URL` (E-12).
|
||||
* `X-Real-IP` (accepted): NPM's `nginx.conf` takes the client address from `X-Real-IP` when the connection comes from 10/8, 172.16/12 or 192.168/16 (and the CDN ranges it downloads), so a device on the home LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the router's forward keeps the source address), so their `X-Real-IP` is ignored.
|
||||
* HSTS: NPM sends `Strict-Transport-Security` with `max-age=63072000; preload` (two years, NPM's value) at server level. A browser then uses only HTTPS for `<KVITT_PUBLIC_HOST>`, on every port, and refuses certificate warnings for it; turning HSTS off later does not clear browsers that already stored it. Because the name is Kvitt's own nothing else is affected, but anything ever served under `<KVITT_PUBLIC_HOST>` (any port) must be HTTPS with a valid certificate too. The app does not rely on HSTS (release builds allow no cleartext, §8.5); it protects the landing page in a browser.
|
||||
* Health checks (D-125, revises D-120 point 6; no script runs them): after a start or a deploy the owner runs `curl -s localhost:8080/health` on the Kvitt Pi, and checks `/health` through NPM, from outside (`https://<KVITT_PUBLIC_HOST>/health` on the phone with Wi-Fi off) or from the Pi (`curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health`, with the certificate checked); `infra/README.md` "Pi: what the owner does" steps 3, 4 and 6.
|
||||
* Health checks (D-125, revises D-120 point 6; no script runs them): after a start or a deploy the owner runs `curl -s localhost:8080/health` on the Kvitt Pi, and checks `/health` through NPM from the Pi (`curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health`, with the certificate checked; the Kvitt Pi is on the LAN, so no key is needed, D-126) and from outside: with the app gate in place (D-126) a browser outside the LAN gets a closed connection by design, so the outside check is the app's About screen (S-22) on the phone with Wi-Fi off, or `curl -fsS -H "X-Kvitt-App: $KEY" https://<KVITT_PUBLIC_HOST>/health` from outside the home network (before the gate: `https://<KVITT_PUBLIC_HOST>/health` in Chrome on the phone with Wi-Fi off); `infra/README.md` "Pi: what the owner does" steps 3, 4, 4a and 6.
|
||||
|
||||
#### 10.5.2 Custom Nginx Configuration
|
||||
|
||||
The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of `62f5279` it reads (`infra/README.md`, step 4 since D-125, unchanged):
|
||||
The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of v0.25 it reads (`infra/README.md`, step 4; the app gate lines added by D-126, the rest as of `62f5279`):
|
||||
|
||||
```nginx
|
||||
# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
|
||||
# Kvitt (SPEC §10.5.2, D-120, D-126). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
|
||||
# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes;
|
||||
# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from
|
||||
# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key.
|
||||
# The LAN check reads $realip_remote_addr, the address that really connected: NPM replaces $remote_addr with the
|
||||
# request's own X-Real-IP header for private peers, so $remote_addr could be forged. Let's Encrypt's renewal passes.
|
||||
set $kvitt_gate 0;
|
||||
if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; }
|
||||
if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; }
|
||||
if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; }
|
||||
if ($kvitt_gate = 0) { return 444; }
|
||||
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
|
||||
client_max_body_size 6m;
|
||||
# No long-poll in the API (SPEC §14.3); 60 s between two reads from the server.
|
||||
@@ -1032,6 +1043,8 @@ location ^~ /i/ {
|
||||
* Its own `location /`: nginx drops every `add_header` inherited from the server level in a location that has an `add_header` of its own, and NPM's default `location /` has one (`X-Served-By`), which would silently drop the four headers. NPM leaves its default location out when the Custom Nginx Configuration contains `location /` (NPM matches it with the regex `^(?:.*;)?\s*?location\s*?\/\s*?{`), so the block brings its own, without `add_header`. Its `proxy_pass` uses NPM's Details values (`$forward_scheme`, `$server`, `$port`). If a later NPM release changes the proxy host template, the headers are checked again (§10.10).
|
||||
* `client_max_body_size 6m`: above the server's own limits (1 MiB, 5 MiB for `PUT /me/picture`, §8.6), which answer `payload_too_large` themselves; larger bodies get NPM's 413. `proxy_read_timeout 60s`: the API has no long-poll.
|
||||
* Logs (§8.7): NPM writes one access log line per request to `/data/logs/proxy-host-<id>_access.log` inside NPM, in its `proxy` format: time, cache and upstream status, status, method, scheme, host, `"$request_uri"` (the path with its query string), `[Client <address>]`, length, gzip ratio, `[Sent-to <KVITT_PI_HOST>]`, user agent and referer. It logs no request headers, so the `Authorization` token is never logged. Query strings are logged for every path except `/i/`; the API's other query parameters are `since` and `limit` (`GET /sync`) and `size` and `v` (pictures), which hold no personal content. `location ^~ /i/` turns the access log off and sends its error log to `/dev/null`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. Errors on other paths (for example a 502 while the server is down) go to `proxy-host-<id>_error.log` with the request line.
|
||||
* App gate (§8.5, D-126): `set`, `if` and `return` are rewrite-module directives; at server level they run in NPM's server rewrite phase, before a location is chosen, so the gate covers every location, `/i/` included, and no `add_header` is involved. `KVITT_APP_KEY` is replaced by the key from `android/local.properties`; a rotation adds a second `if ($http_x_kvitt_app = …)` line. `$realip_remote_addr` is the TCP peer: NPM's `nginx.conf` has `set_real_ip_from` 10/8, 172.16/12 and 192.168/16 with `real_ip_header X-Real-IP`, which would let a LAN client, or every client if NPM saw a Docker bridge address (security review I-5), choose `$remote_addr`. The Let's Encrypt exemption tests the normalised `$uri`, which location matching uses, so `/.well-known/acme-challenge/../../health` is closed; that path is served by NPM's own `location ^~ /.well-known/acme-challenge/` from its webroot, never proxied. NPM's own server-level rules come first in its template: Force SSL answers port 80 with a 301 to `https://` (Let's Encrypt follows it, which is why the exemption is needed on 443), and Block Common Exploits answers its patterns with 403. If NPM saw a Docker bridge address for every client, the LAN check would match nothing; if the router rewrote internet clients to its LAN address, everything; NAT hairpin makes a LAN client appear as the router's `192.168.1.1`, still inside the /24. The owner's acceptance test (`infra/README.md` step 4a): on the home Wi-Fi `https://<KVITT_PUBLIC_HOST>/i/TEST` shows the page in a browser, with Wi-Fi off it gives a connection error, and the app's About screen still shows the server version.
|
||||
* Verified on the Mac on 2026-10-21 (D-126): the block above, with a test key, inside a server block with NPM v2.16.0's `letsencrypt-acme-challenge.conf` and `block-exploits.conf` includes and its `real_ip` settings, run by the openresty 1.31.1.1 of the `jc21/nginx-proxy-manager:2` image (v2.16.0): `nginx -t` successful; without the header, with a wrong key, with a forged `X-Real-IP: 192.168.1.50`, on `/i/` and `/apk/files/` without the header, and on `/.well-known/acme-challenge/../../health`, the connection was closed with no reply; with the key `/health` and `/i/` reached the upstream with the D-117 headers; an ACME token file was served; a second key line passed; a peer matching the LAN pattern passed without the header; with `$remote_addr` instead of `$realip_remote_addr` the forged `X-Real-IP` passed (why the block uses the latter). Not checked: HTTPS and HTTP/2 through NPM's real proxy host, the router and the real LAN addresses (the owner's acceptance test).
|
||||
* Verified on the Mac on 2026-10-19 (D-120): `nginx -t` (nginx:alpine 1.31.7) of the block inside a minimal server block and inside a copy of NPM v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the §10.5.1 settings, both successful; a Podman rehearsal of the server image built from `server/Dockerfile` (arm64, hardened as on the Pi) behind that nginx with a self-signed certificate, 21 checks passed: the four headers and `Strict-Transport-Security` once each over HTTP/2 and no `X-Served-By`; one `Referrer-Policy` on the landing page; `http://` answered 301 to `https://`; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (`payload_too_large`); the rate limit applied per forwarded client and a forged `X-Forwarded-For` did not escape it; the invite code, the name and `/i/` absent from nginx's access and error logs after `/i/`, `//i/` and `/%69/` requests and a 502 on `/i/`. Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for the Kvitt host name, the router forward.
|
||||
|
||||
#### 10.5.3 Fallback: NPM down
|
||||
@@ -1065,9 +1078,10 @@ location ^~ /i/ {
|
||||
}
|
||||
```
|
||||
|
||||
* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, no port, so it matches every port of the host, path prefix `/i/`, `android:autoVerify="false"`, D-124) and as `RELEASE_SERVER_URL` = `releaseServerUrl(host)` = `https://<host>` (`link/ReleaseServer.kt`, `releaseServerUrl` in `:feature:onboarding`'s `ServerAddress.kt`; `RELEASE_SERVER_PORT` removed, D-124); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1), and `:data` through `AppBuildInfo.releaseServerUrl`, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link and drops an explicit default port (`:443`, `:80`) (AC-2.6, D-124). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` in `infra/pi/.env` (above, §10.5, D-122); the owner may instead set the key in `android/local.properties`.
|
||||
* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, no port, so it matches every port of the host, path prefix `/i/`, `android:autoVerify="false"`, D-124) and as `RELEASE_SERVER_URL` = `releaseServerUrl(host)` = `https://<host>` (`link/ReleaseServer.kt`, `releaseServerUrl` in `:feature:onboarding`'s `ServerAddress.kt`; `RELEASE_SERVER_PORT` removed, D-124); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1), and `:data` through `AppBuildInfo.releaseServerUrl`, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link and drops an explicit default port (`:443`, `:80`) (AC-2.6, D-124). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` in `infra/pi/.env` (above, §10.5, D-122); the owner may instead set the key in `android/local.properties`. Its sibling `kvitt.appKey` (D-126, next item) is read the same way.
|
||||
* The app key is a build property too (D-126, modelled on D-121): `kvitt.appKey` from the Gradle property (`-Pkvitt.appKey=…`), else the key `kvitt.appKey` in `android/local.properties`, else empty (no `X-Kvitt-App` header sent); `BuildConfig.APP_KEY` (§8.5). A release APK carries the key it was built with, so the APK must be rebuilt after the key is set or rotated; `infra/publish-apk.sh` needs no change, because it builds on the Mac, where `android/local.properties` holds the key. An APK without the key cannot reach the server from outside the home LAN once the app gate is in NPM, its update download included, so both phones run a keyed APK before the gate goes in (`infra/README.md` step 4a). Anyone holding the APK holds the key; the owner alone distributes it (first install on both phones by hand) and rotates the key if an APK leaks (§8.5).
|
||||
* 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. If one address still fetches `/apk/files/` over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124, `infra/README.md`).
|
||||
* 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. If one address still fetches `/apk/files/` over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124, `infra/README.md`). Since the app gate (D-126) a request from outside without the app key never reaches the server, so this concerns the home LAN or a leaked key.
|
||||
* Older APKs stay in `data/apk/` on the Pi until the owner removes them by hand (D-125; `install-apk.sh` kept the newest 5, D-103); `infra/pi/out/apk/` on the Mac keeps the newest 5.
|
||||
|
||||
### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
|
||||
@@ -1179,7 +1193,8 @@ Restore (step by step in `infra/RESTORE.md`, run on the Pi in `~/kvitt` as the o
|
||||
* Bootstrap and invites: `docker compose exec server kvitt-server bootstrap` (first install only: the admin invite code, link and expiry of FR-1); once the admin is paired, the partner's invite `docker compose exec server kvitt-server invite-partner`; an extra device `docker compose exec server kvitt-server pair-device --user <user_id>` (§10.4).
|
||||
* Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing.
|
||||
* Image digests (D-117 point 5, revised by D-125): the Pi keeps no digest record. After a pull, `docker image inspect --format '{{.RepoDigests}}' gitea.lan/jacobh2/kvitt-server:<tag>` (and `kvitt-backup`) prints the `sha256:` digest, which the owner compares with the workflow's "Print image digests" step for that run (§10.3); the registry is plain HTTP on the LAN (security review L-9), so a mismatch is investigated before the stack is trusted.
|
||||
* Client IPs (once, after the NPM proxy host and the router check, D-117, D-120, D-124): with the phone off Wi-Fi, open `https://<KVITT_PUBLIC_HOST>/health`, then within a minute on the Kvitt Pi `sudo conntrack -L -p tcp --orig-port-dst 8080` (package `conntrack`, installed once): in every line the first `src=` is `HA_HOST` and the reply half (from the second `src=`) has `dst=<HA_HOST>` with no `172.*` address, so the server sees NPM's address and trusts its `X-Forwarded-For`; a `172.*` address there means every internet client shares one per-IP limit of §8.6 (security review I-5; the commands are in `infra/README.md`'s operations table).
|
||||
* Client IPs (once, after the NPM proxy host and the router check, D-117, D-120, D-124): with the phone off Wi-Fi, open the app's About screen (S-22; before the app gate of D-126, `https://<KVITT_PUBLIC_HOST>/health` in Chrome does as well), then within a minute on the Kvitt Pi `sudo conntrack -L -p tcp --orig-port-dst 8080` (package `conntrack`, installed once): in every line the first `src=` is `HA_HOST` and the reply half (from the second `src=`) has `dst=<HA_HOST>` with no `172.*` address, so the server sees NPM's address and trusts its `X-Forwarded-For`; a `172.*` address there means every internet client shares one per-IP limit of §8.6 (security review I-5; the commands are in `infra/README.md`'s operations table).
|
||||
* Health from outside (D-126): the app's About screen (S-22) on the phone with Wi-Fi off shows the server version, or `curl -fsS -H "X-Kvitt-App: $KEY" https://<KVITT_PUBLIC_HOST>/health` from outside the home network; without the header the connection is closed (§8.5).
|
||||
* Headers through NPM (after the first setup and after every NPM upgrade): `curl -sS -D - -o /dev/null --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health` lists `content-security-policy`, `x-content-type-options`, `x-frame-options`, `referrer-policy` and `strict-transport-security` once each and no `x-served-by` (§10.5.2).
|
||||
|
||||
---
|
||||
@@ -2112,6 +2127,7 @@ Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt
|
||||
|
||||
* Content type `application/json; charset=utf-8` for every request and response body, except: `PUT /me/picture` (request `image/jpeg`, `image/png` or `image/webp`, raw bytes), `GET /users/{user_id}/picture` (response `image/jpeg`), `GET /i/{code}` (response `text/html; charset=utf-8`), `GET /apk/files/{file_name}` (response `application/vnd.android.package-archive`).
|
||||
* Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: <versionCode>`, `X-Kvitt-App-Version-Name: <versionName>` (optional for the server; stored per device at E-3 and on the hourly last-seen update, §8.2; blank or longer than 32 characters is ignored; declared on every operation through `CommonRules`, D-090), `Accept-Language: <device locale>`, and `Authorization: Bearer <token>` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then stops syncing until its installed version code changes, and shows the update banner when a newer APK is published, §14.2.4). The protocol check comes before the token check.
|
||||
* `X-Kvitt-App: <key>` is a proxy-level header, not part of the API contract: the release app sends it on every request when built with `kvitt.appKey`, NPM's app gate checks it (§8.5, D-126), and the server neither reads nor declares it (`contracts/openapi.yaml` unchanged).
|
||||
* Every response carries `X-Kvitt-Server-Version: <crate version>`.
|
||||
* A known path with a method it does not support gets 405 with an empty body (no error code; the only error response without the JSON error shape), the `X-Kvitt-Server-Version` header included. An unknown path gets 404 `not_found` with the JSON error shape.
|
||||
* Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`. Values outside §9.6: 422 `validation_failed` with `field`. The §9.6 value limits are not expressed as JSON Schema constraints in `contracts/openapi.yaml`; the schema fixes types only, and breaking a limit gives 422 `validation_failed`.
|
||||
@@ -2870,3 +2886,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
|
||||
| 0.22 | 2026-10-19 | The D-121 follow-ups (`4a4e45f`, `4b1ae0c`), the server's host defaults (`c9abd24`, `e381e37`) and the `install.sh` step number (`ac17b41`, no decision). D-122 (`4a4e45f`, `4b1ae0c`; extends D-121): `AppBuildInfo` (defined in `:data`'s `DataModule.kt`, provided by `:app`'s `AppBuildInfoModule`) gains `releaseServerUrl` = `RELEASE_SERVER_URL`; S-22 shows the paired server, before pairing `AppBuildInfo.releaseServerUrl`; `DataModule.DEFAULT_SERVER_URL` removed; fakes and tests on `https://kvitt.example.org:8443` (30 S-21/S-22 goldens re-recorded); `infra/publish-apk.sh` reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` and passes `-Pkvitt.publicHost` (§6.2, §10.6, §13.3 S-22, §14.2.4). D-123 (`c9abd24`, `e381e37`; revises D-120's leftovers): `KVITT_PUBLIC_URL` has no default and every `kvitt-server` command refuses to start without it; `KVITT_TRUSTED_PROXY` defaults to `none`, never looked up; the OpenAPI server is `https://{host}:8443` with variable `host` (default `kvitt.example.invalid`), representational for §14.8; E-7 and E-12 examples on `kvitt.example.invalid`; `tools/contracts-check.py` expects the `{host}` URL; 276 server tests (§10.4, §11.3, §14.3, E-7, E-12). The owner's Kvitt host name `kvitt.suorra.duckdns.org` (2026-10-19, recorded in HANDOFF; §10.1, §10.5, §10.5.1). |
|
||||
| 0.23 | 2026-10-19 | Port 443 (`a86ad24` server and contracts, `4852803` bundle, `038c603` Android, `ad35231` portless Android fixtures, 30 S-21/S-22 goldens re-recorded) and the pinned-root template (`038c603`, no decision). D-124 (owner decision 2026-10-19): Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts: OpenAPI server `https://{host}`; E-7 and E-12 examples without a port; `tools/contracts-check.py` expects the portless header. Bundle: `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`; health checks through NPM on 443; README router step "no new rule"; the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android: `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 intent filter has no port and matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443`, `:80`); S-1 goldens on `https://kvitt.example.org` (28 re-recorded) (§1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §11.4, §14.2.4, §14.3, E-7, E-12). Without a decision id: the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, only when `kvitt.tls.pinnedRoot=true`, and the build stops while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (§8.5, §10.5.3). |
|
||||
| 0.24 | 2026-10-20 | The self-contained Pi compose project (`6e5981d`), the `.env` without informational keys (`200c99f`) and the sparse-checkout workflow (`3820f9c`, no decision), with the workflow's first results; the compose deploy hint in the workflow and `infra/build-images.sh` (`28fa71b`, no decision). D-125 (owner decision 2026-10-20, revises D-103, D-105, D-117 point 5, D-120 point 6): the Kvitt Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project (`compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`); both containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi) with a read-only root and a 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; each mounts only its own secret file read-only (`create_host_path: false`), secret files the owner's, mode 0600; `.env` holds exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; no install script, no `/opt`, no Docker daemon or system changes (Docker and the registry trust are prerequisites; private packages mean a one-time `docker login gitea.lan`); the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, their equivalents plain `docker compose` commands (backup `exec backup /backup.sh`, bootstrap and partner invite through `exec server kvitt-server`, deploy = backup, set `KVITT_TAG`, `pull`, `up -d`, rollback = the previous tag, APK = `scp` of the APK and then `latest.json` into `data/apk/`, old APKs removed by hand, digests with `docker image inspect`, no digest record, health checks by hand); restore moves files on the host, only rclone and sqlite3 run in the backup container; the compose default network; the development stack unchanged (uid 10001); rehearsed with Podman as uid 501 (§8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 OQ-3, §14.7). Without a decision id: the `.env` keys `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped (`200c99f`, revises D-103), `<KVITT_PI_HOST>` is now `<kvitt-pi-address>` (§8.9, §10.1, §10.5.1, §10.5.3, OQ-3); the Gitea workflow's results on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (a misnamed user-level secret, fixed by the owner 2026-10-19), run 69 built `v1.0.0` (`kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min, first build with `CARGO_BUILD_JOBS=1`), the sparse-checkout push built `v1.0.1`, the tag the Pi uses; the checkout is a cone-mode sparse checkout of `server` and `infra/backup` with `fetch-depth: 0` and `--filter=blob:none`; the diagnostic step removed (§10.3, §11.2). |
|
||||
| 0.25 | 2026-10-21 | The app gate (D-126, owner decision 2026-10-21; Android `34e5cad`): outside the home LAN `192.168.1.0/24` NPM closes the connection (`return 444`) of every request without `X-Kvitt-App: <key>`, every route included (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*`); only `/.well-known/acme-challenge/` passes (Let's Encrypt renewal), tested on `$uri`; the LAN check reads `$realip_remote_addr`, because NPM's `real_ip_header X-Real-IP` for private peers makes `$remote_addr` forgeable; the server and `contracts/openapi.yaml` are unchanged, the bearer token stays the authentication. The app sends the header in `KvittApi`'s common headers and `PictureAuth` when built with `kvitt.appKey` (Gradle property, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey`; 952 Android tests); the key is the owner's `openssl rand -hex 32`, only in `local.properties` and NPM; rotation by a second `if` line, a new APK through FR-16, then the old line removed. Accepted: landing page and browser use only on the LAN, outside health check through the app's About screen or `curl -H`, a leaked APK leaks the key until rotation, no flood protection. The gate rehearsed in NPM v2.16.0's openresty with its includes (AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1). Without a decision id: `infra/README.md` step 4a (key, APK, gate, acceptance test), its `CARGO_BUILD_JOBS` text (in effect since `b4d9467`; run 69: `kvitt-server` 2 h 42 min) and its owner decisions marked answered. |
|
||||
|
||||
Reference in New Issue
Block a user