diff --git a/.env.example b/.env.example index ecac050..013704f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ APP_HOST=cleanstream-connector.test SHARED_NETWORK=local-proxy MEDIA_ROOT=/absolute/path/to/video-library -PAIRING_SECRET=replace-with-a-long-random-secret +# Production: generate with `node hash-password.js `. +PAIRING_PASSWORD_HASH= +# Staging only. Do not set alongside PAIRING_PASSWORD_HASH. +PAIRING_SECRET=246810 diff --git a/README.md b/README.md index 27f8300..48189c1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ For production, place the service behind HTTPS and set `PUBLIC_BASE_URL` and approval form protected by `PAIRING_SECRET`; replace that form with your own identity provider for a production connector. +For a password-protected production connector, generate a local owner password +hash with `node hash-password.js ` and set `PAIRING_PASSWORD_HASH`. +`PAIRING_SECRET` is a staging-only convenience PIN and must not be used for a +shared deployment. + ## Local OrbStack development Copy `.env.example` to `.env`, set `MEDIA_ROOT` to an absolute directory with diff --git a/docker-compose.local-domain.yml b/docker-compose.local-domain.yml index 7330bc2..a135032 100644 --- a/docker-compose.local-domain.yml +++ b/docker-compose.local-domain.yml @@ -6,13 +6,15 @@ services: command: ["node", "--watch", "server.js"] environment: PORT: 8787 - PUBLIC_BASE_URL: https://${APP_HOST} - APPROVAL_BASE_URL: https://${APP_HOST} + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://${APP_HOST}} + APPROVAL_BASE_URL: ${APPROVAL_BASE_URL:-https://${APP_HOST}} PAIRING_SECRET: ${PAIRING_SECRET} MEDIA_ROOT: /media volumes: - ./:/app - ${MEDIA_ROOT:?Set MEDIA_ROOT in .env}:/media:ro + ports: + - "127.0.0.1:8787:8787" labels: caddy: ${APP_HOST} caddy.tls: internal diff --git a/hash-password.js b/hash-password.js new file mode 100644 index 0000000..e8bd8ff --- /dev/null +++ b/hash-password.js @@ -0,0 +1,7 @@ +import crypto from 'node:crypto'; + +const password = process.argv[2]; +if (!password) throw new Error('Usage: node hash-password.js '); +const salt = crypto.randomBytes(16).toString('base64url'); +const hash = crypto.scryptSync(password, salt, 32).toString('base64url'); +console.log(`${salt}:${hash}`); diff --git a/server.js b/server.js index e9b5918..4ce0494 100644 --- a/server.js +++ b/server.js @@ -8,8 +8,9 @@ const mediaRoot = path.resolve(process.env.MEDIA_ROOT || './media'); const publicBaseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\/$/, ''); const approvalBaseUrl = (process.env.APPROVAL_BASE_URL || publicBaseUrl).replace(/\/$/, ''); const pairingSecret = process.env.PAIRING_SECRET; +const pairingPasswordHash = process.env.PAIRING_PASSWORD_HASH; const demoStreamUrl = process.env.DEMO_STREAM_URL; -if (!pairingSecret) throw new Error('PAIRING_SECRET is required'); +if (!pairingSecret && !pairingPasswordHash) throw new Error('PAIRING_PASSWORD_HASH or PAIRING_SECRET is required'); const sessions = new Map(); const tokens = new Map(); @@ -24,6 +25,15 @@ const readJson = req => new Promise((resolve, reject) => { let raw = ''; req.on( const bearer = req => req.headers.authorization?.replace(/^Bearer\s+/i, ''); const authenticated = req => tokens.has(bearer(req)); const id = () => crypto.randomBytes(24).toString('base64url'); +const passwordMatches = value => { + if (pairingPasswordHash) { + const [salt, expected] = pairingPasswordHash.split(':'); + if (!salt || !expected) return false; + const actual = crypto.scryptSync(value, salt, 32).toString('base64url'); + return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected)); + } + return Boolean(pairingSecret) && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(pairingSecret)); +}; http.createServer(async (req, res) => { const url = new URL(req.url, publicBaseUrl); @@ -38,7 +48,7 @@ http.createServer(async (req, res) => { return json(res, 200, { sessionId, approvalUri: `${approvalBaseUrl}/pair/approve?session=${sessionId}`, expiresAt: new Date(Date.now() + 300000).toISOString(), pollIntervalSeconds: 2 }); } if (req.method === 'GET' && url.pathname === '/pair/approve') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end('
'); } - if (req.method === 'POST' && url.pathname === '/pair/approve') { let raw=''; req.on('data', d => raw += d); req.on('end', () => { const session=sessions.get(url.searchParams.get('session')); if (!session || new URLSearchParams(raw).get('secret') !== pairingSecret) { res.statusCode=403; return res.end('Denied'); } session.code=id(); res.end('Approved. Return to your TV.'); }); return; } + if (req.method === 'POST' && url.pathname === '/pair/approve') { let raw=''; req.on('data', d => raw += d); req.on('end', () => { const session=sessions.get(url.searchParams.get('session')); if (!session || !passwordMatches(new URLSearchParams(raw).get('secret') || '')) { res.statusCode=403; return res.end('Denied'); } session.code=id(); res.end('Approved. Return to your TV.'); }); return; } if (req.method === 'GET' && url.pathname.startsWith('/v1/pair/')) { const session=sessions.get(url.pathname.split('/').pop()); if (!session || session.expires < Date.now()) return json(res, 200, { status: 'expired' }); return json(res, 200, session.code ? { status: 'approved', authorizationCode: session.code } : { status: 'pending' }); } if (req.method === 'POST' && url.pathname === '/v1/pair/token') { const body=await readJson(req), session=sessions.get(body.sessionId); const challenge=crypto.createHash('sha256').update(body.codeVerifier || '').digest('base64url'); if (!session || session.code !== body.authorizationCode || session.challenge !== challenge) return json(res, 403, { error: 'Invalid pairing exchange' }); const token=id(); tokens.set(token, Date.now()+2592000000); sessions.delete(body.sessionId); return json(res, 200, { accessToken: token, expiresAt: new Date(tokens.get(token)).toISOString(), scopes: ['catalog','stream'] }); } if (req.method === 'GET' && url.pathname === '/v1/catalog') return authenticated(req) ? json(res, 200, { items: videos() }) : json(res, 401, { error: 'Unauthorized' });