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 –