Ctrl K
ComunidadGuías…

src/shared/security.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import {
  MARK_MAX_LENGTH,
  MARK_MIN_LENGTH,
  RATE_LIMIT_MAX,
  RATE_LIMIT_WINDOW_MS,
  RESERVED_MARKS,
  TOKEN_MAX_LENGTH,
  TOKEN_MIN_LENGTH,
} from "./constants";

/**
 * Generate a high-entropy write token (base64url, ~32 chars of entropy).
 */
export function generateWriteToken(): string {
  const bytes = new Uint8Array(24);
  crypto.getRandomValues(bytes);
  return `tok_${bytesToBase64Url(bytes)}`;
}

/**
 * Generate a higher-entropy collection mark.
 * Format: adjective-noun-randomhex (hex adds ~32 bits of entropy).
 */
export function generateSecureMark(): string {
  const adjectives = [
    "swift",
    "quiet",
    "bright",
    "calm",
    "bold",
    "clear",
    "vivid",
    "noble",
    "rapid",
    "solid",
    "keen",
    "wise",
  ];
  const nouns = [
    "harbor",
    "meadow",
    "summit",
    "river",
    "forest",
    "canvas",
    "signal",
    "orbit",
    "ledger",
    "anchor",
    "prism",
    "vault",
  ];
  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
  const noun = nouns[Math.floor(Math.random() * nouns.length)];
  const entropy = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(4)))
    .replace(/[^a-zA-Z0-9]/g, "")
    .slice(0, 6)
    .toLowerCase();
  return `${adj}-${noun}-${entropy}`;
}

export function isValidMarkFormat(mark: string): boolean {
  if (mark.length < MARK_MIN_LENGTH || mark.length > MARK_MAX_LENGTH) {
    return false;
  }
  if (!/^[a-zA-Z0-9_-]+$/.test(mark)) {
    return false;
  }
  if (RESERVED_MARKS.has(mark.toLowerCase())) {
    return false;
  }
  return true;
}

export function isValidTokenFormat(token: string): boolean {
  return (
    token.length >= TOKEN_MIN_LENGTH &&
    token.length <= TOKEN_MAX_LENGTH &&
    /^[a-zA-Z0-9_-]+$/.test(token)
  );
}

/**
 * SHA-256 hash of a write token as hex string.
 * Only the hash is stored in D1; plaintext tokens live on the client.
 */
export async function hashWriteToken(token: string): Promise<string> {
  const data = new TextEncoder().encode(token);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function verifyWriteToken(
  token: string,
  storedHash: string,
): Promise<boolean> {
  if (!isValidTokenFormat(token) || !storedHash) {
    return false;
  }
  const hash = await hashWriteToken(token);
  return timingSafeEqual(hash, storedHash);
}

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return result === 0;
}

function bytesToBase64Url(bytes: Uint8Array): string {
  let binary = "";
  for (const b of bytes) {
    binary += String.fromCharCode(b);
  }
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

/**
 * Simple sliding-window rate limiter backed by D1.
 * Returns true if the request is allowed.
 */
export async function checkRateLimit(
  db: D1Database,
  key: string,
): Promise<boolean> {
  const now = Date.now();
  const row = await db
    .prepare("SELECT count, window_start FROM rate_limits WHERE key = ?")
    .bind(key)
    .first<{ count: number; window_start: number }>();

  if (!row || now - row.window_start > RATE_LIMIT_WINDOW_MS) {
    await db
      .prepare(
        `INSERT INTO rate_limits (key, count, window_start) VALUES (?, 1, ?)
         ON CONFLICT(key) DO UPDATE SET count = 1, window_start = excluded.window_start`,
      )
      .bind(key, now)
      .run();
    return true;
  }

  if (row.count >= RATE_LIMIT_MAX) {
    return false;
  }

  await db
    .prepare("UPDATE rate_limits SET count = count + 1 WHERE key = ?")
    .bind(key)
    .run();
  return true;
}