Skip to content

ไลบรารีหลัก ​

Cap มีไลบรารีฝั่งเซิร์ฟเวอร์แบบไร้สถานะสำหรับสร้างและตรวจสอบ challenge ที่อิงกับ JWT ซึ่ง Standalone ก็ใช้ไลบรารีนี้ภายใน

สำหรับผู้ใช้ส่วนใหญ่ เราแนะนำ Cap Standalone ที่รันบน Docker และมีทุกอย่างครบ เราแนะนำให้ใช้ไลบรารีหลักโดยตรงเฉพาะเมื่อคุณรัน Docker ไม่ได้ ต้องการฝังการสร้าง challenge เข้าไปในบริการที่มีอยู่ หรือต้องดีพลอยไปยังสภาพแวดล้อมที่ไม่มีที่เก็บข้อมูลถาวร (Cloudflare Workers, Lambda, edge function)

การติดตั้ง ​

sh
bun add capjs-core
sh
npm i capjs-core
sh
pnpm i capjs-core

เริ่มต้นใช้งาน ​

js
import { generateChallenge, validateChallenge } from "capjs-core";

// ยาว สุ่ม และมีเอนโทรปีสูง ต้องใช้ค่าเดียวกันทุกโพรเซส
const SECRET = process.env.CAP_SECRET;

// 1) เส้นทางฝั่งเซิร์ฟเวอร์: สร้าง challenge
const ch = await generateChallenge(SECRET, {
  scope: "signup", // ไม่บังคับ
  instrumentation: true, // ไม่บังคับ ดูด้านล่าง
});
// → { challenge: { c, s, d }, token, expires, instrumentation? }

// 2) เส้นทางฝั่งเซิร์ฟเวอร์: ตรวจสอบ challenge ที่ถูกใช้
const result = await validateChallenge(
  SECRET,
  {
    token: req.body.token,
    solutions: req.body.solutions,
    instr: req.body.instr,
  },
  {
    scope: "signup",
    consumeNonce: async (sigHex, ttlMs) => myStore.setIfNotExists(`cap:${sigHex}`, 1, ttlMs),
  },
);

if (result.success) {
  // result.token, result.tokenKey, result.expires, result.scope
}

วิดเจ็ตเรียก generateChallenge เพื่อรับ { challenge, token, expires, instrumentation } แก้ proof-of-work ฝั่งไคลเอนต์ แล้ว POST { token, solutions, instr } กลับมา จากนั้นคุณเรียก validateChallenge เพื่อตรวจสอบ

ต่างจาก @cap.js/server อย่างไร ​

ประเด็น@cap.js/servercapjs-core
สถานะเก็บ token ในหน่วยความจำและระบบไฟล์ไร้สถานะ challenge token เป็น JWT ที่ลงลายเซ็น
ตัวสร้างnew Cap({ ... })ไม่มี — ส่ง secret ทุกครั้งที่เรียก
ป้องกัน replayมีรายการ token ในตัวพร้อมรอบทำความสะอาดเลือกเปิดผ่าน callback consumeNonce
hook ทำความสะอาดflush ตอน SIGINT/beforeExitไม่มี — TTL ฝังอยู่ใน exp ของ JWT
ระบบไฟล์จำเป็นสำหรับการเก็บถาวรไม่แตะเลย
ใช้กับ Worker ได้ไม่ได้ (เพราะใช้ระบบไฟล์)ได้

ต่างจากไลบรารีเดิม capjs-core ไม่ตรวจสอบ redeem token ให้คุณ มันคืน tokenKey ที่คุณต้องเก็บเอง และ token ที่คุณส่งให้ผู้ใช้ เวลาจะตรวจสอบภายหลัง ให้คำนวณคีย์ใหม่จาก token ที่ผู้ใช้ส่งมาแล้วค้นหา:

js
import { createHash } from "node:crypto";

// เส้นทางสำหรับตรวจสอบ
const [id, verToken] = req.body.token.split(":");
const tokenKey = `${id}:${createHash("sha256").update(verToken).digest("hex")}`;
const expires = await myStore.get(`cap-token:${tokenKey}`);
if (!expires || Number(expires) < Date.now()) {
  return res.status(401).end();
}

