Files
openchamber/docs/pairing-v2-implementation-plan.md
T

949 lines
17 KiB
Markdown
Raw Normal View History

# Pairing v2 Trusted-Device Issuance Backend Plan
## Scope
Implement the Pairing v2 mechanism without UI.
Included:
- Backend pairing session runtime.
- Pairing create/redeem/cancel routes.
- Trusted-device token issuance through the existing remote client auth runtime.
- Backward-compatible remote client metadata extension.
- Password/passkey issuance metadata alignment.
- Shared v2 `openchamber://connect` payload helpers.
Not included:
- Settings page.
- QR modal.
- Pair Device button.
- Device list UI.
- Translations/copy.
- Relay implementation.
- LAN discovery.
- End-user polished mobile/desktop screens.
## Naming
Use the existing `client-auth` domain.
New module:
```text
packages/web/server/lib/client-auth/pairing.js
```
Existing durable token module remains:
```text
packages/web/server/lib/client-auth/remote-clients.js
```
Conceptual names:
```text
Remote client
Trusted-device client token
Pairing session
Pairing secret
Pairing redeem
```
Deep link stays:
```text
openchamber://connect
```
Versions:
```text
v=1 => legacy server + long-lived token import
v=2 => one-time pairing handshake
```
## New Files
### 1. `packages/web/server/lib/client-auth/pairing.js`
Create a new backend runtime module for short-lived pairing sessions.
Responsibilities:
```text
createPairingSession
getPairingSession
cancelPairingSession
redeemPairingSession
sweepExpiredSessions
```
Store file:
```text
OPENCHAMBER_DATA_DIR/client-pairing-sessions.json
```
Suggested store shape:
```json
{
"version": 1,
"sessions": [
{
"id": "pair_...",
"secretHash": "...",
"createdAt": "...",
"expiresAt": "...",
"usedAt": null,
"cancelledAt": null,
"clientId": null,
"label": "Pair new device",
"fingerprint": "ABCD-1234",
"allowedClientKinds": ["mobile", "desktop"],
"createdByClientId": null
}
]
}
```
Security requirements:
```text
Persist only secretHash.
Return plaintext secret only from createPairingSession.
Redeem is one-time.
Redeem is expiry-aware.
Redeem is cancellation-aware.
Redeem must be mutation-serialized to avoid double issuance.
No raw token/secret logging.
```
Public methods should accept injected dependencies, following `remote-clients.js` style:
```js
createClientPairingRuntime({
fsPromises,
path,
crypto,
storePath,
remoteClientAuthRuntime,
})
```
## Existing Files To Update
### 2. `packages/web/server/lib/client-auth/remote-clients.js`
Extend trusted-device metadata backward-compatibly.
Current `createClient` input:
```js
{
label,
expiresAt,
clientKind,
dedupeKey,
}
```
Extend to:
```js
{
label,
expiresAt,
clientKind,
dedupeKey,
authMethod,
pairingId,
deviceName,
devicePlatform,
deviceModel,
appVersion,
}
```
Add normalized public fields:
```text
authMethod
pairingId
deviceName
devicePlatform
deviceModel
appVersion
```
Backward compatibility rules:
```text
Existing remote-clients.json remains valid.
Missing new fields normalize to null.
Existing tokens continue authenticating.
Public client output never exposes tokenHash.
Raw token is returned only from createClient.
```
Recommended `authMethod` values:
```text
pairing
password
passkey
desktop-local
manual
legacy
```
Do not force migration for old records. Treat missing `authMethod` as legacy/null.
### 3. `packages/web/server/index.js`
Instantiate the new pairing runtime next to `remoteClientAuthRuntime`.
Existing:
```js
const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
fsPromises,
path,
crypto,
storePath: REMOTE_CLIENTS_FILE_PATH,
});
```
Add:
```js
const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join(
OPENCHAMBER_DATA_DIR,
'client-pairing-sessions.json',
);
```
Then:
```js
const clientPairingRuntime = createClientPairingRuntime({
fsPromises,
path,
crypto,
storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH,
remoteClientAuthRuntime,
});
```
Pass `clientPairingRuntime` into `registerAuthAndAccessRoutes` dependencies.
### 4. `packages/web/server/lib/opencode/core-routes.js`
Add pairing routes near existing client-auth routes:
```text
/api/client-auth/clients
```
Add:
```http
POST /api/client-auth/pairing/sessions
DELETE /api/client-auth/pairing/sessions/:id
POST /api/client-auth/pairing/redeem
```
Optional, can be deferred:
```http
GET /api/client-auth/pairing/sessions/:id
```
Since UI polling is out of scope, `GET` is not required for this phase.
#### Route: `POST /api/client-auth/pairing/sessions`
Purpose:
```text
Create one short-lived pairing session and return data needed to build QR/deep link.
```
Auth:
```text
Require UI session auth.
Allow desktop-local client only if consistent with existing client-create exception.
Reject arbitrary remote client tokens.
Reject url-token auth.
```
Request:
```json
{
"label": "Pair new device",
"allowedClientKinds": ["mobile", "desktop"]
}
```
Response:
```json
{
"pairing": {
"id": "pair_...",
"secret": "one_time_secret",
"expiresAt": "...",
"fingerprint": "ABCD-1234",
"label": "Pair new device"
},
"server": {
"label": "OpenChamber",
"candidates": [
{
"type": "lan",
"url": "http://192.168.1.20:4096",
"priority": 10
},
{
"type": "tunnel",
"url": "https://abc.ngrok.app",
"priority": 20
}
]
}
}
```
Headers:
```http
Cache-Control: no-store
```
Note:
```text
This route does not render QR.
UI can later encode the returned data into openchamber://connect?v=2&p=...
```
#### Route: `DELETE /api/client-auth/pairing/sessions/:id`
Purpose:
```text
Cancel an unused pairing session.
```
Auth:
```text
Require owner/session auth.
```
Behavior:
```text
Set cancelledAt.
Do not delete immediately.
If already used, cancellation should not revoke the issued client.
```
Response:
```json
{
"cancelled": true
}
```
#### Route: `POST /api/client-auth/pairing/redeem`
Purpose:
```text
Exchange pairingId + one-time secret for a trusted-device client token.
```
Auth:
```text
No existing auth required.
The one-time pairing secret is the authentication factor.
```
Request:
```json
{
"pairingId": "pair_...",
"secret": "one_time_secret",
"clientLabel": "Iryna iPhone",
"clientKind": "mobile",
"deviceName": "Iryna iPhone",
"devicePlatform": "ios",
"deviceModel": "iPhone",
"appVersion": "1.12.0",
"dedupeKey": "optional-stable-device-key"
}
```
Server behavior:
```text
Validate pairing exists.
Validate secret using constant-time comparison.
Validate not expired.
Validate not cancelled.
Validate not used.
Validate clientKind is allowed.
Mark pairing used.
Create remote client through remoteClientAuthRuntime.createClient.
Return clientToken once.
```
Create client with:
```js
{
label: clientLabel || deviceName || 'Remote client',
clientKind,
dedupeKey,
authMethod: 'pairing',
pairingId,
deviceName,
devicePlatform,
deviceModel,
appVersion,
}
```
Response:
```json
{
"ok": true,
"server": {
"label": "OpenChamber",
"url": "https://selected-or-current-url",
"fingerprint": "ABCD-1234"
},
"client": {
"id": "device_...",
"label": "Iryna iPhone",
"clientKind": "mobile",
"authMethod": "pairing",
"createdAt": "..."
},
"clientToken": "oc_client_..."
}
```
Headers:
```http
Cache-Control: no-store
```
Failure response should be generic:
```json
{
"error": "Invalid or expired pairing session"
}
```
Do not reveal whether id, secret, expiry, used, or cancellation caused failure.
### 5. `packages/web/server/lib/ui-auth/ui-auth.js`
Preserve existing password/passkey behavior.
Only add metadata to client token issuance when `issueClientToken === true`.
Password issuance should pass:
```js
authMethod: 'password'
clientKind: req.body?.clientKind
dedupeKey: req.body?.dedupeKey
deviceName: req.body?.deviceName
devicePlatform: req.body?.devicePlatform
deviceModel: req.body?.deviceModel
appVersion: req.body?.appVersion
```
Passkey issuance should pass:
```js
authMethod: 'passkey'
clientKind: req.body?.clientKind
dedupeKey: req.body?.dedupeKey
deviceName: req.body?.deviceName
devicePlatform: req.body?.devicePlatform
deviceModel: req.body?.deviceModel
appVersion: req.body?.appVersion
```
Backward compatibility:
```text
Existing POST /auth/session payload still works.
Existing response shape still works.
Existing clientToken issuance still works.
Password login remains disabled for tunnel/public scope.
```
### 6. `packages/ui/src/lib/connectionPayload.ts`
Extend existing connect payload helpers.
Keep current v1 behavior:
```text
openchamber://connect?v=1&server=...&token=...&label=...
```
Add v2 payload types and helpers.
Suggested types:
```ts
export type ClientConnectionPayloadV1 = {
v: 1;
serverUrl: string;
token: string;
label?: string;
};
export type PairingEndpointCandidate = {
type: 'lan' | 'tunnel' | 'relay';
url: string;
priority?: number;
};
export type PairingConnectionPayloadV2 = {
v: 2;
pairingId: string;
secret: string;
label?: string;
fingerprint?: string;
expiresAt?: string;
candidates: PairingEndpointCandidate[];
};
```
Suggested helpers:
```ts
encodePairingConnectionPayload(payload: PairingConnectionPayloadV2): string
parsePairingConnectionPayload(value: string): PairingConnectionPayloadV2 | null
```
Use deep link format:
```text
openchamber://connect?v=2&p=<base64url-json>
```
Validation:
```text
Require v=2.
Require pairingId.
Require secret.
Require at least one valid http/https candidate.
Reject malformed URL.
Reject oversized payload.
Reject expired payload locally if expiresAt is clearly in the past.
```
Do not break current exports used by mobile QR/manual connect.
### 7. `packages/ui/src/apps/mobileQrScan.ts`
Update parser only.
Current scan parser recognizes legacy fields like:
```text
server
label
```
Add support for v2 connect links.
Output should be able to distinguish:
```text
legacy v1 token import
pairing v2 payload
plain URL
```
Do not implement full mobile UI flow in this scope unless there is already a non-UI callable path.
### 8. `packages/ui/src/apps/mobileConnections.ts`
Add non-visual callable mechanism for redeeming pairing payload.
Add a function conceptually like:
```ts
redeemPairingConnection(payload: PairingConnectionPayloadV2): Promise<void>
```
Responsibilities:
```text
Try endpoint candidates.
POST /api/client-auth/pairing/redeem.
Persist issued token securely.
Persist connection metadata.
Switch runtime only after token write succeeds.
```
No new screens/buttons.
Existing password flow remains unchanged.
Candidate selection:
```text
Normalize candidates.
Probe /health with timeout.
Try candidates by priority.
Prefer HTTPS when priority ties.
If network failure, try next candidate.
If server says invalid/expired/used, stop.
```
Mobile native should reuse existing native HTTP fallback path for LAN HTTP.
### 9. `packages/electron/main.mjs`
Extend existing connect deep-link handling.
Current v1 behavior:
```text
openchamber://connect?v=1&server=...&token=...
```
Keep it.
Add v2 branch:
```text
openchamber://connect?v=2&p=...
```
Behavior:
```text
Parse v2 payload.
Show confirmation before redeem/write/switch.
Probe candidates.
Redeem pairing secret.
Store returned clientToken in desktop hosts config.
Ask/switch according to existing remote host behavior.
Never show token.
Never write config before confirmation.
```
If this phase is strictly backend-only, this file can be deferred. But if desktop app as client must be functionally supported by deep link in this phase, include this change.
### 10. `packages/electron/preload.mjs`
No change expected unless a renderer-side desktop API is needed for pairing redeem.
Prefer keeping pairing redeem in main process only for deep-link handling if desktop v2 is implemented there.
### 11. `packages/web/server/lib/ui-auth/DOCUMENTATION.md`
Update module documentation to reflect the unified issuance model:
```text
Password, passkey, and pairing are issuance methods.
Trusted-device client token is the durable credential.
Pairing v2 uses one-time secrets and issues remote client tokens.
```
Optionally add:
```text
packages/web/server/lib/client-auth/DOCUMENTATION.md
```
if the client-auth module needs ownership docs.
## Route Registration Summary
Add to `registerAuthAndAccessRoutes`:
```http
POST /api/client-auth/pairing/sessions
DELETE /api/client-auth/pairing/sessions/:id
POST /api/client-auth/pairing/redeem
```
Optional later:
```http
GET /api/client-auth/pairing/sessions/:id
```
Route placement:
```text
Register before generic OpenCode proxy.
Place near existing /api/client-auth/clients routes.
```
## Execution Sequence
### Step 1: Extend Remote Client Metadata
Files:
```text
packages/web/server/lib/client-auth/remote-clients.js
```
Do:
```text
Add metadata normalization.
Extend createClient input.
Extend publicClient output.
Keep old records valid.
Do not change token generation/authentication behavior.
```
### Step 2: Add Password/Passkey Metadata Issuance
Files:
```text
packages/web/server/lib/ui-auth/ui-auth.js
```
Do:
```text
When issueClientToken is true, pass authMethod='password' from password login.
When issueClientToken is true, pass authMethod='passkey' from passkey auth.
Pass optional device metadata through.
Preserve response shape.
```
### Step 3: Create Pairing Runtime Module
Files:
```text
packages/web/server/lib/client-auth/pairing.js
```
Do:
```text
Implement session creation.
Implement hashed secret storage.
Implement cancel.
Implement redeem.
Implement expiry/used/cancelled checks.
Integrate remoteClientAuthRuntime.createClient in redeem.
```
### Step 4: Instantiate Pairing Runtime
Files:
```text
packages/web/server/index.js
```
Do:
```text
Define CLIENT_PAIRING_SESSIONS_FILE_PATH.
Instantiate createClientPairingRuntime.
Pass clientPairingRuntime to registerAuthAndAccessRoutes.
```
### Step 5: Add Pairing Routes
Files:
```text
packages/web/server/lib/opencode/core-routes.js
```
Do:
```text
Destructure clientPairingRuntime from dependencies.
Add POST /api/client-auth/pairing/sessions.
Add DELETE /api/client-auth/pairing/sessions/:id.
Add POST /api/client-auth/pairing/redeem.
Use correct auth gates.
Set Cache-Control: no-store where secrets/tokens are returned.
Keep error responses generic for redeem.
```
### Step 6: Add v2 Payload Helpers
Files:
```text
packages/ui/src/lib/connectionPayload.ts
```
Do:
```text
Keep v1 helpers unchanged.
Add v2 payload type.
Add encode v2 helper.
Add parse v2 helper.
Use openchamber://connect?v=2&p=<base64url-json>.
Validate candidates.
Reject malformed/expired/oversized payloads.
```
### Step 7: Update QR Scan Parser Shape
Files:
```text
packages/ui/src/apps/mobileQrScan.ts
```
Do:
```text
Recognize v2 connect payload.
Return structured v2 result.
Do not add new UI.
Do not break v1/manual URL behavior.
```
### Step 8: Add Non-UI Mobile Redeem Plumbing
Files:
```text
packages/ui/src/apps/mobileConnections.ts
```
Do:
```text
Add callable redeem pairing function.
Try endpoint candidates.
Redeem via /api/client-auth/pairing/redeem.
Persist token before runtime switch.
Reuse existing storage model.
Keep password/manual connect unchanged.
```
### Step 9: Add Desktop Deep-Link v2 Handling If In Scope
Files:
```text
packages/electron/main.mjs
```
Do:
```text
Extend connect deep-link parser to recognize v2.
Confirm before redeem.
Redeem against candidate endpoint.
Store remote host config with returned token.
Switch only after confirmation and successful storage.
Keep v1 behavior unchanged.
```
If desktop client deep-link support is deferred, skip this step and document that v2 backend/shared payload exists but desktop consumer is not wired yet.
### Step 10: Update Documentation
Files:
```text
packages/web/server/lib/ui-auth/DOCUMENTATION.md
```
Optionally add:
```text
packages/web/server/lib/client-auth/DOCUMENTATION.md
```
Document:
```text
Unified trusted-device token issuance.
Pairing v2 flow.
Password/passkey/pairing authMethod values.
Security rules.
Backward compatibility guarantees.
```
## Important Non-Goals
Do not implement:
```text
Settings page
Pair Device button
QR modal
Device list UI
Translations
Visual design
Relay transport
LAN discovery
Account/cloud sync
Token migration to OS keychain on desktop
```
## Backward Compatibility Requirements
Must remain true:
```text
Existing v1 openchamber://connect links keep working.
Existing password login with issueClientToken keeps working.
Existing passkey issueClientToken keeps working.
Existing remote-clients.json keeps loading.
Existing client tokens keep authenticating.
Existing mobile saved connections keep working.
Existing desktop remote hosts keep working.
```
## Security Requirements
Must hold:
```text
No long-lived token in v2 link.
Pairing secret persisted only as hash.
Pairing secret returned only once.
Client token returned only once.
Token hash persisted server-side.
Redeem is one-time.
Redeem is expiry-aware.
Redeem is cancellation-aware.
Redeem errors are generic.
Password login remains disabled for tunnel/public scope.
Pairing session creation requires owner/session auth.
Pairing redeem requires no prior auth but requires valid one-time secret.
Desktop v2 connect confirms before writing host config or switching runtime.
```