Skip to content

Pairing: the pairing public key is treated as an authenticator, so anyone who sees the QR gets a permanent account token #1829

Open

Description

@ethicnology-agent

Related to #1503, which reports the relay-side half of this area. These are four distinct defects in the same flow; all were verified against 383c9f8b.

The pairing public key is not a secret. It is rendered on screen as a QR (packages/happy-cli/src/ui/auth.ts:106), and it is sent to the server in the clear when the request is created. Four separate controls treat it as if it were a credential.

1. The poll endpoints hand out the account bearer token in cleartext. POST /v1/auth/request and POST /v1/auth/account/request return token beside the sealed payload once the request is approved (packages/happy-server/sources/app/api/routes/authRoutes.ts:77, :202). Knowledge of the public key is the only thing required. The same file already implements the correct control 55 lines earlier: POST /v1/auth verifies a signature over a challenge (authRoutes.ts:22).

2. The approval is a permanent, publicly addressable token mint. The handler upserts on the caller-supplied public key with update: {} and mints a fresh token on every call whenever responseAccountId is set (authRoutes.ts:69, :90). There is no consumption, no expiry, no attempt counter. TerminalAuthRequest (packages/happy-server/prisma/schema.prisma:59) has no consumed, expiresAt or initiator column. We obtained four distinct valid tokens from a single approval.

3. The approver is not bound to the initiator. Any authenticated account may answer a pairing request it did not create; the approving account is simply the first caller to reach POST /v1/auth/response for that key. The losing approval returns 200 {"success":true} and is silently discarded. The 32 bytes the approver supplies become the machine's RPC encryption key (packages/happy-cli/src/api/api.ts:164), so a workstation can end up attached to someone else's account under a key that account chose.

4. The peer selects which secret the approval discloses. supportsV2 is an unauthenticated field chosen by the requesting peer. Omitted or null yields the legacy answer, which is the raw 32-byte account master secret rather than the encrypt-only content key (packages/happy-app/sources/hooks/useConnectTerminal.ts:38). The schema default mirrors the insecure choice (schema.prisma:62), and update: {} makes the peer's first choice permanent.

Because the same 32 bytes are both the E2E root key and the account signing seed, and because tokens never expire (see the companion issue on token lifetime), any one of these is durable.

Suggested fix

  • Seal the token to the pairing public key instead of returning it in cleartext.
  • Add consumed and expiresAt to both request tables; mint at most once, in the same statement that marks consumption; reject stale rows.
  • Return the approving account's public key with state: "authorized" so the initiator — the only party holding the pairing secret — can check who approved, and claim the row with an atomic updateMany.
  • Drop the answerV1 branch entirely so a linked terminal never receives the master secret.

Happy to open PRs for any of these if the shape is agreeable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions