diff --git a/extensions/remote-control/auth/pairing.ts b/extensions/remote-control/auth/pairing.ts new file mode 100644 index 0000000..3f4a06d --- /dev/null +++ b/extensions/remote-control/auth/pairing.ts @@ -0,0 +1,85 @@ +/** + * QR-based pairing. + * + * Implements IC-3 — the `pi-remote://` URL scheme and `POST /pair` exchange. + * + * Pairing flow: + * 1. Server generates a one-time pairingToken (short-lived, 10 min). + * 2. `pi-remote pair` CLI prints the QR code containing the URL. + * 3. iOS app scans the QR, extracts host/port/pairingToken/fingerprint. + * 4. iOS app calls `POST /pair` with { pairingToken, deviceToken?, environment?, deviceName? }. + * 5. Server validates pairingToken, creates a bearer token, returns { bearerToken, sidecarId }. + * + * Owner: T-1.3 + */ + +import { randomBytes } from "node:crypto"; + +export interface PairingToken { + token: string; + expiresAt: number; // unix ms +} + +// In-memory store of active pairing tokens (cleared on server restart) +const _pairingTokens = new Map(); + +const PAIRING_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** Generate a new pairing token. Invalidates any existing one. */ +export function generatePairingToken(): PairingToken { + const token = randomBytes(16).toString("base64url"); + const entry: PairingToken = { + token, + expiresAt: Date.now() + PAIRING_TOKEN_TTL_MS, + }; + _pairingTokens.set(token, entry); + return entry; +} + +/** Validate and consume a pairing token. Returns true if valid. */ +export function consumePairingToken(token: string): boolean { + const entry = _pairingTokens.get(token); + if (!entry) return false; + _pairingTokens.delete(token); + if (Date.now() > entry.expiresAt) return false; + return true; +} + +/** + * Build the IC-3 pairing URL. + * + * pi-remote://:?pair=&fp=&name= + */ +export function buildPairingUrl(opts: { + host: string; + port: number; + pairingToken: string; + fingerprint: string; + sidecarName?: string; +}): string { + const { + host, + port, + pairingToken, + fingerprint, + sidecarName = "pi-remote", + } = opts; + const params = new URLSearchParams({ + pair: pairingToken, + fp: fingerprint, + name: sidecarName, + }); + return `pi-remote://${host}:${port}?${params.toString()}`; +} + +/** + * Render the pairing URL as a QR code to the terminal. + * Uses the `qrcode` package bundled as a dependency. + */ +export async function printPairingQr(url: string): Promise { + // Dynamic import so this file loads even if qrcode isn't installed + const qrcode = await import("qrcode"); + const qr = await qrcode.toString(url, { type: "terminal", small: true }); + console.log(qr); + console.log(url); +} diff --git a/extensions/remote-control/auth/tls.ts b/extensions/remote-control/auth/tls.ts new file mode 100644 index 0000000..a68cbe5 --- /dev/null +++ b/extensions/remote-control/auth/tls.ts @@ -0,0 +1,116 @@ +/** + * Self-signed TLS certificate generation + fingerprint. + * + * Generates a self-signed cert for the sidecar server. The SHA-256 fingerprint + * is included in the QR pairing URL (IC-3 `fp` field) so the iOS app can pin. + * + * Uses openssl CLI (available on macOS/Linux). Falls back to plain HTTP if + * openssl is not available. + * + * Owner: T-1.3 + */ + +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface TlsCert { + certPath: string; + keyPath: string; + /** SHA-256 hex fingerprint of the DER-encoded cert */ + fingerprint: string; +} + +export interface TlsCertOptions { + stateDir?: string; + /** Common name for the cert (default: "pi-remote") */ + cn?: string; + /** Days the cert is valid (default: 3650 — 10 years) */ + days?: number; +} + +function certDir(stateDir?: string): string { + return path.join( + stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"), + "tls", + ); +} + +/** + * Load existing cert or generate a new one. + * Returns paths + fingerprint. + */ +export async function loadOrCreateCert( + opts: TlsCertOptions = {}, +): Promise { + const { stateDir, cn = "pi-remote", days = 3650 } = opts; + const dir = certDir(stateDir); + await fs.mkdir(dir, { recursive: true }); + + const certPath = path.join(dir, "cert.pem"); + const keyPath = path.join(dir, "key.pem"); + + // Reuse existing cert if present + try { + await fs.access(certPath); + await fs.access(keyPath); + const fingerprint = await computeFingerprint(certPath); + return { certPath, keyPath, fingerprint }; + } catch { + // generate new + } + + // Generate via openssl + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + String(days), + "-nodes", + "-subj", + `/CN=${cn}`, + ]); + await fs.chmod(keyPath, 0o600); + + const fingerprint = await computeFingerprint(certPath); + return { certPath, keyPath, fingerprint }; +} + +/** + * Compute SHA-256 fingerprint of a PEM cert. + * Returns hex string (no colons), e.g. "a1b2c3...". + */ +export async function computeFingerprint(certPath: string): Promise { + // Use openssl to get DER bytes, then hash + const { stdout } = await execFileAsync("openssl", [ + "x509", + "-in", + certPath, + "-outform", + "DER", + ]); + return createHash("sha256").update(stdout).digest("hex"); +} + +/** + * Check if openssl is available on PATH. + */ +export async function isOpensslAvailable(): Promise { + try { + await execFileAsync("openssl", ["version"]); + return true; + } catch { + return false; + } +} diff --git a/extensions/remote-control/auth/tokens.ts b/extensions/remote-control/auth/tokens.ts new file mode 100644 index 0000000..e75bdf2 --- /dev/null +++ b/extensions/remote-control/auth/tokens.ts @@ -0,0 +1,139 @@ +/** + * Bearer-token CRUD. + * + * Extends the minimal token support in auth.ts (legacy single-token) with + * named multi-token management. Each token entry has: + * - id: short random identifier + * - token: the bearer secret (base64url, 32 bytes) + * - name: human label (e.g. "Jay's iPhone") + * - createdAt: ISO timestamp + * - deviceToken?: APNs device token (set when device pairs in Phase 2) + * - environment?: "production" | "sandbox" + * + * Stored as JSON in $state_dir/auth/tokens.json (mode 0o600). + * + * Owner: T-1.3 + */ + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export interface TokenEntry { + id: string; + token: string; + name: string; + createdAt: string; + deviceToken?: string; // APNs — optional pre-Phase-2, mandatory Phase-2+ + environment?: "production" | "sandbox"; +} + +function tokensPath(stateDir?: string): string { + const base = + stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"); + return path.join(base, "auth", "tokens.json"); +} + +async function loadTokens(stateDir?: string): Promise { + try { + const raw = await fs.readFile(tokensPath(stateDir), "utf8"); + return JSON.parse(raw) as TokenEntry[]; + } catch { + return []; + } +} + +async function saveTokens( + entries: TokenEntry[], + stateDir?: string, +): Promise { + const fp = tokensPath(stateDir); + await fs.mkdir(path.dirname(fp), { recursive: true }); + await fs.writeFile(fp, JSON.stringify(entries, null, 2), { + encoding: "utf8", + mode: 0o600, + }); +} + +/** Create a new named bearer token. Returns the new entry (token visible once). */ +export async function createToken( + name: string, + stateDir?: string, +): Promise { + const entries = await loadTokens(stateDir); + const entry: TokenEntry = { + id: randomBytes(6).toString("base64url"), + token: randomBytes(32).toString("base64url"), + name, + createdAt: new Date().toISOString(), + }; + entries.push(entry); + await saveTokens(entries, stateDir); + return entry; +} + +/** List all tokens (token field is included — protect at the API layer). */ +export async function listTokens(stateDir?: string): Promise { + return loadTokens(stateDir); +} + +/** Revoke a token by id. Returns true if found and removed. */ +export async function revokeToken( + id: string, + stateDir?: string, +): Promise { + const entries = await loadTokens(stateDir); + const before = entries.length; + const filtered = entries.filter((e) => e.id !== id); + if (filtered.length === before) return false; + await saveTokens(filtered, stateDir); + return true; +} + +/** Rename a token. Returns true if found. */ +export async function renameToken( + id: string, + newName: string, + stateDir?: string, +): Promise { + const entries = await loadTokens(stateDir); + const entry = entries.find((e) => e.id === id); + if (!entry) return false; + entry.name = newName; + await saveTokens(entries, stateDir); + return true; +} + +/** Update device token (called during Phase-2 pairing). */ +export async function setDeviceToken( + id: string, + deviceToken: string, + environment: "production" | "sandbox", + stateDir?: string, +): Promise { + const entries = await loadTokens(stateDir); + const entry = entries.find((e) => e.id === id); + if (!entry) return false; + entry.deviceToken = deviceToken; + entry.environment = environment; + await saveTokens(entries, stateDir); + return true; +} + +/** + * Validate a bearer token string against the store. + * Returns the matching entry or null. + */ +export async function validateBearer( + bearer: string, + stateDir?: string, +): Promise { + const entries = await loadTokens(stateDir); + const b = Buffer.from(bearer); + for (const entry of entries) { + const a = Buffer.from(entry.token); + if (a.length === b.length && timingSafeEqual(a, b)) return entry; + } + return null; +} diff --git a/extensions/remote-control/cli/index.ts b/extensions/remote-control/cli/index.ts new file mode 100644 index 0000000..09658f9 --- /dev/null +++ b/extensions/remote-control/cli/index.ts @@ -0,0 +1,223 @@ +/** + * pi-remote CLI entrypoints. + * + * Subcommands: + * pi-remote pair — generate QR code for device pairing + * pi-remote auth list — list bearer tokens + * pi-remote auth revoke — revoke a token + * pi-remote auth name — rename a token + * + * Invoked by the extension via pi's flag registration or as a standalone + * script: `node cli/index.js ` + * + * Owner: T-1.3 + */ + +import os from "node:os"; +import path from "node:path"; +import { + buildPairingUrl, + generatePairingToken, + printPairingQr, +} from "../auth/pairing.js"; +import { loadOrCreateCert } from "../auth/tls.js"; +import { + createToken, + listTokens, + renameToken, + revokeToken, +} from "../auth/tokens.js"; +import { readRemoteControlConfig } from "../config.js"; + +const DEFAULT_STATE_DIR = path.join( + os.homedir(), + ".local", + "share", + "pi-remote", +); + +export async function runCli( + argv: string[] = process.argv.slice(2), +): Promise { + const [cmd, sub, ...rest] = argv; + + switch (cmd) { + case "pair": + await cmdPair(); + break; + + case "auth": + await cmdAuth(sub, rest); + break; + + case "help": + case "--help": + case "-h": + printHelp(); + break; + + default: + console.error(`Unknown command: ${cmd ?? "(none)"}`); + printHelp(); + process.exitCode = 1; + } +} + +// --------------------------------------------------------------------------- +// pair +// --------------------------------------------------------------------------- + +async function cmdPair(): Promise { + const config = await readRemoteControlConfig(); + const stateDir = DEFAULT_STATE_DIR; + + // Try to get TLS fingerprint; fall back to empty string if openssl unavailable + let fingerprint = ""; + try { + const cert = await loadOrCreateCert({ stateDir }); + fingerprint = cert.fingerprint; + } catch { + console.warn( + "[pi-remote] Warning: openssl not available; fingerprint will be empty.", + ); + } + + const pairingTokenEntry = generatePairingToken(); + + // Determine host for QR — use advertised URL or fall back to hostname + const bindAddress = config.bindAddress ?? "0.0.0.0:7777"; + const portMatch = bindAddress.match(/:(\d+)$/); + const port = portMatch ? parseInt(portMatch[1], 10) : 7777; + const host = + (config.publicBaseUrl ?? config.advertisedBaseUrl)?.replace( + /^https?:\/\//, + "", + ) ?? os.hostname(); + + const url = buildPairingUrl({ + host, + port, + pairingToken: pairingTokenEntry.token, + fingerprint, + sidecarName: "pi-remote", + }); + + console.log("\nScan this QR code with the pi-remote iOS app:\n"); + await printPairingQr(url); + console.log( + `\nPairing token expires in 10 minutes. Run "pi-remote pair" again to refresh.`, + ); +} + +// --------------------------------------------------------------------------- +// auth +// --------------------------------------------------------------------------- + +async function cmdAuth(sub: string | undefined, args: string[]): Promise { + const stateDir = DEFAULT_STATE_DIR; + + switch (sub) { + case "list": { + const tokens = await listTokens(stateDir); + if (tokens.length === 0) { + console.log( + "No tokens. Use `pi-remote auth create ` to create one.", + ); + return; + } + console.log("ID NAME CREATED"); + for (const t of tokens) { + const created = new Date(t.createdAt).toLocaleDateString(); + console.log( + `${t.id.padEnd(12)}${t.name.padEnd(22)}${created}${t.deviceToken ? " [device paired]" : ""}`, + ); + } + break; + } + + case "create": { + const name = args[0] ?? "unnamed"; + const entry = await createToken(name, stateDir); + console.log(`Created token "${entry.name}" (id: ${entry.id})`); + console.log(`Bearer token: ${entry.token}`); + console.log("Save this token — it won't be shown again."); + break; + } + + case "revoke": { + const id = args[0]; + if (!id) { + console.error("Usage: pi-remote auth revoke "); + process.exitCode = 1; + return; + } + const ok = await revokeToken(id, stateDir); + if (ok) { + console.log(`Revoked token ${id}.`); + } else { + console.error(`Token ${id} not found.`); + process.exitCode = 1; + } + break; + } + + case "name": { + const [id, ...nameParts] = args; + const newName = nameParts.join(" "); + if (!id || !newName) { + console.error("Usage: pi-remote auth name "); + process.exitCode = 1; + return; + } + const ok = await renameToken(id, newName, stateDir); + if (ok) { + console.log(`Renamed token ${id} to "${newName}".`); + } else { + console.error(`Token ${id} not found.`); + process.exitCode = 1; + } + break; + } + + default: + console.error(`Unknown auth subcommand: ${sub ?? "(none)"}`); + console.error("Available: list, create, revoke, name"); + process.exitCode = 1; + } +} + +// --------------------------------------------------------------------------- +// help +// --------------------------------------------------------------------------- + +function printHelp(): void { + console.log(` +pi-remote — CLI for the pi-remote-control sidecar + +Commands: + pair Generate a QR code to pair the iOS app + auth list List all bearer tokens + auth create Create a new named bearer token + auth revoke Revoke a token by id + auth name Rename a token + +Options: + --help, -h Show this help +`); +} + +// --------------------------------------------------------------------------- +// Standalone entrypoint +// --------------------------------------------------------------------------- + +// Run when invoked as: node cli/index.js +if ( + process.argv[1] && + (process.argv[1].endsWith("cli/index.js") || + process.argv[1].endsWith("cli/index.ts")) +) { + runCli().catch((err) => { + console.error(err); + process.exit(1); + }); +}