feat: 实现演示验证码和令牌轮换
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
|
||||
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);
|
||||
if (decoded.length < min)
|
||||
throw new Error(`${name} must be at least ${min} bytes`);
|
||||
return decoded;
|
||||
}
|
||||
const REQUIRED_KEYS = [
|
||||
"PHONE_ENCRYPTION_KEY",
|
||||
"PHONE_HMAC_KEY",
|
||||
"VERIFICATION_CODE_HMAC_KEY",
|
||||
"REFRESH_TOKEN_HMAC_KEY",
|
||||
"JWT_SECRET",
|
||||
] as const;
|
||||
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
||||
let secrets: AuthSecrets | undefined;
|
||||
|
||||
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");
|
||||
const fingerprints = REQUIRED_KEYS.map((name) =>
|
||||
loaded[name].toString("hex"),
|
||||
);
|
||||
if (new Set(fingerprints).size !== fingerprints.length)
|
||||
throw new Error("Auth secrets must be independent");
|
||||
secrets = loaded;
|
||||
}
|
||||
export function resetAuthEnvironmentForTests(): void {
|
||||
if (process.env.NODE_ENV !== "test")
|
||||
throw new Error("Auth environment reset is test-only");
|
||||
secrets = undefined;
|
||||
}
|
||||
function authSecrets(): AuthSecrets {
|
||||
if (!secrets) validateAuthEnvironment();
|
||||
return secrets!;
|
||||
}
|
||||
export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET;
|
||||
export const phoneHmac = (phone: string): string =>
|
||||
createHmac("sha256", authSecrets().PHONE_HMAC_KEY)
|
||||
.update(phone)
|
||||
.digest("hex");
|
||||
export const codeHmac = (phoneDigest: string, code: string): string =>
|
||||
createHmac("sha256", authSecrets().VERIFICATION_CODE_HMAC_KEY)
|
||||
.update(`${phoneDigest}:${code}`)
|
||||
.digest("hex");
|
||||
export const refreshHmac = (token: string): string =>
|
||||
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
|
||||
.update(token)
|
||||
.digest("hex");
|
||||
export const safeEqual = (a: string, b: string): boolean => {
|
||||
const x = Buffer.from(a);
|
||||
const y = Buffer.from(b);
|
||||
return x.length === y.length && timingSafeEqual(x, y);
|
||||
};
|
||||
export function encryptPhone(phone: string): Buffer {
|
||||
const key = authSecrets().PHONE_ENCRYPTION_KEY;
|
||||
if (key.length !== 32)
|
||||
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
||||
const body = Buffer.concat([cipher.update(phone, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
||||
}
|
||||
export function decryptPhone(value: Buffer): string {
|
||||
const key = authSecrets().PHONE_ENCRYPTION_KEY;
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, value.subarray(0, 12));
|
||||
decipher.setAuthTag(value.subarray(12, 28));
|
||||
return Buffer.concat([
|
||||
decipher.update(value.subarray(28)),
|
||||
decipher.final(),
|
||||
]).toString();
|
||||
}
|
||||
export const envInt = (name: string, fallback: number): number => {
|
||||
const n = Number(process.env[name] ?? fallback);
|
||||
return Number.isInteger(n) && n > 0 ? n : fallback;
|
||||
};
|
||||
Reference in New Issue
Block a user