@fabrika/app 0.6.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.
- package/package.json +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +167 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
MAX_WITHIN_VALUES,
|
|
16
16
|
type Ref as RefCapability,
|
|
17
17
|
type RefGrant,
|
|
18
|
+
type RowGrant,
|
|
19
|
+
type RowRef as RowRefCapability,
|
|
18
20
|
type Within as WithinCapability,
|
|
19
21
|
type WithinGrant,
|
|
20
22
|
} from '@fabrika/db/capability'
|
|
@@ -28,8 +30,13 @@ export const CAPABILITY_REFUSALS = {
|
|
|
28
30
|
wrong_target: "an IAM request was not passed this request's ctx.target",
|
|
29
31
|
undeclared_action: "the job's declaration or its token does not carry the action",
|
|
30
32
|
undeclared_dimension: 'the job named a dimension the contract does not declare',
|
|
33
|
+
undeclared_kick: 'the site kicked a job it does not declare in kicks',
|
|
34
|
+
undeclared_binding: 'the site named a webhook it does not declare in binds',
|
|
35
|
+
undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
|
|
36
|
+
undeclared_egress: 'the site called an origin it does not declare in egress',
|
|
37
|
+
wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
|
|
31
38
|
missing_id: 'the job named an object without an id',
|
|
32
|
-
wrong_capability: '
|
|
39
|
+
wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
|
|
33
40
|
} as const
|
|
34
41
|
|
|
35
42
|
export type CapabilityRefusal = keyof typeof CAPABILITY_REFUSALS
|
|
@@ -173,6 +180,7 @@ interface Bound {
|
|
|
173
180
|
let readRef: (value: unknown) => Bound | undefined = () => undefined
|
|
174
181
|
let readWithin: (value: unknown) => Bound | undefined = () => undefined
|
|
175
182
|
let readApp: (value: unknown) => Bound | undefined = () => undefined
|
|
183
|
+
let readRow: (value: unknown) => Bound | undefined = () => undefined
|
|
176
184
|
|
|
177
185
|
/** One authorized object for one action, in one request. `chain` holds the containers the object lies in. */
|
|
178
186
|
export class Ref<D extends string, A extends string> implements RefCapability<D, A> {
|
|
@@ -344,10 +352,70 @@ export class AppCapability<A extends string> implements AppCapabilityType<A> {
|
|
|
344
352
|
}
|
|
345
353
|
}
|
|
346
354
|
|
|
355
|
+
interface RowFacts {
|
|
356
|
+
readonly table: string
|
|
357
|
+
readonly action: string
|
|
358
|
+
readonly principal: string
|
|
359
|
+
readonly id: string
|
|
360
|
+
readonly chain: Chain
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* One row a claim leased, for one action, in one job attempt: `row.ref`, which `tx.db.on(row.ref)` finishes the row
|
|
365
|
+
* through. `chain` holds the row's containment columns as it was claimed.
|
|
366
|
+
*/
|
|
367
|
+
export class RowRef<T extends string, A extends string> implements RowRefCapability<T, A> {
|
|
368
|
+
declare readonly '~capability': { readonly kind: 'row'; readonly table: T; readonly action: A }
|
|
369
|
+
readonly #facts: RowFacts
|
|
370
|
+
readonly #frame: RequestFrame
|
|
371
|
+
readonly #issuer: object
|
|
372
|
+
|
|
373
|
+
constructor(token: typeof MINT, issuer: object, frame: RequestFrame, grant: Omit<RowGrant, 'kind'>) {
|
|
374
|
+
if (token !== MINT) {
|
|
375
|
+
throw new CapabilityRefusedError('not_minted', 'a row capability is minted by the framework only: claim the row')
|
|
376
|
+
}
|
|
377
|
+
this.#facts = freeze({ table: grant.table, action: grant.action, principal: grant.principal, id: grant.id, chain: chainOf(grant.chain) })
|
|
378
|
+
this.#frame = frame
|
|
379
|
+
this.#issuer = issuer
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
get table(): string {
|
|
383
|
+
return this.#facts.table
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
get action(): string {
|
|
387
|
+
return this.#facts.action
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The row's key. */
|
|
391
|
+
get value(): string {
|
|
392
|
+
return this.#facts.id
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
static {
|
|
396
|
+
readRow = (value) => {
|
|
397
|
+
if (typeof value !== 'object' || value === null || !(#facts in value)) {
|
|
398
|
+
return undefined
|
|
399
|
+
}
|
|
400
|
+
const facts = value.#facts
|
|
401
|
+
const grant: RowGrant = freeze({
|
|
402
|
+
kind: 'row',
|
|
403
|
+
table: facts.table,
|
|
404
|
+
action: facts.action,
|
|
405
|
+
principal: facts.principal,
|
|
406
|
+
id: facts.id,
|
|
407
|
+
chain: mapOf(facts.chain),
|
|
408
|
+
})
|
|
409
|
+
return { grant, frame: value.#frame, issuer: value.#issuer }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
347
414
|
// Application code could otherwise redefine a getter that the framework or a handler reads.
|
|
348
415
|
freeze(Ref.prototype)
|
|
349
416
|
freeze(Within.prototype)
|
|
350
417
|
freeze(AppCapability.prototype)
|
|
418
|
+
freeze(RowRef.prototype)
|
|
351
419
|
|
|
352
420
|
/** Makes the capabilities one authority accepts; a runtime's is held by the `boot` call that made it alone. */
|
|
353
421
|
export interface CapabilityMint {
|
|
@@ -410,7 +478,7 @@ export function createCapabilityIssuer(): CapabilityIssuer {
|
|
|
410
478
|
})
|
|
411
479
|
const authority: CapabilityAuthority = freeze({
|
|
412
480
|
resolve(capability: unknown): CapabilityGrant {
|
|
413
|
-
const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability)
|
|
481
|
+
const bound = readRef(capability) ?? readWithin(capability) ?? readApp(capability) ?? readRow(capability)
|
|
414
482
|
if (bound === undefined || bound.issuer !== issuer) {
|
|
415
483
|
throw new CapabilityRefusedError('not_minted', 'not a capability the framework minted: pass the ctx.target or ctx.within of this request')
|
|
416
484
|
}
|
|
@@ -431,6 +499,14 @@ export function createCapabilityIssuer(): CapabilityIssuer {
|
|
|
431
499
|
return grant
|
|
432
500
|
},
|
|
433
501
|
|
|
502
|
+
row(grant: Omit<RowGrant, 'kind'>): RowRef<string, string> {
|
|
503
|
+
const frame = currentRequest.getStore()
|
|
504
|
+
if (frame === undefined || frame.ended) {
|
|
505
|
+
throw new CapabilityRefusedError('ended', 'a claimed row is minted only inside the job attempt that claims it')
|
|
506
|
+
}
|
|
507
|
+
return new RowRef(MINT, issuer, frame, grant)
|
|
508
|
+
},
|
|
509
|
+
|
|
434
510
|
fence(): LeaseFence | null {
|
|
435
511
|
return currentRequest.getStore()?.fence ?? null
|
|
436
512
|
},
|
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The client a per-client bucket counts. The buckets live in the application's database (`@fabrika/db`'s
|
|
2
|
+
// `Admissions`), so every instance shares them. Admission is not authorization: it only bounds how much one client
|
|
3
|
+
// calls a public procedure.
|
|
4
4
|
|
|
5
5
|
import { CLIENT_ADDRESS_HEADER } from '@fabrika/auth-core'
|
|
6
6
|
import { createHash } from 'node:crypto'
|
|
7
|
-
import { FORM_CHALLENGE_FIELD } from '../contract/defaults'
|
|
8
|
-
|
|
9
|
-
/** The bucket of a form's accepted submissions; a client's bucket is `client:<digest>`, so the two never meet. */
|
|
10
|
-
export const ACCEPTED_BUCKET = 'accepted'
|
|
11
7
|
|
|
12
8
|
/** Longest header value read; an IPv6 address with a zone id fits with room to spare. */
|
|
13
9
|
const MAX_ADDRESS_LENGTH = 64
|
|
@@ -87,34 +83,3 @@ export function clientKey(header: string | null): string {
|
|
|
87
83
|
export function clientBucket(request: Request): string {
|
|
88
84
|
return `client:${createHash('sha256').update(clientKey(request.headers.get(CLIENT_ADDRESS_HEADER))).digest('hex')}`
|
|
89
85
|
}
|
|
90
|
-
|
|
91
|
-
/** What the form declared beside its row: its honeypot field and whether a challenge token travels in the input. */
|
|
92
|
-
export interface SubmissionFields {
|
|
93
|
-
readonly honeypot: string | null
|
|
94
|
-
readonly challenge: boolean
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface Submission {
|
|
98
|
-
/** The honeypot was filled: the answer is the constant one, and nothing is written. */
|
|
99
|
-
readonly trapped: boolean
|
|
100
|
-
/** The challenge token, when the form declares a challenge. */
|
|
101
|
-
readonly token: string | null
|
|
102
|
-
/** The columns the inbox row gets: the input without the honeypot and the token. */
|
|
103
|
-
readonly row: Readonly<Record<string, unknown>>
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function submissionOf(input: Readonly<Record<string, unknown>>, fields: SubmissionFields): Submission {
|
|
107
|
-
const row: Record<string, unknown> = {}
|
|
108
|
-
for (const [name, value] of Object.entries(input)) {
|
|
109
|
-
if (name !== fields.honeypot && !(fields.challenge && name === FORM_CHALLENGE_FIELD)) {
|
|
110
|
-
row[name] = value
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
const trap = fields.honeypot === null ? undefined : input[fields.honeypot]
|
|
114
|
-
const token = fields.challenge ? input[FORM_CHALLENGE_FIELD] : undefined
|
|
115
|
-
return {
|
|
116
|
-
trapped: typeof trap === 'string' && trap !== '',
|
|
117
|
-
token: typeof token === 'string' ? token : null,
|
|
118
|
-
row,
|
|
119
|
-
}
|
|
120
|
-
}
|