diff --git a/app/changelog.json b/app/changelog.json index 8c2f3895..264bd74b 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -6,6 +6,13 @@ "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/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) + } +} diff --git a/web/changelog.json b/web/changelog.json index 8c2f3895..264bd74b 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -6,6 +6,13 @@ "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",