security: hash operator passwords (scrypt) + persistent session secret

- 운영자 로그인 비밀번호를 평문 비교(===)에서 Node 내장 scrypt 해시 + 상수시간
  비교(verifyPassword)로 전환. 새 파일 src/server/password.ts. 기존 account.json
  의 평문 비밀번호는 그대로 검증되며, 로그인 성공 시 scrypt 해시로 자동 업그레이드
  후 저장(writeAccounts 추가). 외부 의존성 없음.
- 세션 시크릿을 하드코딩 폴백('...dev-secret') 대신, 환경변수 우선 → 없으면
  .session-secret 파일에 영구 랜덤값 생성/보관하도록 변경(세션 위조 방지, 재시작
  후에도 세션 유지). .session-secret 은 .gitignore 에 추가.

서버(사이트) 전용 변경이라 설치기 exe 는 영향 없음(재빌드 불필요).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:24:42 +09:00
parent 704d58d3ac
commit 00344084c7
5 changed files with 91 additions and 4 deletions

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ conversations/
.env .env
.env.local .env.local
.env.*.local .env.*.local
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
.session-secret

View File

@@ -1,10 +1,12 @@
import express from 'express' import express from 'express'
import session from 'express-session' import session from 'express-session'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import crypto from 'node:crypto'
import { import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath fileDirPath, viewsDirPath, publicDirPath, projectRoot
} from '../shared/paths.js' } from '../shared/paths.js'
import { import {
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
@@ -38,8 +40,28 @@ app.use((_req, res, next) => {
next() next()
}) })
// 세션 시크릿: 환경변수 우선, 없으면 하드코딩(위조 위험) 대신 영구 랜덤 시크릿을
// 파일로 생성/보관한다(재시작해도 세션 유지). 파일 접근 불가 시엔 프로세스 수명 동안만
// 유효한 랜덤값으로 폴백(그래도 하드코딩보다 안전).
function resolveSessionSecret(): string {
const fromEnv = process.env.SESSION_SECRET
if (fromEnv && fromEnv.length >= 16) return fromEnv
const secretPath = path.join(projectRoot, '.session-secret')
try {
if (fs.existsSync(secretPath)) {
const existing = fs.readFileSync(secretPath, 'utf8').trim()
if (existing.length >= 16) return existing
}
const generated = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(secretPath, generated, { mode: 0o600 })
return generated
} catch {
return crypto.randomBytes(32).toString('hex')
}
}
app.use(session({ app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret', secret: resolveSessionSecret(),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {

48
src/server/password.ts Normal file
View File

@@ -0,0 +1,48 @@
import crypto from 'node:crypto'
// 운영자 비밀번호 저장/검증. 외부 의존성 없이 Node 내장 scrypt 사용.
// 저장 형식: `scrypt$<saltHex>$<hashHex>`. 검증은 항상 상수시간 비교.
// 기존 account.json 의 평문 비밀번호는 verifyPassword 가 그대로 검증할 수 있고,
// 로그인 성공 시 호출측에서 hashPassword 로 재저장(자동 업그레이드)한다.
const SCHEME = 'scrypt'
const KEY_LEN = 32
const SALT_LEN = 16
export function hashPassword(plain: string): string {
const salt = crypto.randomBytes(SALT_LEN)
const hash = crypto.scryptSync(plain, salt, KEY_LEN)
return `${SCHEME}$${salt.toString('hex')}$${hash.toString('hex')}`
}
export function isHashed(stored: string): boolean {
return typeof stored === 'string' && stored.startsWith(`${SCHEME}$`)
}
export function verifyPassword(plain: string, stored: string): boolean {
if (typeof stored !== 'string' || stored.length === 0) return false
if (isHashed(stored)) {
const parts = stored.split('$')
if (parts.length !== 3) return false
let salt: Buffer
let expected: Buffer
try {
salt = Buffer.from(parts[1], 'hex')
expected = Buffer.from(parts[2], 'hex')
} catch {
return false
}
if (expected.length === 0) return false
let derived: Buffer
try {
derived = crypto.scryptSync(plain, salt, expected.length)
} catch {
return false
}
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected)
}
// 레거시 평문: 길이 노출을 피하려 양쪽을 sha256 으로 고정 길이화한 뒤 상수시간 비교.
const a = crypto.createHash('sha256').update(plain, 'utf8').digest()
const b = crypto.createHash('sha256').update(stored, 'utf8').digest()
return crypto.timingSafeEqual(a, b)
}

View File

@@ -22,8 +22,10 @@ import {
saveTerm, saveTerm,
savePackList, savePackList,
setPackPublic, setPackPublic,
setTermVisibility setTermVisibility,
writeAccounts
} from '../../shared/store.js' } from '../../shared/store.js'
import { hashPassword, isHashed, verifyPassword } from '../password.js'
import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
@@ -58,11 +60,20 @@ opRouter.post('/op', async (req, res, next) => {
try { try {
const password = pickFirstValue(req.body.password) const password = pickFirstValue(req.body.password)
const accounts = await readAccounts() const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password) const matched = accounts.find((entry) => verifyPassword(password, entry.password))
if (!matched) { if (!matched) {
res.status(401).render('op/login', { error: t('login.wrongPassword') }) res.status(401).render('op/login', { error: t('login.wrongPassword') })
return return
} }
// 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드.
if (!isHashed(matched.password)) {
try {
matched.password = hashPassword(password)
await writeAccounts(accounts)
} catch {
// 업그레이드 실패는 로그인 자체에 영향 주지 않음.
}
}
req.session.userId = matched.id req.session.userId = matched.id
res.redirect('/op/dashboard') res.redirect('/op/dashboard')
} catch (error) { } catch (error) {

View File

@@ -789,3 +789,7 @@ export async function readAccounts(): Promise<AccountEntry[]> {
throw error throw error
} }
} }
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
await fsp.writeFile(accountFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8')
}