Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-store-password-direct-display
This commit is contained in:
commit
6b8f3ed277
@ -2,6 +2,8 @@
|
||||
|
||||
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
|
||||
|
||||
> 2026-09-08 存储变更:按管理层决定,店铺账号密码/银行卡号取消数据库加密,改为明文落库;存量 `v1:` 密文已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。本契约的读取链路兼容明文与历史密文,认证、审计、限流要求不变;下文"存储事实与授权边界"中的加密描述保留为历史设计记录。
|
||||
|
||||
## 存储事实与授权边界
|
||||
|
||||
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`,使用现有 `src/lib/store-account-crypto.ts` 的 AES-256-GCM(随机 12 字节 IV、认证标签、`v1:` 封装)。不是明文或不可逆 Hash。
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 一次性迁移:把 StoreAccount 的历史 v1: 密文解密改写为明文。
|
||||
*
|
||||
* 背景:2026-09-08 按管理层决定,店铺账号密码/银行卡号取消数据库加密存储。
|
||||
* 应用侧读取已兼容两种格式,本脚本负责把存量密文统一改写为明文。
|
||||
* 幂等:只处理 LIKE 'v1:%' 的行;先全部解密成功后再写入,任一失败则整批不落库。
|
||||
*
|
||||
* 用法(在应用根目录):
|
||||
* node scripts/migrate-store-account-plaintext-20260908.js
|
||||
* 环境变量:DATABASE_URL(缺省时依次读取 .env.local / .env),
|
||||
* STORE_ACCOUNT_ENCRYPTION_KEY(生产必填,本地回退 JWT_SECRET)。
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { createDecipheriv, createHash } = require('node:crypto')
|
||||
|
||||
const PREFIX = 'v1:'
|
||||
|
||||
function loadEnvFile(repoRoot) {
|
||||
for (const name of ['.env.local', '.env']) {
|
||||
const file = path.join(repoRoot, name)
|
||||
if (!fs.existsSync(file)) continue
|
||||
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/)
|
||||
if (m && !(m[1] in process.env)) {
|
||||
process.env[m[1]] = m[2].replace(/^["']|["']$/g, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getKey() {
|
||||
const secret = (process.env.STORE_ACCOUNT_ENCRYPTION_KEY || '').trim()
|
||||
|| (process.env.NODE_ENV === 'production' ? '' : (process.env.JWT_SECRET || '').trim())
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error('缺少可用的解密密钥:生产需 STORE_ACCOUNT_ENCRYPTION_KEY,本地回退 JWT_SECRET(至少 32 字符)')
|
||||
}
|
||||
return createHash('sha256').update(secret).digest()
|
||||
}
|
||||
|
||||
function decryptLegacy(value) {
|
||||
if (!value.startsWith(PREFIX)) return value
|
||||
const [ivPart, tagPart, dataPart] = value.slice(PREFIX.length).split('.')
|
||||
if (!ivPart || !tagPart || !dataPart) throw new Error('密文格式不合法')
|
||||
const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64url'))
|
||||
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'))
|
||||
return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
loadEnvFile(repoRoot)
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
if (!connectionString) throw new Error('缺少 DATABASE_URL')
|
||||
|
||||
const { Client } = require('pg')
|
||||
const client = new Client({ connectionString })
|
||||
await client.connect()
|
||||
|
||||
try {
|
||||
const { rows } = await client.query(
|
||||
"SELECT id, \"storeName\", \"passwordCiphertext\", \"boundBankAccountNumberCiphertext\"" +
|
||||
" FROM \"StoreAccount\" WHERE \"passwordCiphertext\" LIKE 'v1:%' OR \"boundBankAccountNumberCiphertext\" LIKE 'v1:%'"
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
console.log('没有需要迁移的 v1: 密文行,完成。')
|
||||
return
|
||||
}
|
||||
|
||||
const failures = []
|
||||
const updates = rows.map((row) => {
|
||||
try {
|
||||
return {
|
||||
id: row.id,
|
||||
storeName: row.storeName,
|
||||
password: row.passwordCiphertext && row.passwordCiphertext.startsWith(PREFIX)
|
||||
? decryptLegacy(row.passwordCiphertext)
|
||||
: row.passwordCiphertext,
|
||||
bank: row.boundBankAccountNumberCiphertext && row.boundBankAccountNumberCiphertext.startsWith(PREFIX)
|
||||
? decryptLegacy(row.boundBankAccountNumberCiphertext)
|
||||
: row.boundBankAccountNumberCiphertext,
|
||||
}
|
||||
} catch (e) {
|
||||
failures.push({ id: row.id, storeName: row.storeName, reason: e.message })
|
||||
return null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('以下行解密失败,本次不写入任何数据:')
|
||||
for (const f of failures) console.error(' - ' + f.id + ' ' + f.storeName + ':' + f.reason)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await client.query('BEGIN')
|
||||
for (const u of updates) {
|
||||
await client.query(
|
||||
'UPDATE "StoreAccount" SET "passwordCiphertext" = $2, "boundBankAccountNumberCiphertext" = $3 WHERE id = $1',
|
||||
[u.id, u.password, u.bank]
|
||||
)
|
||||
}
|
||||
const after = await client.query(
|
||||
"SELECT COUNT(*)::int AS n FROM \"StoreAccount\" WHERE \"passwordCiphertext\" LIKE 'v1:%' OR \"boundBankAccountNumberCiphertext\" LIKE 'v1:%'"
|
||||
)
|
||||
if (after.rows[0].n !== 0) {
|
||||
throw new Error('迁移后仍存在 v1: 行,回滚')
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
|
||||
const verify = await client.query(
|
||||
"SELECT COUNT(*)::int AS n FROM \"StoreAccount\" WHERE (\"passwordCiphertext\" <> '' AND \"passwordCiphertext\" NOT LIKE 'v1:%')"
|
||||
)
|
||||
console.log('迁移完成:改写行数 ' + updates.length + ',剩余 v1: 行 ' + after.rows[0].n + ',当前非空明文密码行 ' + verify.rows[0].n + '。')
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('迁移失败:' + e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -1,9 +1,11 @@
|
||||
import 'server-only'
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||
import { createDecipheriv, createHash } from 'node:crypto'
|
||||
|
||||
const PREFIX = 'v1:'
|
||||
// 2026-09-08 按管理层决定,店铺账号密码与银行卡号改为明文落库:
|
||||
// 写入不再加密;读取对历史 v1: 密文行继续解密,其余值原样返回。
|
||||
const LEGACY_PREFIX = 'v1:'
|
||||
|
||||
function getKey() {
|
||||
function getLegacyKey() {
|
||||
const secret = process.env.STORE_ACCOUNT_ENCRYPTION_KEY?.trim()
|
||||
|| (process.env.NODE_ENV === 'production' ? '' : process.env.JWT_SECRET?.trim())
|
||||
if (!secret || secret.length < 32) {
|
||||
@ -14,19 +16,15 @@ function getKey() {
|
||||
|
||||
export function encryptStoreAccountPassword(value: string) {
|
||||
if (!value) return ''
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
|
||||
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return `${PREFIX}${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}`
|
||||
return value
|
||||
}
|
||||
|
||||
export function decryptStoreAccountPassword(value: string) {
|
||||
if (!value) return ''
|
||||
if (!value.startsWith(PREFIX)) throw new Error('Unsupported store account password format')
|
||||
const [ivPart, tagPart, dataPart] = value.slice(PREFIX.length).split('.')
|
||||
if (!value.startsWith(LEGACY_PREFIX)) return value
|
||||
const [ivPart, tagPart, dataPart] = value.slice(LEGACY_PREFIX.length).split('.')
|
||||
if (!ivPart || !tagPart || !dataPart) throw new Error('Invalid store account password')
|
||||
const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64url'))
|
||||
const decipher = createDecipheriv('aes-256-gcm', getLegacyKey(), Buffer.from(ivPart, 'base64url'))
|
||||
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'))
|
||||
return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ describe('finance password reveal security boundary', () => {
|
||||
})
|
||||
|
||||
it('records decryption failure without including the exception or credential', async () => {
|
||||
tx.findPassword.mockResolvedValue('invalid-envelope')
|
||||
tx.findPassword.mockResolvedValue('v1:invalid-envelope')
|
||||
const result = await service.reveal(request())
|
||||
expect(result.status).toBe(503)
|
||||
expect(events[0].status).toBe(503)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user