From 9897d52cf6c51fce4e200c13f2ea3579b43fe01f Mon Sep 17 00:00:00 2001 From: efiten Date: Sat, 10 Oct 2026 18:08:51 +0200 Subject: [PATCH] feat(users): companion linking store (schema v6, device sessions, link challenges) (#2174) Part 1 of 3 for #2173 (companion linking). This PR adds only the `users.db` side: the schema and store methods the server (part 2) and ingestor (part 3) build on. No route, no UI and no behaviour change for a running server. ## Changes in this PR - **Design spec** `docs/specs/2026-10-08-companion-linking-design.md`, including the optional linked-only ingest. - **Schema v6** (`internal/users/schema.go`): - `sessions` gains `kind` (`'web'` or `'device'`), `label` and `scopes` (`''` = full, for web sessions). - `companion_links (pubkey PRIMARY KEY, user_id, name, linked_at)`, cascading on user delete. - `link_challenges`: single-use challenges bound to a user and a pubkey, with an expiry. - **Device sessions** (`sessions.go`): create a device session with a label and scopes, list sessions with kind and label. - **Challenges** (`challenges.go`): issue and consume a challenge exactly once; consumed, expired, or bound to another user or pubkey all fail. - **Companion links** (`companions.go`): link, list, unlink. Linking a pubkey that belongs to another user moves it and reports the previous owner (transfer detection), so the server can audit and mail. - **Janitor**: the existing janitor also prunes expired challenges (`cmd/server/auth_service.go`, 3 lines). ## Upgrade note The store migrates `users.db` to v6 on start. A binary that only knows v5 refuses to open a v6 file (`users: database schema version 6 is newer than this binary supports (5)`), so rolling back after this lands needs the pre-upgrade `users.db` backup. ## Verification - `internal/users`: new tests for the v6 migration from v5, device sessions, challenges (single use, expiry, binding) and companion links (transfer, cascade on user delete). `go vet` and `go test ./...` green. - `cmd/server` full suite green against the v6 store. ## Not in this PR - Routes, bearer auth and the link handshake: part 2. - Ingestor filter, account page and "My coverage": part 3. --------- Co-authored-by: Claude Opus 5.5 (1M context) --- cmd/server/auth_janitor_challenges_test.go | 27 +++ cmd/server/auth_service.go | 3 + .../2026-10-08-companion-linking-design.md | 216 ++++++++++++++++++ internal/users/challenges.go | 79 +++++++ internal/users/challenges_test.go | 104 +++++++++ internal/users/companions.go | 148 ++++++++++++ internal/users/companions_test.go | 126 ++++++++++ internal/users/device_sessions_test.go | 137 +++++++++++ internal/users/schema.go | 19 ++ internal/users/schema_v6_test.go | 76 ++++++ internal/users/sessions.go | 105 ++++++++- internal/users/store.go | 9 + 12 files changed, 1041 insertions(+), 8 deletions(-) create mode 100644 cmd/server/auth_janitor_challenges_test.go create mode 100644 docs/specs/2026-10-08-companion-linking-design.md create mode 100644 internal/users/challenges.go create mode 100644 internal/users/challenges_test.go create mode 100644 internal/users/companions.go create mode 100644 internal/users/companions_test.go create mode 100644 internal/users/device_sessions_test.go create mode 100644 internal/users/schema_v6_test.go diff --git a/cmd/server/auth_janitor_challenges_test.go b/cmd/server/auth_janitor_challenges_test.go new file mode 100644 index 00000000..0d78b9a5 --- /dev/null +++ b/cmd/server/auth_janitor_challenges_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/meshcore-analyzer/users" +) + +func TestJanitorPrunesExpiredLinkChallenges(t *testing.T) { + f := newAuthFixture(t) + dave := f.registerAndActivate(t, "dave@example.org", "Dave", pw) + pk := strings.Repeat("cd", 32) + f.st.SetClock(func() time.Time { return time.Now().Add(-10 * time.Minute) }) + ch, _, err := f.st.CreateLinkChallenge(dave.me.ID, pk) + if err != nil { + t.Fatal(err) + } + f.st.SetClock(time.Now) + f.srv.auth.prune() + // Pruned → missing. Without the janitor it would still be there, expired. + if err := f.st.ConsumeLinkChallenge(dave.me.ID, pk, ch); !errors.Is(err, users.ErrChallengeMissing) { + t.Fatalf("expired challenge survived the janitor: %v", err) + } +} diff --git a/cmd/server/auth_service.go b/cmd/server/auth_service.go index 0a4fc84a..de00e1eb 100644 --- a/cmd/server/auth_service.go +++ b/cmd/server/auth_service.go @@ -149,6 +149,9 @@ func (a *authService) prune() { if _, err := a.st.PruneExpiredSessions(); err != nil { log.Printf("[users] prune sessions: %v", err) } + if _, err := a.st.PruneLinkChallenges(); err != nil { + log.Printf("[users] prune link challenges: %v", err) + } if _, err := a.st.PruneTokens(7 * 24 * time.Hour); err != nil { log.Printf("[users] prune tokens: %v", err) } diff --git a/docs/specs/2026-10-08-companion-linking-design.md b/docs/specs/2026-10-08-companion-linking-design.md new file mode 100644 index 00000000..7051f84b --- /dev/null +++ b/docs/specs/2026-10-08-companion-linking-design.md @@ -0,0 +1,216 @@ +# Companion Linking (User Management, Sub-project F), Design Spec + +**Date:** 2026-10-08 +**Status:** Approved (design). Not implemented. +**Scope:** sub-project **F** of the optional user-management track. Builds on A +(accounts, sessions), B (settings sync, `meshcore-my-nodes`) and E (notifications). +The client side lives in CoreDrive RX (`efiten/coredrive-rx`, +`docs/superpowers/specs/2026-10-08-corescope-login-design.md`). + +--- + +## Problem + +CoreDrive RX companions publish coverage under `meshcore/client//packets` +with one shared broker account. CoreScope knows a companion only by that pubkey. +With user management on, a logged-in user wants: + +1. **Ownership.** Every companion they drive with shows up under their "My nodes", + so the existing watch and notification features apply to it. +2. **Attribution.** The coverage their companions collected is theirs: a + "My coverage" view, and their linked companions listed on the account page. + +Two things block this today: + +- Nothing ties a pubkey to a user. `meshcore-my-nodes` is a free-form synced list; + anyone can put any pubkey in it, and two users can both "own" the same one. That + is fine for favourites but not for attributing data. +- RX is a separate app, often on another origin. Sessions are cookies with CSRF, and + `corsMiddleware` deliberately allows no credentialed CORS (`cors.go:44`). Cookies + across origins are also unreliable on iOS Safari, which is where people drive. + +## Decisions + +| # | Topic | Decision | +|---|---|---| +| 1 | Auth for RX | A **device token** (bearer), issued by email + password login. One code path for same-origin and cross-origin deployments. No cookies, no CSRF. | +| 2 | Token storage | A row in the existing `sessions` table with `kind = 'device'`, so it shows under **Devices** on the account page and is revoked the same way. | +| 3 | Token scope | Narrow and server-enforced: `/api/auth/me`, `/api/auth/logout`, `/api/account/companions*`, `/api/account/settings`. Everything else answers 403 to a bearer token. | +| 4 | Ownership proof | The companion signs a server challenge with its own Ed25519 identity key (MeshCore companion `CMD_SIGN_*`). No signature, no link. | +| 5 | Conflicts | The newest valid proof wins. Whoever holds the key owns the companion (devices change hands). The previous owner gets an audit row, and a mail when notifications are enabled. | +| 6 | Attribution | A read-time join `client_receptions.rx_pubkey → companion_links`. All coverage of a linked companion counts for its current owner, including rows from before the link. The ingestor and the RX payload contract do not change. | +| 7 | My nodes | Linking also adds the pubkey to the user's `meshcore-my-nodes`. Unlinking does **not** remove it: that list is the user's own choice. | +| 8 | Linked-only ingest | Opt-in admin setting `clientRxCoverage.requireLinkedCompanion`. When it is on, the ingestor drops every `meshcore/client//…` message whose pubkey is not in `companion_links`. It is a filter, not a security boundary (see *Linked-only ingest*). | +| 9 | Per-user API keys | Out of scope. The token table gets a `kind` and `scopes` so a later `api_key` kind (named, shown once, own scopes, revoked separately) is a small addition. The RX device token is never reused for external access. | + +## Non-goals + +- Per-user publish credentials or broker ACLs (a possible later sub-project; it + would change the RX payload contract). +- Per-user API keys for external tools (see decision 8). +- Showing other users who owns a companion. Links are private to their owner and + to admins. + +## Data model (`users.db`, schema v6) + +```sql +ALTER TABLE sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'web' + CHECK (kind IN ('web','device')); +ALTER TABLE sessions ADD COLUMN label TEXT NOT NULL DEFAULT ''; -- device name +ALTER TABLE sessions ADD COLUMN scopes TEXT NOT NULL DEFAULT ''; -- '' = full (web) + +CREATE TABLE companion_links ( + pubkey TEXT PRIMARY KEY, -- 64 lowercase hex + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + linked_at INTEGER NOT NULL +); +CREATE INDEX companion_links_user ON companion_links(user_id); + +CREATE TABLE link_challenges ( + challenge_hash TEXT PRIMARY KEY, -- SHA-256 of the raw challenge + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pubkey TEXT NOT NULL, + expires_at INTEGER NOT NULL +); +``` + +Device tokens reuse the session token format (256-bit random, stored as SHA-256). +`csrf_token` is filled but unused for `kind = 'device'`. Expiry is 90 days, sliding +on use, like web sessions but with its own constant. The janitor prunes expired +challenges with the other expired rows. + +"Last seen" for a companion is not stored: the account page reads the newest +`client_receptions` row for that pubkey from the analyzer database. + +## API + +All routes exist only when `userManagement.enabled`, and answer 404 otherwise, like +the rest of A–E. Rate limits use the existing token buckets (per IP and per user). + +### Device token + +- `POST /api/auth/device-token {email, password, deviceName}` + → `200 {token, expiresAt, user: {id, displayName}}`. + The same constant-cost failure path, rate limits and login audit row as + `/api/auth/login`. `deviceName` is trimmed, control characters are stripped, and + it is capped at 64 characters. +- Authentication: `Authorization: Bearer `. `withUser` accepts either a + cookie session (with the CSRF check for writes) or a bearer token (no CSRF, + scope check). A bearer token on a route outside its scope answers 403. +- `POST /api/auth/logout` with a bearer token revokes that device row. +- `GET /api/account/sessions` returns `kind` and `label`. The **Devices** list shows + a device token as "CoreDrive RX –