@fabrika/app 0.7.0 → 0.8.0

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.
@@ -1,10 +1,15 @@
1
1
  // A form's challenge, verified on the server before its row is written. The provider list is closed: Turnstile's
2
2
  // siteverify is the only endpoint the framework asks, with the secret from the application variable the contract
3
- // names. A token verifies only for this host and this form's action, while it is fresh; the dispatcher then records
3
+ // names; an ALTCHA challenge the application issues and verifies itself, with the HMAC key that variable holds, and asks
4
+ // no one. A token verifies only for this host and this form's action, while it is fresh; the dispatcher then records
4
5
  // it, so its second use is a replay whatever the provider says.
5
6
 
6
- import type { ChallengeProvider } from '@fabrika/auth-core'
7
+ import type { AltchaChallenge, ChallengeProvider } from '@fabrika/auth-core'
8
+ import { createChallenge, randomInt, verifySolution } from 'altcha-lib'
9
+ import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2'
10
+ import { createHmac } from 'node:crypto'
7
11
  import { z } from 'zod/v4'
12
+ import { ALTCHA_LIMITS } from '../contract/defaults'
8
13
  import { readBoundedBytes } from './bounded'
9
14
 
10
15
  export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
@@ -37,11 +42,23 @@ export interface ChallengeCheck {
37
42
  readonly action: string
38
43
  }
39
44
 
45
+ /**
46
+ * A verification's answer. A passed one names what the dispatcher records it by: the token itself, or, for ALTCHA, the
47
+ * signature of the challenge it solved, since one solution can be encoded as many tokens. Its record lasts at least
48
+ * as long as this token could pass again, even if the issuer's configured lifetime has since changed.
49
+ */
50
+ export type ChallengeVerdict =
51
+ | { readonly outcome: 'passed'; readonly replayKey: string; readonly recordMs: number }
52
+ | { readonly outcome: Exclude<ChallengeOutcome, 'passed'> }
53
+
40
54
  export interface ChallengeVerifier {
41
55
  readonly provider: ChallengeProvider
42
- verify(check: ChallengeCheck): Promise<ChallengeOutcome>
56
+ verify(check: ChallengeCheck): Promise<ChallengeVerdict>
43
57
  }
44
58
 
59
+ const verdictOf = (outcome: ChallengeOutcome, replayKey: string, recordMs: number): ChallengeVerdict =>
60
+ outcome === 'passed' ? { outcome, replayKey, recordMs } : { outcome }
61
+
45
62
  /** A token as the widget issues it: printable ASCII without spaces, within the provider's length. */
46
63
  const TOKEN = new RegExp(`^[\\x21-\\x7e]{1,${CHALLENGE_LIMITS.maxTokenLength}}$`)
47
64
 
