Skip to content

fix(server): never move a linked companion to another account - #750

Merged
efiten merged 2 commits into
masterfrom
fix/companion-link-no-reassign
Oct 23, 2026
Merged

efiten merged 2 commits into
masterfrom
fix/companion-link-no-reassign

Conversation

@efiten

@efiten efiten commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #748

Linking a companion that is already linked to another account no longer moves it to the new account.

  • store.LinkCompanion inserts with ON CONFLICT(pubkey) DO NOTHING. It returns ErrCompanionTaken when the pubkey belongs to another user. Re-linking your own companion is still a no-op.
  • link-companion answers 409 companion_taken.
  • register checks before it creates the account and answers 409 companion_taken. No account is left behind.
  • The app shows "This companion is already linked to another account." The register path previously said "That username is taken", and the link path reported a connection error.
  • There is a release note in changelog.d/.

Tests: TestLinkCompanionDoesNotReassign (store), TestLinkCompanionTakenByAnotherUser and TestRegisterWithTakenCompanion (httpapi). Each was mutation-checked: putting back the old upsert, or removing the register check, turns the matching test red. go vet ./... and go test ./... pass in server/. In app/, npx vitest run passes (1743) and vite build succeeds. The app messages were not smoke-tested on a device.

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>
@efiten
efiten force-pushed the fix/companion-link-no-reassign branch from c9f9dff to fe4c35b Compare October 23, 2026 09:35
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
efiten added a commit that referenced this pull request Oct 23, 2026
… (#754)

Closes #753

AGENTS.md said a PR with a release note touches only `changelog.d/` and
that CI on master rebuilds both `changelog.json` copies.
`web/parity.test.js` runs on every PR and fails until both copies match
`changelog.d/`, so in practice a PR regenerates and commits them (#745,
#738, #750).

- AGENTS.md: a PR with an entry runs `node scripts/build-changelog.mjs`
and commits both copies. Two such PRs, or a PR stacked on a branch with
an entry, conflict on the copies. Resolve that by running the generator
on the merged tree.
- `.github/workflows/changelog.yml`: the header comment now matches this
and calls the master job a safety net.

Docs and a comment only. The test, the generator and the workflow steps
are unchanged. No release note, since nothing changes for users.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@efiten
efiten merged commit a225b96 into master Oct 23, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 23, 2026
efiten added a commit that referenced this pull request Oct 23, 2026
Closes #751

Stacked on #750 (base `fix/companion-link-no-reassign`). Merge #750
first, then retarget this PR to `master`.

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.
- Register and link-companion take `challenge` and `signature`, an
Ed25519 signature over `core-hunter-link:` + challenge. Without a valid
proof they answer `403 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.
- The app gets the challenge and has the connected radio sign it through
`createSigner`, the same CMD_SIGN path the broker tokens use. Firmware
`CMD_SIGN_FINISH` signs with `self_id` and 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) and `TestCompanionChallenge`. Existing register/link
tests now send a real proof. Disabling either handler check turns the
test red.
- `app/src/__tests__/auth.test.js`: `companionProof` signs the prefixed
challenge, and returns null without a challenge or without a signature.
The body builders carry the proof.

`go vet ./...` and `go test ./...` pass in `server/`. `npx vitest run`
passes in `app/` (1748), and `vite build` succeeds.

Not done:
- Not tested on a real radio. Only dummy Ed25519 keys were used, and
whether the firmware signature verifies under Go's `ed25519.Verify` was
not run end to end. The firmware uses the standard ref10 `ed25519_sign`
(`lib/ed25519/sign.c`), and the broker tokens already depend on the same
signatures.
- An older cached app version cannot register or link until it updates,
because the server now requires the proof.
- Companions that are already linked are not re-checked.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: linking a companion that is already linked moves it to the new account

1 participant