Skip to content

Core

Cap enthält eine zustandslose serverseitige Bibliothek zum Erzeugen und Verifizieren JWT-basierter Challenges, die intern auch von Standalone genutzt wird.

Den meisten Nutzern empfehlen wir Cap Standalone, das auf Docker läuft und alles Nötige mitbringt. Die Core-Bibliothek direkt zu verwenden empfehlen wir nur, wenn du Docker nicht betreiben kannst, die Challenge-Erzeugung in einen bestehenden Dienst einbetten willst oder in eine Umgebung ohne persistenten Speicher deployen musst (Cloudflare Workers, Lambda, Edge Functions).

Installation

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

Erste Schritte

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

// Lang, zufällig, hohe Entropie. Halte das über alle Prozesse hinweg konsistent.
const SECRET = process.env.CAP_SECRET;

// 1) Server-Route: eine Challenge erzeugen
const ch = await generateChallenge(SECRET, {
  scope: "signup", // optional
  instrumentation: true, // optional, siehe unten
});
// → { challenge: { c, s, d }, token, expires, instrumentation? }

// 2) Server-Route: die eingelöste Challenge validieren
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
}

Das Widget ruft generateChallenge auf, erhält { challenge, token, expires, instrumentation }, löst den Proof-of-Work clientseitig und schickt { token, solutions, instr } per POST zurück. Anschließend rufst du validateChallenge zur Prüfung auf.

Unterschiede zu @cap.js/server

Aspekt@cap.js/servercapjs-core
StateIn-Memory- plus Dateisystem-Token-StoreZustandslos. Challenge-Tokens sind signierte JWTs
Konstruktornew Cap({ ... })Keiner — secret pro Aufruf übergeben
Replay-SchutzEingebaute Token-Liste mit AufräumintervallOpt-in über consumeNonce-Callback
Cleanup-HooksFlush bei SIGINT/beforeExitKeine — TTL steckt im JWT-exp
DateisystemFür Persistenz erforderlichWird nie angefasst
Worker-kompatibelNein (Dateisystem)Ja

Anders als die alte Bibliothek validiert capjs-core Redeem-Tokens nicht für dich: Es liefert einen tokenKey, den du selbst speicherst, und ein token, das du dem Nutzer gibst. Zum späteren Validieren leitest du den Key aus dem eingereichten Token erneut ab und schlägst ihn nach:

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

// Validierungs-Route
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?)

Liefert Promise<{ challenge, token, expires, instrumentation? }>.

  • secret — String oder Buffer, ≥16 Bytes. Master-HMAC-Key, muss über alle Prozesse hinweg gleich sein.
  • opts.challengeCount — Anzahl der PoW-Puzzles. Standard 50.
  • opts.challengeSize — Salt-Länge in Hex-Zeichen. Standard 32.
  • opts.challengeDifficulty — Länge des Zielpräfixes in Hex-Zeichen. Standard 4.
  • opts.expiresMs — TTL der Challenge. Standard 600_000 (10 Min.).
  • opts.scope — optionaler String, der an die Challenge gebunden wird. Die Validierung muss denselben scope übergeben.
  • opts.extra — optionales Objekt, das in die JWT-Payload eingebettet wird (für jeden mit dem Token sichtbar).
  • opts.instrumentationtrue für Standardwerte, oder ein Objekt: { blockAutomatedBrowsers, obfuscationLevel }.
  • opts.instrumentationGenerator — Notausgang, um die Skripterzeugung an einen Worker-Pool auszulagern.

Das token ist ein signiertes JWT mit der Challenge-Konfiguration. expires ist der JWT-Ablauf in ms. instrumentation ist, falls angefordert, das mit deflate+base64 kodierte Client-Skript, das das Widget ausführt.

validateChallenge(secret, body, opts?)

Liefert Promise<{ success: true, token, tokenKey, expires, scope, iat } | { success: false, reason, instr_error? }>.

body:

  • token — Challenge-Token aus generateChallenge
  • solutions — Zahlen-Array, Länge muss challenge.c entsprechen
  • instr — Instrumentation-Ergebnis (falls aktiviert)
  • instr_blocked, instr_timeout — Flags des Widgets, wenn die Instrumentation die Seite abgelehnt hat

