openvisengine/src/lib/music-sessions.ts
2026-10-29 07:19:50 +00:00

281 lines
9 KiB
TypeScript
Executable file

// 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<Record<ServiceId, { savedAt: number }>>
/** 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<Record<ServiceId, StoredSession>>
}
// ---- 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<IDBDatabase> {
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<CryptoKey> {
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<void>((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<ArrayBuffer> {
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<StoredSession> {
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<string | null> {
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<SessionSummary> {
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<void> {
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<void> {
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<CookieAuth | null> {
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
}
}