mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 08:37:25 +00:00
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>
80 lines
2.6 KiB
Go
80 lines
2.6 KiB
Go
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()
|
|
}
|