RFC-0002: Security Posture — First-Boot Claim, Sessions, Reset #156
Closed
kn4oqw-clint
announced in
RFCs
Replies: 1 comment
|
This has already been implemented. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
A Waypoint device ships with no admin credential. On first boot it serves exactly one interactive surface — a claim page — and refuses every other route until someone sets an admin username and password. After claim, all configuration and activity surfaces require an authenticated session. Recovery from a lost credential requires OS-level or physical authority and always returns the device to the unclaimed state; it never grants access by itself.
This RFC fixes the claim state machine and its route allowlists, credential storage, the session model, brute-force damping, the reset procedure, the TLS story, and the release-blocking test contract. It also closes RFC-0001's open question 2 (secrets at rest). It contains no implementation — the daemon and UI changes are follow-up PRs.
Motivation
Hotspots are the amateur-radio device most likely to be exposed to the internet, and the incumbent platforms shipped with well-known default credentials for years. The results are on the public record:
pi-star/raspberry(and the underlyingpi/raspberryOS account) are not secrets; they are published in every setup guide.The failure is structural: the config UI is reachable before anyone has established who owns the device. A default credential is a shared secret with the entire internet, and "no auth on the LAN" assumes a trust boundary that home networks do not provide (a compromised IoT device, a guest, a port-forward someone forgot).
SECURITY.md already states the design commitment this RFC implements:
There is no
pi-star/raspberryequivalent at any point in a Waypoint device's life, and no config surface is served to an unauthenticated caller once the device is claimed. This document specifies exactly how that commitment is enforced.Design
Claim state machine
A device is in exactly one of two states, derived from the store (RFC-0001's
metatable): unclaimed or claimed. The HTTP server consults this state on every request and serves a strict route allowlist per state; everything not on the allowlist returns 403 Forbidden.Unclaimed
The device is unclaimed when no admin credential exists and
meta.claimed_atis null. In this state the HTTP server serves only:POST /api/claim;GET /api/health.Every other route returns 403 — including the config API, the log/MQTT tails, and, explicitly, the
GET /api/eventsSSE stream. The event stream carries live callsign and talkgroup activity; serving it pre-claim would leak the operator's on-air identity and traffic to any unauthenticated caller, which is precisely the RadioReference 496832 exposure. It is behind the wall from the first boot.GET /api/healthis intentionally unauthenticated in both states: it returns liveness and the claim state only (a boolean and a schema/version stamp), never configuration or activity. It exists so provisioning tooling and the claim page itself can discover whether a device still needs claiming.Claiming
POST /api/claimaccepts{username, password}. The first successful claim wins, atomically. The handler performs the credential write and themeta.claimed_atstamp in a single store transaction; a concurrent second claim that loses the race observes the already-set state and returns 409 Conflict. There is no window in which two callers can both believe they own the device. A successful claim issues a session (see below) so the claimer is logged in immediately, without a second round-trip through the login page.Password strength policy (minimum length, rejection of the most-common passwords) is applied at claim time; the exact policy is a UI/validation detail, not an architectural one, and is out of scope here beyond "there is one."
Claimed
The device is claimed when an admin credential exists and
meta.claimed_atis set. In this state all configuration and activity surfaces require an authenticated session. The pre-authentication allowlist shrinks to:POST /api/session(log in — establish a session);GET /api/health.Every other route — the entire
/api/config/*surface from RFC-0001,/api/events, profile management, hardware operations, the log tails — requires a valid session cookie or it returns 403. The claim page andPOST /api/claimare not served once claimed; claiming again requires first returning to the unclaimed state via the reset procedure.Acceptance criteria (restated from issue #10)
POST /api/claim, andGET /api/health, none of which read or write configuration or activity.Credential storage
golang.org/x/crypto/argon2), parameters:time = 1(iterations),memory = 64 MiB,threads = 4,settingskey tree. They are never exposed through any/api/configsection, never appear in the config view or the generated-INI preview, and are never part of a profile. RFC-0001 already excludes auth from profile namespaces — "Keys outside the profile's namespaces (device identity, auth, hardware calibration) are never part of a profile" — and this RFC depends on that exclusion: exporting or importing a profile can neither leak nor overwrite the admin credential.Sessions
sessionstable inconfig.db:id— a 256-bit random token, stored hashed at rest (the raw token exists only in the client cookie; a store compromise does not hand over live sessions);created_at,expires_at,last_seen.HttpOnly,SameSite=Lax, andSecureonce TLS lands (see below).HttpOnlykeeps the token out of reach of any injected script;SameSite=Laxblunts CSRF against state-changing routes.last_seenis older than the idle window is invalid and is swept. Activity refresheslast_seen.DELETE /api/session, which deletes the server-side record — logging out actually revokes the session, it does not merely drop the cookie.waypointdfor an update does not log the operator out.Brute-force damping
Two mechanisms, both intentionally modest:
This is damping, not a WAF. It raises the cost of online guessing against a single device; it is not rate-limiting infrastructure, it does not defend against a distributed attacker, and it is not a substitute for a strong admin password (which the claim-time policy exists to encourage). Stated plainly so no one mistakes it for more than it is. The primary defense against credential attacks is that there is no default credential to guess and no config surface exposed before one is set.
Reset procedure
There are exactly two reset paths, and both require OS-level or physical authority. Neither grants access on its own; both return the device to the unclaimed (claim-mode) state, from which a new owner must claim it afresh.
a.
waypointd reset-claimsubcommand. Run from a local shell (SSH or serial console) by someone who already has OS-level access to the device. It wipes the admin credential, revokes all sessions, and clearsmeta.claimed_at. Having a shell on the box is itself the authorization; the subcommand simply performs the reset cleanly rather than leaving the operator to hand-edit the store.b. Boot-partition marker file. At daemon startup,
waypointdchecks for a reset marker at both/boot/waypoint-resetand/boot/firmware/waypoint-reset— Raspberry Pi OS Bookworm moved the boot mount from/bootto/boot/firmware, and Waypoint targets both, so it checks both locations. If the marker is present, the daemon:meta.claimed_at,Placing the marker requires root on the running system or removing the SD card and writing to the boot partition on another machine. The subsequent power cycle is what triggers the daemon to consume the marker and complete the reset — the file alone does nothing until the device restarts. This is the recovery path for the operator who has lost the credential but has physical possession of the device.
Both paths land the device back in claim mode. Recovery is return the device to me to re-own, never let me in.
SSH posture. Waypoint does not create OS accounts and does not touch
sshd. It manages its own application credential and nothing else. Since Raspberry Pi OS Bullseye there is no defaultpi/raspberryaccount — the OS account is created at imaging time (Raspberry Pi Imager) by the person flashing the card. That imaging-time account is the operator's own OS credential and their recovery credential for path (a); Waypoint neither knows it nor depends on it, and does not weaken it. Hardeningsshd(keys-only, etc.) is the operator's call and outside Waypoint's scope.TLS
Secureflag flips on when TLS ships — it is gated on TLS being present so that a pre-TLS build does not set a flag that would make the cookie unusable over plain HTTP during bring-up.Secrets at rest
(This section closes RFC-0001 open question 2, which deferred secrets-at-rest to this document.)
RFC-0001 handles secrets in transit and in exports —
sensitive: trueschema keys are excluded from profile exports and redacted in diffs and logs — and explicitly deferred secrets at rest to this RFC.Phase 1 position: configuration secrets (DMR/BrandMeister passwords, APRS-IS passcodes, DAPNET credentials, etc.) remain plaintext JSON in
config.db, protected by file permissions: the database is mode0600and owned by the dedicatedwaypointdservice user. Reading a secret requires already being that user or root — i.e. already having OS-level control of the device, at which point the secrets are the least of the operator's problems.OS keyring / at-rest encryption is explicitly deferred, and why: on a headless appliance with no operator present at boot, any key used to decrypt the store must itself be recoverable by the daemon unattended — which means the key lives on the same disk as the ciphertext, or in a TPM the launch-tier target hardware (Pi Zero/3/4) does not have. Encrypting
config.dbwith a key stored beside it raises the bar against casual disk inspection but not against anyone with root, while adding real key-management and recovery complexity (a lost key bricks the config). The honest Phase 1 tradeoff is0600+ a dedicated service user.This is an accepted, revisitable risk, stated plainly. It is revisited when the Phase 3 image (read-only root, separate config partition) lands, where full-partition encryption keyed to hardware becomes a coherent option rather than a half-measure.
The security contract (test harness)
CI enforces, as release-blocking tests, at minimum:
POST /api/claim, andGET /api/health— and assert explicitly thatGET /api/eventsand a representative/api/config/*route return 403. The matrix is exhaustive over the registered route table, so a newly added route defaults to denied and fails the test until it is deliberately placed on an allowlist.POST /api/claimrequests concurrently at a fresh device; assert exactly one succeeds and issues a session, the other receives 409 Conflict, and the store ends with a single admin credential and oneclaimed_atvalue.passwordcolumn holds an argon2id record (params + salt + digest), and the sessionidcolumn holds a hash, not the raw token. A grep of the raw DB for a known test password / token finds nothing.waypointd, assert the same cookie still authenticates; then advance past idle expiry and assert it no longer does; thenDELETE /api/sessionand assert the record is gone and the cookie is rejected.reset-claim, and separately (b) plant a/boot/firmware/waypoint-resetmarker and restart. For each path assert the admin credential is wiped, all sessions are revoked (the previously-live cookie now 403s),claimed_atis null, the device serves the claim page again, the marker file is deleted (path b), and the reset was logged.Alternatives considered
Adding it would mean a standing, always-listening reset surface reachable over the air by a spoofable identity, for no capability the two primary paths don't already cover better. Rejected as an unnecessary standing surface.
Open questions
0600decision is explicitly a Phase 1 decision, not a permanent one.Migrated from
docs/rfcs/0002-security-posture.md; the drafting history is in the git log.All reactions