Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-store-password-direct-display

This commit is contained in:
WuYongJie 2026-09-08 16:17:42 +08:00
commit 6b8f3ed277
4 changed files with 135 additions and 12 deletions

View File

@ -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。

View File

@ -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)
})

View File

@ -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')
}

View File

@ -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)