API ​

generateChallenge(secret, opts?) ​

คืนค่า Promise<{ challenge, token, expires, instrumentation? }>

  • secret — สตริงหรือ Buffer ขนาด ≥16 ไบต์ เป็นคีย์ HMAC หลัก ต้องเหมือนกันทุกโพรเซส
  • opts.challengeCount — จำนวนปริศนา PoW ค่าเริ่มต้น 50
  • opts.challengeSize — ความยาว salt เป็นอักขระฐานสิบหก ค่าเริ่มต้น 32
  • opts.challengeDifficulty — ความยาวคำนำหน้าเป้าหมายเป็นอักขระฐานสิบหก ค่าเริ่มต้น 4
  • opts.expiresMs — TTL ของ challenge ค่าเริ่มต้น 600_000 (10 นาที)
  • opts.scope — สตริงเสริมที่ผูกกับ challenge การตรวจสอบต้องส่ง scope เดียวกัน
  • opts.extra — อ็อบเจกต์เสริมที่ฝังไว้ใน payload ของ JWT (ใครที่ถือ token ก็เห็นได้)
  • opts.instrumentation — true เพื่อใช้ค่าเริ่มต้น หรือเป็นอ็อบเจกต์: { blockAutomatedBrowsers, obfuscationLevel }
  • opts.instrumentationGenerator — ทางออกสำหรับย้ายการสร้างสคริปต์ไปไว้ที่ worker pool

token คือ JWT ที่ลงลายเซ็นและบรรจุการตั้งค่าของ challenge ส่วน expires คือเวลาหมดอายุของ JWT เป็นมิลลิวินาที และ instrumentation (ถ้าร้องขอ) คือสคริปต์ฝั่งไคลเอนต์แบบ deflate+base64 ที่วิดเจ็ตจะนำไปรัน

validateChallenge(secret, body, opts?) ​

คืนค่า Promise<{ success: true, token, tokenKey, expires, scope, iat } | { success: false, reason, instr_error? }>

body:

  • token — challenge token จาก generateChallenge
  • solutions — อาร์เรย์ของตัวเลข ความยาวต้องเท่ากับ challenge.c
  • instr — ผลลัพธ์ instrumentation (ถ้าคุณเปิดใช้)
  • instr_blocked, instr_timeout — แฟล็กจากวิดเจ็ตเมื่อ instrumentation ปฏิเสธหน้านั้น

opts:

  • scope — ต้องตรงกับ scope ของ challenge เดิม
  • tokenTtlMs — TTL ของ redeem token ค่าเริ่มต้น 1_200_000 (20 นาที)
  • consumeNonce(sigHex, ttlMs) — ป้องกัน replay ผ่านที่เก็บข้อมูลของคุณ ดูด้านล่าง
  • signToken(data) — ฟังก์ชัน async ที่คืนรูปแบบ redeem token ของคุณเอง ค่าเริ่มต้นคืน id:secret

สาเหตุที่ล้มเหลว ​

reasonความหมาย
invalid_bodybody ไม่ใช่อ็อบเจกต์
missing_tokenไม่ได้ส่ง token มา
missing_solutionsไม่มี solutions หรือไม่ใช่อาร์เรย์
invalid_tokenลายเซ็น JWT ไม่ตรง / รูปแบบผิด / พารามิเตอร์เกินขอบเขต
scope_mismatchscope ของ token ไม่ตรงกับ opts.scope
expiredJWT ของ challenge หมดอายุ
invalid_solutionsความยาวไม่ตรงหรือมีค่าที่ไม่ใช่ตัวเลข
nonce_store_errorcallback consumeNonce โยนข้อผิดพลาด
already_redeemedconsumeNonce คืนค่า false
invalid_solutionsolutions ไม่ผ่านเงื่อนไข PoW
instr_*instrumentation ล้มเหลว (พร้อม instr_error: true)

การป้องกัน replay ​

