281 lines
9 KiB
TypeScript
Executable file
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
|
|
}
|
|
}
|