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:
48
src/server/password.ts
Normal file
48
src/server/password.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user