ไลบรารีนี้ออกแบบมาให้ไร้สถานะ เพื่อกันไม่ให้ข้อมูลที่ถูกดักจับถูกนำไปใช้ซ้ำ ให้ส่ง callback consumeNonce เข้าไป capjs-core จะเรียกมันพร้อมค่าฐานสิบหกของลายเซ็น JWT และ TTL ที่เหลือ คุณเก็บค่าฐานสิบหกนั้นไว้ใน KV ของคุณด้วยความหมายแบบ SET NX EX แล้วคืน false เมื่อเจอซ้ำ

js
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);

const consumeNonce = async (sigHex, ttlMs) => {
  const ttlSec = Math.ceil(ttlMs / 1000);
  const ok = await redis.set(`cap:${sigHex}`, "1", "NX", "EX", ttlSec);
  return ok === "OK";
};
js
const consumeNonce = async (sigHex, ttlMs) => {
  const key = `cap:${sigHex}`;
  if (await env.NONCES.get(key)) return false;
  await env.NONCES.put(key, "1", {
    expirationTtl: Math.ceil(ttlMs / 1000),
  });
  return true;
};
js
const consumeNonce = async (sigHex, ttlMs) => {
  const expiresAt = new Date(Date.now() + ttlMs).toISOString();
  try {
    await db`INSERT INTO cap_nonces (sig, expires_at) VALUES (${sigHex}, ${expiresAt})`;
    return true;
  } catch (e) {
    if (e.code === "23505") return false; // unique violation
    throw e;
  }
};

การตรวจนี้ทำงาน หลัง การตรวจสอบ PoW และ instrumentation ดังนั้นผู้โจมตีที่เล่นซ้ำข้อมูลที่ดักมาพร้อมคำตอบมั่ว ๆ จะเผา nonce ของผู้ใช้ตัวจริงไม่ได้

Instrumentation ​

ส่ง instrumentation: true (หรืออ็อบเจกต์ตัวเลือก) ให้ generateChallenge เพื่อรับสคริปต์ฝั่งไคลเอนต์แบบ deflate+base64 วิดเจ็ตจะรันมัน ส่งลายนิ้วมือกลับมา แล้ว validateChallenge จะตรวจสอบ

js
const ch = await generateChallenge(SECRET, {
  instrumentation: {
    blockAutomatedBrowsers: true, // ปฏิเสธ playwright/puppeteer/selenium
    obfuscationLevel: 3, // 1-10 ค่าเริ่มต้น 3
  },
});

เมื่อเปิด blockAutomatedBrowsers สคริปต์จะรันการตรวจแบบ realm-escape และการตรวจเชิงพฤติกรรม ซึ่งตรวจจับ Chromium แบบ headless, ร่องรอยของเฟรมเวิร์กอัตโนมัติ และการปลอมตัวเป็น JS sandbox ดูรายละเอียดที่ Instrumentation

ระดับการทำให้อ่านยากที่สูงขึ้นจะสร้างช้าลง ระดับ 4–7 เพิ่มการอ้อมผ่านตารางสตริงแบบกำหนดเอง พร้อมการย่อโค้ดด้วย esbuild ส่วนระดับ 8–10 เพิ่ม javascript-obfuscator เข้าไปอีกชั้น (string-array, control-flow flattening, การแทรกโค้ดตาย) ซึ่งจะบล็อก event loop หลายสิบมิลลิวินาทีต่อ challenge จึงควรใช้เฉพาะกับเส้นทางที่มีปริมาณน้อย หรือใส่ instrumentationGenerator ของคุณเองที่รันอยู่ใน worker pool

รูปแบบการดีพลอยแบบไร้สถานะ ​

คำเตือน: สคริปต์เหล่านี้ไม่มีการป้องกัน replay ในตัว อย่าลืมเพิ่มเอง

Cloudflare Workers ​

js
import { generateChallenge, validateChallenge } from "capjs-core";

const SECRET = (env) => env.CAP_SECRET;