@@ -61,55 +78,60 @@ const SECRET_ERRORS = ['missing-input-secret', 'invalid-input-secret']
61
78
  export function turnstileVerifier(secret: string, fetch: ChallengeFetch, now: () => number = Date.now): ChallengeVerifier {
62
79
  const verifier: ChallengeVerifier = {
63
80
  provider: 'turnstile',
64
- async verify(check: ChallengeCheck): Promise<ChallengeOutcome> {
65
- if (!TOKEN.test(check.token)) {
66
- return 'failed'
67
- }
68
- const deadline = new AbortController()
69
- const timer = setTimeout(() => deadline.abort(), CHALLENGE_LIMITS.timeoutMs)
70
- let raw: Uint8Array<ArrayBuffer> | null
71
- try {
72
- const response = await fetch(
73
- new Request(TURNSTILE_VERIFY_URL, {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
- body: new URLSearchParams({ secret, response: check.token }),
77
- redirect: 'manual',
78
- signal: deadline.signal,
79
- }),
80
- check,
81
- )
82
- const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
83
- if (response.status !== 200 || type !== 'application/json') {
84
- await response.body?.cancel()
85
- return 'unavailable'
86
- }
87
- raw = await readBoundedBytes(response.body, { maxBytes: CHALLENGE_LIMITS.maxAnswerBytes, signal: deadline.signal })
88
- } catch {
89
- return 'unavailable'
90
- } finally {
91
- clearTimeout(timer)
92
- }
93
- const answer = raw === null ? null : parseAnswer(raw)
94
- if (answer === null) {
95
- return 'unavailable'
96
- }
97
- if (!answer.success) {
98
- const codes = answer['error-codes'] ?? []
99
- if (codes.some((code) => SECRET_ERRORS.includes(code))) {
100
- return 'misconfigured'
101
- }
102
- return codes.includes('internal-error') ? 'unavailable' : 'failed'
103
- }
104
- const issued = Date.parse(answer.challenge_ts ?? '')
105
- const age = now() - issued
106
- const fresh = Number.isFinite(issued) && age <= CHALLENGE_LIMITS.maxAgeMs && age >= -CHALLENGE_LIMITS.maxSkewMs
107
- return fresh && answer.hostname === check.hostname && answer.action === check.action ? 'passed' : 'failed'
81
+ async verify(check: ChallengeCheck): Promise<ChallengeVerdict> {
82
+ return verdictOf(await siteverify(secret, fetch, now, check), check.token, CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)
108
83
  },
109
84
  }
110
85
  return Object.freeze(verifier)
111
86
  }
112
87
 
88
+ /** Turnstile's answer for `check`, under one deadline and a byte budget. */
89
+ async function siteverify(secret: string, fetch: ChallengeFetch, now: () => number, check: ChallengeCheck): Promise<ChallengeOutcome> {
90
+ if (!TOKEN.test(check.token)) {
91
+ return 'failed'
92
+ }
93
+ const deadline = new AbortController()
94
+ const timer = setTimeout(() => deadline.abort(), CHALLENGE_LIMITS.timeoutMs)
95
+ let raw: Uint8Array<ArrayBuffer> | null
96
+ try {
97
+ const response = await fetch(
98
+ new Request(TURNSTILE_VERIFY_URL, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
101
+ body: new URLSearchParams({ secret, response: check.token }),
102
+ redirect: 'manual',
103
+ signal: deadline.signal,
104
+ }),
105
+ check,
106
+ )
107
+ const type = response.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase()
108
+ if (response.status !== 200 || type !== 'application/json') {
109
+ await response.body?.cancel()
110
+ return 'unavailable'
111
+ }
112
+ raw = await readBoundedBytes(response.body, { maxBytes: CHALLENGE_LIMITS.maxAnswerBytes, signal: deadline.signal })
113
+ } catch {
114
+ return 'unavailable'
115
+ } finally {
116
+ clearTimeout(timer)
117
+ }
118
+ const answer = raw === null ? null : parseAnswer(raw)
119
+ if (answer === null) {
120
+ return 'unavailable'
121
+ }
122
+ if (!answer.success) {
123
+ const codes = answer['error-codes'] ?? []
124
+ if (codes.some((code) => SECRET_ERRORS.includes(code))) {
125
+ return 'misconfigured'
126
+ }
127
+ return codes.includes('internal-error') ? 'unavailable' : 'failed'
128
+ }
129
+ const issued = Date.parse(answer.challenge_ts ?? '')
130
+ const age = now() - issued
131
+ const fresh = Number.isFinite(issued) && age <= CHALLENGE_LIMITS.maxAgeMs && age >= -CHALLENGE_LIMITS.maxSkewMs
132
+ return fresh && answer.hostname === check.hostname && answer.action === check.action ? 'passed' : 'failed'
133
+ }
134
+
113
135
  function parseAnswer(raw: Uint8Array<ArrayBuffer>): z.infer<typeof siteverifyAnswer> | null {
114
136
  try {
115
137
  const parsed = siteverifyAnswer.safeParse(JSON.parse(new TextDecoder().decode(raw)))
@@ -118,3 +140,137 @@ function parseAnswer(raw: Uint8Array<ArrayBuffer>): z.infer<typeof siteverifyAns
118
140
  return null
119
141
  }
120
142
  }
