@l.x/sessions 1.0.3 → 1.0.5
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/.depcheckrc +20 -0
- package/.eslintrc.js +21 -0
- package/LICENSE +122 -0
- package/README.md +1 -0
- package/env.d.ts +12 -0
- package/package.json +49 -1
- package/project.json +36 -0
- package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
- package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
- package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
- package/src/challenge-solvers/createHashcashSolver.ts +270 -0
- package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
- package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
- package/src/challenge-solvers/createTurnstileSolver.ts +357 -0
- package/src/challenge-solvers/hashcash/core.native.ts +34 -0
- package/src/challenge-solvers/hashcash/core.test.ts +314 -0
- package/src/challenge-solvers/hashcash/core.ts +35 -0
- package/src/challenge-solvers/hashcash/core.web.ts +123 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
- package/src/challenge-solvers/hashcash/shared.ts +70 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
- package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
- package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
- package/src/challenge-solvers/turnstileErrors.ts +49 -0
- package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
- package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
- package/src/challenge-solvers/types.ts +55 -0
- package/src/challengeFlow.integration.test.ts +627 -0
- package/src/device-id/createDeviceIdService.ts +19 -0
- package/src/device-id/types.ts +11 -0
- package/src/index.ts +139 -0
- package/src/lx-identifier/createLXIdentifierService.ts +1 -0
- package/src/lx-identifier/createUniswapIdentifierService.ts +19 -0
- package/src/lx-identifier/lxIdentifierQuery.ts +1 -0
- package/src/lx-identifier/types.ts +11 -0
- package/src/lx-identifier/uniswapIdentifierQuery.ts +20 -0
- package/src/oauth-service/createOAuthService.ts +125 -0
- package/src/oauth-service/types.ts +104 -0
- package/src/performance/createNoopPerformanceTracker.ts +12 -0
- package/src/performance/createPerformanceTracker.ts +43 -0
- package/src/performance/index.ts +7 -0
- package/src/performance/types.ts +11 -0
- package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
- package/src/session-initialization/createSessionInitializationService.ts +184 -0
- package/src/session-initialization/sessionErrors.ts +32 -0
- package/src/session-repository/createSessionClient.ts +10 -0
- package/src/session-repository/createSessionRepository.test.ts +313 -0
- package/src/session-repository/createSessionRepository.ts +242 -0
- package/src/session-repository/errors.ts +22 -0
- package/src/session-repository/types.ts +289 -0
- package/src/session-service/createNoopSessionService.ts +24 -0
- package/src/session-service/createSessionService.test.ts +388 -0
- package/src/session-service/createSessionService.ts +61 -0
- package/src/session-service/types.ts +59 -0
- package/src/session-storage/createSessionStorage.ts +28 -0
- package/src/session-storage/types.ts +15 -0
- package/src/session.integration.test.ts +516 -0
- package/src/sessionLifecycle.integration.test.ts +264 -0
- package/src/test-utils/createLocalCookieTransport.ts +52 -0
- package/src/test-utils/createLocalHeaderTransport.ts +45 -0
- package/src/test-utils/mocks.ts +122 -0
- package/src/test-utils.ts +200 -0
- package/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
- package/src/uniswap-identifier/types.ts +11 -0
- package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
- package/tsconfig.json +26 -0
- package/tsconfig.lint.json +8 -0
- package/tsconfig.spec.json +8 -0
- package/vitest.config.ts +20 -0
- package/vitest.integration.config.ts +14 -0
- package/index.d.ts +0 -1
- package/index.js +0 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base stub for hashcash core functions.
|
|
3
|
+
* Platform-specific implementations override this file.
|
|
4
|
+
*
|
|
5
|
+
* - Web: core.web.ts (Web Crypto + batching)
|
|
6
|
+
* - Native: core.native.ts (mobile uses Nitro modules)
|
|
7
|
+
*
|
|
8
|
+
* Shared types and platform-agnostic functions live in shared.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PlatformSplitStubError } from 'utilities/src/errors'
|
|
12
|
+
|
|
13
|
+
export type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
14
|
+
// Re-export everything from shared — types, checkDifficulty, formatHashcashString
|
|
15
|
+
export { checkDifficulty, formatHashcashString } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
16
|
+
|
|
17
|
+
import type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
18
|
+
|
|
19
|
+
export async function computeHash(_params: { subject: string; nonce: string; counter: number }): Promise<Uint8Array> {
|
|
20
|
+
throw new PlatformSplitStubError('computeHash')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function findProof(_params: {
|
|
24
|
+
challenge: HashcashChallenge
|
|
25
|
+
rangeStart?: number
|
|
26
|
+
rangeSize?: number
|
|
27
|
+
shouldStop?: () => boolean
|
|
28
|
+
batchSize?: number
|
|
29
|
+
}): Promise<ProofResult | null> {
|
|
30
|
+
throw new PlatformSplitStubError('findProof')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function verifyProof(_challenge: HashcashChallenge, _proofCounter: string): Promise<boolean> {
|
|
34
|
+
throw new PlatformSplitStubError('verifyProof')
|
|
35
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-optimized hashcash core implementation.
|
|
3
|
+
*
|
|
4
|
+
* Uses Web Crypto API via @noble/hashes/webcrypto.js for hardware-accelerated
|
|
5
|
+
* SHA-256 hashing. Includes batching to amortize async overhead.
|
|
6
|
+
*
|
|
7
|
+
* This is the web platform implementation - mobile uses native Nitro modules.
|
|
8
|
+
*/
|
|
9
|
+
import { sha256 } from '@noble/hashes/webcrypto.js'
|
|
10
|
+
|
|
11
|
+
export type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
12
|
+
// Re-export shared types and platform-agnostic functions
|
|
13
|
+
export { checkDifficulty, formatHashcashString } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
14
|
+
|
|
15
|
+
import type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
16
|
+
import { checkDifficulty } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
|
|
17
|
+
|
|
18
|
+
// Pre-allocated TextEncoder for memory efficiency (avoids creating new instance per hash)
|
|
19
|
+
const encoder = new TextEncoder()
|
|
20
|
+
|
|
21
|
+
// Default batch size for async hashing
|
|
22
|
+
// Tuned to balance async overhead vs responsiveness to cancellation
|
|
23
|
+
const DEFAULT_BATCH_SIZE = 256
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute SHA-256 hash using Web Crypto API.
|
|
27
|
+
* Async for hardware acceleration.
|
|
28
|
+
*/
|
|
29
|
+
export async function computeHash(params: { subject: string; nonce: string; counter: number }): Promise<Uint8Array> {
|
|
30
|
+
const { subject, nonce, counter } = params
|
|
31
|
+
|
|
32
|
+
// Backend expects: "${subject}:${nonce}:${counter}"
|
|
33
|
+
const solutionString = `${subject}:${nonce}:${counter}`
|
|
34
|
+
|
|
35
|
+
// Hash using Web Crypto (hardware-accelerated)
|
|
36
|
+
const inputBytes = encoder.encode(solutionString)
|
|
37
|
+
return sha256(inputBytes)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find a proof-of-work solution using batched Web Crypto hashing.
|
|
42
|
+
*
|
|
43
|
+
* Processes hashes in batches to amortize async overhead while remaining
|
|
44
|
+
* responsive to cancellation requests.
|
|
45
|
+
*/
|
|
46
|
+
export async function findProof(params: {
|
|
47
|
+
challenge: HashcashChallenge
|
|
48
|
+
rangeStart?: number
|
|
49
|
+
rangeSize?: number
|
|
50
|
+
shouldStop?: () => boolean
|
|
51
|
+
batchSize?: number
|
|
52
|
+
}): Promise<ProofResult | null> {
|
|
53
|
+
const {
|
|
54
|
+
challenge,
|
|
55
|
+
rangeStart = 0,
|
|
56
|
+
rangeSize = challenge.max_proof_length || 1_000_000,
|
|
57
|
+
shouldStop,
|
|
58
|
+
batchSize = DEFAULT_BATCH_SIZE,
|
|
59
|
+
} = params
|
|
60
|
+
|
|
61
|
+
const startTime = Date.now()
|
|
62
|
+
const rangeEnd = rangeStart + rangeSize
|
|
63
|
+
const { subject, nonce, difficulty } = challenge
|
|
64
|
+
|
|
65
|
+
for (let batchStart = rangeStart; batchStart < rangeEnd; batchStart += batchSize) {
|
|
66
|
+
// Check for cancellation at batch boundaries
|
|
67
|
+
if (shouldStop?.()) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Determine actual batch end (don't exceed range)
|
|
72
|
+
const batchEnd = Math.min(batchStart + batchSize, rangeEnd)
|
|
73
|
+
const currentBatchSize = batchEnd - batchStart
|
|
74
|
+
|
|
75
|
+
// Generate counter values for this batch
|
|
76
|
+
const counters: number[] = new Array(currentBatchSize)
|
|
77
|
+
for (let i = 0; i < currentBatchSize; i++) {
|
|
78
|
+
counters[i] = batchStart + i
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Hash all counters in this batch in parallel
|
|
82
|
+
const hashes = await Promise.all(counters.map((counter) => computeHash({ subject, nonce, counter })))
|
|
83
|
+
|
|
84
|
+
// Check each hash for valid proof
|
|
85
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
86
|
+
const hash = hashes[i]
|
|
87
|
+
const counter = counters[i]
|
|
88
|
+
// Safety check (shouldn't happen since arrays are same size)
|
|
89
|
+
if (!hash || counter === undefined) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (checkDifficulty(hash, difficulty)) {
|
|
93
|
+
return {
|
|
94
|
+
counter: counter.toString(),
|
|
95
|
+
hash,
|
|
96
|
+
attempts: counter - rangeStart + 1,
|
|
97
|
+
timeMs: Date.now() - startTime,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify a proof solution.
|
|
108
|
+
* Async because it uses Web Crypto.
|
|
109
|
+
*/
|
|
110
|
+
export async function verifyProof(challenge: HashcashChallenge, proofCounter: string): Promise<boolean> {
|
|
111
|
+
const counter = parseInt(proofCounter)
|
|
112
|
+
|
|
113
|
+
if (isNaN(counter)) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hash = await computeHash({
|
|
118
|
+
subject: challenge.subject,
|
|
119
|
+
nonce: challenge.nonce,
|
|
120
|
+
counter,
|
|
121
|
+
})
|
|
122
|
+
return checkDifficulty(hash, challenge.difficulty)
|
|
123
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { ChallengeType } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_pb'
|
|
2
|
+
import { createWorkerHashcashSolver } from '@l.x/sessions/src/challenge-solvers/hashcash/createWorkerHashcashSolver'
|
|
3
|
+
import type { HashcashWorkerChannelFactory } from '@l.x/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
4
|
+
import type { ChallengeData } from '@l.x/sessions/src/challenge-solvers/types'
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
describe('createWorkerHashcashSolver', () => {
|
|
8
|
+
// Real backend example data
|
|
9
|
+
const backendExample = {
|
|
10
|
+
difficulty: 1,
|
|
11
|
+
subject: 'Lx',
|
|
12
|
+
algorithm: 'sha256' as const,
|
|
13
|
+
nonce: 'Qlquffem7d8RrL6fmveE68XK0KxcoczdiVpFrV1qeUk=',
|
|
14
|
+
max_proof_length: 10000,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Mock channel factory for testing
|
|
18
|
+
function createMockChannelFactory(
|
|
19
|
+
findProofResult: { counter: string; hash: Uint8Array; attempts: number; timeMs: number } | null = {
|
|
20
|
+
counter: '123',
|
|
21
|
+
hash: new Uint8Array([0, 0, 1, 2, 3]),
|
|
22
|
+
attempts: 124,
|
|
23
|
+
timeMs: 50,
|
|
24
|
+
},
|
|
25
|
+
): {
|
|
26
|
+
factory: HashcashWorkerChannelFactory
|
|
27
|
+
mocks: {
|
|
28
|
+
findProof: ReturnType<typeof vi.fn>
|
|
29
|
+
cancel: ReturnType<typeof vi.fn>
|
|
30
|
+
terminate: ReturnType<typeof vi.fn>
|
|
31
|
+
}
|
|
32
|
+
} {
|
|
33
|
+
const findProof = vi.fn().mockResolvedValue(findProofResult)
|
|
34
|
+
const cancel = vi.fn().mockResolvedValue(undefined)
|
|
35
|
+
const terminate = vi.fn()
|
|
36
|
+
|
|
37
|
+
const factory: HashcashWorkerChannelFactory = () => ({
|
|
38
|
+
api: { findProof, cancel },
|
|
39
|
+
terminate,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return { factory, mocks: { findProof, cancel, terminate } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('successfully solves a valid challenge using worker', async () => {
|
|
50
|
+
const { factory, mocks } = createMockChannelFactory()
|
|
51
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
52
|
+
|
|
53
|
+
const challengeData: ChallengeData = {
|
|
54
|
+
challengeId: 'test-challenge-123',
|
|
55
|
+
challengeType: ChallengeType.HASHCASH,
|
|
56
|
+
extra: {
|
|
57
|
+
challengeData: JSON.stringify(backendExample),
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const solution = await solver.solve(challengeData)
|
|
62
|
+
|
|
63
|
+
// Check format: "${subject}:${nonce}:${counter}"
|
|
64
|
+
expect(solution).toBe(`Lx:${backendExample.nonce}:123`)
|
|
65
|
+
|
|
66
|
+
// Verify worker was called correctly
|
|
67
|
+
expect(mocks.findProof).toHaveBeenCalledOnce()
|
|
68
|
+
expect(mocks.findProof).toHaveBeenCalledWith({
|
|
69
|
+
challenge: expect.objectContaining({
|
|
70
|
+
subject: 'Lx',
|
|
71
|
+
difficulty: 1,
|
|
72
|
+
nonce: backendExample.nonce,
|
|
73
|
+
}),
|
|
74
|
+
rangeStart: 0,
|
|
75
|
+
rangeSize: 10000,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Verify channel was terminated
|
|
79
|
+
expect(mocks.terminate).toHaveBeenCalledOnce()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('throws error when challengeData is missing', async () => {
|
|
83
|
+
const { factory } = createMockChannelFactory()
|
|
84
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
85
|
+
|
|
86
|
+
const challengeData: ChallengeData = {
|
|
87
|
+
challengeId: 'test-challenge-123',
|
|
88
|
+
challengeType: ChallengeType.HASHCASH,
|
|
89
|
+
extra: {}, // Missing challengeData
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Missing challengeData in challenge extra field')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('throws error when no proof is found', async () => {
|
|
96
|
+
const { factory } = createMockChannelFactory(null)
|
|
97
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
98
|
+
|
|
99
|
+
const challengeData: ChallengeData = {
|
|
100
|
+
challengeId: 'test-challenge-123',
|
|
101
|
+
challengeType: ChallengeType.HASHCASH,
|
|
102
|
+
extra: {
|
|
103
|
+
challengeData: JSON.stringify(backendExample),
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Failed to find valid proof within allowed range')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('terminates channel even when error occurs', async () => {
|
|
111
|
+
const findProof = vi.fn().mockRejectedValue(new Error('Worker error'))
|
|
112
|
+
const terminate = vi.fn()
|
|
113
|
+
|
|
114
|
+
const factory: HashcashWorkerChannelFactory = () => ({
|
|
115
|
+
api: { findProof, cancel: vi.fn() },
|
|
116
|
+
terminate,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const solver = createWorkerHashcashSolver({ createChannel: factory })
|
|
120
|
+
|
|
121
|
+
const challengeData: ChallengeData = {
|
|
122
|
+
challengeId: 'test-challenge-123',
|
|
123
|
+
challengeType: ChallengeType.HASHCASH,
|
|
124
|
+
extra: {
|
|
125
|
+
challengeData: JSON.stringify(backendExample),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Worker error')
|
|
130
|
+
|
|
131
|
+
// Channel should still be terminated
|
|
132
|
+
expect(terminate).toHaveBeenCalledOnce()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('cancellation', () => {
|
|
136
|
+
it('cancels solving when AbortSignal is triggered', async () => {
|
|
137
|
+
const controller = new AbortController()
|
|
138
|
+
const { factory, mocks } = createMockChannelFactory()
|
|
139
|
+
|
|
140
|
+
// Make findProof take a while and check abort during it
|
|
141
|
+
mocks.findProof.mockImplementation(async () => {
|
|
142
|
+
// Simulate work, then check if cancelled
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
144
|
+
return null // Simulates cancelled result
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const solver = createWorkerHashcashSolver({
|
|
148
|
+
createChannel: factory,
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const challengeData: ChallengeData = {
|
|
153
|
+
challengeId: 'test-challenge-123',
|
|
154
|
+
challengeType: ChallengeType.HASHCASH,
|
|
155
|
+
extra: {
|
|
156
|
+
challengeData: JSON.stringify(backendExample),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Start solving and cancel after a short delay
|
|
161
|
+
const solvePromise = solver.solve(challengeData)
|
|
162
|
+
controller.abort()
|
|
163
|
+
|
|
164
|
+
await expect(solvePromise).rejects.toThrow('Challenge solving was cancelled')
|
|
165
|
+
|
|
166
|
+
// Verify cancel was called on the worker
|
|
167
|
+
expect(mocks.cancel).toHaveBeenCalled()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('throws immediately if already aborted', async () => {
|
|
171
|
+
const controller = new AbortController()
|
|
172
|
+
controller.abort() // Abort before starting
|
|
173
|
+
|
|
174
|
+
const { factory, mocks } = createMockChannelFactory()
|
|
175
|
+
|
|
176
|
+
const solver = createWorkerHashcashSolver({
|
|
177
|
+
createChannel: factory,
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const challengeData: ChallengeData = {
|
|
182
|
+
challengeId: 'test-challenge-123',
|
|
183
|
+
challengeType: ChallengeType.HASHCASH,
|
|
184
|
+
extra: {
|
|
185
|
+
challengeData: JSON.stringify(backendExample),
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Challenge solving was cancelled')
|
|
190
|
+
|
|
191
|
+
// Worker should not be called if already aborted
|
|
192
|
+
expect(mocks.findProof).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-backed hashcash solver.
|
|
3
|
+
*
|
|
4
|
+
* Offloads proof-of-work computation to a Web Worker to avoid
|
|
5
|
+
* blocking the main thread.
|
|
6
|
+
*/
|
|
7
|
+
import { parseHashcashChallenge } from '@l.x/sessions/src/challenge-solvers/createHashcashSolver'
|
|
8
|
+
import type { HashcashChallenge } from '@l.x/sessions/src/challenge-solvers/hashcash/core'
|
|
9
|
+
import type { HashcashWorkerChannelFactory } from '@l.x/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
10
|
+
import type { ChallengeData, ChallengeSolver } from '@l.x/sessions/src/challenge-solvers/types'
|
|
11
|
+
|
|
12
|
+
interface CreateWorkerHashcashSolverContext {
|
|
13
|
+
/**
|
|
14
|
+
* Factory function to create a worker channel.
|
|
15
|
+
* Platform-specific (web, extension, native) implementations
|
|
16
|
+
* are injected here.
|
|
17
|
+
*/
|
|
18
|
+
createChannel: HashcashWorkerChannelFactory
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Optional AbortSignal for external cancellation.
|
|
22
|
+
* When aborted, cancels the in-progress proof search.
|
|
23
|
+
*/
|
|
24
|
+
signal?: AbortSignal
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a hashcash solver that runs proof-of-work in a worker.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createHashcashWorkerChannel } from './worker/createHashcashWorkerChannel.web'
|
|
33
|
+
*
|
|
34
|
+
* const getWorker = () => new Worker(new URL('./hashcash.worker.ts', import.meta.url), { type: 'module' })
|
|
35
|
+
*
|
|
36
|
+
* const solver = createWorkerHashcashSolver({
|
|
37
|
+
* createChannel: () => createHashcashWorkerChannel({ getWorker })
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* const solution = await solver.solve(challengeData)
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
function createWorkerHashcashSolver(ctx: CreateWorkerHashcashSolverContext): ChallengeSolver {
|
|
44
|
+
async function solve(challengeData: ChallengeData): Promise<string> {
|
|
45
|
+
let challenge: HashcashChallenge
|
|
46
|
+
|
|
47
|
+
// Prefer typed challengeData over legacy extra field
|
|
48
|
+
if (challengeData.challengeData?.case === 'hashcash') {
|
|
49
|
+
const typed = challengeData.challengeData.value
|
|
50
|
+
challenge = {
|
|
51
|
+
difficulty: typed.difficulty,
|
|
52
|
+
subject: typed.subject,
|
|
53
|
+
algorithm: typed.algorithm as 'sha256',
|
|
54
|
+
nonce: typed.nonce,
|
|
55
|
+
max_proof_length: typed.maxProofLength,
|
|
56
|
+
verifier: typed.verifier,
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
const challengeDataStr = challengeData.extra?.['challengeData']
|
|
60
|
+
if (!challengeDataStr) {
|
|
61
|
+
throw new Error('Missing challengeData in challenge extra field')
|
|
62
|
+
}
|
|
63
|
+
challenge = parseHashcashChallenge(challengeDataStr)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create worker channel
|
|
67
|
+
const channel = ctx.createChannel()
|
|
68
|
+
|
|
69
|
+
// Handle external cancellation
|
|
70
|
+
const abortHandler = (): void => {
|
|
71
|
+
channel.api.cancel().catch(() => {
|
|
72
|
+
// Ignore cancellation errors
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ctx.signal) {
|
|
77
|
+
ctx.signal.addEventListener('abort', abortHandler)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// Check if already aborted
|
|
82
|
+
if (ctx.signal?.aborted) {
|
|
83
|
+
throw new Error('Challenge solving was cancelled')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Find proof-of-work solution in worker
|
|
87
|
+
const proof = await channel.api.findProof({
|
|
88
|
+
challenge,
|
|
89
|
+
rangeStart: 0,
|
|
90
|
+
rangeSize: challenge.max_proof_length,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!proof) {
|
|
94
|
+
// Could be cancelled or no solution found
|
|
95
|
+
if (ctx.signal?.aborted) {
|
|
96
|
+
throw new Error('Challenge solving was cancelled')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Failed to find valid proof within allowed range (0-${challenge.max_proof_length}). ` +
|
|
101
|
+
'Challenge may have expired or difficulty may be too high.',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Return the solution in the format expected by backend: "${subject}:${nonce}:${counter}"
|
|
106
|
+
return `${challenge.subject}:${challenge.nonce}:${proof.counter}`
|
|
107
|
+
} finally {
|
|
108
|
+
// Clean up
|
|
109
|
+
if (ctx.signal) {
|
|
110
|
+
ctx.signal.removeEventListener('abort', abortHandler)
|
|
111
|
+
}
|
|
112
|
+
channel.terminate()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { solve }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { createWorkerHashcashSolver }
|
|
120
|
+
export type { CreateWorkerHashcashSolverContext }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types and platform-agnostic utilities for hashcash.
|
|
3
|
+
*
|
|
4
|
+
* This file contains code that has zero platform dependencies and is
|
|
5
|
+
* imported by all platform variants (core.ts, core.web.ts, core.native.ts).
|
|
6
|
+
*
|
|
7
|
+
* NOTE: This file intentionally does NOT follow the platform-split naming
|
|
8
|
+
* convention (no .web.ts/.native.ts variants) so it can be safely imported
|
|
9
|
+
* from platform-specific files without circular resolution.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { base64 } from '@scure/base'
|
|
13
|
+
|
|
14
|
+
export interface HashcashChallenge {
|
|
15
|
+
difficulty: number
|
|
16
|
+
subject: string
|
|
17
|
+
algorithm: 'sha256'
|
|
18
|
+
nonce: string
|
|
19
|
+
max_proof_length: number
|
|
20
|
+
verifier?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProofResult {
|
|
24
|
+
counter: string
|
|
25
|
+
hash: Uint8Array
|
|
26
|
+
attempts: number
|
|
27
|
+
timeMs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a hash meets the required difficulty.
|
|
32
|
+
* Difficulty is the number of leading zero bytes required.
|
|
33
|
+
*/
|
|
34
|
+
export function checkDifficulty(hash: Uint8Array, difficulty: number): boolean {
|
|
35
|
+
// Backend uses difficulty as number of zero BYTES, not bits
|
|
36
|
+
// difficulty=1 means first byte must be 0
|
|
37
|
+
// difficulty=2 means first two bytes must be 0, etc.
|
|
38
|
+
|
|
39
|
+
// Check if hash has enough bytes
|
|
40
|
+
if (hash.length < difficulty) {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check that the first 'difficulty' bytes are all zero
|
|
45
|
+
for (let i = 0; i < difficulty; i++) {
|
|
46
|
+
if (hash[i] !== 0) {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Format a proof into hashcash string format for submission.
|
|
56
|
+
* Format: version:bits:date:resource:extension:counter:hash
|
|
57
|
+
*/
|
|
58
|
+
export function formatHashcashString(challenge: HashcashChallenge, proof: ProofResult): string {
|
|
59
|
+
const version = '1'
|
|
60
|
+
const bits = challenge.difficulty.toString()
|
|
61
|
+
const date = new Date().toISOString().slice(2, 10).replace(/-/g, '')
|
|
62
|
+
const resource = challenge.subject.slice(0, 16)
|
|
63
|
+
const extension = ''
|
|
64
|
+
const counter = proof.counter
|
|
65
|
+
|
|
66
|
+
// Encode the proof hash as base64 (truncated)
|
|
67
|
+
const hashB64 = base64.encode(proof.hash).slice(0, 27)
|
|
68
|
+
|
|
69
|
+
return `${version}:${bits}:${date}:${resource}:${extension}:${counter}:${hashB64}`
|
|
70
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native stub for multi-worker hashcash channel factory.
|
|
3
|
+
* Web Workers are not available in React Native.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { HashcashWorkerChannel } from '@l.x/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
7
|
+
import { NotImplementedError } from 'utilities/src/errors'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuration for multi-worker hashcash channel.
|
|
11
|
+
*/
|
|
12
|
+
interface MultiWorkerConfig {
|
|
13
|
+
workerCount?: number
|
|
14
|
+
getWorker: () => Worker
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createHashcashMultiWorkerChannel(_config: MultiWorkerConfig): HashcashWorkerChannel {
|
|
18
|
+
throw new NotImplementedError('createHashcashMultiWorkerChannel')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { createHashcashMultiWorkerChannel }
|
|
22
|
+
export type { MultiWorkerConfig }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base stub for multi-worker hashcash channel factory.
|
|
3
|
+
* Platform-specific implementations override this file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { HashcashWorkerChannel } from '@l.x/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
7
|
+
import { PlatformSplitStubError } from 'utilities/src/errors'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuration for multi-worker hashcash channel.
|
|
11
|
+
*/
|
|
12
|
+
interface MultiWorkerConfig {
|
|
13
|
+
workerCount?: number
|
|
14
|
+
getWorker: () => Worker
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createHashcashMultiWorkerChannel(_config: MultiWorkerConfig): HashcashWorkerChannel {
|
|
18
|
+
throw new PlatformSplitStubError('createHashcashMultiWorkerChannel')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { createHashcashMultiWorkerChannel }
|
|
22
|
+
export type { MultiWorkerConfig }
|