export default {
  async fetch(req, env) {
    const url = new URL(req.url);

    if (url.pathname === "/challenge" && req.method === "POST") {
      const ch = await generateChallenge(SECRET(env), { instrumentation: true });
      return Response.json(ch);
    }

    if (url.pathname === "/redeem" && req.method === "POST") {
      const body = await req.json();
      const result = await validateChallenge(SECRET(env), body, {
        consumeNonce: async (sigHex, ttlMs) => {
          if (await env.NONCES.get(`cap:${sigHex}`)) return false;
          await env.NONCES.put(`cap:${sigHex}`, "1", {
            expirationTtl: Math.ceil(ttlMs / 1000),
          });
          return true;
        },
      });
      return Response.json(result);
    }

    return new Response("not found", { status: 404 });
  },
};

Bun ​

js
import { generateChallenge, validateChallenge } from "capjs-core";
const SECRET = process.env.CAP_SECRET;

Bun.serve({
  port: 3000,
  routes: {
    "/challenge": {
      POST: () => Response.json(generateChallenge(SECRET, { instrumentation: true })),
    },
    "/redeem": {
      POST: async (req) => {
        const body = await req.json();
        return Response.json(await validateChallenge(SECRET, body));
      },
    },
  },
});

challenge แบบ HashWX ​

ตั้งแต่ v0.1.1 และวิดเจ็ต v0.1.51 ทั้งสองฝั่งเข้าใจรูปแบบข้อมูลที่ยืดหยุ่นขึ้น ซึ่งรองรับโปรโตคอล challenge หลายแบบในคำตอบเดียว ได้แก่ PoW แบบ SHA-256 (ค่าเริ่มต้น), HashWX, ปริศนา time-lock แบบ RSW ที่เลิกใช้แล้ว และ instrumentation

การเปิดใช้ขั้นต่ำ ​

js
import { generateChallenge, validateChallenge } from "capjs-core";

const SECRET = process.env.CAP_SECRET;

app.post("/api/challenge", async () => {
  return await generateChallenge(SECRET, {
    format: 2,
    protocols: ["hashwx", "instrumentation"],
    hashwxDifficulty: 1_000_000, // ไม่บังคับ นี่คือค่าเริ่มต้น
  });
});

app.post("/api/redeem", async (req) => {
  return await validateChallenge(SECRET, req.body, { consumeNonce });
});

HashWX ไม่ต้องใช้สาระกุญแจและไม่ต้องตั้งค่าอะไรตอนบูต การตรวจสอบครั้งแรกจะคอมไพล์มอดูล WebAssembly ที่ฝังมาด้วย ซึ่งใช้เวลาไม่กี่มิลลิวินาที ให้เรียก hashwxReady() ตอนเริ่มระบบเพื่อย้ายงานส่วนนั้นออกจากคำขอแรก

hashwxDifficulty คือจำนวนครั้งที่คาดว่าไคลเอนต์ต้องแฮช โดยแบ่งออกเป็น hashwxChallengeCount challenge ย่อยที่เป็นอิสระต่อกัน (ค่าเริ่มต้น 4) challenge เดียวมีเวลาแก้ที่กระจายแบบเอกซ์โพเนนเชียล ผู้เยี่ยมชมคนหนึ่งจึงอาจรอ 30 มิลลิวินาที ขณะที่คนถัดไปรอถึงสามวินาที ที่ความยากรวมเท่ากัน การแบ่งเป็นสี่ challenge ย่อยจะลด p90 ลงราวหนึ่งในสี่ และทำให้ค่ามัธยฐานช้าลงราวหนึ่งในสาม เพราะงานที่คาดหวังของผู้โจมตียังคงเป็น d แฮชไม่ว่าจะแบ่งอย่างไร ดูต้นทุนฝั่งไคลเอนต์ challenge ย่อยแต่ละอันที่เพิ่มขึ้นทำให้การตรวจสอบช้าลงราว 20 µs นอกจากนี้ยังรับ hashwxNoncesPerHash (ค่าเริ่มต้น 65_536) ด้วย

เผยแพร่ภายใต้สัญญาอนุญาต Apache 2.0