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:
2026-09-15 00:18:21 +08:00
parent 1295667c94
commit 3ffbd030b9
9 changed files with 243 additions and 2 deletions
+3
View File
@@ -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:* 脚本) */
+3 -1
View File
@@ -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"
}
}
+50
View File
@@ -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()
})
})
+65
View File
@@ -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')
},
}
}
+33
View File
@@ -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()
+49
View File
@@ -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()
})
})
+36
View File
@@ -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
}