feat(T-1.3): auth tokens, pairing, TLS, CLI (pair/auth list/revoke/name)
This commit is contained in:
parent
17c32e7e93
commit
6f106d2411
|
|
@ -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<string, PairingToken>();
|
||||
|
||||
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://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
||||
*/
|
||||
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<void> {
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -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<TlsCert> {
|
||||
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<string> {
|
||||
// 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<boolean> {
|
||||
try {
|
||||
await execFileAsync("openssl", ["version"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TokenEntry[]> {
|
||||
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<void> {
|
||||
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<TokenEntry> {
|
||||
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<TokenEntry[]> {
|
||||
return loadTokens(stateDir);
|
||||
}
|
||||
|
||||
/** Revoke a token by id. Returns true if found and removed. */
|
||||
export async function revokeToken(
|
||||
id: string,
|
||||
stateDir?: string,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<TokenEntry | null> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 <id> — revoke a token
|
||||
* pi-remote auth name <id> <name> — rename a token
|
||||
*
|
||||
* Invoked by the extension via pi's flag registration or as a standalone
|
||||
* script: `node cli/index.js <args>`
|
||||
*
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <name>` 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 <id>");
|
||||
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 <id> <new-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 <name> Create a new named bearer token
|
||||
auth revoke <id> Revoke a token by id
|
||||
auth name <id> <name> Rename a token
|
||||
|
||||
Options:
|
||||
--help, -h Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Run when invoked as: node cli/index.js <args>
|
||||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue