capjs-core 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,3 +1,15 @@
1
+ import {
2
+ agentFromCodes,
3
+ agentFromHeaders,
4
+ agentFromTrace,
5
+ summarizeAgent,
6
+ verifyWebBotAuth,
7
+ } from "./agents.js";
8
+ import {
9
+ analyzeBehavior,
10
+ DEFAULT_BEHAVIOR_THRESHOLD,
11
+ validateTrace,
12
+ } from "./behavior.js";
1
13
  import {
2
14
  decryptGcm,
3
15
  encryptGcm,
@@ -15,7 +27,29 @@ import {
15
27
  verifyInstrumentationResult,
16
28
  } from "./instrumentation.js";
17
29
 
30
+ export {
31
+ agentFromHeaders,
32
+ agentFromTrace,
33
+ parseSignatureAgent,
34
+ summarizeAgent,
35
+ verifyWebBotAuth,
36
+ } from "./agents.js";
37
+ export {
38
+ analyzeBehavior,
39
+ BEHAVIOR_WEIGHTS,
40
+ DEFAULT_BEHAVIOR_THRESHOLD,
41
+ WEAK_CAP,
42
+ } from "./behavior.js";
43
+
18
44
  export { detectAutomation } from "./detect.js";
45
+ export {
46
+ boundValues,
47
+ canonicalize,
48
+ generateInstrumentation,
49
+ MAX_BOUND_DATA,
50
+ serializeBound,
51
+ verifyInstrumentationResult,
52
+ } from "./instrumentation.js";
19
53
 
20
54
  import { mintHashwxChallenges, verifyHashwxSolution } from "./hashwx.js";
21
55
 
@@ -106,6 +140,161 @@ function assertSecret(secret) {
106
140
  }
107
141
  }
108
142
 
