@fabrika/app 0.7.0 → 0.8.1
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/package.json +10 -9
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +25 -21
- package/skill/references/dev.md +8 -6
- package/skill/references/endpoint.md +9 -7
- package/skill/references/public-intake.md +83 -39
- package/skill/references/spa.md +1 -0
- package/skill/references/tests.md +7 -4
- package/src/client/contract.ts +46 -14
- package/src/client/index.ts +1 -0
- package/src/contract/artifact-schema.ts +63 -4
- package/src/contract/build.ts +103 -11
- package/src/contract/connect.ts +13 -5
- package/src/contract/defaults.ts +82 -6
- package/src/contract/define.ts +23 -4
- package/src/implement/boot.ts +38 -3
- package/src/implement/browser-config.ts +2 -1
- package/src/implement/challenge.ts +203 -47
- package/src/implement/dispatch.ts +27 -6
- package/src/implement/public.ts +32 -7
- package/src/testing/challenge.ts +35 -1
- package/src/testing/endpoint-access.ts +5 -4
- package/src/testing/kit.ts +11 -1
|
@@ -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
|
|
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<
|
|
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<
|
|
65
|
-
|
|
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 {
|
|
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
|
|
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 =
|
|
863
|
+
const refused = verdict?.outcome !== 'passed'
|
|
860
864
|
|| (token !== repeatable
|
|
861
|
-
&& !(await this.#framework().admissions.recordChallenge(action, sha256Hex(
|
|
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.
|
package/src/implement/public.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
71
|
-
|
|
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({
|
package/src/testing/challenge.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
/**
|
package/src/testing/kit.ts
CHANGED
|
@@ -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
|
|
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),
|