feat(server): 数据层与密钥(阶段 1)——crypto / 时间工具 / env 加载 + 建表
- crypto:HKDF 派生 hmac/aes 子密钥;HMAC-SHA256 指纹(key_hash);AES-256-GCM 加解密(nonce||tag||ct) - time:北京时间 +08:00 ISO 序列化、Unix 秒转换、expires_at 0→null、输入宽容解析 - load-env:从仓库根 .env 显式加载(Bun 只自动加载 cwd 的 .env);drizzle.config 同步接入 - 单测 15 项全绿(bun test);db:push 后四表在库(api_keys 17 列 / usage_logs 23 列 + 索引齐)
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
// 从仓库根 .env 加载环境变量(Bun 只自动加载 cwd 的 .env——此处 cwd 是 apps/server)
|
||||
import './src/lib/load-env'
|
||||
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
/** drizzle-kit 配置(迁移产物在 ./drizzle;命令见根 package.json 的 db:* 脚本) */
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"test": "bun test",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push"
|
||||
@@ -17,6 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.10"
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"postgres": "^3.4.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createKeySecrets } from './crypto'
|
||||
|
||||
/** 确定性测试主密钥(32 字节 base64;仅测试用) */
|
||||
const MASTER = Buffer.alloc(32, 7).toString('base64')
|
||||
const OTHER_MASTER = Buffer.alloc(32, 9).toString('base64')
|
||||
|
||||
describe('createKeySecrets', () => {
|
||||
test('同一主密钥 → 同一指纹(派生确定性)', () => {
|
||||
const a = createKeySecrets(MASTER)
|
||||
const b = createKeySecrets(MASTER)
|
||||
expect(a.keyHash('sk-test-123')).toBe(b.keyHash('sk-test-123'))
|
||||
})
|
||||
|
||||
test('不同 key → 不同指纹;指纹为 64 位 hex', () => {
|
||||
const s = createKeySecrets(MASTER)
|
||||
const h1 = s.keyHash('sk-a')
|
||||
const h2 = s.keyHash('sk-b')
|
||||
expect(h1).not.toBe(h2)
|
||||
expect(h1).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
test('不同主密钥 → 不同指纹(子密钥隔离)', () => {
|
||||
const h1 = createKeySecrets(MASTER).keyHash('sk-x')
|
||||
const h2 = createKeySecrets(OTHER_MASTER).keyHash('sk-x')
|
||||
expect(h1).not.toBe(h2)
|
||||
})
|
||||
|
||||
test('加密 → 解密往返一致;两次密文不同(随机 nonce)', () => {
|
||||
const s = createKeySecrets(MASTER)
|
||||
const key = 'sk-roundtrip-测试值'
|
||||
const c1 = s.encryptKey(key)
|
||||
const c2 = s.encryptKey(key)
|
||||
expect(c1.equals(c2)).toBe(false)
|
||||
expect(s.decryptKey(c1)).toBe(key)
|
||||
expect(s.decryptKey(c2)).toBe(key)
|
||||
})
|
||||
|
||||
test('密文被篡改 → 解密抛错(GCM 防篡改)', () => {
|
||||
const s = createKeySecrets(MASTER)
|
||||
const packed = s.encryptKey('sk-tamper-me')
|
||||
packed[packed.length - 1] ^= 0xff
|
||||
expect(() => s.decryptKey(packed)).toThrow()
|
||||
})
|
||||
|
||||
test('主密钥过短 → 报错', () => {
|
||||
expect(() => createKeySecrets(Buffer.from('short').toString('base64'))).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 凭证密码学(api_keys 表字段设计 §3.1):
|
||||
* - `key_hash`:HMAC-SHA256——命中判定用(不可逆;掺入只有服务器持有的子密钥)
|
||||
* - `key_ciphertext`:AES-256-GCM——唯一用途是调上游(可解密;防篡改)
|
||||
* - 主密钥 `KEYS_MASTER_KEY` 经 HKDF 派生 hmac / aes 两个子密钥(同一主密钥下用途隔离)
|
||||
*
|
||||
* 密文打包布局:`nonce(12) || tag(16) || ciphertext`
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes } from 'node:crypto'
|
||||
|
||||
/** HKDF 的固定 salt(区分本项目;子密钥再按用途用 info 隔离) */
|
||||
const HKDF_SALT = Buffer.from('new-api-key-usage/keys-master/v1')
|
||||
const INFO_HMAC = 'naku:key-hash:v1'
|
||||
const INFO_AES = 'naku:key-cipher:v1'
|
||||
|
||||
const NONCE_LEN = 12
|
||||
const TAG_LEN = 16
|
||||
|
||||
export interface KeySecrets {
|
||||
/** HMAC-SHA256 指纹(hex)——入库列 `key_hash` */
|
||||
keyHash(plainKey: string): string
|
||||
/** AES-256-GCM 加密——入库列 `key_ciphertext`(nonce || tag || ciphertext) */
|
||||
encryptKey(plainKey: string): Buffer
|
||||
/** 解密(仅调上游时使用);密文被篡改会抛错 */
|
||||
decryptKey(ciphertext: Buffer): string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从主密钥派生操作集合。主密钥为 base64 字符串(见 `.env.example` 的生成提示)。
|
||||
* 派生是确定性的:同一主密钥 → 同一组子密钥(否则重启后全部 key 失效)。
|
||||
*/
|
||||
export function createKeySecrets(masterKeyBase64: string): KeySecrets {
|
||||
const ikm = Buffer.from(masterKeyBase64, 'base64')
|
||||
if (ikm.length < 32) {
|
||||
throw new Error('KEYS_MASTER_KEY 太短(需 base64 编码的 32 字节随机值,见 .env.example)')
|
||||
}
|
||||
const hmacKey = Buffer.from(hkdfSync('sha256', ikm, HKDF_SALT, INFO_HMAC, 32))
|
||||
const aesKey = Buffer.from(hkdfSync('sha256', ikm, HKDF_SALT, INFO_AES, 32))
|
||||
|
||||
return {
|
||||
keyHash(plainKey: string): string {
|
||||
return createHmac('sha256', hmacKey).update(plainKey, 'utf8').digest('hex')
|
||||
},
|
||||
|
||||
encryptKey(plainKey: string): Buffer {
|
||||
const nonce = randomBytes(NONCE_LEN)
|
||||
const cipher = createCipheriv('aes-256-gcm', aesKey, nonce)
|
||||
const ciphertext = Buffer.concat([cipher.update(plainKey, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([nonce, tag, ciphertext])
|
||||
},
|
||||
|
||||
decryptKey(packed: Buffer): string {
|
||||
if (packed.length < NONCE_LEN + TAG_LEN + 1) {
|
||||
throw new Error('key_ciphertext 长度非法')
|
||||
}
|
||||
const nonce = packed.subarray(0, NONCE_LEN)
|
||||
const tag = packed.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN)
|
||||
const ciphertext = packed.subarray(NONCE_LEN + TAG_LEN)
|
||||
const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 环境变量加载:从仓库根 `.env` 读入 `process.env`(已在环境中的变量不覆盖)。
|
||||
*
|
||||
* 为什么需要它:Bun 只自动加载**当前工作目录**的 `.env`——而服务与 drizzle-kit
|
||||
* 都在 `apps/server/` 下运行,根 `.env` 不会被自动加载。这里显式加载,保持
|
||||
* 「全仓单一 .env」的约定(见实施计划「实现假设」#2)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/** 仓库根 .env 相对本文件的路径(src/lib/load-env.ts → ../../../../.env) */
|
||||
const ENV_PATH = new URL('../../../../.env', import.meta.url)
|
||||
|
||||
export function loadRootEnv(): void {
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(ENV_PATH, 'utf8')
|
||||
} catch {
|
||||
return // .env 不存在时静默跳过(部署环境走真实 env 注入)
|
||||
}
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
const value = trimmed.slice(eq + 1).trim()
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadRootEnv()
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { expiresAtFromUnix, fromUnixSeconds, parseInputTime, toBeijingIso } from './time'
|
||||
|
||||
describe('toBeijingIso', () => {
|
||||
test('UTC 时刻 → 北京时间 ISO(+08:00)', () => {
|
||||
// 2026-09-11T11:37:12Z = 北京时间 19:37:12
|
||||
expect(toBeijingIso(new Date('2026-09-11T11:37:12Z'))).toBe('2026-09-11T19:37:12+08:00')
|
||||
})
|
||||
|
||||
test('毫秒非零时保留 3 位', () => {
|
||||
expect(toBeijingIso(new Date('2026-09-11T11:37:12.250Z'))).toBe('2026-09-11T19:37:12.250+08:00')
|
||||
})
|
||||
|
||||
test('跨日边界:UTC 16:00 = 北京时间次日 00:00', () => {
|
||||
expect(toBeijingIso(new Date('2026-09-11T16:00:00Z'))).toBe('2026-09-12T00:00:00+08:00')
|
||||
})
|
||||
|
||||
test('跨月边界', () => {
|
||||
expect(toBeijingIso(new Date('2026-09-30T16:00:00Z'))).toBe('2026-10-01T00:00:00+08:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromUnixSeconds / expiresAtFromUnix', () => {
|
||||
test('Unix 秒 → Date', () => {
|
||||
expect(fromUnixSeconds(1757590632).getTime()).toBe(1757590632 * 1000)
|
||||
})
|
||||
|
||||
test('expires_at:0 / null → null(永不过期)', () => {
|
||||
expect(expiresAtFromUnix(0)).toBeNull()
|
||||
expect(expiresAtFromUnix(null)).toBeNull()
|
||||
expect(expiresAtFromUnix(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test('expires_at 非 0 → Date', () => {
|
||||
expect(expiresAtFromUnix(1757590632)?.getTime()).toBe(1757590632 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInputTime(输入宽容,契约 约定 9)', () => {
|
||||
test('接受 +08:00 与 Z,解析为同一绝对时刻', () => {
|
||||
expect(parseInputTime('2026-09-11T19:37:12+08:00')?.toISOString()).toBe('2026-09-11T11:37:12.000Z')
|
||||
expect(parseInputTime('2026-09-11T11:37:12Z')?.toISOString()).toBe('2026-09-11T11:37:12.000Z')
|
||||
})
|
||||
|
||||
test('无效输入 → null', () => {
|
||||
expect(parseInputTime('not-a-date')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 时间工具(契约 约定 9/10;同步引擎设计 §3.6):
|
||||
* - 对外时间一律北京时间 `+08:00` 的 ISO 8601(`+08:00` 是产品口径,与服务器时区无关)
|
||||
* - 上游 `created_at` 是 UTC Unix 秒;时间范围左闭右开 `[start, end)`
|
||||
*/
|
||||
|
||||
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000
|
||||
|
||||
const pad = (n: number, width = 2): string => String(n).padStart(width, '0')
|
||||
|
||||
/** Date → `2026-09-11T19:37:12+08:00`(毫秒非零时保留 3 位毫秒) */
|
||||
export function toBeijingIso(date: Date): string {
|
||||
const t = new Date(date.getTime() + BEIJING_OFFSET_MS)
|
||||
const base =
|
||||
`${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}` +
|
||||
`T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}:${pad(t.getUTCSeconds())}`
|
||||
const ms = t.getUTCMilliseconds()
|
||||
return ms === 0 ? `${base}+08:00` : `${base}.${pad(ms, 3)}+08:00`
|
||||
}
|
||||
|
||||
/** 上游 UTC Unix 秒 → Date */
|
||||
export function fromUnixSeconds(seconds: number): Date {
|
||||
return new Date(seconds * 1000)
|
||||
}
|
||||
|
||||
/** 上游 `expires_at` 约定:0 = 永不过期 → null(api_keys 设计 §3.5) */
|
||||
export function expiresAtFromUnix(seconds: number | null | undefined): Date | null {
|
||||
if (!seconds) return null
|
||||
return fromUnixSeconds(seconds)
|
||||
}
|
||||
|
||||
/** 解析输入时间(宽容:任意带偏移的 ISO 8601,含 `Z` / 毫秒;无效返回 null) */
|
||||
export function parseInputTime(input: string): Date | null {
|
||||
const d = new Date(input)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
Reference in New Issue
Block a user