143
+ function behaviorPolicy(opt) {
144
+ if (!opt) return null;
145
+ const o = typeof opt === "object" ? opt : {};
146
+ const th = Number(o.threshold);
147
+ return {
148
+ b: o.block === true ? 1 : 0,
149
+ s: o.strict === true ? 1 : 0,
150
+ r: o.requireInteraction === false ? 0 : 1,
151
+ th: th >= 0.1 && th <= 1 ? th : DEFAULT_BEHAVIOR_THRESHOLD,
152
+ ag: o.blockAgents === "all" ? 2 : o.blockAgents === "identified" ? 1 : 0,
153
+ va: o.allowVerifiedAgents === true ? 1 : 0,
154
+ };
155
+ }
156
+
157
+ async function requestAgentEvidence(evidence, opts) {
158
+ const req = opts.request;
159
+ if (!req?.headers) return null;
160
+ evidence.push(...agentFromHeaders(req.headers));
161
+ if (opts.verifyWebBotAuth !== true || !req.method || !req.url) return null;
162
+ if (!evidence.some((e) => e.evidence.startsWith("signature_agent:")))
163
+ return null;
164
+ return verifyWebBotAuth(req, {
165
+ authority:
166
+ opts.webBotAuthAuthority ??
167
+ (URL.canParse(req.url) ? new URL(req.url).host : null),
168
+ fetch: opts.fetch,
169
+ trustedOrigins: opts.trustedSignatureAgents,
170
+ nonceCheck: opts.webBotAuthNonceCheck,
171
+ });
172
+ }
173
+
174
+ const INSTR_ERROR_REASONS = {
175
+ csp_blocked: "instr_csp_blocked",
176
+ csp_script_blocked: "instr_csp_blocked",
177
+ natives_tampered: "instr_natives_tampered",
178
+ env_failed: "instr_env_failed",
179
+ script_failed: "instr_script_failed",
180
+ };
181
+
182
+ const instrErrorReason = (code) =>
183
+ Object.hasOwn(INSTR_ERROR_REASONS, code)
184
+ ? INSTR_ERROR_REASONS[code]
185
+ : "instr_failed";
186
+
187
+ async function evaluateBehavior(policy, trace, payload, opts, bound) {
188
+ if (policy.ag > 0 && bound?.host === false) {
189
+ return {
190
+ fail: fail(
191
+ bound.isolated
192
+ ? "instr_isolated"
193
+ : bound.legacy
194
+ ? "instr_widget_outdated"
195
+ : "instr_host_unreachable",
196
+ { instr_error: true },
197
+ ),
198
+ };
199
+ }
200
+ const ctx = { strict: policy.s === 1, threshold: policy.th };
201
+ let pre = analyzeBehavior(trace, ctx);
202
+ if (pre.digest && typeof opts.behaviorReplayCheck === "function") {
203
+ const ttlMs = Math.max(60_000, (payload.exp ?? Date.now()) - Date.now());
204
+ let replayed = false;
205
+ try {
206
+ replayed = (await opts.behaviorReplayCheck(pre.digest, ttlMs)) === true;
207
+ } catch {}
208
+ if (replayed) pre = analyzeBehavior(trace, { ...ctx, replayed: true });
209
+ }
210
+ const evidence = (
211
+ trace && !validateTrace(trace) ? agentFromTrace(trace) : []
212
+ ).map((e) =>
213
+ e.agent === "gemini-in-chrome" && bound?.browser === "brave"
214
+ ? { ...e, agent: "brave-leo" }
215
+ : e,
216
+ );
217
+ if (bound) {
218
+ const seen = new Set(evidence.map((e) => e.evidence));
219
+ evidence.push(
220
+ ...agentFromCodes(bound.all).filter((e) => !seen.has(e.evidence)),
221
+ );
222
+ }
223
+ const verification = await requestAgentEvidence(evidence, opts);
224
+ const agent = summarizeAgent(evidence, verification);
225
+
226
+ let verdict = pre.verdict;
227
+ if (agent?.detected && verdict !== "automated") verdict = "agent";
228
+
229
+ const behavior = {
230
+ score: pre.score,
231
+ verdict,
232
+ flags: pre.flags,
233
+ signals: pre.signals,
234
+ ...(agent && { agent }),
235
+ };
236
+ const flags = pre.verdict === "missing" ? ["bh_missing"] : [...pre.flags];
237
+ if (agent?.detected) flags.push(`agent:${agent.name}`);
238
+
239
+ const blockAgent =
240
+ agent?.detected &&
241
+ !(policy.va === 1 && agent.verified) &&
242
+ (policy.ag === 2 || (policy.ag === 1 && agent.confidence === "high"));
243
+ if (blockAgent) {
244
+ return { fail: fail("behavior_agent", { behavior }) };
245
+ }
246
+ if (pre.verdict === "missing") {
247
+ if ((bound?.legacy || bound?.isolated) && policy.b !== 1) {
248
+ return { behavior, flags };
249
+ }
250
+ return { fail: fail("behavior_missing", { behavior }) };
251
+ }
252
+ if (policy.b === 1) {
253
+ if (pre.verdict === "automated") {
254
+ return { fail: fail("behavior_automated", { behavior }) };
255
+ }
256
+ if (policy.r === 1 && pre.verdict === "no_interaction") {
257
+ return { fail: fail("behavior_no_interaction", { behavior }) };
258
+ }
259
+ }
260
+ return { behavior, flags };
261
+ }
262
+
263
+ function sealedInstrMeta(instr, expires) {
264
+ if (!instr?.binding && !Array.isArray(instr?.expectedVals)) {
265
+ throw new Error(
266
+ "[capjs-core] instrumentationGenerator must return the binding from generateInstrumentation, or expectedVals",
267
+ );
268
+ }
269
+ return {
270
+ id: instr.id,
271
+ ...(Number.isFinite(instr.iat) && { iat: instr.iat }),
272
+ vars: instr.vars,
273
+ ...(instr.binding
274
+ ? { binding: instr.binding }
275
+ : { expectedVals: instr.expectedVals }),
276
+ blockAutomatedBrowsers: instr.blockAutomatedBrowsers === true,
277
+ blockAutomatedInteractions: instr.blockAutomatedInteractions === true,
278
+ detectAutomatedInteractions: instr.detectAutomatedInteractions === true,
279
+ behavior: instr.behavior === true,
280
+ behaviorEnforced: instr.behaviorEnforced === true,
281
+ expires,
282
+ };
283
+ }
284
+
285
+ function instrOptions(opts, ttlMs, bh) {
286
+ const base =
287
+ typeof opts.instrumentation === "object" && opts.instrumentation !== null
288
+ ? opts.instrumentation
289
+ : {};
290
+ return {
291
+ ...base,
292
+ ttlMs,
293
+ behavior: !!bh,
294
+ behaviorEnforced: !!bh && (bh.b === 1 || bh.ag > 0),
295
+ };
296
+ }
297
+
109
298
  export async function generateChallenge(secret, opts = {}) {
110
299
  assertSecret(secret);
111
300
 
@@ -145,29 +334,21 @@ export async function generateChallenge(secret, opts = {}) {
145
334
  };
146
335
  if (opts.scope) payload.sk = String(opts.scope);
147
336
  if (opts.extra && typeof opts.extra === "object") payload.x = opts.extra;
337
+ const bh = behaviorPolicy(opts.behavior);
338
+ if (bh) payload.bh = bh;
148
339
 
149
340
  let instrumentation;
150
- if (opts.instrumentation) {
151
- const instrOpts =
152
- typeof opts.instrumentation === "object" ? opts.instrumentation : {};
341
+ if (opts.instrumentation || bh) {
153
342
  const generator = opts.instrumentationGenerator || generateInstrumentation;
154
- const instr = await generator({ ...instrOpts, ttlMs });
155
- payload.ei = encryptGcm(
156
- {
157
- id: instr.id,
158
- expectedVals: instr.expectedVals,
159
- vars: instr.vars,
160
- blockAutomatedBrowsers: instr.blockAutomatedBrowsers,
161
- expires,
162
- },
163
- secret,
164
- );
343
+ const instr = await generator(instrOptions(opts, ttlMs, bh));
344
+ payload.ei = encryptGcm(sealedInstrMeta(instr, expires), secret);
165
345
  instrumentation = instr.instrumentation;
166
346
  }
167
347
 
168
348
  const token = jwtSign(payload, secret);
169
349
  const result = { challenge: { c, s, d }, token, expires };
170
350
  if (instrumentation) result.instrumentation = instrumentation;
351
+ if (bh) result.behavior = true;
171
352
  return result;
172
353
  }
173
354
 
@@ -232,7 +413,22 @@ export async function validateChallenge(secret, body, opts = {}) {
232
413
  }
233
414
  }
