From 3ffbd030b97af5b871ca408e67555f4328218cd2 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Tue, 15 Sep 2026 00:18:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=95=B0=E6=8D=AE=E5=B1=82?= =?UTF-8?q?=E4=B8=8E=E5=AF=86=E9=92=A5=EF=BC=88=E9=98=B6=E6=AE=B5=201?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94crypto=20/=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20/=20env=20=E5=8A=A0=E8=BD=BD=20+=20?= =?UTF-8?q?=E5=BB=BA=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 列 + 索引齐) --- apps/server/drizzle.config.ts | 3 ++ apps/server/package.json | 4 +- apps/server/src/lib/crypto.test.ts | 50 +++++++++++++++++++++++ apps/server/src/lib/crypto.ts | 65 ++++++++++++++++++++++++++++++ apps/server/src/lib/load-env.ts | 33 +++++++++++++++ apps/server/src/lib/time.test.ts | 49 ++++++++++++++++++++++ apps/server/src/lib/time.ts | 36 +++++++++++++++++ bun.lock | 3 ++ package.json | 2 +- 9 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/lib/crypto.test.ts create mode 100644 apps/server/src/lib/crypto.ts create mode 100644 apps/server/src/lib/load-env.ts create mode 100644 apps/server/src/lib/time.test.ts create mode 100644 apps/server/src/lib/time.ts diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts index e985d7f..242026c 100644 --- a/apps/server/drizzle.config.ts +++ b/apps/server/drizzle.config.ts @@ -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:* 脚本) */ diff --git a/apps/server/package.json b/apps/server/package.json index 1e45432..85b24eb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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" } } diff --git a/apps/server/src/lib/crypto.test.ts b/apps/server/src/lib/crypto.test.ts new file mode 100644 index 0000000..9db886e --- /dev/null +++ b/apps/server/src/lib/crypto.test.ts @@ -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() + }) +}) diff --git a/apps/server/src/lib/crypto.ts b/apps/server/src/lib/crypto.ts new file mode 100644 index 0000000..6b4417f --- /dev/null +++ b/apps/server/src/lib/crypto.ts @@ -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') + }, + } +} diff --git a/apps/server/src/lib/load-env.ts b/apps/server/src/lib/load-env.ts new file mode 100644 index 0000000..eb965fa --- /dev/null +++ b/apps/server/src/lib/load-env.ts @@ -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() diff --git a/apps/server/src/lib/time.test.ts b/apps/server/src/lib/time.test.ts new file mode 100644 index 0000000..9370539 --- /dev/null +++ b/apps/server/src/lib/time.test.ts @@ -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() + }) +}) diff --git a/apps/server/src/lib/time.ts b/apps/server/src/lib/time.ts new file mode 100644 index 0000000..4873f2a --- /dev/null +++ b/apps/server/src/lib/time.ts @@ -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 +} diff --git a/bun.lock b/bun.lock index 4864630..0b11ea2 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.31.10", + "postgres": "^3.4.9", }, }, "apps/web": { @@ -808,6 +809,8 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], diff --git a/package.json b/package.json index 46a0be0..cd2e24d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev:web": "bun run --filter '@naku/web' dev", "dev:server": "bun run --filter '@naku/server' dev", "build": "bun run --filter '@naku/web' build", - "test": "bun run --filter '@naku/web' test:unit", + "test": "bun run --filter '@naku/server' test && bun run --filter '@naku/web' test:unit", "lint": "bun run --filter '@naku/web' lint", "format": "bun run --filter '@naku/web' format", "type-check": "bun run --filter '@naku/web' type-check && bunx tsc --noEmit -p apps/server",