Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 34 additions & 1 deletion app/src/__tests__/auth.test.js
Original file line number Diff line number Diff line change
@@ -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) }
Expand Down Expand Up @@ -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' })
})
})
33 changes: 25 additions & 8 deletions app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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.')
})

Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
24 changes: 20 additions & 4 deletions app/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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<Uint8Array(64)>. 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() {
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-10-23-02-companion-link-proof.json
Original file line number Diff line number Diff line change
@@ -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."
}
2 changes: 1 addition & 1 deletion server/cmd/ingestor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions server/internal/auth/companionproof.go
Original file line number Diff line number Diff line change
@@ -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)
}
72 changes: 72 additions & 0 deletions server/internal/auth/companionproof_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
1 change: 1 addition & 0 deletions server/internal/httpapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading