Skip to content

feat: bearer-token pairing auth across iOS, Android, macOS, CLI#64

Open
rafiki270 wants to merge 8 commits into
mainfrom
fix/auth-pairing
Open

feat: bearer-token pairing auth across iOS, Android, macOS, CLI#64
rafiki270 wants to merge 8 commits into
mainfrom
fix/auth-pairing

Conversation

@rafiki270
Copy link
Copy Markdown
Contributor

Summary

Replaces the prior "no authentication on local network" stance with a deny-by-default bearer-token pairing protocol. All /v1/* routes require a valid bearer token; the unauth allowlist is exactly /v1/pair, /v1/pair/status, /v1/get-device-info, and /health. Pairing requires explicit on-device approval (Yes once / Always / No). CLI stores token hashes in ~/.kelpie/tokens.json (mode 0600, parent dir 0700), keyed by deviceId:host:port to defeat mDNS spoofing. kelpie mcp --http binds to 127.0.0.1 by default; non-loopback requires explicit --unsafe-host.

What lands

  • iOS / Android / macOS: PairingStore, AuthMiddleware, PairEndpoints, PairApprovalCoordinator, on-device pairing dialog, paired-clients management UI. SHA-256 token hashing + constant-time comparison; CSPRNG-generated 32-byte request IDs; source-address binding on /v1/pair/status; CSRF gate on POST /v1/pair (Content-Type + Origin); Cache-Control: no-store on pairing responses; no CORS header anywhere (CLI is not a browser).
  • CLI: token store, automatic 401 retry, explicit kelpie pair command, kelpie_pair MCP tool, deviceId+host+port fingerprint binding. Symlink rejection on the token store path.
  • Docs: docs/architecture.md, docs/api/README.md, and docs/functionality.md updated to describe the protocol, error codes, dialog flow, and store layout.
  • Cross-provider review (Codex) integrated into the design plan before implementation.

Test plan

  • pnpm lint && pnpm build && pnpm test in packages/cli (24 files, 362 tests pass)
  • make lint-swift (0 violations across 182 files)
  • ./gradlew ktlintCheck (passes)
  • ./gradlew assembleDebug (compiles cleanly)
  • Manual pairing flow on iOS, Android, macOS via kelpie pair
  • Manual 401 retry after token rotation
  • Manual kelpie mcp --http --bind 0.0.0.0 rejection without --unsafe-host

Note: ./gradlew build surfaces a pre-existing lint failure in KelpieNetworkService.startForeground (from PR #58, already failing on main). Out of scope for this PR.

rafiki270 and others added 8 commits May 17, 2026 11:28
Closes 27 findings from Codex review. Token storage moved to SHA-256
hashes only (no fake macOS AES). Pair-status keyed by requestId nonce,
not enumerable clientId. CSRF gate on POST /v1/pair. Default button is
No, never Always. CLI session-scope tokens stay in-process. mDNS
spoofing mitigated by deviceId+host+port fingerprint pinning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds PairRequest, PairResponse, PairScope, PairStatusState, PairStatusResponse,
and DeviceInfoPairing types for the cross-platform pairing/auth feature.
Registers kelpie_pair as a CLI-level MCP tool so MCP clients can drive the
pairing flow when they hit UNAUTHORIZED.

Refs: docs/plans/2026-05-16-pairing-auth.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the iOS half of the pairing/auth design in
docs/plans/2026-05-16-pairing-auth.md:

- PairingStore: file-backed token-hash store (atomic write, NSFileProtectionComplete,
  0600 perms), in-memory pending requests + session approvals + denied-source
  suppression. SecRandomCopyBytes for all CSPRNG output.
- AuthMiddleware: deny-by-default with exact-match decoded-path unauth allowlist
  (/v1/pair, /v1/pair/status, /v1/get-device-info, /health). CSRF gate on
  POST /v1/pair (Content-Type + Origin rejection). Duplicate Authorization /
  Content-Length and any Transfer-Encoding rejected pre-routing.
- HTTPRequestParser: split out parsing into a pure helper with per-header
  duplicate tracking and percent-decoded path.
- PairingHTTPResponse: shared response builder with Cache-Control: no-store on
  pair endpoints. CORS header removed entirely.
- PairEndpoints: POST /v1/pair (issues requestId nonce), GET /v1/pair/status
  (binds to source address, returns token exactly once then deletes issuance),
  DELETE /v1/pair (derives clientId from bearer; ignores query), unauthenticated
  GET /v1/get-device-info with requiresPairing flag.
- HTTPServer: wired through the middleware; unauth/authenticated route tables.
- PairApprovalCoordinator + PairingDialog: SwiftUI modal sheet with default
  No button (cancel-action keyboard shortcut), Yes once, Always allow.
- PairedClientsView: Settings sub-screen with persistent + session + recently
  denied lists and per-entry revoke buttons.
- BrowserView attaches the pairing dialog modifier at the root so prompts
  surface regardless of which screen is active.

Refs: docs/plans/2026-05-16-pairing-auth.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the iOS pairing implementation for full platform parity.

- PairingStore: SHA-256 hashed token store backed by app-private JSON
  (filesDir/pairings.json), SecureRandom for tokens and request IDs,
  constant-time hex comparison, source-address binding, recent-deny
  suppression, untrusted clientName sanitisation
- AuthMiddleware: deny-by-default with exact-match decoded-path unauth
  allowlist for /health, /v1/pair, /v1/pair/status, /v1/get-device-info;
  CSRF gate on POST /v1/pair (Content-Type + no Origin); duplicate
  Authorization / Content-Length and Transfer-Encoding rejected
- PairEndpoints: POST /v1/pair, GET /v1/pair/status, DELETE /v1/pair,
  GET /v1/get-device-info; no-store cache headers on pairing responses
- HTTPServer: per-route middleware evaluation, decoded path extraction,
  socket peer source-address capture, no CORS headers
- PairApprovalCoordinator: StateFlow<PendingRequest?> for Compose
- PairingDialog: Compose AlertDialog with No (default, dismiss) /
  Yes once / Always allow (orange)
- PairedClientsScreen: persistent / sessions / recently denied sections
  with revoke buttons
- MainActivity / BrowserScreen / SettingsScreen: wire coordinator,
  dialog and Paired Clients entry

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- File-backed PairingStore at <AppSupport>/Kelpie/pairings.json (mode 0600, atomic
  write, parent dir 0700). No Keychain per project policy. Stores SHA-256 hashes
  only; plaintext tokens returned exactly once at issuance.
- AuthMiddleware enforces deny-by-default with an exact-match decoded-path
  allowlist for {/v1/pair, /v1/pair/status, /v1/get-device-info, /health}.
  Bearer header parsed strictly; constant-time hex compare. Duplicate
  Authorization / duplicate Content-Length / Transfer-Encoding all rejected
  before routing. POST /v1/pair gated by Content-Type + no Origin (CSRF).
- HTTPRequestParser splits parsing from routing; preserves existing 16KiB
  header cap, 32MiB body cap, 30s deadline, strict Content-Length parsing,
  and 411 for POSTs without Content-Length.
- PairEndpoints handles request / status / revoke; status poll is keyed by
  requestId nonce and bound to the original source address.
- PairingHTTPResponse drops Access-Control-Allow-Origin (CLI is not a browser)
  and sets Cache-Control: no-store on pair endpoints.
- PairApprovalCoordinator drives PairingDialog and PairedClientsView. Both
  views use AppKit-backed NSButton via NSViewRepresentable per the WebView-
  window CRITICAL rule. Default button is "No" (Return-bound, destructive
  style); "Always allow" is orange to slow muscle-memory taps.
- ServerState wires PairingStore + coordinator into HTTPServer and refreshes
  the coordinator on pending-pair-changed callbacks.
- SettingsView gains a Paired Clients row (AppKit-backed Manage button)
  opening PairedClientsView; BrowserView hosts the pairing sheet so prompts
  surface from any screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New `packages/cli/src/auth/`:
  - token-store.ts: `~/.kelpie/tokens.json` (mode 0600, atomic, parent dir 0700).
    Refuses to operate on symlinked paths, refuses files with unsafe perms
    unless owned by the current user. Keys tokens by `<deviceId>:<host>:<port>`
    so mDNS spoofing at a new socket address forces a re-pair. Companion
    `SessionTokenCache` keeps `Yes, once` tokens in process memory only.
  - pairing.ts: POST /v1/pair + status-polling state machine. Returns
    `approved | denied | expired | not_found | error`. Caller persists
    persistent-scope tokens to disk and session-scope tokens to memory.
    Also exports `redact()` / `redactObject()` helpers (Authorization header,
    `token`/`bearer` body fields, bearer-shaped 40+-char regex).
- `packages/cli/src/client/http-client.ts` now sets `Authorization: Bearer …`
  from the appropriate store and, on a `401`, runs the pair flow once before
  retrying the original call. Stale tokens are dropped before the retry.
- New `kelpie pair` command (`packages/cli/src/commands/pair.ts`) for scripts
  that want to prime credentials before automation runs. Registered in
  `commands/index.ts` and in the MCP `kelpie_pair` tool.
- `kelpie mcp --http` now binds to `127.0.0.1` by default. Non-loopback
  binding requires `--unsafe-host` and emits a loud warning that anyone
  reaching the MCP port can drive paired devices.
- Docs: `docs/cli.md` documents `kelpie pair`; command-metadata adds the
  pair entry consumed by `--llm-help`.
- Tests: 24 new unit tests covering token-store + pairing flow + MCP bind
  safety; updated existing tool/help/server counts for the new `kelpie_pair`
  MCP tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…lity

Replaces the old "no authentication" notes with the actual deny-by-default
bearer-token protocol shipped on iOS, Android, macOS, and the CLI:

- architecture.md "Shared Network Risk": pairing flow, token lifecycle,
  source-address binding, CLI store layout, MCP loopback default.
- api/README.md: bearer requirement on /v1, pairing endpoint table,
  CSRF gate, error codes, kelpie_pair MCP tool.
- functionality.md: new Pairing & Authentication section between Device
  Discovery and Browser Control, with the three on-device responses and
  scope semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 3-arg startForeground(id, notification, serviceType) overload
was added in Android 10 (API 29 / Q); minSdk is 28. Branch on
Build.VERSION.SDK_INT so API 28 uses the 2-arg call, while API 29+
still gets the connectedDevice service type. The manifest still
declares foregroundServiceType=connectedDevice for runtime grant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.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.

1 participant