Encrypt credentials at rest; unify clipboard-link suggestions

Encrypt proxy / youtubePoToken / updateToken on disk via safeStorage (DPAPI on Windows), with a one-time launch migration for legacy plaintext. Decrypted before reaching callers/renderer; backup export still writes clear, as documented. Harden updater token handling to refuse cross-origin redirects on the authenticated REST check.

Extract the clipboard-link logic from DownloadBar into the shared useClipboardLink hook: add an optional filter (library skips single-video links), an offer() for external aerofetch:// / .url links, and a source field driving banner wording. Add looksLikeSingleVideo plus its unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 10:15:59 -04:00
parent f167c02946
commit 5a9f2de390
8 changed files with 278 additions and 100 deletions
+79 -8
View File
@@ -1,4 +1,4 @@
import { app } from 'electron'
import { app, safeStorage } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store'
@@ -149,6 +149,72 @@ function getStore(): Store<Settings> {
return store
}
// --- Credential encryption at rest ------------------------------------------
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
// so a leaked settings.json doesn't expose them. They're still decrypted before
// reaching the renderer and exported in clear by backup — this guards the file at
// rest only.
const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
// legacy plaintext value (written before encryption existed, or while safeStorage
// was unavailable) and migrate it on the next write.
const ENC_PREFIX = 'enc:v1:'
/** Encrypt a secret for storage. Falls back to plaintext where safeStorage is unavailable. */
function encryptSecret(plain: string): string {
if (!plain) return ''
try {
if (safeStorage.isEncryptionAvailable()) {
return ENC_PREFIX + safeStorage.encryptString(plain).toString('base64')
}
} catch {
/* fall through — store plaintext, as it was before encryption existed */
}
return plain
}
/** Decrypt a stored secret. Legacy plaintext is returned as-is; an undecryptable blob → ''. */
function decryptSecret(stored: string): string {
if (!stored.startsWith(ENC_PREFIX)) return stored
try {
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
} catch {
// Different user/machine or a corrupt blob — drop it rather than surface
// ciphertext into the UI or onto a yt-dlp command line.
return ''
}
}
/** A copy of settings with the credential fields decrypted for in-process use. */
function withDecryptedSecrets(raw: Settings): Settings {
const out = { ...raw }
for (const key of SECRET_KEYS) out[key] = decryptSecret(raw[key] ?? '')
return out
}
/**
* One-time (per launch) migration: re-store any credential still held as legacy
* plaintext as ciphertext. Lets settings files written before at-rest encryption
* get protected without a write on the hot getSettings() path. No-op when there's
* nothing to migrate or safeStorage is unavailable.
*/
export function migrateSecretsAtRest(): void {
let available = false
try {
available = safeStorage.isEncryptionAvailable()
} catch {
return
}
if (!available) return
const s = getStore()
for (const key of SECRET_KEYS) {
const raw = s.get(key) ?? ''
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
}
}
export function getSettings(): Settings {
const s = getStore()
// getSettings() is on hot paths (buildCommand, notification checks, the system-
@@ -170,7 +236,9 @@ export function getSettings(): Settings {
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
s.set('accentColor', DEFAULTS.accentColor)
}
return s.store
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
return withDecryptedSecrets(s.store)
}
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
@@ -276,17 +344,20 @@ export function setSettings(partial: Partial<Settings>): Settings {
break
case 'defaultVideoQuality':
case 'defaultAudioQuality':
// proxy may carry plaintext credentials (user:pass@host); they are stored
// and exported by exportBackup as-is — documented, not masked.
case 'proxy':
case 'rateLimit':
case 'youtubePlayerClient':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, value)
break
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
// may embed user:pass@host; youtubePoToken is an access token. exportBackup
// still writes them in clear (via the decrypted getSettings), as documented.
case 'proxy':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, encryptSecret(value))
break
case 'updateToken':
// A Gitea token has no spaces; trim and store as-is (like proxy creds).
if (typeof value === 'string') s.set('updateToken', value.trim())
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
break
}
}