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:
+11
-1
@@ -23,7 +23,13 @@ import {
|
||||
hasActiveDownloads
|
||||
} from './download'
|
||||
import { runTerminal, cancelTerminal } from './terminal'
|
||||
import { getSettings, setSettings, ensureMediaDirs, applyLaunchAtStartup } from './settings'
|
||||
import {
|
||||
getSettings,
|
||||
setSettings,
|
||||
ensureMediaDirs,
|
||||
applyLaunchAtStartup,
|
||||
migrateSecretsAtRest
|
||||
} from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { setupPortableData } from './portable'
|
||||
@@ -398,6 +404,10 @@ if (isPrimaryInstance) {
|
||||
// exist from first launch (downloads are routed into them by kind).
|
||||
ensureMediaDirs()
|
||||
|
||||
// Encrypt any credential still stored as legacy plaintext (from before at-rest
|
||||
// encryption), once safeStorage is available post-ready.
|
||||
migrateSecretsAtRest()
|
||||
|
||||
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
|
||||
// reflects the user's choice even if they changed it on another install.
|
||||
applyLaunchAtStartup(getSettings().launchAtStartup)
|
||||
|
||||
+79
-8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -15,8 +15,12 @@ import {
|
||||
* Authorization header for the update host. Empty unless the user has set an
|
||||
* updateToken in Settings — needed when the release repo is private or the Gitea
|
||||
* instance requires sign-in for anonymous access (the default on this instance).
|
||||
* Only ever sent to the host-pinned UPDATE_HOST (see isTrustedDownloadUrl), so a
|
||||
* redirect can never leak the token to another origin.
|
||||
*
|
||||
* The token must never reach an origin other than the host-pinned UPDATE_HOST.
|
||||
* Every request that carries it guards against that on its own terms: the REST
|
||||
* check refuses to follow redirects (`redirect: 'error'`), and the download paths
|
||||
* use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So
|
||||
* a redirect can't bounce the header to another host on any path.
|
||||
*/
|
||||
function authHeader(): Record<string, string> {
|
||||
const tok = getSettings().updateToken?.trim()
|
||||
@@ -123,6 +127,10 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
||||
try {
|
||||
const res = await fetch(RELEASE_API, {
|
||||
headers: { Accept: 'application/json', ...authHeader() },
|
||||
// The API lives at an exact pinned URL and answers 200 directly. Refusing
|
||||
// redirects keeps an authenticated check from ever forwarding the token to
|
||||
// another origin; a stray redirect fails safe into the catch below.
|
||||
redirect: 'error',
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user