143
+
144
+ /**
145
+ * How hard an ALTCHA challenge is to solve: PBKDF2 attempts of `cost` iterations, up to a count drawn between
146
+ * `minCounter` and `maxCounter`. The contract's by default; a test kit and `fabrika app dev` make it cheap.
147
+ */
148
+ export interface ChallengeWork {
149
+ readonly cost: number
150
+ readonly minCounter: number
151
+ readonly maxCounter: number
152
+ }
153
+
154
+ /** The cheapest work: the test kit's and `fabrika app dev`'s, so a test solves a challenge in milliseconds. */
155
+ export const MINIMAL_CHALLENGE_WORK: ChallengeWork = Object.freeze({ cost: 1, minCounter: 1, maxCounter: 10 })
156
+
157
+ /** What the issuing endpoint answers: the signed parameters, which the widget solves and sends back with its solution. */
158
+ export interface IssuedChallenge {
159
+ readonly parameters: Readonly<Record<string, unknown>>
160
+ readonly signature?: string
161
+ }
162
+
163
+ export interface ChallengeIssuer {
164
+ /** A fresh challenge for this host and the procedure's action. */
165
+ issue(hostname: string): Promise<IssuedChallenge>
166
+ }
167
+
168
+ const HEX = /^[0-9a-f]+$/
169
+ const hex = (length: number) => z.string().length(length).regex(HEX)
170
+
171
+ // Strict: exactly what `issue` signs, so a payload of another shape (the widget's `test` mode, a server-signed one) never
172
+ // reaches the verification.
173
+ const altchaPayload = z.strictObject({
174
+ challenge: z.strictObject({
175
+ parameters: z.strictObject({
176
+ algorithm: z.literal(ALTCHA_LIMITS.algorithm),
177
+ nonce: hex(32),
178
+ salt: hex(32),
179
+ cost: z.int().positive(),
180
+ keyLength: z.literal(32),
181
+ keyPrefix: hex(32),
182
+ keySignature: hex(64),
183
+ expiresAt: z.int().positive(),
184
+ data: z.strictObject({ action: z.string(), host: z.string() }),
185
+ }),
186
+ signature: hex(64),
187
+ }),
188
+ solution: z.strictObject({ counter: z.int().nonnegative(), derivedKey: hex(64), time: z.number().nonnegative().optional() }),
189
+ })
190
+
191
+ type AltchaPayload = z.infer<typeof altchaPayload>
192
+
193
+ /** A payload as the widget sends it: base64 of its JSON, within the bound. */
194
+ const PAYLOAD = new RegExp(`^[A-Za-z0-9+/]{1,${ALTCHA_LIMITS.maxTokenLength}}={0,2}$`)
195
+
196
+ /**
197
+ * The two HMAC keys of an ALTCHA challenge, each derived from the application's key for one use: one signs the
198
+ * parameters, the other the solution the server knows, so neither signature can stand in for the other.
199
+ */
200
+ function altchaKeys(secret: string): { readonly signature: string; readonly key: string } {
201
+ const derive = (use: string) => createHmac('sha256', secret).update(`fabrika-altcha-${use}`).digest('hex')
202
+ return { signature: derive('challenge'), key: derive('solution') }
203
+ }
204
+
205
+ function parsePayload(token: string): AltchaPayload | null {
206
+ if (token.length > ALTCHA_LIMITS.maxTokenLength || !PAYLOAD.test(token)) {
207
+ return null
208
+ }
209
+ try {
210
+ const parsed = altchaPayload.safeParse(JSON.parse(atob(token)))
211
+ return parsed.success ? parsed.data : null
212
+ } catch {
213
+ return null
214
+ }
215
+ }
216
+
217
+ /**
218
+ * The issuer and the verifier of one public procedure's ALTCHA challenge, with the HMAC key its application variable
219
+ * holds. A challenge carries the action and the host it was issued for and expires `maxAgeMs` after it was issued; the
220
+ * server picks the attempt that solves it and signs that attempt's key, so verifying is two HMACs and no proof of work.
221
+ * Nothing leaves the process. `now` is the clock a challenge's expiry is set with; altcha-lib reads it on the real clock,
222
+ * and so does the verifier, so the two never disagree.
223
+ */
224
+ export function altchaChallenge(
225
+ secret: string,
226
+ rules: Pick<AltchaChallenge, 'action' | 'cost' | 'maxAgeMs'>,
227
+ work: ChallengeWork | null = null,
228
+ now: () => number = Date.now,
229
+ ): { readonly issuer: ChallengeIssuer; readonly verifier: ChallengeVerifier } {
230
+ const keys = altchaKeys(secret)
231
+ const effort = work ?? { cost: rules.cost, minCounter: ALTCHA_LIMITS.minCounter, maxCounter: ALTCHA_LIMITS.maxCounter }
232
+ const issuer: ChallengeIssuer = {
233
+ async issue(hostname: string): Promise<IssuedChallenge> {
234
+ const challenge = await createChallenge({
235
+ algorithm: ALTCHA_LIMITS.algorithm,
236
+ cost: effort.cost,
237
+ counter: randomInt(effort.maxCounter, effort.minCounter),
238
+ expiresAt: Math.floor((now() + rules.maxAgeMs) / 1000),
239
+ data: { action: rules.action, host: hostname },
240
+ deriveKey,
241
+ hmacSignatureSecret: keys.signature,
242
+ hmacKeySignatureSecret: keys.key,
243
+ })
244
+ return { parameters: { ...challenge.parameters }, ...(challenge.signature === undefined ? {} : { signature: challenge.signature }) }
245
+ },
246
+ }
247
+ const verifier: ChallengeVerifier = {
248
+ provider: 'altcha',
249
+ async verify(check: ChallengeCheck): Promise<ChallengeVerdict> {
250
+ const payload = parsePayload(check.token)
251
+ if (payload === null) {
252
+ return { outcome: 'failed' }
253
+ }
254
+ const { parameters } = payload.challenge
255
+ // The signature covers these; they are read before it only to answer without the HMAC.
256
+ if (parameters.data.action !== check.action || parameters.data.host !== check.hostname || parameters.expiresAt * 1000 < Date.now()) {
257
+ return { outcome: 'failed' }
258
+ }
259
+ try {
260
+ const result = await verifySolution({
261
+ challenge: payload.challenge,
262
+ solution: payload.solution,
263
+ deriveKey,
264
+ hmacSignatureSecret: keys.signature,
265
+ hmacKeySignatureSecret: keys.key,
266
+ })
267
+ // A previous deployment may have issued longer-lived challenges with this same key.
268
+ const recordMs = Math.max(0, parameters.expiresAt * 1000 - Date.now()) + CHALLENGE_LIMITS.maxSkewMs
269
+ return verdictOf(result.verified ? 'passed' : 'failed', payload.challenge.signature, recordMs)
270
+ } catch {
271
+ return { outcome: 'failed' }
272
+ }
273
+ },
274
+ }
275
+ return Object.freeze({ issuer: Object.freeze(issuer), verifier: Object.freeze(verifier) })
276
+ }
@@ -81,7 +81,7 @@ import {
81
81
  runInRequest,
82
82
  type Within,
83
83
  } from './capability'
