// OVE — encrypted per-visitor music service logins (v6.6). // // A visitor can log in to their own music services (YouTube, SoundCloud, // Spotify, Apple Music) by pasting that service's login cookie. Storage // model — everything stays on the visitor's computer: // // • the cookie text is encrypted with AES-GCM (Web Crypto) using a // 256-bit key that is generated once, is marked NON-extractable and // lives in IndexedDB ("ove-secure") — so no script, not even this one, // can read the key material back out of the browser's key store; // • only the ciphertext (base64 iv + base64 data) is written to // localStorage ("ove.musicSessions.v1"); // • the plaintext cookie exists in memory only: while saving, and for a // moment when a link the visitor plays is resolved (it is sent to this // site's own server as part of that resolve request — the server uses // it transiently for yt-dlp and never stores it). // // If the IndexedDB key is ever missing (cleared) while ciphertext remains, // the sessions are unreadable and get dropped — logging in again is the // only recovery, which is exactly what "remove credentials" means here. 'use client' import { isServiceId, serviceForUrl, type ServiceId } from '@/lib/music-services' const LS_KEY = 'ove.musicSessions.v1' const DB_NAME = 'ove-secure' const DB_VERSION = 1 const KEY_STORE = 'keys' const KEY_ID = 'music-cookies' export const MAX_COOKIE_LENGTH = 65_536 export interface SessionSummary { /** services with a readable (decryptable) saved login */ loggedIn: Partial> /** storage/persistence is unavailable in this browser context */ unavailable?: string } export interface CookieAuth { service: ServiceId cookies: string } interface StoredSession { iv: string ct: string savedAt: number } interface SessionsFile { v: 1 sessions: Partial> } // ---- availability ----------------------------------------------------------- function cryptoAvailable(): boolean { return ( typeof window !== 'undefined' && typeof window.crypto?.subtle?.encrypt === 'function' && typeof indexedDB !== 'undefined' ) } // ---- IndexedDB key store (non-extractable AES-GCM key) ---------------------- function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION) req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(KEY_STORE)) { req.result.createObjectStore(KEY_STORE, { keyPath: 'id' }) } } req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error ?? new Error('IndexedDB unavailable')) req.onblocked = () => reject(new Error('IndexedDB blocked by another tab')) }) } async function getOrCreateKey(): Promise { const db = await openDb() try { const read = (): Promise<{ key?: unknown } | undefined> => new Promise((resolve, reject) => { const tx = db.transaction(KEY_STORE, 'readonly') const req = tx.objectStore(KEY_STORE).get(KEY_ID) req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) const found = await read() if (found && found.key instanceof CryptoKey) return found.key const key = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, false, // non-extractable — the raw key never leaves the browser key store ['encrypt', 'decrypt'], ) await new Promise((resolve, reject) => { const tx = db.transaction(KEY_STORE, 'readwrite') tx.objectStore(KEY_STORE).put({ id: KEY_ID, key }) tx.oncomplete = () => resolve() tx.onabort = () => reject(tx.error ?? new Error('Key store write failed')) tx.onerror = () => reject(tx.error) }) return key } finally { db.close() } } // ---- localStorage session file ---------------------------------------------- function readFile(): SessionsFile { try { const raw = localStorage.getItem(LS_KEY) if (!raw) return { v: 1, sessions: {} } const parsed = JSON.parse(raw) as SessionsFile if (parsed === null || typeof parsed !== 'object' || parsed.v !== 1) { return { v: 1, sessions: {} } } const sessions: SessionsFile['sessions'] = {} for (const [id, s] of Object.entries(parsed.sessions ?? {})) { if ( isServiceId(id) && s !== null && typeof s === 'object' && typeof s.iv === 'string' && typeof s.ct === 'string' && typeof s.savedAt === 'number' ) { sessions[id] = s } } return { v: 1, sessions } } catch { return { v: 1, sessions: {} } } } function writeFile(file: SessionsFile): void { localStorage.setItem(LS_KEY, JSON.stringify(file)) } // ---- encrypt / decrypt ------------------------------------------------------- function toB64(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf) let s = '' for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]) return btoa(s) } function fromB64(s: string): Uint8Array { const bin = atob(s) const bytes = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) return bytes } async function encryptText(key: CryptoKey, text: string): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)) const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(text), ) return { iv: toB64(iv.buffer), ct: toB64(ct), savedAt: Date.now() } } async function decryptText(key: CryptoKey, session: StoredSession): Promise { try { const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: fromB64(session.iv) }, key, fromB64(session.ct), ) return new TextDecoder().decode(plain) } catch { return null // wrong key or tampered ciphertext } } // ---- public API -------------------------------------------------------------- /** Save (or replace) the login cookie for one service. */ export async function saveMusicSession( service: ServiceId, cookieText: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const text = cookieText.trim() if (text.length < 8) { return { ok: false, error: 'That looks too short to be a cookie — paste the whole Cookie header value or a cookies.txt export.' } } if (text.length > MAX_COOKIE_LENGTH) { return { ok: false, error: 'That cookie data is too large (over 64KB).' } } if (!cryptoAvailable()) { return { ok: false, error: 'This browser context is not secure (needs https) — logins cannot be stored here.' } } try { const key = await getOrCreateKey() const file = readFile() file.sessions[service] = await encryptText(key, text) writeFile(file) return { ok: true } } catch { return { ok: false, error: 'Could not store the login in this browser (storage or crypto unavailable).' } } } /** Which services have a readable saved login. */ export async function listMusicSessions(): Promise { if (!cryptoAvailable()) { return { loggedIn: {}, unavailable: 'secure-context' } } try { const key = await getOrCreateKey() const file = readFile() const loggedIn: SessionSummary['loggedIn'] = {} let dropped = false for (const [id, session] of Object.entries(file.sessions)) { if (!isServiceId(id)) continue const plain = await decryptText(key, session) if (plain === null || plain.trim().length === 0) { delete file.sessions[id] dropped = true } else { loggedIn[id] = { savedAt: session.savedAt } } } if (dropped) writeFile(file) return { loggedIn } } catch { return { loggedIn: {}, unavailable: 'storage-error' } } } /** Remove every saved login (the "Log out of all" button). */ export async function clearAllMusicSessions(): Promise { try { localStorage.removeItem(LS_KEY) } catch { /* nothing to remove */ } } /** Remove the saved login for one service only (per-service "Log out"). */ export async function removeMusicSession(service: ServiceId): Promise { try { const file = readFile() if (file.sessions[service]) { delete file.sessions[service] writeFile(file) } } catch { /* nothing to remove */ } } /** * Look up the saved login matching a link the visitor is about to play. * Returns the decrypted cookie text + service id, or null when the link's * service has no login (or the link belongs to no known service). */ export async function getCookieForUrl(url: string): Promise { const service = serviceForUrl(url) if (service === null) return null if (!cryptoAvailable()) return null try { const key = await getOrCreateKey() const session = readFile().sessions[service] if (!session) return null const plain = await decryptText(key, session) if (plain === null || plain.trim().length === 0) return null return { service, cookies: plain } } catch { return null } }