Repository navigation
feat(auth): prove possession of a companion before linking it - #752
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
efiten
force-pushed
the
fix/companion-link-no-reassign
branch
from
October 23, 2026 09:35
c9f9dff to
fe4c35b
Compare
efiten
force-pushed
the
feat/companion-link-proof
branch
from
October 23, 2026 09:35
8ea0755 to
9cc369e
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-proof # Conflicts: # app/changelog.json # app/src/app.js # server/internal/httpapi/auth.go # server/internal/httpapi/auth_test.go # web/changelog.json
Merged
efiten
pushed a commit
that referenced
this pull request
Oct 24, 2026
🤖 I have created a release *beep* *boop* --- <details><summary>app: 1.32.0</summary> ## [1.32.0](app-v1.31.0...app-v1.32.0) (2026-10-23) ### Features * **app:** publish the radio settings with every reception ([#745](#745)) ([8683703](8683703)) * **auth:** prove possession of a companion before linking it ([#752](#752)) ([5d039cd](5d039cd)) ### Bug Fixes * **server:** never move a linked companion to another account ([#750](#750)) ([a225b96](a225b96)) </details> <details><summary>server: 1.11.0</summary> ## [1.11.0](server-v1.10.1...server-v1.11.0) (2026-10-23) ### Features * **auth:** prove possession of a companion before linking it ([#752](#752)) ([5d039cd](5d039cd)) ### Bug Fixes * **server:** never move a linked companion to another account ([#750](#750)) ([a225b96](a225b96)) ### Documentation * **server:** say the positions member gate is not a confidentiality boundary ([#749](#749)) ([72c9224](72c9224)) </details> <details><summary>web: 1.28.0</summary> ## [1.28.0](web-v1.27.0...web-v1.28.0) (2026-10-23) ### Features * **app:** publish the radio settings with every reception ([#745](#745)) ([8683703](8683703)) * **auth:** prove possession of a companion before linking it ([#752](#752)) ([5d039cd](5d039cd)) ### Bug Fixes * **server:** never move a linked companion to another account ([#750](#750)) ([a225b96](a225b96)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #751
Stacked on #750 (base
fix/companion-link-no-reassign). Merge #750 first, then retarget this PR tomaster.Register and link-companion now need proof that the caller holds the companion, not only its public pubkey.
POST /api/auth/companion-challenge {companion_pubkey}returns a challenge. It is stateless: expiry, a 16-byte nonce and an HMAC-SHA256 over pubkey, expiry and nonce. The key is random per process. The challenge is bound to the pubkey and valid for 5 minutes, and a restart voids open challenges.challengeandsignature, an Ed25519 signature overcore-hunter-link:+ challenge. Without a valid proof they answer403 companion_proof_invalid. In register the proof check runs before the companion-taken check, so a caller without the key cannot learn whether a pubkey is linked.createSigner, the same CMD_SIGN path the broker tokens use. FirmwareCMD_SIGN_FINISHsigns withself_idand asks for no confirmation on the radio (examples/companion_radio/MyMesh.cpp:1752). With no radio connected, the app shows a message and sends no request.Tests:
internal/auth/companionproof_test.go: valid proof accepted; refused for another key, a challenge for another pubkey, an expired challenge, another server's key, a tampered challenge, a signature without the prefix, non-hex input and a short pubkey. Mutation-checked: dropping the HMAC, expiry or prefix check each turns a case red.internal/httpapi/auth_test.go:TestCompanionLinkRequiresProof(no proof, or a forged one, gives 403 on both endpoints, and register leaves no account behind) andTestCompanionChallenge. Existing register/link tests now send a real proof. Disabling either handler check turns the test red.app/src/__tests__/auth.test.js:companionProofsigns the prefixed challenge, and returns null without a challenge or without a signature. The body builders carry the proof.go vet ./...andgo test ./...pass inserver/.npx vitest runpasses inapp/(1748), andvite buildsucceeds.Not done:
ed25519.Verifywas not run end to end. The firmware uses the standard ref10ed25519_sign(lib/ed25519/sign.c), and the broker tokens already depend on the same signatures.