Skip to content

feat(auth): prove possession of a companion before linking it - #752

Merged
efiten merged 6 commits into
masterfrom
feat/companion-link-proof
Oct 23, 2026
Merged

efiten merged 6 commits into
masterfrom
feat/companion-link-proof

Conversation

@efiten

@efiten efiten commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

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.

efiten and others added 2 commits October 23, 2026 11:35
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
efiten force-pushed the fix/companion-link-no-reassign branch from c9f9dff to fe4c35b Compare October 23, 2026 09:35
@efiten
efiten force-pushed the feat/companion-link-proof branch from 8ea0755 to 9cc369e Compare October 23, 2026 09:35
efiten and others added 3 commits October 23, 2026 11:38
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>
@efiten
efiten changed the base branch from fix/companion-link-no-reassign to master October 23, 2026 09:58
…-proof

# Conflicts:
#	app/changelog.json
#	app/src/app.js
#	server/internal/httpapi/auth.go
#	server/internal/httpapi/auth_test.go
#	web/changelog.json
@efiten efiten changed the title feat(auth): prove possession of a companion before linking it (#751) feat(auth): prove possession of a companion before linking it Oct 23, 2026
@efiten
efiten merged commit 5d039cd into master Oct 23, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 23, 2026
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.

auth: linking a companion should need the companion itself, not only its pubkey

1 participant