ADR 003: Device Registration, Pairing Protocol, and Security Controls¶
- Status: Accepted
- Date: 2026-10-04
- Deciders: SailEver Architecture & Engineering Team
- Technical Context:
sailever-railsBackend 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:
- Hardware Anchor (
devicestable): - Scoped uniquely by
(platform, device_uid). - Supports multi-platform clients:
garmin,apple_watch,wear_os. - Stores telemetry:
device_model,display_name,app_version,last_registered_app_version, andlast_seen_at. - On Garmin CIQ,
device_uidusesSystem.getDeviceSettings().uniqueIdentifier(firmware-backed 32-character hex ID that persists across app uninstall/reinstall). - Version Adoption Audit Log (
device_registrationstable): - Immutable log record per
(device_id, app_version). - Captures when a device first runs each release, powering admin KPI dashboards, version rollout charts, and device model distribution analytics.
- Registration Endpoint (
POST /api/v1/devices/register): - Invoked silently in the background on client app startup if the app version has changed or the device has never registered.
- Idempotent and unauthenticated.
- 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:¶
- Server-Authoritative Cryptographic Tokens:
- The watch client never generates pairing codes or poll tokens.
- The server generates
pairing_codeas a random 6-digit string (%06d) and checks uniqueness against any active, unexpired pairing requests (pairing_expires_at > Time.current). - The server generates
poll_tokenas a high-entropy 256-bit string ("pt_" + SecureRandom.hex(32)), completely decoupled from the numeric PIN. - 10-Minute Pairing Window:
- 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. - One-Time Token Delivery:
- When
poll_pairing_requestdelivers the newly minteddevice_token, the server immediately clearspoll_token_hash = nilon the device. - Any subsequent attempt to query with that
poll_tokenreceives410 Gone, preventing token interception or replay. - Single-Use Code Consumption:
- Entering the 6-digit code on
/pairimmediately setspairing_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
--- ---andWaiting.... - 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.