234
415
 
416
+ if (typeof opts.consumeNonce === "function") {
417
+ const sig = jwtSigHex(body.token);
418
+ if (!sig) return fail("invalid_token");
419
+ const ttlMs = Math.max(1, payload.exp - Date.now());
420
+ let claimed;
421
+ try {
422
+ claimed = await opts.consumeNonce(sig, ttlMs);
423
+ } catch (err) {
424
+ return fail("nonce_store_error", { error: String(err?.message || err) });
425
+ }
426
+ if (!claimed) return fail("already_redeemed");
427
+ }
428
+
235
429
  let riskFlags = [];
430
+ let bound = null;
431
+ let trace = null;
236
432
  if (payload.ei) {
237
433
  const instrMeta = decryptGcm(payload.ei, secret);
238
434
  if (!instrMeta) return fail("instr_corrupted", { instr_error: true });
@@ -247,6 +443,8 @@ export async function validateChallenge(secret, body, opts = {}) {
247
443
  : "instr_failed",
248
444
  { instr_error: true },
249
445
  );
446
+ } else if (typeof body.instr_error === "string") {
447
+ return fail(instrErrorReason(body.instr_error), { instr_error: true });
250
448
  } else if (body.instr_timeout === true) {
251
449
  return fail("instr_timeout", { instr_error: true });
252
450
  } else if (body.instr) {
@@ -258,22 +456,25 @@ export async function validateChallenge(secret, body, opts = {}) {
258
456
  });
259
457
  }
260
458
  if (r.riskFlags?.length) riskFlags = [...riskFlags, ...r.riskFlags];
459
+ if (r.agent) bound = r.agent;
460
+ trace = r.trace ?? null;
261
461
  } else {
262
462
  return fail("instr_missing", { instr_error: true });
263
463
  }
264
464
  }
265
465
 
266
- if (typeof opts.consumeNonce === "function") {
267
- const sig = jwtSigHex(body.token);
268
- if (!sig) return fail("invalid_token");
269
- const ttlMs = Math.max(1, payload.exp - Date.now());
270
- let claimed;
271
- try {
272
- claimed = await opts.consumeNonce(sig, ttlMs);
273
- } catch (err) {
274
- return fail("nonce_store_error", { error: String(err?.message || err) });
275
- }
276
- if (!claimed) return fail("already_redeemed");
466
+ let behavior;
467
+ let agent = null;
468
+ if (payload.bh && typeof payload.bh === "object") {
469
+ const r = await evaluateBehavior(payload.bh, trace, payload, opts, bound);
470
+ if (r.fail) return r.fail;
471
+ behavior = r.behavior;
472
+ if (r.flags.length) riskFlags = [...riskFlags, ...r.flags];
473
+ } else {
474
+ const evidence = bound ? agentFromCodes(bound.all) : [];
475
+ const verification = await requestAgentEvidence(evidence, opts);
476
+ agent = evidence.length ? summarizeAgent(evidence, verification) : null;
477
+ if (agent?.detected) riskFlags = [...riskFlags, `agent:${agent.name}`];
277
478
  }
278
479
 
279
480
  const tokenTtlMs = opts.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
@@ -292,6 +493,8 @@ export async function validateChallenge(secret, body, opts = {}) {
292
493
  scope: payload.sk ?? null,
293
494
  iat: payload.iat,
294
495
  riskFlags,
496
+ ...(behavior && { behavior }),
497
+ ...(agent && { agent }),
295
498
  };
296
499
  }
