Files
new-api-key-usage/packages/core/src/crypto.ts
T
skycurtain 6980d09750 feat(core,db): 业务核心与数据访问层
core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。
db:八表 Drizzle schema + 仓储 + drizzle 配置。
2026-10-06 01:30:51 +08:00

179 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 加密体系(WebCrypto / Web 标准 API——Bun 纪律红线)
* SK_KEY_ENCRYPTION_SECRET 经 HKDF 派生 hmac / aes 子密钥(api_keys表字段设计 3.1)
* 密码哈希 = PBKDF2-SHA256(两形态统一)
*/
const enc = new TextEncoder();
const dec = new TextDecoder();
function b64url(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(s: string): Uint8Array<ArrayBuffer> {
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(pad + "===".slice((pad.length + 3) % 4));
const out = new Uint8Array(new ArrayBuffer(bin.length));
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hkdfDerive(
master: Uint8Array<ArrayBuffer>,
info: string,
length: number,
): Promise<CryptoKey> {
const base = await crypto.subtle.importKey("raw", master, "HKDF", false, [
"deriveKey",
"deriveBits",
]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: enc.encode("new-api-key-usage"),
info: enc.encode(info),
},
base,
{ name: "HMAC", hash: "SHA-256", length: length * 8 },
false,
["sign", "verify"],
);
}
async function hkdfAesKey(master: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
const base = await crypto.subtle.importKey("raw", master, "HKDF", false, [
"deriveKey",
]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: enc.encode("new-api-key-usage"),
info: enc.encode("aes-gcm"),
},
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export class KeyCrypto {
private constructor(
private readonly hmacKey: CryptoKey,
private readonly aesKey: CryptoKey,
) {}
static async fromEncryptionSecret(secretB64: string): Promise<KeyCrypto> {
const master = b64urlDecode(secretB64);
if (master.length < 32) {
throw new Error("SK_KEY_ENCRYPTION_SECRET too short");
}
const hmacKey = await hkdfDerive(master, "hmac-sha256", 32);
const aesKey = await hkdfAesKey(master);
return new KeyCrypto(hmacKey, aesKey);
}
/** 登记时:HMAC-SHA256(sk-key) → 不可逆指纹(查库优先命中) */
async hashKey(skKey: string): Promise<string> {
const sig = await crypto.subtle.sign("HMAC", this.hmacKey, enc.encode(skKey));
return b64url(new Uint8Array(sig as ArrayBuffer));
}
/** 登记时:AES-256-GCM,打包 nonce||ciphertext||tag */
async sealKey(skKey: string): Promise<Uint8Array> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
this.aesKey,
enc.encode(skKey),
);
const packed = new Uint8Array(new ArrayBuffer(nonce.length + ct.byteLength));
packed.set(nonce, 0);
packed.set(new Uint8Array(ct), nonce.length);
return packed;
}
/** 调上游时解密原文(唯一用途) */
async openKey(packed: Uint8Array): Promise<string> {
const nonce = packed.slice(0, 12);
const ct = packed.slice(12);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
this.aesKey,
ct,
);
return dec.decode(pt);
}
}
/** PBKDF2-SHA256,格式:pbkdf2$<iters>$<b64salt>$<b64hash> */
export const PBKDF2_ITERATIONS = 210_000;
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS },
key,
256,
);
return `pbkdf2$${PBKDF2_ITERATIONS}$${b64url(salt)}$${b64url(new Uint8Array(bits))}`;
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "pbkdf2") return false;
const iters = Number(parts[1]);
const salt = b64urlDecode(parts[2]!);
const expect = b64urlDecode(parts[3]!);
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = new Uint8Array(
await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: iters },
key,
expect.length * 8,
),
);
if (bits.length !== expect.length) return false;
let diff = 0;
for (let i = 0; i < bits.length; i++) diff |= bits[i]! ^ expect[i]!;
return diff === 0;
}
/** 256bit 随机令牌 → b64url 明文;库存其 SHA-256 哈希 */
export async function generateToken(): Promise<{ plaintext: string; hash: string }> {
const raw = crypto.getRandomValues(new Uint8Array(32));
const plaintext = b64url(raw);
const digest = await crypto.subtle.digest("SHA-256", enc.encode(plaintext));
return { plaintext, hash: b64url(new Uint8Array(digest)) };
}
export async function hashToken(plaintext: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", enc.encode(plaintext));
return b64url(new Uint8Array(digest));
}
export function randomSkKeyEncryptionSecret(): string {
return b64url(crypto.getRandomValues(new Uint8Array(32)));
}