From fe4c35b63ccb6920e35a0cacc2679332f688faed Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 23 Oct 2026 11:10:15 +0200 Subject: [PATCH 1/4] fix(server): never move a linked companion to another account LinkCompanion upserted with ON CONFLICT(pubkey) DO UPDATE SET user_id, so linking a companion that another account had already linked moved it to the new account, and the original owner lost it without notice. The insert now does nothing on conflict, and LinkCompanion returns ErrCompanionTaken when the pubkey belongs to someone else. link-companion answers 409 companion_taken. register checks before it creates the account, so a refused registration leaves no account behind. Re-linking your own companion stays idempotent. Instead of a connection or username error, the app now says the companion is already linked. Co-Authored-By: Claude Opus 5.5 --- app/src/app.js | 2 + .../2026-10-23-01-companion-link-stays.json | 7 ++++ server/internal/httpapi/auth.go | 13 ++++++- server/internal/httpapi/auth_test.go | 39 +++++++++++++++++++ server/internal/store/users.go | 20 +++++++++- server/internal/store/users_test.go | 22 ++++++++++- 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2026-10-23-01-companion-link-stays.json diff --git a/app/src/app.js b/app/src/app.js index 3dc751d5..9aecfb23 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -3057,6 +3057,7 @@ function buildSettingsSheet() { const r = await postAuth('/api/auth/link-companion', buildLinkBody(state.rxPubkey)) if (r.ok) { await refreshAccount(); accMsg('Companion linked.', true) } else if (r.status === 401) accMsg('Log in first.') + else if (r.status === 409) accMsg('This companion is already linked to another account.') else accMsg('Linking failed — check your connection.') }) @@ -3109,6 +3110,7 @@ function buildSettingsSheet() { // windowed too (server/internal/httpapi/api.go). accMsg("Account created — you are a hunter: on the web map, filter to your own companion to see its captures in full. Seeing other hunters in full needs an admin to verify you as a member.", true) } + else if (r.status === 409 && r.data.error === 'companion_taken') accMsg('This companion is already linked to another account.') else if (r.status === 409) accMsg('That username is taken.') else if (r.status === 429) accMsg('Too many attempts — wait a minute.') else accMsg('Registration failed — check your connection.') diff --git a/changelog.d/2026-10-23-01-companion-link-stays.json b/changelog.d/2026-10-23-01-companion-link-stays.json new file mode 100644 index 00000000..20793708 --- /dev/null +++ b/changelog.d/2026-10-23-01-companion-link-stays.json @@ -0,0 +1,7 @@ +{ + "id": "2026-10-23-companion-link-stays", + "date": "2026-10-23", + "where": "app", + "title": "A linked companion stays with its account", + "body": "A companion already linked to an account can no longer be linked to a second one; the app says so." +} diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index 45add299..3deea42e 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "encoding/json" + "errors" "net" "net/http" "regexp" @@ -227,6 +228,11 @@ func (h *AuthAPI) Register(w http.ResponseWriter, r *http.Request) { writeErr(w, 409, "username_taken") return } + pk := strings.ToLower(in.CompanionPubkey) + if owner, _ := h.Store.UserIDForCompanion(pk); owner != 0 { + writeErr(w, 409, "companion_taken") + return + } hash, err := auth.HashPassword(in.Password) if err != nil { writeErr(w, 500, "hash_error") @@ -237,7 +243,7 @@ func (h *AuthAPI) Register(w http.ResponseWriter, r *http.Request) { writeErr(w, 409, "username_taken") return } - h.Store.LinkCompanion(uid, strings.ToLower(in.CompanionPubkey)) + h.Store.LinkCompanion(uid, pk) if err := h.startSession(w, uid, true, clientIP(r)); err != nil { writeErr(w, 500, "session_error") return @@ -295,7 +301,10 @@ func (h *AuthAPI) LinkCompanion(w http.ResponseWriter, r *http.Request) { return } pk := strings.ToLower(in.CompanionPubkey) - if err := h.Store.LinkCompanion(a.UserID, pk); err != nil { + if err := h.Store.LinkCompanion(a.UserID, pk); errors.Is(err, store.ErrCompanionTaken) { + writeErr(w, 409, "companion_taken") + return + } else if err != nil { writeErr(w, 500, "link_error") return } diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go index 2f04ebc4..f9182ed8 100644 --- a/server/internal/httpapi/auth_test.go +++ b/server/internal/httpapi/auth_test.go @@ -224,3 +224,42 @@ func TestLinkCompanionRequiresAuth(t *testing.T) { t.Fatalf("companion not linked (lowercased): %d", owner) } } + +func TestLinkCompanionTakenByAnotherUser(t *testing.T) { + h, st := newAuthAPI(t) + defer st.Close() + alice, _ := st.CreateUser("alice", "", "h", "hunter", "active") + st.LinkCompanion(alice, "cc33") + bob, _ := st.CreateUser("bob", "", "h", "hunter", "active") + r := httptest.NewRequest("POST", "/api/auth/link-companion", + strings.NewReader(`{"companion_pubkey":"CC33"}`)) + r = r.WithContext(context.WithValue(r.Context(), authCtxKey, + Auth{UserID: bob, Username: "bob", Role: "hunter"})) + w := httptest.NewRecorder() + h.LinkCompanion(w, r) + if w.Code != 409 || !strings.Contains(w.Body.String(), "companion_taken") { + t.Fatalf("linking alice's companion as bob: want 409 companion_taken, got %d %s", w.Code, w.Body) + } + if owner, _ := st.UserIDForCompanion("cc33"); owner != alice { + t.Fatalf("companion moved to user %d, want it to stay with alice (%d)", owner, alice) + } +} + +func TestRegisterWithTakenCompanion(t *testing.T) { + h, st := newAuthAPI(t) + defer st.Close() + alice, _ := st.CreateUser("alice", "", "h", "hunter", "active") + st.LinkCompanion(alice, "aa11") + w := httptest.NewRecorder() + h.Register(w, httptest.NewRequest("POST", "/api/auth/register", + strings.NewReader(`{"username":"bob","password":"correcthorse","companion_pubkey":"AA11"}`))) + if w.Code != 409 || !strings.Contains(w.Body.String(), "companion_taken") { + t.Fatalf("registering with alice's companion: want 409 companion_taken, got %d %s", w.Code, w.Body) + } + if owner, _ := st.UserIDForCompanion("aa11"); owner != alice { + t.Fatalf("companion moved to user %d, want it to stay with alice (%d)", owner, alice) + } + if u, _ := st.UserByUsername("bob"); u != nil { + t.Fatal("a refused registration must not leave an account behind") + } +} diff --git a/server/internal/store/users.go b/server/internal/store/users.go index 01107e68..a7b49e82 100644 --- a/server/internal/store/users.go +++ b/server/internal/store/users.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "errors" "time" ) @@ -105,12 +106,27 @@ func (s *Store) CountActiveAdmins() (int, error) { return n, err } +// ErrCompanionTaken: the pubkey is already linked to another user. A link is +// never moved: the companion pubkey is public, so moving it would hand that +// companion's full history to whoever claimed it last. +var ErrCompanionTaken = errors.New("companion linked to another user") + func (s *Store) LinkCompanion(userID int64, pubkey string) error { _, err := s.db.Exec( `INSERT INTO companions(pubkey,user_id,linked_at) VALUES(?,?,?) - ON CONFLICT(pubkey) DO UPDATE SET user_id=excluded.user_id`, + ON CONFLICT(pubkey) DO NOTHING`, pubkey, userID, nowRFC3339()) - return err + if err != nil { + return err + } + owner, err := s.UserIDForCompanion(pubkey) + if err != nil { + return err + } + if owner != userID { + return ErrCompanionTaken + } + return nil } func (s *Store) CompanionsFor(userID int64) ([]string, error) { rows, err := s.db.Query(`SELECT pubkey FROM companions WHERE user_id=? ORDER BY linked_at`, userID) diff --git a/server/internal/store/users_test.go b/server/internal/store/users_test.go index 350df062..79f2b1f6 100644 --- a/server/internal/store/users_test.go +++ b/server/internal/store/users_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "errors" + "testing" +) func TestAuthTablesExist(t *testing.T) { st, err := Open(":memory:") @@ -90,6 +93,23 @@ func TestCompanions(t *testing.T) { } } +func TestLinkCompanionDoesNotReassign(t *testing.T) { + st, _ := Open(":memory:") + defer st.Close() + alice, _ := st.CreateUser("alice", "", "h", "hunter", "active") + bob, _ := st.CreateUser("bob", "", "h", "hunter", "active") + st.LinkCompanion(alice, "aa11") + if err := st.LinkCompanion(bob, "aa11"); !errors.Is(err, ErrCompanionTaken) { + t.Fatalf("linking another user's companion: want ErrCompanionTaken got %v", err) + } + if got, _ := st.UserIDForCompanion("aa11"); got != alice { + t.Fatalf("companion moved to user %d, want it to stay with %d", got, alice) + } + if err := st.LinkCompanion(alice, "aa11"); err != nil { + t.Fatalf("re-linking your own companion must stay idempotent: %v", err) + } +} + func TestSessions(t *testing.T) { st, _ := Open(":memory:") defer st.Close() From 9cc369e33a4ed73141a038928603c53f3361a2fc Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 23 Oct 2026 11:18:52 +0200 Subject: [PATCH 2/4] feat(auth): prove possession of a companion before linking it (#751) The companion pubkey is public, so naming it should not be enough to link it to an account. Register and link-companion now also take a challenge and the companion's Ed25519 signature over "core-hunter-link:" + challenge, and they answer 403 companion_proof_invalid without one. POST /api/auth/companion-challenge issues the challenge. It is stateless: expiry, a random nonce and an HMAC over pubkey, expiry and nonce, keyed per process. It is bound to the pubkey and valid for 5 minutes. A restart voids the challenges in flight. The app gets a challenge and has the connected radio sign it, using the same CMD_SIGN_START/DATA/FINISH path the broker tokens use (companionsign.js). Without a connected radio it says so instead of sending the request. Co-Authored-By: Claude Opus 5.5 --- app/src/__tests__/auth.test.js | 35 +++++- app/src/app.js | 33 +++-- app/src/auth.js | 24 +++- .../2026-10-23-02-companion-link-proof.json | 7 ++ server/cmd/ingestor/main.go | 2 +- server/internal/auth/companionproof.go | 88 +++++++++++++ server/internal/auth/companionproof_test.go | 72 +++++++++++ server/internal/httpapi/api.go | 1 + server/internal/httpapi/auth.go | 29 +++++ server/internal/httpapi/auth_test.go | 119 ++++++++++++++++-- 10 files changed, 385 insertions(+), 25 deletions(-) create mode 100644 changelog.d/2026-10-23-02-companion-link-proof.json create mode 100644 server/internal/auth/companionproof.go create mode 100644 server/internal/auth/companionproof_test.go diff --git a/app/src/__tests__/auth.test.js b/app/src/__tests__/auth.test.js index 0c42301e..ca920bbd 100644 --- a/app/src/__tests__/auth.test.js +++ b/app/src/__tests__/auth.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { validateRegistration, buildRegisterBody, buildLoginBody, buildLinkBody, fetchMe, postAuth, accountDisplayState, submitLabelForMode } from '../auth.js' +import { validateRegistration, buildRegisterBody, buildLoginBody, buildLinkBody, fetchMe, postAuth, accountDisplayState, submitLabelForMode, companionProof } from '../auth.js' describe('validateRegistration', () => { const ok = { username: 'alice', password: '0123456789', companionPubkey: 'ab'.repeat(32) } @@ -116,3 +116,36 @@ describe('offline resilience', () => { expect(s.showLogin).toBe(true) }) }) + +describe('companionProof', () => { + it('signs the prefixed challenge the server issued for this pubkey', async () => { + const post = vi.fn(async () => ({ ok: true, status: 200, data: { challenge: 'c0ffee' } })) + const sign = vi.fn(async () => new Uint8Array(64).fill(0xab)) + const proof = await companionProof({ pubkeyHex: 'ff', sign, post }) + expect(post).toHaveBeenCalledWith('/api/auth/companion-challenge', { companion_pubkey: 'ff' }) + expect(new TextDecoder().decode(sign.mock.calls[0][0])).toBe('core-hunter-link:c0ffee') + expect(proof).toEqual({ challenge: 'c0ffee', signature: 'ab'.repeat(64) }) + }) + it('returns null when no challenge comes back', async () => { + const post = vi.fn(async () => ({ ok: false, status: 0, data: {} })) + const sign = vi.fn() + expect(await companionProof({ pubkeyHex: 'ff', sign, post })).toBeNull() + expect(sign).not.toHaveBeenCalled() + }) + it('returns null when the radio does not sign', async () => { + const post = vi.fn(async () => ({ ok: true, status: 200, data: { challenge: 'c0ffee' } })) + const sign = vi.fn(async () => { throw new Error('timeout') }) + expect(await companionProof({ pubkeyHex: 'ff', sign, post })).toBeNull() + }) +}) + +describe('body builders carry the proof', () => { + const proof = { challenge: 'c0', signature: 'ab' } + it('buildRegisterBody', () => { + expect(buildRegisterBody({ username: 'a', password: 'p', email: '', companionPubkey: 'ff', proof })) + .toEqual({ username: 'a', password: 'p', companion_pubkey: 'ff', challenge: 'c0', signature: 'ab' }) + }) + it('buildLinkBody', () => { + expect(buildLinkBody('ff', proof)).toEqual({ companion_pubkey: 'ff', challenge: 'c0', signature: 'ab' }) + }) +}) diff --git a/app/src/app.js b/app/src/app.js index 9aecfb23..95bb7cdb 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -75,7 +75,7 @@ import { compassHeading, bearingForHeading, nextCompassState, compassGlyph, comp import { fabRingSvg } from './fabring.js' import { SOUND_MODES, nextSoundMode, receptionCue, createSoundEngine } from './sound.js' import { parseVersion, isUpdateAvailable } from './update.js' -import { fetchMe, postAuth, validateRegistration, buildRegisterBody, buildLoginBody, buildLinkBody, accountDisplayState, submitLabelForMode } from './auth.js' +import { fetchMe, postAuth, validateRegistration, buildRegisterBody, buildLoginBody, buildLinkBody, accountDisplayState, submitLabelForMode, companionProof } from './auth.js' import { closeSheet } from './sheetfocus.js' // --------------------------------------------------------------------------- @@ -656,17 +656,26 @@ async function legStatusOf(id) { // stored one while it has time left, otherwise a fresh signature -- which takes // the radio, connected and the same one the receptions belong to. null means // not now. +// companionSign returns the connected radio's sign function, or null when no +// radio is connected. +function companionSign() { + if (!state.connected || !state.transport) return null + if (!state.signer || state.signer.transport !== state.transport) { + state.signer = { transport: state.transport, sign: createSigner(state.transport) } + } + return state.signer.sign +} + async function brokerCredentials(broker, owner) { if (broker.auth !== 'companion') return { username: broker.username, password: broker.password } const audience = brokerAudience(broker.url) const nowSec = Math.floor(Date.now() / 1000) const stored = state.brokerTokens[broker.id] if (tokenUsable(stored, { pubkeyHex: owner, audience, nowSec })) return { username: brokerUsername(owner), password: stored } - if (!state.connected || !state.transport || state.rxPubkey !== owner) return null - if (!state.signer || state.signer.transport !== state.transport) { - state.signer = { transport: state.transport, sign: createSigner(state.transport) } - } - const token = await buildBrokerToken({ pubkeyHex: owner, audience, nowSec, sign: state.signer.sign }) + if (state.rxPubkey !== owner) return null + const sign = companionSign() + if (!sign) return null + const token = await buildBrokerToken({ pubkeyHex: owner, audience, nowSec, sign }) state.brokerTokens = { ...state.brokerTokens, [broker.id]: token } saveBrokerTokens(state.brokerTokens) return { username: brokerUsername(owner), password: token } @@ -3054,10 +3063,14 @@ function buildSettingsSheet() { if (!state.rxPubkey) return const short = state.rxPubkey.slice(0, 12) + '…' accMsg(`Linking companion ${short}…`) - const r = await postAuth('/api/auth/link-companion', buildLinkBody(state.rxPubkey)) + const sign = companionSign() + const proof = sign && await companionProof({ pubkeyHex: state.rxPubkey, sign }) + if (!proof) { accMsg('The companion did not sign. Keep it connected and try again.'); return } + const r = await postAuth('/api/auth/link-companion', buildLinkBody(state.rxPubkey, proof)) if (r.ok) { await refreshAccount(); accMsg('Companion linked.', true) } else if (r.status === 401) accMsg('Log in first.') else if (r.status === 409) accMsg('This companion is already linked to another account.') + else if (r.status === 403) accMsg('The companion signature was not accepted. Try again.') else accMsg('Linking failed — check your connection.') }) @@ -3096,8 +3109,11 @@ function buildSettingsSheet() { : 'Connect a companion first.') return } + const sign = companionSign() + const proof = sign && await companionProof({ pubkeyHex: state.rxPubkey, sign }) + if (!proof) { accMsg('The companion did not sign. Keep it connected and try again.'); return } const r = await postAuth('/api/auth/register', - buildRegisterBody({ username, password, email, companionPubkey: state.rxPubkey })) + buildRegisterBody({ username, password, email, companionPubkey: state.rxPubkey, proof })) if (r.ok) { closeAccForm() await refreshAccount() @@ -3112,6 +3128,7 @@ function buildSettingsSheet() { } else if (r.status === 409 && r.data.error === 'companion_taken') accMsg('This companion is already linked to another account.') else if (r.status === 409) accMsg('That username is taken.') + else if (r.status === 403 && r.data.error === 'companion_proof_invalid') accMsg('The companion signature was not accepted. Try again.') else if (r.status === 429) accMsg('Too many attempts — wait a minute.') else accMsg('Registration failed — check your connection.') return diff --git a/app/src/auth.js b/app/src/auth.js index b3570768..2774bece 100644 --- a/app/src/auth.js +++ b/app/src/auth.js @@ -8,8 +8,8 @@ export function validateRegistration({ username, password, companionPubkey }) { return errors } -export function buildRegisterBody({ username, password, email, companionPubkey }) { - const body = { username, password, companion_pubkey: companionPubkey } +export function buildRegisterBody({ username, password, email, companionPubkey, proof }) { + const body = { username, password, companion_pubkey: companionPubkey, ...proof } if (email && email.trim()) body.email = email.trim() return body } @@ -18,8 +18,24 @@ export function buildLoginBody({ username, password, remember }) { return { username, password, remember: !!remember } } -export function buildLinkBody(companionPubkey) { - return { companion_pubkey: companionPubkey } +export function buildLinkBody(companionPubkey, proof) { + return { companion_pubkey: companionPubkey, ...proof } +} + +// companionProof shows the server we hold the companion, not just its public +// pubkey: the radio signs a challenge the server issued for that pubkey +// (server/internal/auth/companionproof.go). `sign` is (bytes) => +// Promise. null means no challenge or no signature. +export async function companionProof({ pubkeyHex, sign, post = postAuth }) { + const r = await post('/api/auth/companion-challenge', { companion_pubkey: pubkeyHex }) + const challenge = r.ok && r.data && r.data.challenge + if (!challenge) return null + try { + const sig = await sign(new TextEncoder().encode('core-hunter-link:' + challenge)) + return { challenge, signature: Array.from(sig, (x) => x.toString(16).padStart(2, '0')).join('') } + } catch (_) { + return null + } } export async function fetchMe() { diff --git a/changelog.d/2026-10-23-02-companion-link-proof.json b/changelog.d/2026-10-23-02-companion-link-proof.json new file mode 100644 index 00000000..8dbb6a17 --- /dev/null +++ b/changelog.d/2026-10-23-02-companion-link-proof.json @@ -0,0 +1,7 @@ +{ + "id": "2026-10-23-companion-link-proof", + "date": "2026-10-23", + "where": "app", + "title": "Linking a companion needs the companion itself", + "body": "Registering or linking a companion now asks the connected radio to sign a one-time challenge, so an account can only claim a companion it actually holds." +} diff --git a/server/cmd/ingestor/main.go b/server/cmd/ingestor/main.go index 975aaab8..d3413995 100644 --- a/server/cmd/ingestor/main.go +++ b/server/cmd/ingestor/main.go @@ -92,7 +92,7 @@ func main() { mailer = &mailerAdapter{sender: sender, baseURL: cfg.BaseURL} } limiter := auth.NewRateLimiter(5, time.Minute) - authAPI := &httpapi.AuthAPI{Store: st, CookieSecure: cfg.CookieSecure, Limiter: limiter, Mailer: mailer, BaseURL: cfg.BaseURL} + authAPI := &httpapi.AuthAPI{Store: st, CookieSecure: cfg.CookieSecure, Limiter: limiter, Mailer: mailer, BaseURL: cfg.BaseURL, Prover: auth.NewCompanionProver()} adminAPI := &httpapi.AdminAPI{Store: st, Mailer: mailer, BaseURL: cfg.BaseURL} resolveAPI := &httpapi.ResolveAPI{Upstreams: cfg.ResolveUpstreams, Client: &http.Client{Timeout: 5 * time.Second}} // Longer timeout than the resolve proxy: this fetches the whole registry diff --git a/server/internal/auth/companionproof.go b/server/internal/auth/companionproof.go new file mode 100644 index 00000000..83dd89c3 --- /dev/null +++ b/server/internal/auth/companionproof.go @@ -0,0 +1,88 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "strings" + "time" +) + +// Proof that a caller holds a companion, before its pubkey is linked to an +// account. The pubkey is public, so naming it proves nothing: the companion +// has to sign a fresh challenge with its own key. The app asks the radio to do +// that (app/src/companionsign.js), so the private key never leaves it. +// +// The challenge is stateless: exp(8) || nonce(16) || HMAC-SHA256(key, pubkey || +// exp || nonce), hex. Nothing is stored per challenge, so asking for one costs +// the server nothing. The key lives in memory only; a restart voids the +// challenges in flight, which the app answers by asking for a new one. + +// ChallengeTTL bounds how long a challenge can be answered. +const ChallengeTTL = 5 * time.Minute + +const proofPrefix = "core-hunter-link:" + +// ProofMessage is what the companion signs: a prefix, so the signature cannot +// be lifted into another protocol that signs with the same key, and the +// challenge. +func ProofMessage(challenge string) []byte { + return []byte(proofPrefix + challenge) +} + +type CompanionProver struct { + key []byte +} + +func NewCompanionProver() *CompanionProver { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + panic(err) + } + return &CompanionProver{key: key} +} + +func (p *CompanionProver) mac(pubkey string, body []byte) []byte { + m := hmac.New(sha256.New, p.key) + m.Write([]byte(pubkey)) + m.Write(body) + return m.Sum(nil) +} + +// Challenge issues a challenge bound to this pubkey. +func (p *CompanionProver) Challenge(pubkeyHex string, now time.Time) string { + body := make([]byte, 24) + binary.BigEndian.PutUint64(body, uint64(now.Add(ChallengeTTL).Unix())) + if _, err := rand.Read(body[8:]); err != nil { + panic(err) + } + return hex.EncodeToString(append(body, p.mac(strings.ToLower(pubkeyHex), body)...)) +} + +// Verify reports whether sigHex is the pubkey's signature over a challenge +// this prover issued for that pubkey and that has not expired. +func (p *CompanionProver) Verify(pubkeyHex, challenge, sigHex string, now time.Time) bool { + pub, err := hex.DecodeString(pubkeyHex) + if err != nil || len(pub) != ed25519.PublicKeySize { + return false + } + raw, err := hex.DecodeString(challenge) + if err != nil || len(raw) != 24+sha256.Size { + return false + } + body, tag := raw[:24], raw[24:] + if !hmac.Equal(tag, p.mac(strings.ToLower(pubkeyHex), body)) { + return false + } + if now.Unix() > int64(binary.BigEndian.Uint64(body)) { + return false + } + sig, err := hex.DecodeString(sigHex) + if err != nil || len(sig) != ed25519.SignatureSize { + return false + } + return ed25519.Verify(pub, ProofMessage(challenge), sig) +} diff --git a/server/internal/auth/companionproof_test.go b/server/internal/auth/companionproof_test.go new file mode 100644 index 00000000..dc1bcad7 --- /dev/null +++ b/server/internal/auth/companionproof_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "strings" + "testing" + "time" +) + +func newCompanion(t *testing.T) (string, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return hex.EncodeToString(pub), priv +} + +func signProof(priv ed25519.PrivateKey, challenge string) string { + return hex.EncodeToString(ed25519.Sign(priv, ProofMessage(challenge))) +} + +func TestCompanionProofAcceptsTheCompanionsOwnSignature(t *testing.T) { + p := NewCompanionProver() + now := time.Now() + pk, priv := newCompanion(t) + ch := p.Challenge(pk, now) + if !p.Verify(pk, ch, signProof(priv, ch), now) { + t.Fatal("a signature by the companion's own key must prove possession") + } + if !p.Verify(strings.ToUpper(pk), ch, signProof(priv, ch), now) { + t.Fatal("pubkey case must not matter") + } +} + +func TestCompanionProofRefuses(t *testing.T) { + p := NewCompanionProver() + now := time.Now() + pk, priv := newCompanion(t) + otherPk, otherPriv := newCompanion(t) + ch := p.Challenge(pk, now) + good := signProof(priv, ch) + flip := map[byte]string{'0': "1"}[ch[len(ch)-1]] + if flip == "" { + flip = "0" + } + tampered := ch[:len(ch)-1] + flip + + cases := []struct { + name string + pk, ch, sig string + at time.Time + prover *CompanionProver + }{ + {"signed by another key", pk, ch, signProof(otherPriv, ch), now, p}, + {"challenge issued for another pubkey", otherPk, ch, signProof(otherPriv, ch), now, p}, + {"expired challenge", pk, ch, good, now.Add(ChallengeTTL + time.Second), p}, + {"challenge from another server", pk, ch, good, now, NewCompanionProver()}, + {"tampered challenge", pk, tampered, signProof(priv, tampered), now, p}, + {"signature over the bare challenge", pk, ch, hex.EncodeToString(ed25519.Sign(priv, []byte(ch))), now, p}, + {"not hex", pk, ch, "zz", now, p}, + {"short pubkey", "aa11", ch, good, now, p}, + {"empty", pk, "", "", now, p}, + } + for _, c := range cases { + if c.prover.Verify(c.pk, c.ch, c.sig, c.at) { + t.Errorf("%s: must not prove possession", c.name) + } + } +} diff --git a/server/internal/httpapi/api.go b/server/internal/httpapi/api.go index c44cc16c..40d18195 100644 --- a/server/internal/httpapi/api.go +++ b/server/internal/httpapi/api.go @@ -316,6 +316,7 @@ func RegisterRoutes(mux *http.ServeMux, s *store.Store, ignore []string, cs *sto mux.HandleFunc("/api/auth/login", deps.Auth.Login) mux.HandleFunc("/api/auth/logout", deps.Auth.Logout) mux.HandleFunc("/api/auth/link-companion", deps.Auth.LinkCompanion) + mux.HandleFunc("/api/auth/companion-challenge", deps.Auth.CompanionChallenge) mux.HandleFunc("/api/auth/reset-request", deps.Auth.ResetRequest) mux.HandleFunc("/api/auth/reset", deps.Auth.Reset) } diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index 3deea42e..95bf9d4f 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -145,8 +145,25 @@ type AuthAPI struct { Limiter *auth.RateLimiter Mailer Mailer BaseURL string + Prover *auth.CompanionProver } +// CompanionChallenge hands out a challenge the companion must sign before +// register or link-companion accepts its pubkey (auth.CompanionProver). +func (h *AuthAPI) CompanionChallenge(w http.ResponseWriter, r *http.Request) { + var in struct { + CompanionPubkey string `json:"companion_pubkey"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || !pubkeyRe.MatchString(in.CompanionPubkey) { + writeErr(w, 400, "companion_required") + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"challenge": h.Prover.Challenge(in.CompanionPubkey, time.Now())}) +} + +var pubkeyRe = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`) + func writeErr(w http.ResponseWriter, code int, reason string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) @@ -207,6 +224,8 @@ func (h *AuthAPI) Register(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` Email string `json:"email"` CompanionPubkey string `json:"companion_pubkey"` + Challenge string `json:"challenge"` + Signature string `json:"signature"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeErr(w, 400, "bad_json") @@ -228,6 +247,10 @@ func (h *AuthAPI) Register(w http.ResponseWriter, r *http.Request) { writeErr(w, 409, "username_taken") return } + if !h.Prover.Verify(in.CompanionPubkey, in.Challenge, in.Signature, time.Now()) { + writeErr(w, 403, "companion_proof_invalid") + return + } pk := strings.ToLower(in.CompanionPubkey) if owner, _ := h.Store.UserIDForCompanion(pk); owner != 0 { writeErr(w, 409, "companion_taken") @@ -295,11 +318,17 @@ func (h *AuthAPI) LinkCompanion(w http.ResponseWriter, r *http.Request) { } var in struct { CompanionPubkey string `json:"companion_pubkey"` + Challenge string `json:"challenge"` + Signature string `json:"signature"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.CompanionPubkey == "" { writeErr(w, 400, "companion_required") return } + if !h.Prover.Verify(in.CompanionPubkey, in.Challenge, in.Signature, time.Now()) { + writeErr(w, 403, "companion_proof_invalid") + return + } pk := strings.ToLower(in.CompanionPubkey) if err := h.Store.LinkCompanion(a.UserID, pk); errors.Is(err, store.ErrCompanionTaken) { writeErr(w, 409, "companion_taken") diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go index f9182ed8..9eab0fca 100644 --- a/server/internal/httpapi/auth_test.go +++ b/server/internal/httpapi/auth_test.go @@ -2,6 +2,10 @@ package httpapi import ( "context" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -61,13 +65,32 @@ func TestResolveAuth(t *testing.T) { func newAuthAPI(t *testing.T) (*AuthAPI, *store.Store) { st, _ := store.Open(":memory:") - return &AuthAPI{Store: st, CookieSecure: false, Limiter: auth.NewRateLimiter(5, time.Minute)}, st + return &AuthAPI{Store: st, CookieSecure: false, Limiter: auth.NewRateLimiter(5, time.Minute), Prover: auth.NewCompanionProver()}, st +} + +// companionKey makes a dummy companion keypair; its pubkey is lowercase hex. +func companionKey(t *testing.T) (string, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return hex.EncodeToString(pub), priv +} + +// proofFields answers a fresh challenge for pk with priv, as JSON members to +// splice into a register or link-companion body. +func proofFields(h *AuthAPI, pk string, priv ed25519.PrivateKey) string { + ch := h.Prover.Challenge(pk, time.Now()) + sig := hex.EncodeToString(ed25519.Sign(priv, auth.ProofMessage(ch))) + return `"companion_pubkey":"` + pk + `","challenge":"` + ch + `","signature":"` + sig + `"` } func TestRegisterThenMe(t *testing.T) { h, st := newAuthAPI(t) defer st.Close() - body := `{"username":"alice","password":"correcthorse","companion_pubkey":"aa11"}` + pk, priv := companionKey(t) + body := `{"username":"alice","password":"correcthorse",` + proofFields(h, pk, priv) + `}` w := httptest.NewRecorder() h.Register(w, httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))) if w.Code != 200 { @@ -78,7 +101,7 @@ func TestRegisterThenMe(t *testing.T) { t.Fatal("register must set ch_session cookie") } // pubkey linked, role hunter - uid, _ := st.UserIDForCompanion("aa11") + uid, _ := st.UserIDForCompanion(pk) if uid == 0 { t.Fatal("companion not linked on register") } @@ -211,8 +234,9 @@ func TestLinkCompanionRequiresAuth(t *testing.T) { } // authenticated via context uid, _ := st.CreateUser("alice", "", "h", "hunter", "active") + pk, priv := companionKey(t) r := httptest.NewRequest("POST", "/api/auth/link-companion", - strings.NewReader(`{"companion_pubkey":"CC33"}`)) + strings.NewReader(`{`+strings.Replace(proofFields(h, pk, priv), pk, strings.ToUpper(pk), 1)+`}`)) r = r.WithContext(context.WithValue(r.Context(), authCtxKey, Auth{UserID: uid, Username: "alice", Role: "hunter"})) w2 := httptest.NewRecorder() @@ -220,7 +244,7 @@ func TestLinkCompanionRequiresAuth(t *testing.T) { if w2.Code != 200 { t.Fatalf("auth link should be 200, got %d", w2.Code) } - if owner, _ := st.UserIDForCompanion("cc33"); owner != uid { // lowercased + if owner, _ := st.UserIDForCompanion(pk); owner != uid { // lowercased t.Fatalf("companion not linked (lowercased): %d", owner) } } @@ -229,10 +253,11 @@ func TestLinkCompanionTakenByAnotherUser(t *testing.T) { h, st := newAuthAPI(t) defer st.Close() alice, _ := st.CreateUser("alice", "", "h", "hunter", "active") - st.LinkCompanion(alice, "cc33") + pk, priv := companionKey(t) + st.LinkCompanion(alice, pk) bob, _ := st.CreateUser("bob", "", "h", "hunter", "active") r := httptest.NewRequest("POST", "/api/auth/link-companion", - strings.NewReader(`{"companion_pubkey":"CC33"}`)) + strings.NewReader(`{`+proofFields(h, pk, priv)+`}`)) r = r.WithContext(context.WithValue(r.Context(), authCtxKey, Auth{UserID: bob, Username: "bob", Role: "hunter"})) w := httptest.NewRecorder() @@ -240,7 +265,7 @@ func TestLinkCompanionTakenByAnotherUser(t *testing.T) { if w.Code != 409 || !strings.Contains(w.Body.String(), "companion_taken") { t.Fatalf("linking alice's companion as bob: want 409 companion_taken, got %d %s", w.Code, w.Body) } - if owner, _ := st.UserIDForCompanion("cc33"); owner != alice { + if owner, _ := st.UserIDForCompanion(pk); owner != alice { t.Fatalf("companion moved to user %d, want it to stay with alice (%d)", owner, alice) } } @@ -249,17 +274,89 @@ func TestRegisterWithTakenCompanion(t *testing.T) { h, st := newAuthAPI(t) defer st.Close() alice, _ := st.CreateUser("alice", "", "h", "hunter", "active") - st.LinkCompanion(alice, "aa11") + pk, priv := companionKey(t) + st.LinkCompanion(alice, pk) w := httptest.NewRecorder() h.Register(w, httptest.NewRequest("POST", "/api/auth/register", - strings.NewReader(`{"username":"bob","password":"correcthorse","companion_pubkey":"AA11"}`))) + strings.NewReader(`{"username":"bob","password":"correcthorse",`+proofFields(h, pk, priv)+`}`))) if w.Code != 409 || !strings.Contains(w.Body.String(), "companion_taken") { t.Fatalf("registering with alice's companion: want 409 companion_taken, got %d %s", w.Code, w.Body) } - if owner, _ := st.UserIDForCompanion("aa11"); owner != alice { + if owner, _ := st.UserIDForCompanion(pk); owner != alice { t.Fatalf("companion moved to user %d, want it to stay with alice (%d)", owner, alice) } if u, _ := st.UserByUsername("bob"); u != nil { t.Fatal("a refused registration must not leave an account behind") } } + +// A pubkey is public, so naming one must not link it: without the companion's +// own signature over a fresh challenge, register and link-companion refuse. +func TestCompanionLinkRequiresProof(t *testing.T) { + h, st := newAuthAPI(t) + defer st.Close() + pk, priv := companionKey(t) + _, otherPriv := companionKey(t) + ch := h.Prover.Challenge(pk, time.Now()) + forged := hex.EncodeToString(ed25519.Sign(otherPriv, auth.ProofMessage(ch))) + bodies := map[string]string{ + "no proof": `"companion_pubkey":"` + pk + `"`, + "signed by another key": `"companion_pubkey":"` + pk + `","challenge":"` + ch + `","signature":"` + forged + `"`, + } + for name, fields := range bodies { + w := httptest.NewRecorder() + h.Register(w, httptest.NewRequest("POST", "/api/auth/register", + strings.NewReader(`{"username":"bob","password":"correcthorse",`+fields+`}`))) + if w.Code != 403 || !strings.Contains(w.Body.String(), "companion_proof_invalid") { + t.Fatalf("register, %s: want 403 companion_proof_invalid, got %d %s", name, w.Code, w.Body) + } + if u, _ := st.UserByUsername("bob"); u != nil { + t.Fatalf("register, %s: a refused registration must not leave an account behind", name) + } + + uid, _ := st.CreateUser("carol-"+strings.ReplaceAll(name, " ", "-"), "", "h", "hunter", "active") + r := httptest.NewRequest("POST", "/api/auth/link-companion", strings.NewReader(`{`+fields+`}`)) + r = r.WithContext(context.WithValue(r.Context(), authCtxKey, Auth{UserID: uid, Role: "hunter"})) + w2 := httptest.NewRecorder() + h.LinkCompanion(w2, r) + if w2.Code != 403 || !strings.Contains(w2.Body.String(), "companion_proof_invalid") { + t.Fatalf("link, %s: want 403 companion_proof_invalid, got %d %s", name, w2.Code, w2.Body) + } + } + if owner, _ := st.UserIDForCompanion(pk); owner != 0 { + t.Fatalf("an unproven companion was linked to user %d", owner) + } + // and the real companion still gets in + w := httptest.NewRecorder() + h.Register(w, httptest.NewRequest("POST", "/api/auth/register", + strings.NewReader(`{"username":"bob","password":"correcthorse",`+proofFields(h, pk, priv)+`}`))) + if w.Code != 200 { + t.Fatalf("register with a valid proof: want 200, got %d %s", w.Code, w.Body) + } +} + +func TestCompanionChallenge(t *testing.T) { + h, st := newAuthAPI(t) + defer st.Close() + pk, priv := companionKey(t) + w := httptest.NewRecorder() + h.CompanionChallenge(w, httptest.NewRequest("POST", "/api/auth/companion-challenge", + strings.NewReader(`{"companion_pubkey":"`+strings.ToUpper(pk)+`"}`))) + if w.Code != 200 { + t.Fatalf("challenge: want 200, got %d %s", w.Code, w.Body) + } + var out struct { + Challenge string `json:"challenge"` + } + json.NewDecoder(w.Body).Decode(&out) + sig := hex.EncodeToString(ed25519.Sign(priv, auth.ProofMessage(out.Challenge))) + if !h.Prover.Verify(pk, out.Challenge, sig, time.Now()) { + t.Fatal("the served challenge must be answerable by the companion it names") + } + w2 := httptest.NewRecorder() + h.CompanionChallenge(w2, httptest.NewRequest("POST", "/api/auth/companion-challenge", + strings.NewReader(`{"companion_pubkey":"aa11"}`))) + if w2.Code != 400 { + t.Fatalf("challenge for a short pubkey: want 400, got %d", w2.Code) + } +} From 532851bac6dd72a3af484154e7923185efcc0975 Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 23 Oct 2026 11:38:51 +0200 Subject: [PATCH 3/4] chore(changelog): regenerate the shipped copies from changelog.d Co-Authored-By: Claude Opus 5.5 --- app/changelog.json | 7 +++++++ web/changelog.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/app/changelog.json b/app/changelog.json index 1b6cf6aa..8c2f3895 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-10-23-companion-link-stays", + "date": "2026-10-23", + "where": "app", + "title": "A linked companion stays with its account", + "body": "A companion already linked to an account can no longer be linked to a second one; the app says so." + }, { "id": "2026-10-16-radio-on-published", "date": "2026-10-16", diff --git a/web/changelog.json b/web/changelog.json index 1b6cf6aa..8c2f3895 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-10-23-companion-link-stays", + "date": "2026-10-23", + "where": "app", + "title": "A linked companion stays with its account", + "body": "A companion already linked to an account can no longer be linked to a second one; the app says so." + }, { "id": "2026-10-16-radio-on-published", "date": "2026-10-16", From 0f203079951763c48fac0e0e13d531afce846b78 Mon Sep 17 00:00:00 2001 From: efiten Date: Fri, 23 Oct 2026 11:38:57 +0200 Subject: [PATCH 4/4] chore(changelog): regenerate the shipped copies from changelog.d Co-Authored-By: Claude Opus 5.5 --- app/changelog.json | 14 ++++++++++++++ web/changelog.json | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/changelog.json b/app/changelog.json index 1b6cf6aa..264bd74b 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,18 @@ [ + { + "id": "2026-10-23-companion-link-stays", + "date": "2026-10-23", + "where": "app", + "title": "A linked companion stays with its account", + "body": "A companion already linked to an account can no longer be linked to a second one; the app says so." + }, + { + "id": "2026-10-23-companion-link-proof", + "date": "2026-10-23", + "where": "app", + "title": "Linking a companion needs the companion itself", + "body": "Registering or linking a companion now asks the connected radio to sign a one-time challenge, so an account can only claim a companion it actually holds." + }, { "id": "2026-10-16-radio-on-published", "date": "2026-10-16", diff --git a/web/changelog.json b/web/changelog.json index 1b6cf6aa..264bd74b 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,4 +1,18 @@ [ + { + "id": "2026-10-23-companion-link-stays", + "date": "2026-10-23", + "where": "app", + "title": "A linked companion stays with its account", + "body": "A companion already linked to an account can no longer be linked to a second one; the app says so." + }, + { + "id": "2026-10-23-companion-link-proof", + "date": "2026-10-23", + "where": "app", + "title": "Linking a companion needs the companion itself", + "body": "Registering or linking a companion now asks the connected radio to sign a one-time challenge, so an account can only claim a companion it actually holds." + }, { "id": "2026-10-16-radio-on-published", "date": "2026-10-16",