297
500
 
@@ -306,14 +509,21 @@ export async function validateChallenge(secret, body, opts = {}) {
306
509
  scope: payload.sk ?? null,
307
510
  iat: payload.iat,
308
511
  riskFlags,
512
+ ...(behavior && { behavior }),
513
+ ...(agent && { agent }),
309
514
  };
310
515
  }
311
516
 
312
517
  async function generateChallengeV2(secret, opts) {
313
- const protocols =
518
+ const bh = behaviorPolicy(opts.behavior);
519
+ const requested =
314
520
  Array.isArray(opts.protocols) && opts.protocols.length
315
521
  ? opts.protocols
316
522
  : ["rsw"];
523
+ const protocols =
524
+ bh && !requested.includes("instrumentation")
525
+ ? [...requested, "instrumentation"]
526
+ : requested;
317
527
  const ttlMs = opts.expiresMs ?? DEFAULT_CHALLENGE_TTL_MS;
318
528
  const now = Date.now();
319
529
  const exp = now + ttlMs;
@@ -361,27 +571,16 @@ async function generateChallengeV2(secret, opts) {
361
571
  expected.push({ protocol: "hashwx", ...minted.expected });
362
572
  }
363
573
  } else if (proto === "instrumentation") {
364
- const instrOpts =
365
- typeof opts.instrumentation === "object" &&
366
- opts.instrumentation !== null
367
- ? opts.instrumentation
368
- : {};
369
574
  const generator =
370
575
  opts.instrumentationGenerator || generateInstrumentation;
371
- const instr = await generator({ ...instrOpts, ttlMs });
576
+ const instr = await generator(instrOptions(opts, ttlMs, bh));
372
577
  challenges.push({
373
578
  protocol: "instrumentation",
374
579
  payload: { blob: instr.instrumentation },
375
580
  });
376
581
  expected.push({
377
582
  protocol: "instrumentation",
378
- instrMeta: {
379
- id: instr.id,
380
- expectedVals: instr.expectedVals,
381
- vars: instr.vars,
382
- blockAutomatedBrowsers: instr.blockAutomatedBrowsers,
383
- expires: exp,
384
- },
583
+ instrMeta: sealedInstrMeta(instr, exp),
385
584
  });
386
585
  } else {
387
586
  throw new Error(`[capjs-core] unknown protocol '${proto}'`);
@@ -397,9 +596,12 @@ async function generateChallengeV2(secret, opts) {
397
596
  };
398
597
  if (opts.scope) tokenPayload.sk = String(opts.scope);
399
598
  if (opts.extra && typeof opts.extra === "object") tokenPayload.x = opts.extra;
599
+ if (bh) tokenPayload.bh = bh;
400
600
 
401
601
  const token = jwtSign(tokenPayload, secret);
402
- return { token, format: 2, challenges, expires: exp };
602
+ const result = { token, format: 2, challenges, expires: exp };
603
+ if (bh) result.behavior = true;
604
+ return result;
403
605
  }
404
606
 
