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) <noreply@anthropic.com>
This commit is contained in:
efiten
2026-10-10 18:08:51 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f3004d977e
commit 9897d52cf6
12 changed files with 1041 additions and 8 deletions
@@ -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)
}
}
+3
View File
@@ -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)
}
@@ -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/<pubkey>/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/<pubkey>/…` 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 <token>`. `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 – <label>" with its last use, and the existing
"log out" button revokes it.
### Companions
- `POST /api/account/companions/challenge {pubkey}` → `200 {challenge, expiresAt, host}`. `host` is the host of `userManagement.publicBaseUrl`; the client signs it as-is, so a client that reaches CoreScope under another name still signs what the server verifies.
32 random bytes as hex, valid for 5 minutes, single use, bound to user and pubkey.
- `POST /api/account/companions {pubkey, challenge, signature, name}`:
1. Look up the challenge by hash. Missing, expired, or bound to another user or
pubkey → 410. It is consumed in every case.
2. Verify `signature` (64 bytes hex, Ed25519) over the UTF-8 message
`"corescope-link:" + host + ":" + challenge`, where `host` is the host of
`userManagement.publicBaseUrl`. This uses `internal/sigvalidate`. Invalid → 400.
3. Upsert `companion_links`. If the pubkey belonged to another user, write an
audit row for both users, and mail the previous owner if notifications are
enabled for them.
4. Add `{pubkey, name, addedAt}` to the user's `meshcore-my-nodes`, unless it is
already there. This is a server-side read-modify-write of the settings document
that bumps its revision, so open web clients pick it up through the normal
B conflict flow. If the document is at the size cap, the link still succeeds
and the response says `myNodes: "full"`.
5. `200 {pubkey, name, linkedAt, myNodes: "added" | "present" | "full"}`.
- `GET /api/account/companions` → `[{pubkey, name, linkedAt, lastSeenAt}]`.
- `DELETE /api/account/companions/{pubkey}` → 204. Leaves `meshcore-my-nodes` alone.
- Admin: `GET /api/admin/users/{id}` gains `companions`. The audit kinds are
`companion_link`, `companion_unlink` and `companion_transfer`.
### Coverage attribution
- `GET /api/rx-coverage?mine=1` filters to the caller's linked companions. It needs a
session; without one it answers 401. The existing response shape is unchanged.
- The coverage page shows a "My coverage" toggle only to logged-in users.
### Linked-only ingest
- Config: `clientRxCoverage.requireLinkedCompanion` (bool, default false). It only
takes effect with `userManagement.enabled`. If user management is off, the server
and the ingestor log a startup warning and ignore the setting.
- Ingestor: before any client handler runs (packets, rf, regions), it checks the
topic pubkey against an in-memory set of linked pubkeys read from `users.db`.
- The set refreshes every 60 s, which picks up unlinks.
- On a miss, the ingestor re-reads the set at most once per 5 s before dropping.
A companion that was just linked is therefore accepted within seconds, and
an RX client can publish right after a successful link.
- Dropped messages are counted per reason in the ingestor stats file
(`client_unlinked_dropped`), never logged per message.
- `/api/config/client` gains `clientRxRequireLinkedCompanion: true` when the setting
is in effect. The field is omitted otherwise. RX reads it to hold its queue
instead of publishing data that would be dropped.
- **It is not a security boundary.** All RX clients share one broker account, so
anyone with that password can publish under a linked pubkey. The setting keeps
honest unlinked clients out. Real enforcement needs per-user publish credentials
(the later "publish token" sub-project).
- Unlinking does not delete coverage that is already stored.
### CORS
For origins in `corsAllowedOrigins`, and only on the bearer-scoped routes above, the
preflight also allows `POST, PUT, DELETE` and the `Authorization` and `Content-Type`
headers. `Access-Control-Allow-Credentials` stays off. Same-origin needs nothing.
## Account page (`public/account.js`)
- **Devices:** device tokens render with their label and a device marker; nothing
else changes.
- A new **Companions** section below Devices lists name, short pubkey, linked since
and last seen, each with an **Unlink** button. When the list is empty, it explains
that companions are linked by logging in from CoreDrive RX.
## Error handling
| Case | Answer |
|---|---|
| Feature off | 404 on every route above. |
| Wrong credentials | 401, the same message as `/api/auth/login`. |
| Bearer token outside its scope | 403. |
| Expired or revoked token | 401. RX clears the token. |
| Challenge missing, expired or used | 410. |
| Bad signature or malformed pubkey | 400. |
| Rate limited | 429 with `Retry-After`. |
Tokens, challenges and signatures are never logged.
## Testing
- `internal/users`: the v6 migration; the device-session kind, label and scopes;
sliding expiry; the challenge lifecycle (expiry, single use, binding); the link
upsert and transfer; cascade on user delete.
- `cmd/server`:
- Device-token issue: success, the constant-cost failure, the rate limit, the audit row.
- Bearer scope: allowed routes pass, others answer 403, and no CSRF is needed.
- Linking with a real MeshCore key and signature fixture: success, wrong host in
the message, reused challenge, a challenge bound to another pubkey.
- Transfer between two users: the audit rows and the mail.
- The `meshcore-my-nodes` merge: keeps existing items, skips duplicates, reports
`full` at the cap.
- `rx-coverage?mine=1`.
- CORS headers only for allowlisted origins and only on scoped routes.
- Everything answers 404 with the feature off.
- `cmd/ingestor`: with `requireLinkedCompanion` off, nothing changes. With it on:
linked pubkeys pass, unlinked pubkeys are dropped and counted, a link made after
the last refresh is accepted through the miss refresh, the 5 s cap holds under a
flood of unknown pubkeys, an unlink is honoured after the periodic refresh, and
user management off means the setting is ignored with a warning.
- `/api/config/client`: the field is present only when the setting is in effect.
- e2e: log in on the account page, see a seeded device token under Devices and a
linked companion under Companions, then unlink it.
## Rollout
Opt-in through `userManagement.enabled`, like A–E. Deployments where RX runs on
another origin add that origin to `corsAllowedOrigins`. An RX build without account
support is unaffected, **except** under `requireLinkedCompanion`: then its data is
dropped. Turn that setting on only once the RX clients in use support linking.
+79
View File
@@ -0,0 +1,79 @@
package users
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// LinkChallengeTTL is how long a companion-link challenge stays valid.
const LinkChallengeTTL = 5 * time.Minute
// challengeHash is the stored form of a challenge: SHA-256 of its 32 raw bytes.
func challengeHash(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
// CreateLinkChallenge issues a single-use challenge bound to userID and
// pubkey. It returns the 32 random bytes as lowercase hex; only their hash
// is stored.
func (s *Store) CreateLinkChallenge(userID int64, pubkey string) (string, time.Time, error) {
pk, err := NormalizePubkey(pubkey)
if err != nil {
return "", time.Time{}, err
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, fmt.Errorf("users: challenge: %w", err)
}
exp := unix(s.now().Add(LinkChallengeTTL))
if _, err := s.db.Exec(`INSERT INTO link_challenges (challenge_hash, user_id, pubkey, expires_at) VALUES (?, ?, ?, ?)`,
challengeHash(raw), userID, pk, exp); err != nil {
return "", time.Time{}, fmt.Errorf("users: create link challenge: %w", err)
}
return hex.EncodeToString(raw), fromUnix(exp), nil
}
// ConsumeLinkChallenge checks a challenge returned by the client and deletes
// it in every case. It returns ErrChallengeMissing (unknown, malformed or
// already used), ErrChallengeExpired, or ErrChallengeMismatch (bound to
// another user or pubkey). One DELETE … RETURNING statement, so two
// concurrent consumers cannot both succeed.
func (s *Store) ConsumeLinkChallenge(userID int64, pubkey, challenge string) error {
raw, err := hex.DecodeString(challenge)
if err != nil || len(raw) != 32 {
return ErrChallengeMissing
}
var owner, exp int64
var pk string
err = s.db.QueryRow(`DELETE FROM link_challenges WHERE challenge_hash = ? RETURNING user_id, pubkey, expires_at`,
challengeHash(raw)).Scan(&owner, &pk, &exp)
if errors.Is(err, sql.ErrNoRows) {
return ErrChallengeMissing
}
if err != nil {
return fmt.Errorf("users: consume link challenge: %w", err)
}
if !s.now().Before(fromUnix(exp)) {
return ErrChallengeExpired
}
want, perr := NormalizePubkey(pubkey)
if owner != userID || perr != nil || pk != want {
return ErrChallengeMismatch
}
return nil
}
// PruneLinkChallenges deletes challenges that have expired.
func (s *Store) PruneLinkChallenges() (int64, error) {
res, err := s.db.Exec(`DELETE FROM link_challenges WHERE expires_at <= ?`, unix(s.now()))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+104
View File
@@ -0,0 +1,104 @@
package users
import (
"encoding/hex"
"errors"
"strings"
"testing"
"time"
)
func TestNormalizePubkey(t *testing.T) {
pk := strings.Repeat("ab", 32)
if got, err := NormalizePubkey(" " + strings.ToUpper(pk) + " "); err != nil || got != pk {
t.Fatalf("NormalizePubkey = %q, %v", got, err)
}
for _, bad := range []string{"", pk[:62], pk + "00", strings.Repeat("zz", 32)} {
if _, err := NormalizePubkey(bad); !errors.Is(err, ErrBadPubkey) {
t.Fatalf("NormalizePubkey(%q) err = %v", bad, err)
}
}
}
func TestLinkChallengeLifecycle(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
pk := strings.Repeat("ab", 32)
ch, exp, err := st.CreateLinkChallenge(a.ID, strings.ToUpper(pk))
if err != nil {
t.Fatal(err)
}
raw, err := hex.DecodeString(ch)
if err != nil || len(raw) != 32 || ch != strings.ToLower(ch) || !exp.Equal(clk.Now().Add(LinkChallengeTTL)) {
t.Fatalf("CreateLinkChallenge = %q, %v, %v", ch, exp, err)
}
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM link_challenges WHERE challenge_hash = ? AND pubkey = ? AND user_id = ?`,
challengeHash(raw), pk, a.ID).Scan(&n); err != nil || n != 1 {
t.Fatalf("stored challenge rows = %d, %v (want SHA-256 of the raw bytes)", n, err)
}
if _, _, err := st.CreateLinkChallenge(a.ID, "nothex"); !errors.Is(err, ErrBadPubkey) {
t.Fatalf("bad pubkey err = %v", err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); err != nil {
t.Fatalf("first consume: %v", err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeMissing) {
t.Fatalf("second consume err = %v, want single use", err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, "zz"); !errors.Is(err, ErrChallengeMissing) {
t.Fatalf("malformed challenge err = %v", err)
}
}
func TestLinkChallengeBindingAndExpiry(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
b := mustCreate(t, st, "b@example.org", "B")
pk := strings.Repeat("ab", 32)
otherPk := strings.Repeat("cd", 32)
ch, _, _ := st.CreateLinkChallenge(a.ID, pk)
if err := st.ConsumeLinkChallenge(b.ID, pk, ch); !errors.Is(err, ErrChallengeMismatch) {
t.Fatalf("other user err = %v", err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeMissing) {
t.Fatalf("mismatch did not consume: %v", err)
}
ch, _, _ = st.CreateLinkChallenge(a.ID, pk)
if err := st.ConsumeLinkChallenge(a.ID, otherPk, ch); !errors.Is(err, ErrChallengeMismatch) {
t.Fatalf("other pubkey err = %v", err)
}
ch, _, _ = st.CreateLinkChallenge(a.ID, pk)
clk.Advance(LinkChallengeTTL)
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeExpired) {
t.Fatalf("expired err = %v", err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeMissing) {
t.Fatalf("expired challenge not consumed: %v", err)
}
}
func TestPruneLinkChallenges(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
pk := strings.Repeat("ab", 32)
old, _, _ := st.CreateLinkChallenge(a.ID, pk)
clk.Advance(3 * time.Minute)
fresh, _, _ := st.CreateLinkChallenge(a.ID, pk)
clk.Advance(3 * time.Minute) // old expired, fresh has 2 minutes left
n, err := st.PruneLinkChallenges()
if err != nil || n != 1 {
t.Fatalf("PruneLinkChallenges = %d, %v", n, err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, old); !errors.Is(err, ErrChallengeMissing) {
t.Fatalf("pruned challenge err = %v", err)
}
if err := st.ConsumeLinkChallenge(a.ID, pk, fresh); err != nil {
t.Fatalf("live challenge pruned: %v", err)
}
}
+148
View File
@@ -0,0 +1,148 @@
package users
import (
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// NormalizePubkey returns a companion's public key as 64 lowercase hex
// characters, or ErrBadPubkey.
func NormalizePubkey(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
if len(s) != 64 {
return "", ErrBadPubkey
}
if _, err := hex.DecodeString(s); err != nil {
return "", ErrBadPubkey
}
return s, nil
}
// CompanionLink ties a companion's pubkey to the user who proved they hold
// its key. A pubkey has at most one owner.
type CompanionLink struct {
Pubkey string
UserID int64
Name string
LinkedAt time.Time
}
const companionCols = `pubkey, user_id, name, linked_at`
func scanCompanion(row rowScanner) (*CompanionLink, error) {
var l CompanionLink
var at int64
if err := row.Scan(&l.Pubkey, &l.UserID, &l.Name, &at); err != nil {
return nil, err
}
l.LinkedAt = fromUnix(at)
return &l, nil
}
// UpsertCompanionLink links pubkey to userID; the newest proof wins. When
// the pubkey belonged to another user it moves to userID with a fresh
// LinkedAt, and prevOwner is that user's id. Otherwise prevOwner is 0; a
// re-link by the same owner only updates the name. name is cleaned with
// CleanLabel. The caller must have consumed a valid challenge and verified
// the signature first.
func (s *Store) UpsertCompanionLink(userID int64, pubkey, name string) (*CompanionLink, int64, error) {
pk, err := NormalizePubkey(pubkey)
if err != nil {
return nil, 0, err
}
name = CleanLabel(name)
tx, err := s.db.Begin()
if err != nil {
return nil, 0, err
}
var prev, linkedAt int64
err = tx.QueryRow(`SELECT user_id, linked_at FROM companion_links WHERE pubkey = ?`, pk).Scan(&prev, &linkedAt)
switch {
case errors.Is(err, sql.ErrNoRows):
prev, linkedAt = 0, unix(s.now())
case err != nil:
tx.Rollback()
return nil, 0, fmt.Errorf("users: read companion link: %w", err)
case prev != userID:
linkedAt = unix(s.now())
}
if _, err := tx.Exec(`INSERT INTO companion_links (pubkey, user_id, name, linked_at) VALUES (?, ?, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET user_id = excluded.user_id, name = excluded.name, linked_at = excluded.linked_at`,
pk, userID, name, linkedAt); err != nil {
tx.Rollback()
return nil, 0, fmt.Errorf("users: upsert companion link: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, 0, fmt.Errorf("users: upsert companion link: %w", err)
}
if prev == userID {
prev = 0
}
return &CompanionLink{Pubkey: pk, UserID: userID, Name: name, LinkedAt: fromUnix(linkedAt)}, prev, nil
}
// ListCompanionLinks returns a user's linked companions, newest first.
func (s *Store) ListCompanionLinks(userID int64) ([]CompanionLink, error) {
rows, err := s.db.Query(`SELECT `+companionCols+` FROM companion_links WHERE user_id = ? ORDER BY linked_at DESC, pubkey`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CompanionLink
for rows.Next() {
l, err := scanCompanion(rows)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
// GetCompanionLink returns the link for pubkey, or ErrNotFound.
func (s *Store) GetCompanionLink(pubkey string) (*CompanionLink, error) {
pk, err := NormalizePubkey(pubkey)
if err != nil {
return nil, err
}
l, err := scanCompanion(s.db.QueryRow(`SELECT `+companionCols+` FROM companion_links WHERE pubkey = ?`, pk))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("users: get companion link: %w", err)
}
return l, nil
}
// DeleteCompanionLink unlinks pubkey, only if it is linked to userID.
func (s *Store) DeleteCompanionLink(userID int64, pubkey string) error {
pk, err := NormalizePubkey(pubkey)
if err != nil {
return err
}
return expectOne(s.db.Exec(`DELETE FROM companion_links WHERE pubkey = ? AND user_id = ?`, pk, userID))
}
// LinkedPubkeys returns every linked pubkey, sorted. (The ingestor does not
// call this: it reads users.db read-only with raw SQL.)
func (s *Store) LinkedPubkeys() ([]string, error) {
rows, err := s.db.Query(`SELECT pubkey FROM companion_links ORDER BY pubkey`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var pk string
if err := rows.Scan(&pk); err != nil {
return nil, err
}
out = append(out, pk)
}
return out, rows.Err()
}
+126
View File
@@ -0,0 +1,126 @@
package users
import (
"errors"
"reflect"
"strings"
"testing"
"time"
)
func TestCompanionLinkUpsertAndTransfer(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
b := mustCreate(t, st, "b@example.org", "B")
pk := strings.Repeat("ab", 32)
l, prev, err := st.UpsertCompanionLink(a.ID, strings.ToUpper(pk), " Car\n ")
if err != nil || prev != 0 {
t.Fatalf("first link: prev=%d err=%v", prev, err)
}
first := clk.Now()
if l.Pubkey != pk || l.UserID != a.ID || l.Name != "Car" || !l.LinkedAt.Equal(first) {
t.Fatalf("first link = %+v", l)
}
clk.Advance(time.Hour)
l, prev, err = st.UpsertCompanionLink(a.ID, pk, "Car 2")
if err != nil || prev != 0 || l.Name != "Car 2" || !l.LinkedAt.Equal(first) {
t.Fatalf("re-link by owner = %+v, prev=%d, err=%v", l, prev, err)
}
if got, _ := st.GetCompanionLink(pk); got == nil || got.Name != "Car 2" || !got.LinkedAt.Equal(first) {
t.Fatalf("after re-link Get = %+v", got)
}
clk.Advance(time.Hour)
l, prev, err = st.UpsertCompanionLink(b.ID, pk, "Bike")
if err != nil || prev != a.ID {
t.Fatalf("transfer: prev=%d (want %d) err=%v", prev, a.ID, err)
}
if l.UserID != b.ID || !l.LinkedAt.Equal(clk.Now()) {
t.Fatalf("transferred link = %+v", l)
}
got, err := st.GetCompanionLink(pk)
if err != nil || got.UserID != b.ID || got.Name != "Bike" {
t.Fatalf("Get after transfer = %+v, %v", got, err)
}
if list, _ := st.ListCompanionLinks(a.ID); len(list) != 0 {
t.Fatalf("previous owner still lists it: %+v", list)
}
if list, _ := st.ListCompanionLinks(b.ID); len(list) != 1 || list[0].Pubkey != pk {
t.Fatalf("new owner list = %+v", list)
}
if _, _, err := st.UpsertCompanionLink(a.ID, "short", "x"); !errors.Is(err, ErrBadPubkey) {
t.Fatalf("bad pubkey err = %v", err)
}
}
func TestCompanionLinkListDeleteAndAll(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
b := mustCreate(t, st, "b@example.org", "B")
pk1, pk2, pk3 := strings.Repeat("03", 32), strings.Repeat("01", 32), strings.Repeat("02", 32)
st.UpsertCompanionLink(a.ID, pk1, "one")
clk.Advance(time.Minute)
st.UpsertCompanionLink(a.ID, pk2, "two")
st.UpsertCompanionLink(b.ID, pk3, "three")
list, err := st.ListCompanionLinks(a.ID)
if err != nil || len(list) != 2 || list[0].Pubkey != pk2 || list[1].Pubkey != pk1 {
t.Fatalf("ListCompanionLinks (newest first) = %+v, %v", list, err)
}
all, err := st.LinkedPubkeys()
if err != nil || !reflect.DeepEqual(all, []string{pk2, pk3, pk1}) {
t.Fatalf("LinkedPubkeys = %v, %v", all, err)
}
if err := st.DeleteCompanionLink(b.ID, pk1); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleting someone else's link = %v", err)
}
if err := st.DeleteCompanionLink(a.ID, strings.ToUpper(pk1)); err != nil {
t.Fatal(err)
}
if _, err := st.GetCompanionLink(pk1); !errors.Is(err, ErrNotFound) {
t.Fatalf("Get after delete err = %v", err)
}
if err := st.DeleteCompanionLink(a.ID, pk1); !errors.Is(err, ErrNotFound) {
t.Fatalf("second delete err = %v", err)
}
if _, err := st.GetCompanionLink("nothex"); !errors.Is(err, ErrBadPubkey) {
t.Fatalf("Get bad pubkey err = %v", err)
}
}
// Deleting a user removes their links, challenges and device tokens.
func TestCompanionDataCascadesOnUserDelete(t *testing.T) {
st, _ := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
b := mustCreate(t, st, "b@example.org", "B")
pkA, pkB := strings.Repeat("aa", 32), strings.Repeat("bb", 32)
st.UpsertCompanionLink(a.ID, pkA, "")
st.UpsertCompanionLink(b.ID, pkB, "")
if _, _, err := st.CreateLinkChallenge(a.ID, pkA); err != nil {
t.Fatal(err)
}
raw, _, err := st.CreateDeviceSession(a.ID, "Phone", []string{"rx"}, "")
if err != nil {
t.Fatal(err)
}
if err := st.Delete(a.ID); err != nil {
t.Fatal(err)
}
if _, err := st.GetCompanionLink(pkA); !errors.Is(err, ErrNotFound) {
t.Fatalf("link survived user delete: %v", err)
}
if all, _ := st.LinkedPubkeys(); !reflect.DeepEqual(all, []string{pkB}) {
t.Fatalf("LinkedPubkeys after delete = %v", all)
}
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM link_challenges WHERE user_id = ?`, a.ID).Scan(&n); err != nil || n != 0 {
t.Fatalf("challenges after user delete = %d, %v", n, err)
}
if _, err := st.LookupSession(raw); !errors.Is(err, ErrNotFound) {
t.Fatalf("device token survived user delete: %v", err)
}
}
+137
View File
@@ -0,0 +1,137 @@
package users
import (
"errors"
"reflect"
"strings"
"testing"
"time"
"unicode/utf8"
)
func TestDeviceSessionKindLabelScopes(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "dev@example.org", "Dev")
_, web, err := st.CreateSession(u.ID, time.Hour, "browser")
if err != nil {
t.Fatal(err)
}
if web.Kind != SessionKindWeb || web.Label != "" || web.Scopes != nil || !web.HasScope("anything") {
t.Fatalf("web session = %+v", web)
}
raw, dev, err := st.CreateDeviceSession(u.ID, " Pixel\x07 8 ", []string{"rx", "account"}, "CoreDriveRX/1.0")
if err != nil {
t.Fatal(err)
}
if raw == "" || dev.Kind != SessionKindDevice || dev.Label != "Pixel 8" || dev.CSRFToken == "" ||
!reflect.DeepEqual(dev.Scopes, []string{"rx", "account"}) || !dev.ExpiresAt.Equal(clk.Now().Add(DeviceSessionTTL)) {
t.Fatalf("CreateDeviceSession = %+v", dev)
}
got, err := st.LookupSession(raw)
if err != nil {
t.Fatal(err)
}
if got.ID != dev.ID || got.Kind != SessionKindDevice || got.Label != "Pixel 8" || got.UserAgent != "CoreDriveRX/1.0" ||
!reflect.DeepEqual(got.Scopes, []string{"rx", "account"}) {
t.Fatalf("LookupSession = %+v", got)
}
if !got.HasScope("rx") || !got.HasScope("account") || got.HasScope("admin") || got.HasScope("") {
t.Fatalf("HasScope wrong for %v", got.Scopes)
}
list, err := st.ListSessions(u.ID)
if err != nil || len(list) != 2 {
t.Fatalf("ListSessions = %+v, %v", list, err)
}
kinds := map[string]string{}
for _, s := range list {
kinds[s.Kind] = s.Label
}
if l, ok := kinds[SessionKindDevice]; !ok || l != "Pixel 8" {
t.Fatalf("device row missing from list: %+v", list)
}
if l, ok := kinds[SessionKindWeb]; !ok || l != "" {
t.Fatalf("web row missing from list: %+v", list)
}
}
func TestDeviceSessionSlidingExpiry(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "slide@example.org", "Slide")
raw, dev, err := st.CreateDeviceSession(u.ID, "Phone", []string{"rx"}, "")
if err != nil {
t.Fatal(err)
}
clk.Advance(DeviceSessionTTL - time.Hour)
if err := st.ExtendSession(dev.ID, DeviceSessionTTL); err != nil {
t.Fatal(err)
}
clk.Advance(DeviceSessionTTL - time.Hour) // well past the original expiry
if _, err := st.LookupSession(raw); err != nil {
t.Fatalf("extended device session expired early: %v", err)
}
clk.Advance(2 * time.Hour)
if _, err := st.LookupSession(raw); !errors.Is(err, ErrNotFound) {
t.Fatalf("expired device session err = %v", err)
}
}
func TestDeviceSessionRevoke(t *testing.T) {
st, _ := newTestStore(t)
u := mustCreate(t, st, "rev@example.org", "Rev")
other := mustCreate(t, st, "oth@example.org", "Oth")
raw1, d1, _ := st.CreateDeviceSession(u.ID, "One", []string{"rx"}, "")
raw2, _, _ := st.CreateDeviceSession(u.ID, "Two", []string{"rx"}, "")
if err := st.DeleteSession(other.ID, d1.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("revoking someone else's device = %v", err)
}
if err := st.DeleteSession(u.ID, d1.ID); err != nil {
t.Fatal(err)
}
if _, err := st.LookupSession(raw1); !errors.Is(err, ErrNotFound) {
t.Fatalf("revoked device token still resolves: %v", err)
}
if err := st.DeleteSessionByToken(raw2); err != nil {
t.Fatal(err)
}
if _, err := st.LookupSession(raw2); !errors.Is(err, ErrNotFound) {
t.Fatalf("logged-out device token still resolves: %v", err)
}
}
func TestDeviceSessionRejectsBadScopes(t *testing.T) {
st, _ := newTestStore(t)
u := mustCreate(t, st, "scope@example.org", "Scope")
if _, _, err := st.CreateDeviceSession(u.ID, "x", nil, ""); !errors.Is(err, ErrNoScopes) {
t.Fatalf("nil scopes err = %v", err)
}
for _, bad := range []string{"", "a,b", "Rx", "rx scope"} {
if _, _, err := st.CreateDeviceSession(u.ID, "x", []string{bad}, ""); err == nil {
t.Fatalf("scope %q accepted", bad)
}
}
if list, _ := st.ListSessions(u.ID); len(list) != 0 {
t.Fatalf("rejected device sessions were stored: %+v", list)
}
}
func TestCleanLabel(t *testing.T) {
cases := map[string]string{
" Car ": "Car",
"a\tb\nc\x00d": "abcd",
"bad\xffutf8": "badutf8",
"": "",
}
for in, want := range cases {
if got := CleanLabel(in); got != want {
t.Errorf("CleanLabel(%q) = %q, want %q", in, got, want)
}
}
long := CleanLabel(strings.Repeat("é", 70))
if utf8.RuneCountInString(long) != 64 {
t.Fatalf("CleanLabel cap = %d runes", utf8.RuneCountInString(long))
}
}
+19
View File
@@ -124,6 +124,25 @@ var migrations = [][]string{
PRIMARY KEY (user_id, event, subject)
)`,
},
{ // v6: sub-project F, companion linking (device sessions, linked companions)
`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 ''`,
`ALTER TABLE sessions ADD COLUMN scopes TEXT NOT NULL DEFAULT ''`,
`CREATE TABLE companion_links (
pubkey TEXT PRIMARY KEY,
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,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pubkey TEXT NOT NULL,
expires_at INTEGER NOT NULL
)`,
},
}
func (s *Store) migrate() error {
+76
View File
@@ -0,0 +1,76 @@
package users
import (
"database/sql"
"path/filepath"
"strings"
"testing"
)
// A users.db written by a v5 binary gains the companion-linking schema and
// keeps its sessions, which become web sessions with no label or scopes.
func TestMigrateV5DatabaseToV6(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
stmts := []string{`CREATE TABLE schema_version (version INTEGER NOT NULL)`, `INSERT INTO schema_version (version) VALUES (5)`}
for _, m := range migrations[:5] {
stmts = append(stmts, m...)
}
stmts = append(stmts,
`INSERT INTO users (email, display_name, password_hash, created_at) VALUES ('old@example.org', 'Old', 'x', 1)`,
`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at) VALUES ('h', 1, 'c', 1, 4102444800, 1)`)
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("build v5 db: %v", err)
}
}
db.Close()
st, err := Open(path)
if err != nil {
t.Fatalf("Open v5 db: %v", err)
}
defer st.Close()
if len(migrations) < 6 {
t.Fatal("migration v6 missing")
}
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
}
var kind, label, scopes string
if err := st.db.QueryRow(`SELECT kind, label, scopes FROM sessions WHERE token_hash = 'h'`).Scan(&kind, &label, &scopes); err != nil {
t.Fatalf("v5 session lost: %v", err)
}
if kind != "web" || label != "" || scopes != "" {
t.Fatalf("migrated session kind=%q label=%q scopes=%q", kind, label, scopes)
}
if _, err := st.db.Exec(`UPDATE sessions SET kind = 'api_key' WHERE token_hash = 'h'`); err == nil || !strings.Contains(err.Error(), "CHECK") {
t.Fatalf("kind outside web/device accepted: %v", err)
}
if _, err := st.db.Exec(`UPDATE sessions SET kind = 'device' WHERE token_hash = 'h'`); err != nil {
t.Fatalf("kind device rejected: %v", err)
}
pk := strings.Repeat("ab", 32)
if _, err := st.db.Exec(`INSERT INTO companion_links (pubkey, user_id, linked_at) VALUES (?, 1, 1)`, pk); err != nil {
t.Fatalf("companion_links: %v", err)
}
var name string
if err := st.db.QueryRow(`SELECT name FROM companion_links WHERE pubkey = ?`, pk).Scan(&name); err != nil || name != "" {
t.Fatalf("companion_links default name = %q, %v", name, err)
}
if _, err := st.db.Exec(`INSERT INTO companion_links (pubkey, user_id, linked_at) VALUES (?, 99, 1)`, strings.Repeat("cd", 32)); err == nil {
t.Fatal("companion_links accepted an unknown user (foreign key off?)")
}
if _, err := st.db.Exec(`INSERT INTO link_challenges (challenge_hash, user_id, pubkey, expires_at) VALUES ('x', 1, ?, 1)`, pk); err != nil {
t.Fatalf("link_challenges: %v", err)
}
var idx int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'companion_links_user'`).Scan(&idx); err != nil || idx != 1 {
t.Fatalf("index companion_links_user = %d, %v", idx, err)
}
}
+97 -8
View File
@@ -6,35 +6,122 @@ import (
"fmt"
"strings"
"time"
"unicode"
)
// Session is one logged-in device. The raw token exists only in the cookie.
// Session kinds (sessions.kind).
const (
SessionKindWeb = "web"
SessionKindDevice = "device"
)
// DeviceSessionTTL is a device token's lifetime, sliding on use: callers
// pass it to ExtendSession whenever the token is used.
const DeviceSessionTTL = 90 * 24 * time.Hour
// Session is one logged-in browser or device. The raw token exists only in
// the cookie (web) or on the device (bearer).
type Session struct {
ID int64
UserID int64
CSRFToken string
CSRFToken string // filled but unused for device sessions
CreatedAt time.Time
ExpiresAt time.Time
LastSeenAt time.Time
UserAgent string
Kind string // SessionKindWeb or SessionKindDevice
Label string // device name; "" for web sessions
Scopes []string // nil = full access (web sessions)
}
// HasScope reports whether the session may use scope. Web sessions have
// full access; device sessions only their listed scopes.
func (s *Session) HasScope(scope string) bool {
if s.Kind != SessionKindDevice {
return true
}
for _, sc := range s.Scopes {
if sc == scope {
return true
}
}
return false
}
const maxUserAgent = 200
const sessionCols = `id, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent`
const maxLabel = 64
// CleanLabel makes a user-supplied device or companion name safe to store:
// invalid UTF-8 and control characters removed, trimmed, at most 64 runes.
func CleanLabel(s string) string {
s = strings.ToValidUTF8(s, "")
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > maxLabel {
s = strings.TrimSpace(string(r[:maxLabel]))
}
return s
}
func validScope(sc string) bool {
if sc == "" {
return false
}
for _, r := range sc {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == ':' || r == '.' || r == '_' || r == '-') {
return false
}
}
return true
}
func splitScopes(v string) []string {
if v == "" {
return nil
}
return strings.Split(v, ",")
}
const sessionCols = `id, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent, kind, label, scopes`
func scanSession(row rowScanner) (*Session, error) {
var s Session
var created, expires, seen int64
if err := row.Scan(&s.ID, &s.UserID, &s.CSRFToken, &created, &expires, &seen, &s.UserAgent); err != nil {
var scopes string
if err := row.Scan(&s.ID, &s.UserID, &s.CSRFToken, &created, &expires, &seen, &s.UserAgent, &s.Kind, &s.Label, &scopes); err != nil {
return nil, err
}
s.CreatedAt, s.ExpiresAt, s.LastSeenAt = fromUnix(created), fromUnix(expires), fromUnix(seen)
s.Scopes = splitScopes(scopes)
return &s, nil
}
// CreateSession starts a session and returns the raw cookie token.
// CreateSession starts a web session and returns the raw cookie token.
func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string) (string, *Session, error) {
return s.createSession(userID, ttl, userAgent, SessionKindWeb, "", nil)
}
// CreateDeviceSession issues a device (bearer) token for userID, limited to
// scopes and valid for DeviceSessionTTL. label is cleaned with CleanLabel.
func (s *Store) CreateDeviceSession(userID int64, label string, scopes []string, userAgent string) (string, *Session, error) {
if len(scopes) == 0 {
return "", nil, ErrNoScopes
}
for _, sc := range scopes {
if !validScope(sc) {
return "", nil, fmt.Errorf("users: invalid scope %q", sc)
}
}
return s.createSession(userID, DeviceSessionTTL, userAgent, SessionKindDevice, CleanLabel(label), scopes)
}
func (s *Store) createSession(userID int64, ttl time.Duration, userAgent, kind, label string, scopes []string) (string, *Session, error) {
raw, hash, err := NewToken()
if err != nil {
return "", nil, err
@@ -47,8 +134,9 @@ func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string)
userAgent = strings.ToValidUTF8(userAgent[:maxUserAgent], "")
}
now := s.now()
res, err := s.db.Exec(`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`, hash, userID, csrf, unix(now), unix(now.Add(ttl)), unix(now), userAgent)
res, err := s.db.Exec(`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent, kind, label, scopes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash, userID, csrf, unix(now), unix(now.Add(ttl)), unix(now), userAgent,
kind, label, strings.Join(scopes, ","))
if err != nil {
return "", nil, err
}
@@ -57,7 +145,8 @@ func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string)
return "", nil, err
}
return raw, &Session{ID: id, UserID: userID, CSRFToken: csrf, CreatedAt: fromUnix(unix(now)),
ExpiresAt: fromUnix(unix(now.Add(ttl))), LastSeenAt: fromUnix(unix(now)), UserAgent: userAgent}, nil
ExpiresAt: fromUnix(unix(now.Add(ttl))), LastSeenAt: fromUnix(unix(now)), UserAgent: userAgent,
Kind: kind, Label: label, Scopes: splitScopes(strings.Join(scopes, ","))}, nil
}
// LookupSession resolves a raw cookie token. Unknown and expired sessions
+9
View File
@@ -26,6 +26,15 @@ var (
// password hash the caller verified (a re-register or an admin got
// there first).
ErrAccountChanged = errors.New("users: account changed since it was read")
// ErrNoScopes: a device session must name what it may do; an empty
// scope list would mean full (web) access.
ErrNoScopes = errors.New("users: a device session needs at least one scope")
ErrBadPubkey = errors.New("users: pubkey must be 64 hex characters")
// Link challenges (companion linking). A challenge is consumed by every
// lookup, whichever of these it returns.
ErrChallengeMissing = errors.New("users: link challenge unknown or already used")
ErrChallengeExpired = errors.New("users: link challenge expired")
ErrChallengeMismatch = errors.New("users: link challenge bound to another user or pubkey")
)
// noLimit is SQLite's LIMIT value for "every row" (the account export).