opts:

  • scope — muss zum Scope der ursprünglichen Challenge passen
  • tokenTtlMs — TTL des Redeem-Tokens. Standard 1_200_000 (20 Min.).
  • consumeNonce(sigHex, ttlMs) — Replay-Schutz über deinen Speicher. Siehe unten.
  • signToken(data) — asynchrone Funktion, die ein eigenes Redeem-Token-Format liefert. Standardmäßig id:secret.

Fehlergründe

reasonBedeutung
invalid_bodyBody ist kein Objekt
missing_tokenkein Token übergeben
missing_solutionsSolutions fehlen oder sind kein Array
invalid_tokenJWT-Signatur passt nicht / fehlerhaft / Parameter außerhalb des Bereichs
scope_mismatchScope des Tokens passt nicht zu opts.scope
expiredChallenge-JWT abgelaufen
invalid_solutionsLängenkonflikt oder keine Zahlen
nonce_store_errorconsumeNonce-Callback hat geworfen
already_redeemedconsumeNonce hat false geliefert
invalid_solutionSolutions erfüllen den PoW nicht
instr_*Instrumentation fehlgeschlagen (mit instr_error: true)

Replay-Schutz

Die Bibliothek ist bewusst zustandslos. Damit eine abgefangene Übermittlung nicht zweimal eingelöst werden kann, übergib einen consumeNonce-Callback. capjs-core ruft ihn mit dem Signatur-Hex des JWT und der verbleibenden TTL auf; du speicherst dieses Hex in deinem KV mit SET NX EX-Semantik und gibst bei Wiederholungen false zurück.

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;
  }
};

Die Prüfung läuft nach der PoW- und Instrumentation-Verifizierung. Ein Angreifer, der eine abgefangene Übermittlung mit Unsinn-Solutions wiederholt, kann die Nonce des legitimen Nutzers also nicht verbrennen.

Instrumentation

Übergib instrumentation: true (oder ein Options-Objekt) an generateChallenge, um ein mit deflate+base64 kodiertes Client-Skript zu erhalten. Das Widget führt es aus, schickt einen Fingerprint zurück, und validateChallenge prüft ihn.

js
const ch = await generateChallenge(SECRET, {
  instrumentation: {
    blockAutomatedBrowsers: true, // playwright/puppeteer/selenium ablehnen
    obfuscationLevel: 3, // 1-10, Standard 3
  },
});

Ist blockAutomatedBrowsers aktiv, führt das Skript Realm-Escape- und Verhaltensprüfungen aus, die Headless-Chromium, Marker von Automatisierungs-Frameworks und JS-Sandbox-Imitationen erkennen. Details siehe Instrumentation.

Höhere Obfuskationsstufen sind langsamer zu erzeugen. Die Stufen 4–7 ergänzen eine eigene String-Table-Indirektion plus esbuild-Minifizierung. Die Stufen 8–10 legen javascript-obfuscator obendrauf (String-Array, Control-Flow-Flattening, Dead-Code-Injection). Diese blockieren die Event-Loop pro Challenge um mehrere zehn Millisekunden, nutze sie also nur für Routen mit geringem Volumen oder liefere einen eigenen instrumentationGenerator, der in einem Worker-Pool läuft.

Zustandslose Deployment-Muster

Warnung: Diese Skripte enthalten keinen eingebauten Replay-Schutz. Ergänze ihn unbedingt selbst.

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));
      },
    },
  },
});

RSW-Challenges

Seit v0.1.1 und Widget v0.1.51 verstehen beide Seiten ein reicheres Wire-Format, das mehrere Challenge-Protokolle in einer Antwort unterstützt: SHA-256-PoW (der Standard), das neue RSW-Time-Lock-Puzzle und Instrumentation.

Minimales Opt-in

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

const SECRET = process.env.CAP_SECRET;
const KEYPAIR = generateRswKeypair(2048); // einmal beim Start, unbedingt persistieren!

app.post("/api/challenge", async () => {
  return await generateChallenge(SECRET, {
    format: 2,
    protocols: ["rsw", "instrumentation"],
    keypair: KEYPAIR,
    t: 75_000, // optional. wir empfehlen, es bei 75_000 zu belassen
  });
});

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