|
| 1 | +import cors from '@fastify/cors'; |
| 2 | +import { env } from '@powersync/cli-core'; |
| 3 | +import Fastify from 'fastify'; |
| 4 | +import { createDecipheriv, createPrivateKey, createPublicKey, generateKeyPairSync, privateDecrypt } from 'node:crypto'; |
| 5 | +import open from 'open'; |
| 6 | + |
| 7 | +/** Hybrid encryption format from dashboard: [encrypted_cek (256)] [iv (12)] [ciphertext] [tag (16)]. */ |
| 8 | +const RSA_ENCRYPTED_KEY_BYTES = 256; |
| 9 | +const IV_BYTES = 12; |
| 10 | +const GCM_TAG_BYTES = 16; |
| 11 | + |
| 12 | +/** Decode base64 or base64url (URL-safe) to bytes. Accepts standard base64 or base64url from query params. */ |
| 13 | +function decodeBase64Payload(payload: string): Buffer { |
| 14 | + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); |
| 15 | + const pad = base64.length % 4; |
| 16 | + const padded = pad === 0 ? base64 : base64 + '='.repeat(4 - pad); |
| 17 | + return Buffer.from(padded, 'base64'); |
| 18 | +} |
| 19 | + |
| 20 | +/** |
| 21 | + * Decrypt a token encrypted by the dashboard with the hybrid scheme (RSA-OAEP-wrapped CEK + AES-256-GCM). |
| 22 | + * Payload is base64 or base64url: decoded = encrypted_cek (256 bytes) || iv (12) || ciphertext || auth_tag (16). |
| 23 | + * Uses Node crypto for RSA-OAEP and AES-GCM decryption. |
| 24 | + */ |
| 25 | +function decryptTokenFromDashboard(base64Payload: string, privateKeyPem: string): string { |
| 26 | + const raw = decodeBase64Payload(base64Payload.trim()); |
| 27 | + if (raw.length < RSA_ENCRYPTED_KEY_BYTES + IV_BYTES + GCM_TAG_BYTES) { |
| 28 | + throw new Error('Payload too short'); |
| 29 | + } |
| 30 | + const encryptedCek = raw.subarray(0, RSA_ENCRYPTED_KEY_BYTES); |
| 31 | + const iv = raw.subarray(RSA_ENCRYPTED_KEY_BYTES, RSA_ENCRYPTED_KEY_BYTES + IV_BYTES); |
| 32 | + const ciphertextWithTag = raw.subarray(RSA_ENCRYPTED_KEY_BYTES + IV_BYTES); |
| 33 | + const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - GCM_TAG_BYTES); |
| 34 | + const tag = ciphertextWithTag.subarray(-GCM_TAG_BYTES); |
| 35 | + |
| 36 | + const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem' }); |
| 37 | + const cek = privateDecrypt({ key: keyObject, oaepHash: 'sha256' }, encryptedCek); |
| 38 | + |
| 39 | + const decipher = createDecipheriv('aes-256-gcm', cek, iv); |
| 40 | + decipher.setAuthTag(tag); |
| 41 | + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8'); |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * Server login handshake (asymmetric encryption) |
| 46 | + * |
| 47 | + * Flow: |
| 48 | + * 1. CLI starts a local HTTP server and generates an ephemeral RSA keypair. |
| 49 | + * 2. CLI opens the dashboard with a base64-encoded `request` param containing |
| 50 | + * { redirect_url, public_key }. The dashboard learns where to send the token |
| 51 | + * and how to encrypt it. |
| 52 | + * 3. User creates a token in the dashboard. The dashboard encrypts the token |
| 53 | + * with the CLI's public key and redirects the browser to redirect_url with |
| 54 | + * the encrypted token in the query string (GET), so the secret never appears in history. |
| 55 | + * 4. CLI receives GET /response?token=<base64> (see decryptTokenFromDashboard), decrypts it, |
| 56 | + * and stores the plaintext token. |
| 57 | + * |
| 58 | + * Why encryption: |
| 59 | + * In some cases the dashboard needs to hand the token back via a URL (e.g. |
| 60 | + * redirect or opening the CLI callback URL in the browser). If we sent the |
| 61 | + * token in the query string or fragment, it would appear in browser history, |
| 62 | + * logs, and referrers. Encrypting with the CLI's public key ensures only this |
| 63 | + * CLI instance can decrypt the token; the value that might appear in a URL or |
| 64 | + * in transit is ciphertext, not the secret. |
| 65 | + */ |
| 66 | +export async function startPATLoginServer(): Promise<{ |
| 67 | + address: string; |
| 68 | + tokenPromise: Promise<string>; |
| 69 | +}> { |
| 70 | + const { publicKey: publicKeyPem, privateKey: privateKeyPem } = generateKeyPairSync('rsa', { |
| 71 | + modulusLength: 2048, |
| 72 | + publicKeyEncoding: { type: 'spki', format: 'pem' }, |
| 73 | + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } |
| 74 | + }); |
| 75 | + const publicKeyJwk = createPublicKey(publicKeyPem).export({ format: 'jwk' }) as JsonWebKey; |
| 76 | + |
| 77 | + let settled = false; |
| 78 | + let resolveToken!: (value: string) => void; |
| 79 | + let rejectToken!: (err: Error) => void; |
| 80 | + const tokenPromise = new Promise<string>((resolve, reject) => { |
| 81 | + resolveToken = resolve; |
| 82 | + rejectToken = reject; |
| 83 | + }); |
| 84 | + |
| 85 | + const app = Fastify({ logger: false }); |
| 86 | + const allowOrigin = env._PS_DASHBOARD_URL.replace(/\/$/, ''); |
| 87 | + await app.register(cors, { origin: allowOrigin }); |
| 88 | + |
| 89 | + app.get<{ Querystring: { token?: string } }>('/response', async (request, reply) => { |
| 90 | + const rawToken = typeof request.query?.token === 'string' ? request.query.token.trim() : null; |
| 91 | + if (!rawToken) { |
| 92 | + await reply.status(400).send({ error: 'Missing or empty "token" query parameter' }); |
| 93 | + if (!settled) { |
| 94 | + settled = true; |
| 95 | + rejectToken(new Error('Invalid request: GET /response must include a non-empty "token" query parameter')); |
| 96 | + } |
| 97 | + return; |
| 98 | + } |
| 99 | + let tokenValue: string; |
| 100 | + try { |
| 101 | + tokenValue = decryptTokenFromDashboard(rawToken, privateKeyPem); |
| 102 | + } catch { |
| 103 | + await reply.status(400).send({ error: 'Failed to decrypt token from dashboard' }); |
| 104 | + if (!settled) { |
| 105 | + settled = true; |
| 106 | + rejectToken(new Error('Failed to decrypt token from dashboard')); |
| 107 | + } |
| 108 | + return; |
| 109 | + } |
| 110 | + if (settled) return; |
| 111 | + settled = true; |
| 112 | + await reply.status(200).send(); |
| 113 | + resolveToken(tokenValue); |
| 114 | + await app.close(); |
| 115 | + }); |
| 116 | + |
| 117 | + app.setErrorHandler((err, _request, reply) => { |
| 118 | + if (!settled) { |
| 119 | + settled = true; |
| 120 | + rejectToken(err instanceof Error ? err : new Error(String(err))); |
| 121 | + } |
| 122 | + void reply.status(500).send({ error: 'Internal server error' }); |
| 123 | + }); |
| 124 | + |
| 125 | + const address = await app.listen({ port: 0, host: '127.0.0.1' }); |
| 126 | + const responseUrl = `${address}/response`; |
| 127 | + const requestPayload: { redirect_url: string; public_key: JsonWebKey } = { |
| 128 | + redirect_url: responseUrl, |
| 129 | + public_key: publicKeyJwk |
| 130 | + }; |
| 131 | + const requestBase64 = Buffer.from(JSON.stringify(requestPayload), 'utf-8').toString('base64'); |
| 132 | + |
| 133 | + open(`${env._PS_DASHBOARD_URL}/account/access-tokens/create?request=${encodeURIComponent(requestBase64)}`); |
| 134 | + |
| 135 | + return { address, tokenPromise }; |
| 136 | +} |
0 commit comments