Skip to content

ADR 003: Device Registration, Pairing Protocol, and Security Controls

  • Status: Accepted
  • Date: 2026-10-04
  • Deciders: SailEver Architecture & Engineering Team
  • Technical Context: sailever-rails Backend API & Web Application, sailever-watchapp (Garmin CIQ), Multi-Platform Client Roadmap (Apple Watch, Wear OS)

1. Context & Problem Statement

SailEver previously tightly coupled device identity with user account pairing. When pairing was disabled during early betas, the backend had zero visibility into how many devices had installed the application, which hardware models were in use, or when users updated to new releases.

Furthermore, an architectural audit of the initial pairing flow revealed critical security vulnerabilities: 1. Predictable Polling Credentials: The client generated both the human-facing 6-digit code and derived the poll token directly ("pt_" + code), creating a tiny search space ($10^6$) that allowed attackers to intercept permanent API bearer tokens. 2. Brute-Force Vulnerability: The web claim form (POST /pair) lacked rate limiting, enabling brute-force guessing of active 6-digit codes within a 10-minute window. 3. Unauthenticated Denial of Service / Device Hijack: Any client calling POST /api/v1/devices/pairing_requests with a known device_uid immediately stripped the device's ownership (user_id = nil), bricking legitimate user connections. 4. Code Collisions: Multiple devices pairing concurrently had no active code uniqueness guarantees, risking cross-user account cross-wiring under the Birthday Paradox. 5. Secondary Market & Hardware Resale: When a user sells their watch, the new buyer must be able to pair it without requiring the previous owner to unpair it first, while ensuring that the seller's historical tracks remain secure and the seller's old credentials are instantaneously revoked.


2. Architectural Decisions

2.1 Separation of Device Registration vs. Account Pairing

We decouple persistent hardware registration from user account pairing:

  1. Hardware Anchor (devices table):
  2. Scoped uniquely by (platform, device_uid).
  3. Supports multi-platform clients: garmin, apple_watch, wear_os.
  4. Stores telemetry: device_model, display_name, app_version, last_registered_app_version, and last_seen_at.
  5. On Garmin CIQ, device_uid uses System.getDeviceSettings().uniqueIdentifier (firmware-backed 32-character hex ID that persists across app uninstall/reinstall).
  6. Version Adoption Audit Log (device_registrations table):
  7. Immutable log record per (device_id, app_version).
  8. Captures when a device first runs each release, powering admin KPI dashboards, version rollout charts, and device model distribution analytics.
  9. Registration Endpoint (POST /api/v1/devices/register):
  10. Invoked silently in the background on client app startup if the app version has changed or the device has never registered.
  11. Idempotent and unauthenticated.
  12. Rate-limited to 30 requests per hour per IP.

2.2 Secure Two-Phase Pairing Protocol

sequenceDiagram
    autonumber
    actor Buyer as Buyer (User B)
    participant Watch as Garmin Watch
    participant API as Rails API (V1)
    participant Web as Rails Web (/pair)
    actor Seller as Seller (User A)

    Note over Watch,API: Initial State: Device currently paired to Seller (User A)

    Buyer->>Watch: Opens "Pair device" menu
    Watch->>API: POST /api/v1/devices/pairing_requests (platform, device_uid, model, version)
    Note over API: 1. Generate unique 6-digit PIN<br/>2. Generate 256-bit poll_token<br/>3. Set 10-minute expiry<br/>4. Keep existing User A pairing intact
    API-->>Watch: 201 Created { pairing_code, poll_token, expires_in: 600 }
    Watch->>Watch: Displays 6-digit PIN on screen

    par Polling Loop
        loop Every 5 seconds
            Watch->>API: GET /api/v1/devices/pairing_requests/poll?poll_token=pt_...
            API-->>Watch: 202 Accepted { status: "pending" }
        end
    and Physical Claim
        Buyer->>Web: POST /pair { pairing_code: "123456" } (authenticated as User B)
        Note over Web: 1. Verify unexpired code PIN<br/>2. Transfer device.user = User B<br/>3. Invalidate old token_hash & cache<br/>4. Nullify pairing_code_hash
        Web-->>Buyer: 302 Redirect /devices (Success)
    end

    Note over Seller,API: Seller's old bearer token is now completely invalid (401 Unauthorized)

    Watch->>API: GET /api/v1/devices/pairing_requests/poll?poll_token=pt_...
    Note over API: 1. Device is paired<br/>2. Issue fresh device_token (dt_...)<br/>3. Clear poll_token_hash (one-time delivery)
    API-->>Watch: 200 OK { status: "paired", device_token: "dt_..." }
    Watch->>Watch: Stores device_token, switches to "Paired"

Protocol Guarantees:

  1. Server-Authoritative Cryptographic Tokens:
  2. The watch client never generates pairing codes or poll tokens.
  3. The server generates pairing_code as a random 6-digit string (%06d) and checks uniqueness against any active, unexpired pairing requests (pairing_expires_at > Time.current).
  4. The server generates poll_token as a high-entropy 256-bit string ("pt_" + SecureRandom.hex(32)), completely decoupled from the numeric PIN.
  5. 10-Minute Pairing Window:
  6. Pairing requests expire after 10 minutes (pairing_expires_at = 10.minutes.from_now), providing sufficient headroom for users who need to sign up or complete magic-link authentication.
  7. One-Time Token Delivery:
  8. When poll_pairing_request delivers the newly minted device_token, the server immediately clears poll_token_hash = nil on the device.
  9. Any subsequent attempt to query with that poll_token receives 410 Gone, preventing token interception or replay.
  10. Single-Use Code Consumption:
  11. Entering the 6-digit code on /pair immediately sets pairing_code_hash = nil, preventing replay attacks.

