Files
reforgermods-launcher/docs/client-identification.md
T
SowinskiBraedenandClaude Opus 5 377f3a31b3 initial commit: reforgermods.net launcher 0.1.0
A desktop server browser for Arma Reforger, built on the reforgermods.net v2
API. Live server list with search, sort and filters; a detail inspector with
population history and mod readiness; measured ICMP latency; favorites, recents
and settings; and direct joining through Steam.

Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in
one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested
directly; src-tauri is the window, the command surface and the wiring.

Packaged for the public beta: a per-user NSIS installer, a portable .exe and an
Arch package, with signature-verified background updates on Windows and a
release pipeline that builds from a tag. The artifacts are not yet
Authenticode-signed — that is wired and waiting on an Azure Trusted Signing
identity, and docs/distribution.md says so plainly rather than letting anyone
discover it at the SmartScreen dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:29:00 -07:00

249 lines
11 KiB
Markdown

# Launcher client identification — API implementation notes
**Audience:** whoever maintains `api.reforgermods.net` (ReforgerWorkshopAPI).
**Status: implemented on both sides.** The launcher sends the header described
below, and the API records it as a derived key. The sections headed *Suggested
API-side work* are kept as the rationale for what was built; what actually
shipped is summarised in [What the API does](#what-the-api-does).
The header remains additive: an API that ignores it behaves exactly as before,
and the launcher does not care whether it is read.
## The problem
Today the API can see "50 000 requests from the launcher" but not whether that
is 5 000 installs making 10 requests each, or 50 installs making 1 000 each.
`User-Agent` identifies the *application*; client IP is a poor proxy for a
*person* (CGNAT, shared households, mobile, VPNs) and is also the thing you least
want to retain.
## What the launcher sends
Every request carries both of:
```http
User-Agent: reforgermods.net-launcher/0.1.0
X-Launcher-Install-Id: 8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e
```
| Property | Value |
| --- | --- |
| Header | `X-Launcher-Install-Id` |
| Format | Lowercase canonical UUID, `8-4-4-4-12` hex. Always exactly 36 chars. UUID version 8 (custom derivation) where machine-derived, version 4 where random. |
| Generation | `SHA-256(application salt ‖ machine value)`, first 16 bytes, formatted as a UUID. |
| Lifetime | Survives uninstall and reinstall, because it is derived rather than stored. Changes when the OS is reinstalled. |
| Fallback | A random v4 UUID persisted in the launcher's state file, used only where no machine value is readable. That one does *not* survive a reinstall. |
| Reset | None. There is no reset action, by design — see below. |
| Absent when | The stored value fails validation and no machine value is readable. The launcher sends **no header at all** rather than a malformed one. |
### Machine value by platform
| Platform | Source |
| --- | --- |
| Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` |
| Linux | `/etc/machine-id`, else `/var/lib/dbus/machine-id` |
| Other / unreadable | random fallback |
These are per-OS-installation values. They are not device serials, not MAC
addresses, not tied to a user account, and they change when the OS is
reinstalled — which is the intended granularity.
**The machine value never leaves the process.** Only the salted digest is sent,
so the API cannot reverse the identifier into a machine id, and the same machine
running different software derives a different value. An all-zero or blank
machine value is rejected rather than used, since it would collapse every
affected machine onto one identifier.
**Why not IP.** An address changes with DHCP, VPNs, tethering and moving
networks, so it would split one install into many, while merging everyone behind
a shared NAT into one. It is also the one genuinely personal field in the
request, and the API already sees it.
Client-side references: `INSTALL_ID_HEADER` in
`crates/rfm-core/src/api/client.rs`, `stable_install_id` in
`crates/rfm-core/src/identity.rs`.
## What it is not
**It is not authentication, and it must not gate anything.** The launcher is a
public, untrusted client. Anyone can send any value, rotate it per request, or
copy someone else's. Treat it exactly as what it is: a voluntary, spoofable
analytics hint.
Concretely:
- **Do not** rate limit on it alone. Keep the existing IP-based limiting as the
enforcement mechanism. A caller that wants to evade a per-install limit only
has to send a new UUID each time, so an install-keyed limit would penalise
honest clients and stop nobody.
- **Do not** use it for entitlement, quota assignment, or moderation.
- **Do** use it for counting, cohorting, and retention analysis.
If per-client enforcement is ever needed, that requires real launcher
authentication, which is a separate design.
## What the API does
Implemented in `ReforgerWorkshopAPI`, matching the design below except where
noted:
| Concern | Where |
| --- | --- |
| Header constant | `telemetry.InstallIDHeader` |
| Validation | `telemetry.ValidInstallID` — canonical lowercase UUID, any version; the nil UUID is rejected |
| Derived key | `Anonymizer.InstallKey` — HMAC-SHA256(secret, id), truncated to 128 bits, domain-separated from `NetworkID` |
| Storage | `request_events.install_key`, with a partial index; added by the existing `ensureColumn` migration |
| Classification | `launcher` client kind and source; counts as activity |
| Version | split out of the User-Agent into `client_version` |
| Redaction | the raw header is in the middleware's redacted list, so it never reaches the stored header snapshot |
Two deliberate departures from the design below:
- **The key is stored as hex `TEXT`, not `BLOB(16)`**, for consistency with
`network_id` and `quota_subject_hash`. The value is the same 128 bits.
- **The key is not rotated per time window**, unlike `NetworkID`. A key that
changes underneath the data every month cannot answer "how many distinct
installs, and do they come back", which is the only reason the field exists.
Correlation is bounded by rotating the *secret* instead, which re-partitions
the whole keyspace from that point on. This is the lever referred to in
[Note for the API side](#note-for-the-api-side), and it matters more given the
client value is now stable and derived.
The five assertions under [Testing the API side](#testing-the-api-side) are
covered by tests: `telemetry/install_id_test.go` for validation and keying, and
`TestLauncher*` in `api/telemetry_middleware_test.go` end to end, including that
a caller rotating the id per request gets no more requests than one sending none.
## Suggested API-side work
### 1. Accept and validate the header
Reject-by-ignoring, never by erroring. Suggested handling:
```text
raw := r.Header.Get("X-Launcher-Install-Id")
if raw is not exactly a canonical lowercase v4-shaped UUID -> treat as absent
```
Never log the raw value unvalidated, and never echo it back in a response body
or error envelope.
### 2. Store a salted hash, not the value
The launcher generates the id, but the API does not need to retain it verbatim
to count distinct installs:
```text
install_key = truncate(HMAC-SHA256(server_secret, install_id), 128 bits)
```
With a server-side secret this keeps the stored value useless if the analytics
store leaks, and it prevents anyone who guesses or harvests an id from querying
for that install's activity. Rotating the secret on a schedule (say quarterly)
caps how far back any single key can be correlated.
### 3. Record it on the existing telemetry path
The API already classifies routes in `telemetry/classify.go` and records request
telemetry in `api/telemetry_middleware.go`. The natural shape is one extra
nullable column on whatever already records a request, plus a rollup:
| Field | Type | Notes |
| --- | --- | --- |
| `install_key` | `BLOB(16)` nullable | Hashed per above. Null for non-launcher traffic. |
| `client_kind` | `TEXT` | `launcher` / `web` / `other`, parsed from User-Agent. |
| `client_version` | `TEXT` | From the launcher's User-Agent suffix. |
Daily rollup suitable for the question that prompted this:
```sql
SELECT
date(ts) AS day,
client_version,
COUNT(*) AS requests,
COUNT(DISTINCT install_key) AS installs,
COUNT(*) * 1.0 /
NULLIF(COUNT(DISTINCT install_key), 0) AS requests_per_install
FROM request_telemetry
WHERE client_kind = 'launcher'
GROUP BY day, client_version;
```
That answers "50 000 requests, 5 000 installs, 10 each" directly, and splits it
by launcher version so a regression in request volume is attributable to a
release.
### 4. Retention
The install key is only needed at daily/weekly granularity. Recommend keeping
per-request rows for the existing telemetry window, and rolling up to
`(day, client_version, install_key)` presence rows beyond that — or to pure
counts once the cohort window has passed. There is no product reason to retain
per-install request logs indefinitely.
### 5. Do not add it to the public rate-limit response
`GET /v2/rate-limits` reports the caller's limits. Since the install id does not
affect limits (see above), it should not appear there — surfacing it would imply
it does something it does not.
## Privacy position
Worth stating plainly, because this changed: the identifier is now **derived
from the machine rather than randomly generated**, and there is **no reset**.
That is deliberate — a resettable random value made reinstalls look like new
installs, which was the exact number the metric exists to get right — but it is
a real trade, and the honest framing is:
- The identifier is a salted hash of a per-OS-installation value. It carries no
personal data and cannot be reversed into a machine identity.
- It is stable across reinstalls of the launcher, and changes when the OS is
reinstalled.
- It is not linked to a reforgermods.net account, because the launcher has none.
- Its sole purpose is aggregate usage measurement.
- It is not shown in the UI. It appears only when the launcher is started with
`RFM_DEBUG=1`, which is a developer affordance, not a user setting.
Anyone wanting genuinely uncorrelatable requests has to block the header, which
is a thing a user cannot do from inside the launcher. If that matters for the
project's public posture, the mitigation is disclosure — a line in the
launcher's description or privacy text — rather than an in-app toggle, because a
toggle would reintroduce exactly the undercount the derivation fixes.
## Note for the API side
Because the client value is now stable and derived, the server-side hashing in
step 2 matters more, not less: it is what stops the analytics store from holding
a long-lived per-machine key. Rotating the server secret on a schedule also caps
how far back any single key can be correlated, which is the main lever left for
limiting retention.
## Testing the API side
Once implemented, these should hold:
1. A request with no header is accepted and recorded with a null `install_key`.
2. A request with a malformed header (`not-a-uuid`, 10 KB of text, CRLF
injection attempt) is accepted and recorded as if absent — never 4xx, never
logged raw.
3. Two requests with the same id produce the same `install_key`; with different
ids, different keys.
4. The same id sent to two different deployments with different secrets produces
different keys.
5. Rate limiting behaviour is unchanged by the presence or value of the header.
## Open question for the API owner
Should the launcher also send a coarse platform token (`windows` / `linux`) so
the rollup can distinguish "5 000 Windows installs" from a Proton cohort? That
would be one more low-cardinality header and would answer the Linux-support
question from real data rather than guesswork. The launcher does **not** send one
today; say the word and it is a two-line change.
**Still open, and now worth deciding.** The beta publishes an Arch package, so
there is a Linux cohort to measure for the first time. Note that the question is
partly answerable without a new header: the User-Agent is recorded, and the
Windows and Linux builds could carry distinguishable version suffixes. A separate
low-cardinality header is cleaner, and neither is built.