Thin .NET wrapper around Cap — the privacy-first, self-hosted CAPTCHA built on proof-of-work, RSW time-lock puzzles, and optional browser instrumentation.
The library does not reimplement Cap's challenge algorithm. It calls upstream capjs-core through Jering.Javascript.NodeJS, keeping the .NET surface small (~150 LOC) and delegating cryptography, RSW math, and instrumentation script generation to the library Cap's maintainer ships and tests. The .NET side owns three things: HTTP entry points, redeem-token storage, and JWT-signature replay protection.
If your app is .NET (ASP.NET MVC 5 / Framework 4.8 or newer) and you want Cap's protections without running Cap's standalone Bun server as a sidecar, embed the bridge in-process.
<PackageReference Include="CapNet" Version="0.1.0" />Ships the .NET library. The Node bridge (bridge.js + node_modules) lives in the repo's CapNet.Bridge.Js/ folder; deploy it next to your application binaries and point CapService at its path. Node.exe must be available on the host (the typical Jering-hosting requirement).
using CapNet;
using CapNet.Challenges;
using CapNet.Storage;
using Jering.Javascript.NodeJS;
// At startup
var services = new ServiceCollection();
services.AddNodeJS();
var node = services.BuildServiceProvider().GetRequiredService<INodeJSService>();
var cap = new CapService(
secret: Environment.GetEnvironmentVariable("CAP_SECRET"), // ≥16 UTF-8 bytes
node: node,
bridgePath: Path.Combine(AppContext.BaseDirectory, "bridge", "bridge.js"),
state: new MyAppCacheAdapter(), // ICapStateStore — see below
defaults: new ChallengeOptions
{
ChallengeCount = 30,
ChallengeSize = 32,
ChallengeDifficulty = 3,
Instrumentation = new { blockAutomatedBrowsers = true, obfuscationLevel = 3 },
Scope = "my-app",
});
// In your captcha controller
[HttpPost, Route("cap/challenge")]
public async Task<HttpResponseMessage> Challenge()
=> Json(await cap.IssueChallengeJsonAsync());
[HttpPost, Route("cap/redeem")]
public async Task<HttpResponseMessage> Redeem(HttpRequestMessage req)
{
var body = await req.Content.ReadAsStringAsync();
var outcome = await cap.RedeemAsync(body);
return new HttpResponseMessage(outcome.HttpStatus)
{
Content = new StringContent(outcome.ResponseJson, Encoding.UTF8, "application/json"),
};
}
// In your registration / login action filter
bool ok = await cap.VerifyRedeemTokenAsync(submittedToken);CapNet keeps no in-process state. It needs a tiny key/value store with TTL, which you wire to whatever cache your app already runs — IMemoryCache, IDistributedCache, Redis via StackExchange, Azure Cache for Redis, etc. The library prefixes its keys (capnet:nonce:…, capnet:redeem:…), so the namespace is self-contained.
public interface ICapStateStore
{
Task PutAsync(string key, string value, TimeSpan ttl);
Task<string> GetAsync(string key);
Task RemoveAsync(string key);
}For a single-process dev box, use the bundled MemoryCapStateStore. For multi-server deployments, write a 15-line adapter against your existing distributed cache.
| CapNet handles | Your app handles | |
|---|---|---|
| Challenge generation (PoW / RSW / instrumentation) | ✅ via capjs-core | |
| JWT sign/verify | ✅ via capjs-core | |
| Redeem-token TTL | ✅ via ICapStateStore |
|
| Replay protection | ✅ via ICapStateStore |
|
| HTTP plumbing | one controller, three routes | |
| Form integration / hidden field | yours | |
| Action filter wiring | yours | |
| Backing cache (memory or Redis) | you provide via ICapStateStore |
|
| RSW keypair persistence | you persist once, load on startup |
Browser (Cap widget)
│
│ POST /cap/challenge ← capjs-core's JSON, verbatim
│ POST /cap/redeem
▼
ASP.NET (your app)
│
│ CapService.IssueChallengeJsonAsync
│ CapService.RedeemAsync
▼
Jering.Javascript.NodeJS ──► bridge.js ──► capjs-core (npm)
│ │
│ ICapStateStore (your impl) │
└─► redis / memory cache │
└──► generateChallenge / validateChallenge
- Widget v0.1.51 has a format-2 speculative-fetch race. When the response is
format: 2, the widget can settle its internal state machine beforesolve()registers its listener, hanging forever. Use format 1 (sha256-pow + optional top-level instrumentation) with the upstream widget. The library exposes format 2 / RSW correctly for clients that drive the protocol directly (seeCapNet.E2E/tests/format2-all-protocols.spec.ts). capjs-coreis pre-1.0 — only versions0.1.xare published. Pin a specific version transitively; treat each bump as a deliberate release of CapNet.- Node.exe required on every host. Jering manages a child-process pool. If "no Node on the auth tier" is a hard policy, this library isn't the right shape — run Cap's standalone Bun server out-of-process instead.
# Build everything
dotnet build CapNet.sln
# Run the demo (http://localhost:5500/)
dotnet run --project CapNet.Demo.Web -- http://localhost:5500/
# Run unit + E2E tests
npm --prefix CapNet.E2E install
npm --prefix CapNet.E2E exec playwright install chromium
npm --prefix CapNet.E2E testThe demo loads the real @cap.js/widget from jsDelivr, so a network connection is required for the manual smoke test.
If you've cloned the repo and want to consume your local build from another solution (e.g. to try an unreleased change):
./scripts/pack-local.ps1 # writes ./packages/CapNet.<version>.nupkgThen in the consumer's nuget.config, add the folder as a source and reference the version you built.
This is a wrapper. The whole captcha — PoW, RSW, instrumentation, the JS bridge between server and widget — is the work of Tiago Rangel and the Cap contributors. If you find CapNet useful, please star the upstream Cap repo and consider supporting Tiago directly.
MIT. Cap and capjs-core are also MIT.