2.3 Hardware Resale & Secondary Market Handover (Case 3)

When a device is sold or handed over without the seller unpairing it first: 1. No Preemptive Unpairing on Code Generation: Calling create_pairing_request does not reset user_id or token_hash. This prevents malicious or accidental unpairing of legitimate users simply by sending their device_uid to the public API. 2. Proof of Physical Possession: The 6-digit PIN is only rendered on the physical watch screen. Entering this PIN on the web proves physical possession of the hardware. 3. Atomic Ownership Transfer & Token Revocation: When the buyer submits the PIN via POST /pair: - device.user is updated to the buyer. - device.token_hash is nullified, and cached tokens are cleared from Rails.cache. - The seller's access is immediately revoked; subsequent API requests with the seller's token receive 401 Unauthorized. - The watch is removed from the seller's web dashboard and appears under the buyer's dashboard. 4. Historical Trip Isolation: Previously synced trips and tracks remain associated with the seller (tracks.user_id = seller.id), ensuring privacy and data integrity.


2.4 Multi-Tier Rate Limiting Architecture

All unauthenticated and brute-forceable endpoints are guarded with Rails 8 rate limiting (ActionController::RateLimiting). To prevent cross-action quota contention, each action is isolated with an explicit name:

Endpoint Action Key / Identity Quota Purpose
POST /pair DevicesController#create current_user.id or remote_ip (name: "pair") 5 / 5 minutes Stops 6-digit PIN brute forcing
POST /api/v1/devices/register Api::V1::DevicesController#register remote_ip (name: "register") 30 / 1 hour Prevents DB Sybil / registration flooding
POST /api/v1/devices/pairing_requests Api::V1::DevicesController#create_pairing_request remote_ip (name: "pairing_request") 10 / 10 minutes Throttles pairing code generation
GET /api/v1/devices/pairing_requests/poll Api::V1::DevicesController#poll_pairing_request remote_ip (name: "poll") 120 / 10 minutes Allows normal 5 s poll while blocking rapid scanning

Implementation Note: A dynamic cache delegator RateLimitStore.instance (app/lib/rate_limit_store.rb, reloaded by Zeitwerk) directs rate limiting to Rails.cache, ensuring high performance in production via solid_cache_store while remaining transparently testable with ActiveSupport::Cache::MemoryStore.


3. Threat Model & Verification Matrix

Threat / Attack Vector Vulnerability Pre-Fix Mitigation Applied Test Verification
Poll Token Interception pollToken = "pt_" + code allowed scanning 1M keys. Server generates 256-bit cryptographically random poll_token. Client parameters ignored. Api::V1::DevicesControllerTest#test_ignores_client_supplied_pairing_code_and_poll_token
PIN Brute-Force Unlimited attempts on /pair. 5 attempts per 5 minutes per user/IP; 10-minute PIN expiry. DevicesControllerTest#test_enforces_rate_limiting_on_web_pairing_attempts
PIN Replay / Multiple Claims Active code persisted until expiry. Atomic single-use code nullification on claim. DevicesControllerTest#test_should_complete_pairing_with_valid_6-digit_code_and_consume_the_code
Unauthenticated Forced Unpair create_pairing_request cleared user_id. Ownership preserved until valid PIN submitted on web. Api::V1::DevicesControllerTest#test_pairing_request_does_not_unpair_an_already-paired_device_prematurely
Resale Hijack / Zombie Tokens Re-pairing left old tokens valid. Atomic transfer rebinds user, clears token_hash, and purges cache. DevicesControllerTest#test_device_resale_transfers_ownership_and_cancels_previous_pairing & Api::V1::DevicesControllerTest#test_device_resale_end-to-end
Poll Replay / Token Sniffing Repeated polls returned bearer token. One-time token delivery; poll_token_hash nullified on delivery. Subsequent polls return 410 Gone. Api::V1::DevicesControllerTest#test_polls_claimed_pairing_request_and_delivers_bearer_token_only_once
Active PIN Collision Random 6-digit codes could collide. Generator verifies active code uniqueness before issuing. Api::V1::DevicesControllerTest#test_pairing_code_generation_avoids_collisions_with_active_non-expired_codes
Registration DB Exhaustion Unlimited unauthenticated registration writes. Rate-limited to 30 requests / hour / IP. Api::V1::DevicesControllerTest#test_enforces_rate_limiting_on_device_registration_and_pairing_requests

4. Consequences

Positive

  • Robust Security: Bearer tokens are protected by 256-bit entropy, rate limits, one-time delivery, and physical PIN possession.
  • Seamless Second-Hand Market: Watches can be freely sold, gifted, or re-paired without prior seller action, with zero data leakage between owners.
  • Accurate Telemetry: Device installations and version rollouts are tracked independently of account pairing.
  • Comprehensive Test Coverage: All security controls and resale edge cases are verified by automated tests across API and Web layers.

Trade-offs & Operational Notes

  • Network Requirement for Pairing: The watch must have phone/BLE connectivity when opening the pairing screen to fetch the server-generated PIN (previously, it could render an offline random PIN, but that PIN was unverified). If offline, the watch displays --- --- and Waiting....
  • 10-Minute Timeout: Users have 10 minutes to complete pairing on the web before the PIN expires, giving sufficient headroom for account creation and magic-link authentication.