84
- import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
84
+ import type { ChallengeIssuer, ChallengeVerifier } from './challenge'
85
85
  import { connectContext, type ImportWire } from './connect'
86
86
  import { runAsSite } from './egress'
87
87
  import type { FailureReports } from './failures'
@@ -197,6 +197,8 @@ export type ServedEndpoint =
197
197
  readonly admission: ProcedureAdmission
198
198
  readonly site: ServedSite
199
199
  }
200
+ /** The framework's own `GET` that issues a public procedure's ALTCHA challenge, behind buckets of its own. */
201
+ | { readonly kind: 'challenge'; readonly row: EndpointRow; readonly issuer: ChallengeIssuer; readonly admission: ProcedureAdmission }
200
202
  | {
201
203
  readonly kind: 'webhook'
202
204
  readonly row: EndpointRow
@@ -371,6 +373,8 @@ export class ContractRuntime {
371
373
  return await this.#procedure(served, request, exec)
372
374
  case 'public':
373
375
  return await this.#public(served, request, exec)
376
+ case 'challenge':
377
+ return await this.#issueChallenge(served, request)
374
378
  case 'webhook':
375
379
  return await this.#webhook(served, request, plan.params, exec)
376
380
  case 'description':
@@ -849,16 +853,16 @@ export class ContractRuntime {
849
853
  * failure is 400, as a 0.5 form answered, an unreachable provider 503.
850
854
  */
851
855
  async #challenge(action: string, verifier: ChallengeVerifier, token: string | null, request: Request, repeatable: string | null): Promise<void> {
852
- const outcome = token === null ? 'failed' : await verifier.verify({ token, hostname: new URL(request.url).hostname, action })
853
- if (outcome === 'unavailable') {
856
+ const verdict = token === null ? null : await verifier.verify({ token, hostname: new URL(request.url).hostname, action })
857
+ if (verdict?.outcome === 'unavailable') {
854
858
  throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${action}' could not be verified`, 503)
855
859
  }
856
- if (outcome === 'misconfigured') {
860
+ if (verdict?.outcome === 'misconfigured') {
857
861
  throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${action}' refused the application's secret`, 503)
858
862
  }
859
- const refused = token === null || outcome === 'failed'
863
+ const refused = verdict?.outcome !== 'passed'
860
864
  || (token !== repeatable
861
- && !(await this.#framework().admissions.recordChallenge(action, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)))
865
+ && !(await this.#framework().admissions.recordChallenge(action, sha256Hex(verdict.replayKey), verdict.recordMs)))
862
866
  if (refused) {
863
867
  throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
864
868
  }
@@ -911,6 +915,23 @@ export class ContractRuntime {
911
915
  }
912
916
  }
913
917
 
918
+ /**
919
+ * A fresh ALTCHA challenge for this host: within the anonymous share and after the endpoint's buckets, as a public
920
+ * procedure is admitted. It reads no body and writes nothing but the buckets.
921
+ */
922
+ async #issueChallenge(served: Extract<ServedEndpoint, { kind: 'challenge' }>, request: Request): Promise<Response> {
923
+ const release = this.#parts.publicShare.enter()
924
+ if (release === null) {
925
+ throw new HttpError(503, 'public_share_full', 'Too many anonymous requests at once; try again shortly')
926
+ }
927
+ try {
928
+ await this.#admit(served.row, served.admission, request, null)
929
+ return jsonResponse(await served.issuer.issue(new URL(request.url).hostname), 200)
930
+ } finally {
931
+ release()
932
+ }
933
+ }
934
+
914
935
  /**
915
936
  * Sends each job once, after the commit: in the background, never awaited and never rejecting. Without an execution
916
937
  * context a kick runs detached.
@@ -4,8 +4,16 @@
4
4
 
5
5
  import { type AdmissionRate, CLIENT_ADDRESS_HEADER, type ContractArtifact, type ProcedureArtifact } from '@fabrika/auth-core'
6
6
  import { createHash } from 'node:crypto'
7
+ import { ALTCHA_LIMITS } from '../contract/defaults'
7
8
  import { REQUEST_BUDGET_MS } from './bounded'
8
- import { type ChallengeFetch, type ChallengeVerifier, turnstileVerifier } from './challenge'
9
+ import {
10
+ altchaChallenge,
11
+ type ChallengeFetch,
12
+ type ChallengeIssuer,
13
+ type ChallengeVerifier,
14
+ type ChallengeWork,
15
+ turnstileVerifier,
16
+ } from './challenge'
9
17
  import { clientBucket, clientKey } from './client-address'
10
18
  import type { RequestClient } from './types'
11
19
 
@@ -26,10 +34,12 @@ export interface ProcedureAdmission {
26
34
  readonly timeoutMs: number
27
35
  }
28
36
 
29
- /** A challenge's verifier and the widget action the verification expects back. */
37
+ /** A challenge's verifier and the widget action the verification expects back; an ALTCHA one's issuer too. */
30
38
  export interface AdmittedChallenge {
31
39
  readonly verifier: ChallengeVerifier
32
40
  readonly action: string
41
+ /** What the procedure's issuing endpoint answers with; `null` for a provider that issues its own (Turnstile). */
42
+ readonly issuer: ChallengeIssuer | null
33
43
  }
34
44
 
35
45
  /** A procedure the artifact holds no rules for (a `defineContract` one): no bucket, the request budget. */
@@ -44,11 +54,13 @@ export interface AdmissionOptions {
44
54
  readonly application: Readonly<Record<string, unknown>>
45
55
  readonly challengeFetch?: ChallengeFetch
46
56
  readonly repeatableChallengeToken?: string
57
+ /** The work of every ALTCHA challenge in place of the contract's: a test kit's and `fabrika app dev`'s. */
58
+ readonly challengeWork?: ChallengeWork
47
59
  }
48
60
 
49
61
  /**
50
- * The admission of endpoint `id`, or `null` after a problem: a challenge whose secret's application variable is not set
51
- * keeps the application from booting, as a form's does.
62
+ * The admission of endpoint `id`, or `null` after a problem: a challenge whose secret's application variable is not set,
63
+ * or an ALTCHA key too short to resist a guess, keeps the application from booting, as a form's does.
52
64
  */
53
65
  export function procedureAdmission(id: string, artifact: ContractArtifact, options: AdmissionOptions, problems: string[]): ProcedureAdmission | null {
54
66
  const rules: ProcedureArtifact | undefined = artifact.procedures !== undefined && Object.hasOwn(artifact.procedures, id)
@@ -66,9 +78,22 @@ export function procedureAdmission(id: string, artifact: ContractArtifact, optio
66
78
  )
67
79
  return null
68
80
  }
69
- challenge = {
70
- verifier: turnstileVerifier(secret.trim(), options.challengeFetch ?? ((request) => fetch(request))),
71
- action: rules.challenge.action,
81
+ if (rules.challenge.provider === 'altcha') {
82
+ // Every issued challenge carries a signature made with this key, so a short one can be guessed offline.
83
+ if (secret.trim().length < ALTCHA_LIMITS.minKeyLength) {
84
+ problems.push(
85
+ `procedure '${id}' signs its altcha challenge with the application variable ${rules.challenge.secret}, which holds fewer than ${ALTCHA_LIMITS.minKeyLength} characters; set it to a long random value (openssl rand -base64 32)`,
86
+ )
87
+ return null
88
+ }
89
+ const altcha = altchaChallenge(secret.trim(), rules.challenge, options.challengeWork ?? null)
90
+ challenge = { verifier: altcha.verifier, action: rules.challenge.action, issuer: altcha.issuer }
91
+ } else {
92
+ challenge = {
93
+ verifier: turnstileVerifier(secret.trim(), options.challengeFetch ?? ((request) => fetch(request))),
94
+ action: rules.challenge.action,
95
+ issuer: null,
96
+ }
72
97
  }
73
98
  }
74
99
  return Object.freeze({
@@ -1,5 +1,10 @@
1
- // A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`.
1
+ // A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`; and the solver of an ALTCHA
2
+ // challenge, which needs no stand-in: the application issues and verifies it itself.
2
3
 
4
+ import { type Challenge, solveChallenge } from 'altcha-lib'
5
+ import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2'
6
+ import { z } from 'zod/v4'
7
+ import type { RpcFetch } from '../client/transport'
3
8
  import type { ChallengeFetch } from '../implement/challenge'
4
9
 
5
10
  /** The token Turnstile's always-passing test widget (site key `1x00000000000000000000AA`) hands out every time. */
@@ -8,3 +13,32 @@ export const TURNSTILE_TEST_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
8
13
  /** Turnstile's siteverify that passes every token: a fresh answer for the host and the form the check expects. */
9
14
  export const passingChallenge: ChallengeFetch = (_request, check) =>
10
15
  Promise.resolve(Response.json({ success: true, challenge_ts: new Date().toISOString(), hostname: check.hostname, action: check.action }))
16
+
17
+ // Read as loosely as the widget reads it: the verification is what holds a challenge to its shape.
18
+ const issuedChallenge = z.object({
19
+ parameters: z.object({ algorithm: z.string(), nonce: z.string(), salt: z.string(), cost: z.number(), keyLength: z.number(), keyPrefix: z.string() })
20
+ .catchall(z.unknown()),
21
+ signature: z.string().optional(),
22
+ })
23
+
24
+ /**
25
+ * Fetches a challenge from `url` through `fetch` and solves it as the widget does, answering the payload the page puts
26
+ * into `challengeToken`. The kit issues the cheapest challenge, so this takes milliseconds.
27
+ */
28
+ export async function solveChallengeAt(fetch: RpcFetch, url: string): Promise<string> {
29
+ const response = await fetch(url, { headers: { Accept: 'application/json' } })
30
+ if (response.status !== 200) {
31
+ throw new Error(`the challenge at ${url} answered ${response.status}: ${await response.text()}`)
32
+ }
33
+ const parsed = issuedChallenge.safeParse(await response.json())
34
+ if (!parsed.success) {
35
+ throw new Error(`the challenge at ${url} is no ALTCHA challenge`)
36
+ }
37
+ const { parameters, signature } = parsed.data
38
+ const challenge: Challenge = { parameters: { ...parameters }, ...(signature === undefined ? {} : { signature }) }
39
+ const solution = await solveChallenge({ challenge, deriveKey, timeout: 10_000 })
40
+ if (solution === null) {
41
+ throw new Error(`the challenge at ${url} was not solved in time`)
42
+ }
43
+ return btoa(JSON.stringify({ challenge, solution }))
44
+ }
@@ -12,7 +12,7 @@ import {
12
12
  endpointDirectory,
13
13
  endpointMembers,
14
14
  type EndpointMethod,
15
- type FormChallenge,
15
+ type ProcedureChallenge,
16
16
  type SchemaShape,
17
17
  type Scope,
18
18
  } from '@fabrika/auth-core'
@@ -230,13 +230,14 @@ const cleanScanner: FileScanner = {
230
230
  */
231
231
  function unusedVariables(app: ContractImplementation<AnyContract>): ApplicationEnvironment {
232
232
  const artifact = compileContract(app.contract, app.database?.tables ?? {})
233
- const challengeVariables = (challenge: FormChallenge): readonly string[] =>
234
- challenge.siteKey === undefined ? [challenge.secret] : [challenge.secret, challenge.siteKey]
233
+ const challengeVariables = (challenge: ProcedureChallenge): readonly string[] =>
234
+ challenge.provider === 'altcha' || challenge.siteKey === undefined ? [challenge.secret] : [challenge.secret, challenge.siteKey]
235
235
  const names = Object.values(artifact.mounts).flatMap((mount): readonly string[] => mount.kind === 'spa' ? (mount.public ?? []) : [])
236
236
  const procedures = Object.values(artifact.procedures ?? {}).flatMap((procedure) =>
237
237
  procedure.challenge === null ? [] : challengeVariables(procedure.challenge)
238
238
  )
239
- return Object.fromEntries([...names, ...procedures].map((name) => [name, 'unused']))
239
+ // Long enough for an ALTCHA key, which boot refuses under 32 characters.
240
+ return Object.fromEntries([...names, ...procedures].map((name) => [name, 'unused-by-every-generated-case-of-the-kit']))
240
241
  }
241
242
 
242
243
  /**
@@ -36,6 +36,7 @@ import { exportJWK, generateKeyPair, SignJWT } from 'jose'
36
36
  import { existsSync } from 'node:fs'
37
37
  import { resolve } from 'node:path'
38
38
  import type { z } from 'zod/v4'
39
+ import { challengeUrl } from '../client/contract'
39
40
  import type { RpcFetch } from '../client/transport'
40
41
  import { compileContract } from '../contract/compile'
41
42
  import type { AnyContract, DimensionOf } from '../contract/define'
@@ -45,7 +46,7 @@ import type { RequestExecutionContext } from '../fetch'
45
46
  import type { FILE_SWEEP_JOB } from '../files/kinds'
46
47
  import type { FILE_SCAN_JOB, FileScanner } from '../files/scan'
47
48
  import { boot, type BootOptions, type ContractApp } from '../implement/boot'
48
- import type { ChallengeFetch } from '../implement/challenge'
49
+ import { type ChallengeFetch, MINIMAL_CHALLENGE_WORK } from '../implement/challenge'
49
50
  import { type ConnectFetch, connectVariables } from '../implement/connect'
50
51
  import type { SpaAssets } from '../implement/dispatch'
51
52
  import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
@@ -64,6 +65,7 @@ import {
64
65
  type TestGrant,
65
66
  type TestScope,
66
67
  } from './caller'
68
+ import { solveChallengeAt } from './challenge'
67
69
  import {
68
70
  credentialCallers,
69
71
  type JobCredential,
@@ -192,6 +194,12 @@ export interface TestKit<C extends AnyContract> {
192
194
  * what a test sends where an input carries `signedToken('<purpose>')`.
193
195
  */
194
196
  signToken<const P extends TokenPurposeName<C>>(purpose: P, claims: TokenClaimsInput<C, P>, options: { readonly days: number }): string
197
+ /**
198
+ * A solved ALTCHA challenge of `procedure`, a procedure of a client over this kit whose challenge is
199
+ * `provider: 'altcha'`: what the page sends as `challengeToken`. Each one passes once. The kit issues the cheapest
200
+ * challenge whatever the contract's `cost`, so solving one takes milliseconds.
201
+ */
202
+ solveChallenge(procedure: object): Promise<string>
195
203
  }
196
204
 
197
205
  /** The names of the contract's cron jobs. */
@@ -416,6 +424,7 @@ export function bootTestKit<C extends AnyContract>(
416
424
  files: options.files,
417
425
  ...(options.scanner === undefined ? {} : { scanner: options.scanner }),
418
426
  ...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
427
+ challengeWork: MINIMAL_CHALLENGE_WORK,
419
428
  ...dev,
420
429
  ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
421
430
  membership,
@@ -702,6 +711,7 @@ export function bootTestKit<C extends AnyContract>(
702
711
  mail,
703
712
  mailKeyScope,
704
713
  signToken,
714
+ solveChallenge: (procedure: object) => solveChallengeAt(kitFetch, new URL(challengeUrl(procedure), origin).href),
705
715
  }),
706
716
  standIn,
707
717
  callerOf: (value) => [...memberships.keys()].find((caller) => caller === value),