405
607
  async function validateChallengeV2(secret, body, payload, opts = {}) {
@@ -412,9 +614,13 @@ async function validateChallengeV2(secret, body, payload, opts = {}) {
412
614
  const expected = decrypted.expected;
413
615
  const solutions = body.solutions;
414
616
  let riskFlags = [];
617
+ let bound = null;
618
+ let trace = null;
415
619
 
416
- if (solutions.length !== expected.length) return fail("invalid_solutions");
620
+ if (!Array.isArray(solutions) || solutions.length !== expected.length)
621
+ return fail("invalid_solutions");
417
622
 
623
+ const instrSteps = [];
418
624
  for (let i = 0; i < expected.length; i++) {
419
625
  const e = expected[i];
420
626
  const s = solutions[i];
@@ -432,32 +638,7 @@ async function validateChallengeV2(secret, body, payload, opts = {}) {
432
638
  } else if (e.protocol === "hashwx") {
433
639
  if (!(await verifyHashwxSolution(e, s))) return fail("invalid_solution");
434
640
  } else if (e.protocol === "instrumentation") {
435
- const meta = e.instrMeta;
436
- if (!meta) return fail("instr_corrupted", { instr_error: true });
437
- if (meta.expires && Date.now() > meta.expires) {
438
- return fail("instr_expired", { instr_error: true });
439
- }
440
- if (s.blocked === true) {
441
- return fail(
442
- meta.blockAutomatedBrowsers
443
- ? "instr_automated_browser"
444
- : "instr_failed",
445
- { instr_error: true },
446
- );
447
- } else if (s.timeout === true) {
448
- return fail("instr_timeout", { instr_error: true });
449
- } else if (s.instr) {
450
- const r = verifyInstrumentationResult(meta, s.instr);
451
- if (!r.valid) {
452
- return fail(r.reason || "instr_failed", {
453
- instr_error: true,
454
- ...(r.blockedBy && { blockedBy: r.blockedBy }),
455
- });
456
- }
457
- if (r.riskFlags?.length) riskFlags = [...riskFlags, ...r.riskFlags];
458
- } else {
459
- return fail("instr_missing", { instr_error: true });
460
- }
641
+ instrSteps.push([e.instrMeta, s]);
461
642
  } else {
462
643
  return fail("invalid_solution");
463
644
  }
@@ -476,6 +657,52 @@ async function validateChallengeV2(secret, body, payload, opts = {}) {
476
657
  if (!claimed) return fail("already_redeemed");
477
658
  }
478
659
 
660
+ for (const [meta, s] of instrSteps) {
661
+ if (!meta) return fail("instr_corrupted", { instr_error: true });
662
+ if (meta.expires && Date.now() > meta.expires) {
663
+ return fail("instr_expired", { instr_error: true });
664
+ }
665
+ if (s.blocked === true) {
666
+ return fail(
667
+ meta.blockAutomatedBrowsers
668
+ ? "instr_automated_browser"
669
+ : "instr_failed",
670
+ { instr_error: true },
671
+ );
672
+ }
673
+ if (typeof s.error === "string") {
674
+ return fail(instrErrorReason(s.error), { instr_error: true });
675
+ }
676
+ if (s.timeout === true) {
677
+ return fail("instr_timeout", { instr_error: true });
678
+ }
679
+ if (!s.instr) return fail("instr_missing", { instr_error: true });
680
+ const r = verifyInstrumentationResult(meta, s.instr);
681
+ if (!r.valid) {
682
+ return fail(r.reason || "instr_failed", {
683
+ instr_error: true,
684
+ ...(r.blockedBy && { blockedBy: r.blockedBy }),
685
+ });
686
+ }
687
+ if (r.riskFlags?.length) riskFlags = [...riskFlags, ...r.riskFlags];
688
+ if (r.agent) bound = r.agent;
689
+ trace = r.trace ?? null;
690
+ }
691
+
692
+ let behavior;
693
+ let agent = null;
694
+ if (payload.bh && typeof payload.bh === "object") {
695
+ const r = await evaluateBehavior(payload.bh, trace, payload, opts, bound);
696
+ if (r.fail) return r.fail;
697
+ behavior = r.behavior;
698
+ if (r.flags.length) riskFlags = [...riskFlags, ...r.flags];
699
+ } else {
700
+ const evidence = bound ? agentFromCodes(bound.all) : [];
701
+ const verification = await requestAgentEvidence(evidence, opts);
702
+ agent = evidence.length ? summarizeAgent(evidence, verification) : null;
703
+ if (agent?.detected) riskFlags = [...riskFlags, `agent:${agent.name}`];
704
+ }
705
+
479
706
  const tokenTtlMs = opts.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
480
707
  const tokenExpires = Date.now() + tokenTtlMs;
481
708
 
@@ -492,6 +719,8 @@ async function validateChallengeV2(secret, body, payload, opts = {}) {
492
719
  scope: payload.sk ?? null,
493
720
  iat: payload.iat,
494
721
  riskFlags,
722
+ ...(behavior && { behavior }),
723
+ ...(agent && { agent }),
495
724
  };
496
725
  }
497
726
 
@@ -506,5 +735,7 @@ async function validateChallengeV2(secret, body, payload, opts = {}) {
506
735
  scope: payload.sk ?? null,
507
736
  iat: payload.iat,
508
737
  riskFlags,
738
+ ...(behavior && { behavior }),
739
+ ...(agent && { agent }),
509
740
  };
510
741
  }