feat: 实现公平捞瓶和领取租约

This commit is contained in:
root
2026-09-15 13:56:56 +08:00
parent 969a10d664
commit 69fc8d51ce
23 changed files with 1221 additions and 20 deletions
+49 -6
View File
@@ -9,10 +9,9 @@ import {
function required(name: string, min = 32): Buffer {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
const decoded =
name === "PHONE_ENCRYPTION_KEY"
? Buffer.from(value, "base64")
: Buffer.from(value);
const decoded = name.endsWith("_ENCRYPTION_KEY")
? Buffer.from(value, "base64")
: Buffer.from(value);
if (decoded.length < min)
throw new Error(`${name} must be at least ${min} bytes`);
return decoded;
@@ -22,6 +21,8 @@ const REQUIRED_KEYS = [
"PHONE_HMAC_KEY",
"VERIFICATION_CODE_HMAC_KEY",
"REFRESH_TOKEN_HMAC_KEY",
"LEASE_TOKEN_HMAC_KEY",
"LEASE_TOKEN_ENCRYPTION_KEY",
"JWT_SECRET",
] as const;
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
@@ -56,8 +57,13 @@ export function validateAuthEnvironment(): void {
const loaded = Object.fromEntries(
REQUIRED_KEYS.map((name) => [name, required(name)]),
) as unknown as AuthSecrets;
if (loaded.PHONE_ENCRYPTION_KEY.length !== 32)
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
for (const name of [
"PHONE_ENCRYPTION_KEY",
"LEASE_TOKEN_ENCRYPTION_KEY",
] as const) {
if (loaded[name].length !== 32)
throw new Error(`${name} must decode to exactly 32 bytes`);
}
const fingerprints = REQUIRED_KEYS.map((name) =>
loaded[name].toString("hex"),
);
@@ -103,6 +109,43 @@ export const refreshHmac = (token: string): string =>
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
.update(token)
.digest("hex");
export const leaseHmac = (token: string): string =>
createHmac("sha256", authSecrets().LEASE_TOKEN_HMAC_KEY)
.update(token)
.digest("hex");
export function encryptLeaseToken(
token: string,
leaseId: string,
): Uint8Array<ArrayBuffer> {
const nonce = randomBytes(12);
const cipher = createCipheriv(
"aes-256-gcm",
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
nonce,
);
cipher.setAAD(Buffer.from(leaseId));
const body = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
const encrypted = Buffer.concat([nonce, cipher.getAuthTag(), body]);
return new Uint8Array(encrypted).slice();
}
export function decryptLeaseToken(value: Uint8Array, leaseId: string): string {
// nonce (12) + authentication tag (16) + canonical 32-byte token (43 base64url chars)
if (value.length !== 71) throw new Error("Invalid lease token ciphertext");
const decipher = createDecipheriv(
"aes-256-gcm",
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
Buffer.from(value.subarray(0, 12)),
);
decipher.setAAD(Buffer.from(leaseId));
decipher.setAuthTag(Buffer.from(value.subarray(12, 28)));
const token = Buffer.concat([
decipher.update(Buffer.from(value.subarray(28))),
decipher.final(),
]).toString();
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
throw new Error("Invalid lease token ciphertext");
return token;
}
export const safeEqual = (a: string, b: string): boolean => {
const x = Buffer.from(a);
const y = Buffer.from(b);