altcha-lib 2.2.0 → 2.3.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/README.md +34 -0
- package/dist/cjs/v2/frameworks/express.js +5 -2
- package/dist/cjs/v2/frameworks/fastify.js +5 -2
- package/dist/cjs/v2/frameworks/h3.d.ts +1 -1
- package/dist/cjs/v2/frameworks/h3.js +8 -2
- package/dist/cjs/v2/frameworks/hono.d.ts +18 -0
- package/dist/cjs/v2/frameworks/hono.js +7 -2
- package/dist/cjs/v2/frameworks/nestjs.d.ts +8 -5
- package/dist/cjs/v2/frameworks/nestjs.js +11 -6
- package/dist/cjs/v2/frameworks/nextjs.js +5 -2
- package/dist/cjs/v2/frameworks/shared.d.ts +4 -4
- package/dist/cjs/v2/frameworks/shared.js +22 -3
- package/dist/cjs/v2/frameworks/sveltekit.js +6 -3
- package/dist/cjs/v2/frameworks/types.d.ts +17 -5
- package/dist/cjs/v2/index.d.ts +3 -1
- package/dist/cjs/v2/index.js +4 -1
- package/dist/cjs/v2/types.d.ts +54 -0
- package/dist/cjs/v2/verify-server.d.ts +3 -0
- package/dist/cjs/v2/verify-server.js +57 -0
- package/dist/esm/v2/frameworks/express.js +5 -2
- package/dist/esm/v2/frameworks/fastify.js +5 -2
- package/dist/esm/v2/frameworks/h3.d.ts +1 -1
- package/dist/esm/v2/frameworks/h3.js +8 -2
- package/dist/esm/v2/frameworks/hono.d.ts +18 -0
- package/dist/esm/v2/frameworks/hono.js +7 -2
- package/dist/esm/v2/frameworks/nestjs.d.ts +8 -5
- package/dist/esm/v2/frameworks/nestjs.js +12 -6
- package/dist/esm/v2/frameworks/nextjs.js +5 -2
- package/dist/esm/v2/frameworks/shared.d.ts +4 -4
- package/dist/esm/v2/frameworks/shared.js +22 -3
- package/dist/esm/v2/frameworks/sveltekit.js +6 -3
- package/dist/esm/v2/frameworks/types.d.ts +17 -5
- package/dist/esm/v2/index.d.ts +3 -1
- package/dist/esm/v2/index.js +3 -1
- package/dist/esm/v2/types.d.ts +54 -0
- package/dist/esm/v2/verify-server.d.ts +3 -0
- package/dist/esm/v2/verify-server.js +54 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ The library includes plugins for several popular frameworks to simplify integrat
|
|
|
55
55
|
|
|
56
56
|
If your framework is not listed, see the [Advanced Usage](/docs/advanced-usage.md) guide for custom integrations.
|
|
57
57
|
|
|
58
|
+
All plugins also support remote verification via ALTCHA Sentinel (`verifyServer` option) — see [`/docs/server-signatures.md`](/docs/server-signatures.md#remote-verification-sentinel-api).
|
|
59
|
+
|
|
58
60
|
## Documentation
|
|
59
61
|
|
|
60
62
|
- Advanced Usage: [`/docs/advanced-usage.md`](/docs/advanced-usage.md)
|
|
@@ -96,6 +98,12 @@ bun run --port 3000 examples/hono-example.ts
|
|
|
96
98
|
deno serve --allow-net --sloppy-imports --port 3000 examples/hono-example.ts
|
|
97
99
|
```
|
|
98
100
|
|
|
101
|
+
**Hono + Sentinel remote verification example (Bun):**
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bun run --port 3000 examples/hono-sentinel-example.ts
|
|
105
|
+
```
|
|
106
|
+
|
|
99
107
|
## Breaking Changes (v2)
|
|
100
108
|
|
|
101
109
|
Version 2 introduces a new proof-of-work mechanism and challenge format. See the [PoW documentation](https://playground.altcha.org/#/about) for details.
|
|
@@ -217,6 +225,32 @@ Returns `VerifyServerSignatureResult` (extends `VerifySolutionResult`):
|
|
|
217
225
|
| `verified` | `boolean` | Whether the signature is valid. |
|
|
218
226
|
| `verificationData` | `ServerSignatureVerificationData \| null` | Parsed verification data. |
|
|
219
227
|
|
|
228
|
+
#### `verifyServer(options: VerifyServerOptions): Promise<VerifyServerResult>`
|
|
229
|
+
|
|
230
|
+
Verifies a payload remotely by calling ALTCHA Sentinel's `POST /v1/verify/signature` API, instead of verifying the HMAC signature locally.
|
|
231
|
+
|
|
232
|
+
| Option | Type | Description |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| `payload` | `string \| ServerSignaturePayload \| Record<string, unknown>` | The payload to verify, as received from `POST /v1/verify`. |
|
|
235
|
+
| `url` | `string` | Full URL of the Sentinel `/v1/verify/signature` endpoint. |
|
|
236
|
+
| `secret` | `string?` | API key secret. If provided, Sentinel checks that it matches the API key associated with the payload. |
|
|
237
|
+
| `fetch` | `typeof fetch?` | Custom fetch implementation. Defaults to the global `fetch`. |
|
|
238
|
+
| `headers` | `Record<string, string>?` | Additional headers to send with the request. |
|
|
239
|
+
| `controller` | `AbortController?` | For cancelling the verification request. |
|
|
240
|
+
| `timeout` | `number?` | Per-attempt request timeout in milliseconds. Defaults to `10000`. |
|
|
241
|
+
| `retries` | `number?` | Number of retry attempts after the first try. Defaults to `0`. |
|
|
242
|
+
| `retryDelay` | `number?` | Base delay in milliseconds between retries. Defaults to `300`. |
|
|
243
|
+
| `retryBackoff` | `'fixed' \| 'exponential'?` | Backoff strategy for `retryDelay`. Defaults to `'exponential'`. |
|
|
244
|
+
|
|
245
|
+
Returns `VerifyServerResult`:
|
|
246
|
+
|
|
247
|
+
| Field | Type | Description |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| `verified` | `boolean` | Whether the payload was successfully verified. |
|
|
250
|
+
| `apiKey` | `string \| null?` | API key associated with the verification. |
|
|
251
|
+
| `reason` | `string?` | Reason or error message if verification failed. |
|
|
252
|
+
| `verificationData` | `ServerSignatureVerificationData \| null?` | Verification data returned by Sentinel. |
|
|
253
|
+
|
|
220
254
|
#### `obfuscate(str: string, options?): Promise<string>`
|
|
221
255
|
|
|
222
256
|
> Import from `altcha-lib/obfuscation`
|
|
@@ -13,8 +13,11 @@ const asyncHandler = (fn) => (req, res, next) => {
|
|
|
13
13
|
fn(req, res, next).catch(next);
|
|
14
14
|
};
|
|
15
15
|
function create(options) {
|
|
16
|
-
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, } = options;
|
|
16
|
+
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, verifyServer: verifyServerOptions, } = options;
|
|
17
17
|
const challengeHandler = asyncHandler(async (req, res) => {
|
|
18
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
19
|
+
throw new Error('deriveKey and createChallengeParameters are required to generate challenges. Omit challengeHandler when relying on Sentinel to issue challenges.');
|
|
20
|
+
}
|
|
18
21
|
const challenge = await (0, pow_js_1.createChallenge)({
|
|
19
22
|
deriveKey,
|
|
20
23
|
hmacSignatureSecret,
|
|
@@ -45,7 +48,7 @@ function create(options) {
|
|
|
45
48
|
const { throwOnFailure = true } = options;
|
|
46
49
|
return asyncHandler(async (req, res, next) => {
|
|
47
50
|
const payload = await getPayloadFromRequest(req, setCookie?.name);
|
|
48
|
-
const { error, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
51
|
+
const { error, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
49
52
|
res.locals.altcha = {
|
|
50
53
|
error,
|
|
51
54
|
payload: resultPayload,
|
|
@@ -10,7 +10,7 @@ Object.defineProperty(exports, "deriveHmacKeySecret", { enumerable: true, get: f
|
|
|
10
10
|
const capped_map_js_1 = require("../capped-map.js");
|
|
11
11
|
Object.defineProperty(exports, "CappedMap", { enumerable: true, get: function () { return capped_map_js_1.CappedMap; } });
|
|
12
12
|
function create(options) {
|
|
13
|
-
const { createChallengeParameters, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, } = options;
|
|
13
|
+
const { createChallengeParameters, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, verifyServer: verifyServerOptions, } = options;
|
|
14
14
|
const deleteCookie = (reply, name, path = '/') => {
|
|
15
15
|
reply.header('Set-Cookie', `${name}=; Path=${path ?? '/'}; Max-Age=0`);
|
|
16
16
|
};
|
|
@@ -34,6 +34,9 @@ function create(options) {
|
|
|
34
34
|
return request.body?.altcha;
|
|
35
35
|
};
|
|
36
36
|
const challengeHandler = async (request, reply) => {
|
|
37
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
38
|
+
throw new Error('deriveKey and createChallengeParameters are required to generate challenges. Omit challengeHandler when relying on Sentinel to issue challenges.');
|
|
39
|
+
}
|
|
37
40
|
const challenge = await (0, pow_js_1.createChallenge)({
|
|
38
41
|
deriveKey,
|
|
39
42
|
hmacSignatureSecret,
|
|
@@ -58,7 +61,7 @@ function create(options) {
|
|
|
58
61
|
const { throwOnFailure = true } = options;
|
|
59
62
|
return async (request, reply) => {
|
|
60
63
|
const payload = await getPayloadFromRequest(request, setCookie?.name);
|
|
61
|
-
const { error, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
64
|
+
const { error, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
62
65
|
request.altcha = {
|
|
63
66
|
error,
|
|
64
67
|
payload: resultPayload,
|
|
@@ -22,7 +22,7 @@ export declare function create(options: AltchaOptions): {
|
|
|
22
22
|
verifyHandler: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
23
23
|
error: string | null;
|
|
24
24
|
payload: import("../types.js").Payload | import("../types.js").ServerSignaturePayload | null;
|
|
25
|
-
verification: import("../types.js").VerifySolutionResult | null;
|
|
25
|
+
verification: import("../types.js").VerifySolutionResult | import("../types.js").VerifyServerResult | null;
|
|
26
26
|
}>>;
|
|
27
27
|
getPayloadFromEvent: (event: H3Event, cookieName?: string) => Promise<string | undefined>;
|
|
28
28
|
middleware: (options?: AltchaMiddlewareOptions) => import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<void>>;
|
|
@@ -11,8 +11,14 @@ Object.defineProperty(exports, "CappedMap", { enumerable: true, get: function ()
|
|
|
11
11
|
const shared_js_1 = require("./shared.js");
|
|
12
12
|
Object.defineProperty(exports, "deriveHmacKeySecret", { enumerable: true, get: function () { return shared_js_1.deriveHmacKeySecret; } });
|
|
13
13
|
function create(options) {
|
|
14
|
-
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, } = options;
|
|
14
|
+
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, verifyServer: verifyServerOptions, } = options;
|
|
15
15
|
const challengeHandler = (0, h3_1.defineEventHandler)(async (event) => {
|
|
16
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
17
|
+
throw new h3_1.HTTPError({
|
|
18
|
+
message: 'deriveKey and createChallengeParameters are required to generate challenges. Omit challengeHandler when relying on Sentinel to issue challenges.',
|
|
19
|
+
status: 500,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
16
22
|
(0, h3_1.setResponseHeader)(event, 'Cache-Control', 'no-store');
|
|
17
23
|
return {
|
|
18
24
|
configuration: setCookie
|
|
@@ -60,7 +66,7 @@ function create(options) {
|
|
|
60
66
|
const middleware = (options = {}) => {
|
|
61
67
|
const { throwOnFailure = true } = options;
|
|
62
68
|
return (0, h3_1.defineEventHandler)(async (event) => {
|
|
63
|
-
const { error, payload, verification } = await (0, shared_js_1.verify)(await getPayloadFromEvent(event, setCookie?.name), deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
69
|
+
const { error, payload, verification } = await (0, shared_js_1.verify)(await getPayloadFromEvent(event, setCookie?.name), deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
64
70
|
event.context.altcha = {
|
|
65
71
|
error,
|
|
66
72
|
payload,
|
|
@@ -84,6 +84,24 @@ export declare function create(options: AltchaOptions): {
|
|
|
84
84
|
invalidSolution: boolean | null;
|
|
85
85
|
time: number;
|
|
86
86
|
verified: boolean;
|
|
87
|
+
} | {
|
|
88
|
+
apiKey?: string | null | undefined;
|
|
89
|
+
reason?: string | undefined;
|
|
90
|
+
verificationData?: {
|
|
91
|
+
[x: string]: import("hono/utils/types.js").JSONValue;
|
|
92
|
+
classification?: import("../types.js").ServerClassification | undefined;
|
|
93
|
+
email?: string | undefined;
|
|
94
|
+
expire?: number | undefined;
|
|
95
|
+
fields?: string[] | undefined;
|
|
96
|
+
fieldsHash?: string | undefined;
|
|
97
|
+
id?: string | undefined;
|
|
98
|
+
ipAddress?: string | undefined;
|
|
99
|
+
reasons?: string[] | undefined;
|
|
100
|
+
score?: number | undefined;
|
|
101
|
+
time?: number | undefined;
|
|
102
|
+
verified?: boolean | undefined;
|
|
103
|
+
} | null | undefined;
|
|
104
|
+
verified: boolean;
|
|
87
105
|
} | null;
|
|
88
106
|
}, import("hono/utils/http-status.js").ContentfulStatusCode, "json">>;
|
|
89
107
|
getPayloadFromContext: (c: Context, cookieName?: string) => Promise<string | undefined>;
|
|
@@ -12,8 +12,13 @@ Object.defineProperty(exports, "CappedMap", { enumerable: true, get: function ()
|
|
|
12
12
|
const shared_js_1 = require("./shared.js");
|
|
13
13
|
Object.defineProperty(exports, "deriveHmacKeySecret", { enumerable: true, get: function () { return shared_js_1.deriveHmacKeySecret; } });
|
|
14
14
|
function create(options) {
|
|
15
|
-
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, } = options;
|
|
15
|
+
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, verifyServer: verifyServerOptions, } = options;
|
|
16
16
|
const challengeHandler = async (c) => {
|
|
17
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
18
|
+
throw new http_exception_1.HTTPException(500, {
|
|
19
|
+
message: 'deriveKey and createChallengeParameters are required to generate challenges. Omit challengeHandler when relying on Sentinel to issue challenges.',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
17
22
|
c.header('Cache-Control', 'no-store');
|
|
18
23
|
return c.json({
|
|
19
24
|
configuration: setCookie
|
|
@@ -54,7 +59,7 @@ function create(options) {
|
|
|
54
59
|
const middleware = (options = {}) => {
|
|
55
60
|
const { throwOnFailure = true } = options;
|
|
56
61
|
return async (c, next) => {
|
|
57
|
-
const { error, payload, verification } = await (0, shared_js_1.verify)(await getPayloadFromContext(c, setCookie?.name), deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
62
|
+
const { error, payload, verification } = await (0, shared_js_1.verify)(await getPayloadFromContext(c, setCookie?.name), deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
58
63
|
c.set('altcha', {
|
|
59
64
|
error,
|
|
60
65
|
payload,
|
|
@@ -22,13 +22,14 @@ export declare function createAltchaMiddleware(options?: AltchaMiddlewareOptions
|
|
|
22
22
|
};
|
|
23
23
|
};
|
|
24
24
|
export declare class AltchaService {
|
|
25
|
-
private readonly hmacSignatureSecret
|
|
25
|
+
private readonly hmacSignatureSecret?;
|
|
26
26
|
private readonly hmacKeySignatureSecret?;
|
|
27
27
|
private readonly createChallengeParameters;
|
|
28
|
-
private readonly deriveKey
|
|
28
|
+
private readonly deriveKey?;
|
|
29
29
|
private readonly fieldName;
|
|
30
30
|
private readonly setCookieOptions?;
|
|
31
31
|
private readonly store?;
|
|
32
|
+
private readonly verifyServerOptions?;
|
|
32
33
|
constructor(options: AltchaOptions);
|
|
33
34
|
get setCookie(): RequireField<SetCookieOptions, 'name'> | undefined;
|
|
34
35
|
getChallenge(): Promise<{
|
|
@@ -40,10 +41,12 @@ export declare class AltchaService {
|
|
|
40
41
|
} | undefined;
|
|
41
42
|
}>;
|
|
42
43
|
getPayloadFromRequest(req: Request, cookieName?: string): string | undefined;
|
|
43
|
-
verify(payload: string | undefined
|
|
44
|
+
verify(payload: string | undefined, options?: {
|
|
45
|
+
allowRemote?: boolean;
|
|
46
|
+
}): Promise<{
|
|
44
47
|
error: string | null;
|
|
45
48
|
payload: import("../types.js").Payload | import("../types.js").ServerSignaturePayload | null;
|
|
46
|
-
verification: import("../types.js").VerifySolutionResult | null;
|
|
49
|
+
verification: import("../types.js").VerifySolutionResult | import("../types.js").VerifyServerResult | null;
|
|
47
50
|
}>;
|
|
48
51
|
}
|
|
49
52
|
export declare class AltchaController {
|
|
@@ -60,7 +63,7 @@ export declare class AltchaController {
|
|
|
60
63
|
verifySolution(req: Request): Promise<{
|
|
61
64
|
error: string | null;
|
|
62
65
|
payload: import("../types.js").Payload | import("../types.js").ServerSignaturePayload | null;
|
|
63
|
-
verification: import("../types.js").VerifySolutionResult | null;
|
|
66
|
+
verification: import("../types.js").VerifySolutionResult | import("../types.js").VerifyServerResult | null;
|
|
64
67
|
}>;
|
|
65
68
|
}
|
|
66
69
|
export declare class AltchaMiddleware implements NestMiddleware {
|
|
@@ -36,7 +36,7 @@ function createAltchaMiddleware(options = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
async use(req, res, next) {
|
|
38
38
|
const payload = this.altchaService.getPayloadFromRequest(req);
|
|
39
|
-
const { error, payload: resultPayload, verification, } = await this.altchaService.verify(payload);
|
|
39
|
+
const { error, payload: resultPayload, verification, } = await this.altchaService.verify(payload, { allowRemote: true });
|
|
40
40
|
req.altcha = {
|
|
41
41
|
error,
|
|
42
42
|
payload: resultPayload,
|
|
@@ -67,16 +67,21 @@ let AltchaService = class AltchaService {
|
|
|
67
67
|
this.fieldName = options.fieldName || 'altcha';
|
|
68
68
|
this.setCookieOptions = options.setCookie;
|
|
69
69
|
this.store = options.store;
|
|
70
|
+
this.verifyServerOptions = options.verifyServer;
|
|
70
71
|
}
|
|
71
72
|
get setCookie() {
|
|
72
73
|
return this.setCookieOptions;
|
|
73
74
|
}
|
|
74
75
|
async getChallenge() {
|
|
76
|
+
const { createChallengeParameters, deriveKey } = this;
|
|
77
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
78
|
+
throw new common_1.HttpException('deriveKey and createChallengeParameters are required to generate challenges. Omit the /challenge route when relying on Sentinel to issue challenges.', common_1.HttpStatus.INTERNAL_SERVER_ERROR);
|
|
79
|
+
}
|
|
75
80
|
const challenge = await (0, pow_js_1.createChallenge)({
|
|
76
|
-
deriveKey
|
|
81
|
+
deriveKey,
|
|
77
82
|
hmacSignatureSecret: this.hmacSignatureSecret,
|
|
78
83
|
hmacKeySignatureSecret: this.hmacKeySignatureSecret,
|
|
79
|
-
...
|
|
84
|
+
...createChallengeParameters(),
|
|
80
85
|
});
|
|
81
86
|
return {
|
|
82
87
|
configuration: this.setCookieOptions
|
|
@@ -91,8 +96,8 @@ let AltchaService = class AltchaService {
|
|
|
91
96
|
}
|
|
92
97
|
return req.body?.[this.fieldName];
|
|
93
98
|
}
|
|
94
|
-
async verify(payload) {
|
|
95
|
-
return (0, shared_js_1.verify)(payload, this.deriveKey, this.hmacSignatureSecret, this.hmacKeySignatureSecret, this.store);
|
|
99
|
+
async verify(payload, options = {}) {
|
|
100
|
+
return (0, shared_js_1.verify)(payload, this.deriveKey, this.hmacSignatureSecret, this.hmacKeySignatureSecret, this.store, options.allowRemote ? this.verifyServerOptions : undefined);
|
|
96
101
|
}
|
|
97
102
|
};
|
|
98
103
|
exports.AltchaService = AltchaService;
|
|
@@ -138,7 +143,7 @@ let AltchaMiddleware = class AltchaMiddleware {
|
|
|
138
143
|
}
|
|
139
144
|
async use(req, res, next) {
|
|
140
145
|
const payload = this.altchaService.getPayloadFromRequest(req, this.altchaService.setCookie?.name);
|
|
141
|
-
const { error, payload: resultPayload, verification, } = await this.altchaService.verify(payload);
|
|
146
|
+
const { error, payload: resultPayload, verification, } = await this.altchaService.verify(payload, { allowRemote: true });
|
|
142
147
|
req.altcha = {
|
|
143
148
|
error,
|
|
144
149
|
payload: resultPayload,
|
|
@@ -21,9 +21,12 @@ function deleteCookie(res, name, path) {
|
|
|
21
21
|
res.headers.append('Set-Cookie', `${name}=; Path=${path ?? '/'}; Max-Age=0`);
|
|
22
22
|
}
|
|
23
23
|
function create(options) {
|
|
24
|
-
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, } = options;
|
|
24
|
+
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, verifyServer: verifyServerOptions, } = options;
|
|
25
25
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
26
26
|
async function challengeHandler(_req) {
|
|
27
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
28
|
+
throw new Error('deriveKey and createChallengeParameters are required to generate challenges. Omit challengeHandler when relying on Sentinel to issue challenges.');
|
|
29
|
+
}
|
|
27
30
|
const challenge = await (0, pow_js_1.createChallenge)({
|
|
28
31
|
deriveKey,
|
|
29
32
|
hmacSignatureSecret,
|
|
@@ -69,7 +72,7 @@ function create(options) {
|
|
|
69
72
|
}
|
|
70
73
|
async function middleware(req, throwOnFailure = true) {
|
|
71
74
|
const payload = await getPayloadFromRequest(req, setCookie?.name);
|
|
72
|
-
const { error, payload: verifiedPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
75
|
+
const { error, payload: verifiedPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
73
76
|
const result = {
|
|
74
77
|
error,
|
|
75
78
|
payload: verifiedPayload,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { DeriveKeyFunction, Payload, ServerSignaturePayload, VerifySolutionResult } from '../types.js';
|
|
2
|
-
import type { Store } from './types.js';
|
|
1
|
+
import { DeriveKeyFunction, Payload, ServerSignaturePayload, VerifyServerResult, VerifySolutionResult } from '../types.js';
|
|
2
|
+
import type { AltchaVerifyServerOptions, Store } from './types.js';
|
|
3
3
|
export declare function deriveHmacKeySecret(masterSecret: string): Promise<string>;
|
|
4
|
-
export declare function verify(payload: unknown, deriveKey: DeriveKeyFunction, hmacSignatureSecret: string, hmacKeySignatureSecret?: string, store?: Store): Promise<{
|
|
4
|
+
export declare function verify(payload: unknown, deriveKey: DeriveKeyFunction | undefined, hmacSignatureSecret: string | undefined, hmacKeySignatureSecret?: string, store?: Store, verifyServerOptions?: AltchaVerifyServerOptions): Promise<{
|
|
5
5
|
error: string | null;
|
|
6
6
|
payload: Payload | ServerSignaturePayload | null;
|
|
7
|
-
verification: VerifySolutionResult | null;
|
|
7
|
+
verification: VerifySolutionResult | VerifyServerResult | null;
|
|
8
8
|
}>;
|
|
@@ -4,12 +4,13 @@ exports.deriveHmacKeySecret = deriveHmacKeySecret;
|
|
|
4
4
|
exports.verify = verify;
|
|
5
5
|
const pow_js_1 = require("../pow.js");
|
|
6
6
|
const server_signature_js_1 = require("../server-signature.js");
|
|
7
|
+
const verify_server_js_1 = require("../verify-server.js");
|
|
7
8
|
const helpers_js_1 = require("../helpers.js");
|
|
8
9
|
const types_js_1 = require("../types.js");
|
|
9
10
|
async function deriveHmacKeySecret(masterSecret) {
|
|
10
11
|
return (0, helpers_js_1.bufferToHex)(await (0, helpers_js_1.hmac)(types_js_1.HmacAlgorithm.SHA_256, masterSecret, 'derived-secret'));
|
|
11
12
|
}
|
|
12
|
-
async function verify(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store) {
|
|
13
|
+
async function verify(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions) {
|
|
13
14
|
if (!payload) {
|
|
14
15
|
return {
|
|
15
16
|
error: 'ALTCHA payload is missing.',
|
|
@@ -33,6 +34,12 @@ async function verify(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureS
|
|
|
33
34
|
try {
|
|
34
35
|
switch (type) {
|
|
35
36
|
case 'client':
|
|
37
|
+
if (!deriveKey) {
|
|
38
|
+
throw new Error('deriveKey is required to verify self-hosted ALTCHA challenges.');
|
|
39
|
+
}
|
|
40
|
+
if (!hmacSignatureSecret) {
|
|
41
|
+
throw new Error('hmacSignatureSecret is required to verify self-hosted ALTCHA challenges.');
|
|
42
|
+
}
|
|
36
43
|
challengeId = getChallengeId(payload);
|
|
37
44
|
if (store && challengeId) {
|
|
38
45
|
await checkChallengeId(store, challengeId);
|
|
@@ -44,7 +51,18 @@ async function verify(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureS
|
|
|
44
51
|
if (store && challengeId) {
|
|
45
52
|
await checkChallengeId(store, challengeId);
|
|
46
53
|
}
|
|
47
|
-
|
|
54
|
+
if (verifyServerOptions) {
|
|
55
|
+
verification = await (0, verify_server_js_1.verifyServer)({
|
|
56
|
+
...verifyServerOptions,
|
|
57
|
+
payload: payload,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (!hmacSignatureSecret) {
|
|
62
|
+
throw new Error('hmacSignatureSecret or verifyServer must be configured to verify this payload.');
|
|
63
|
+
}
|
|
64
|
+
verification = await verifyServerSignaturePayload(payload, hmacSignatureSecret);
|
|
65
|
+
}
|
|
48
66
|
break;
|
|
49
67
|
default:
|
|
50
68
|
throw new Error('ALTCHA payload is invalid.');
|
|
@@ -59,7 +77,8 @@ async function verify(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureS
|
|
|
59
77
|
}
|
|
60
78
|
if (!verification?.verified) {
|
|
61
79
|
return {
|
|
62
|
-
error: '
|
|
80
|
+
error: (verification && 'reason' in verification && verification.reason) ||
|
|
81
|
+
'ALTCHA verification failed.',
|
|
63
82
|
payload: payload,
|
|
64
83
|
verification,
|
|
65
84
|
};
|
|
@@ -27,8 +27,11 @@ async function getPayloadFromEvent(event, fieldName, cookieName) {
|
|
|
27
27
|
return body?.[fieldName];
|
|
28
28
|
}
|
|
29
29
|
function create(options) {
|
|
30
|
-
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, } = options;
|
|
30
|
+
const { createChallengeParameters, deriveKey, fieldName = 'altcha', hmacSignatureSecret, hmacKeySignatureSecret, setCookie, store, verifyServer: verifyServerOptions, } = options;
|
|
31
31
|
async function challengeHandler() {
|
|
32
|
+
if (!deriveKey || !createChallengeParameters) {
|
|
33
|
+
(0, kit_1.error)(500, 'deriveKey and createChallengeParameters are required to generate challenges. Omit challengeHandler when relying on Sentinel to issue challenges.');
|
|
34
|
+
}
|
|
32
35
|
const challenge = await (0, pow_js_1.createChallenge)({
|
|
33
36
|
deriveKey,
|
|
34
37
|
hmacSignatureSecret,
|
|
@@ -53,7 +56,7 @@ function create(options) {
|
|
|
53
56
|
const { throwOnFailure = true } = middlewareOptions;
|
|
54
57
|
return async ({ event, resolve }) => {
|
|
55
58
|
const payload = await getPayloadFromEvent(event, fieldName, setCookie?.name);
|
|
56
|
-
const { error: verifyError, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
59
|
+
const { error: verifyError, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
57
60
|
event.locals.altcha = {
|
|
58
61
|
error: verifyError,
|
|
59
62
|
payload: resultPayload,
|
|
@@ -72,7 +75,7 @@ function create(options) {
|
|
|
72
75
|
}
|
|
73
76
|
async function verifyEvent(event) {
|
|
74
77
|
const payload = await getPayloadFromEvent(event, fieldName, setCookie?.name);
|
|
75
|
-
const { error: verifyError, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store);
|
|
78
|
+
const { error: verifyError, payload: resultPayload, verification, } = await (0, shared_js_1.verify)(payload, deriveKey, hmacSignatureSecret, hmacKeySignatureSecret, store, verifyServerOptions);
|
|
76
79
|
if (setCookie) {
|
|
77
80
|
event.cookies.delete(setCookie.name, {
|
|
78
81
|
path: setCookie.path ?? '/',
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import type { CreateChallengeOptions, DeriveKeyFunction, Payload, ServerSignaturePayload, SetCookieOptions, VerifySolutionResult } from '../types.js';
|
|
1
|
+
import type { CreateChallengeOptions, DeriveKeyFunction, Payload, ServerSignaturePayload, SetCookieOptions, VerifyServerOptions, VerifyServerResult, VerifySolutionResult } from '../types.js';
|
|
2
|
+
export type AltchaVerifyServerOptions = Omit<VerifyServerOptions, 'payload'>;
|
|
2
3
|
export interface AltchaOptions {
|
|
3
4
|
/**
|
|
4
5
|
* Returns configuration options passed to `createChallenge`.
|
|
5
6
|
* Must include `algorithm` and `cost`; all other `CreateChallengeOptions` fields are optional.
|
|
7
|
+
* Required to use `challengeHandler`; optional if challenges are issued by Sentinel and
|
|
8
|
+
* only `verifyServer` is used.
|
|
6
9
|
*/
|
|
7
|
-
createChallengeParameters
|
|
10
|
+
createChallengeParameters?: () => Pick<CreateChallengeOptions, 'algorithm' | 'cost'> & Partial<CreateChallengeOptions>;
|
|
8
11
|
/**
|
|
9
12
|
* Algorithm-specific key derivation function used to generate challenge keys.
|
|
13
|
+
* Required to use `challengeHandler` or to verify self-hosted (client-type) payloads;
|
|
14
|
+
* optional if challenges are issued by Sentinel and only `verifyServer` is used.
|
|
10
15
|
*/
|
|
11
|
-
deriveKey
|
|
16
|
+
deriveKey?: DeriveKeyFunction;
|
|
12
17
|
/**
|
|
13
18
|
* Name of the form field that carries the ALTCHA payload.
|
|
14
19
|
* Defaults to `'altcha'` if not specified.
|
|
@@ -16,8 +21,10 @@ export interface AltchaOptions {
|
|
|
16
21
|
fieldName?: string;
|
|
17
22
|
/**
|
|
18
23
|
* HMAC secret used to sign and verify challenge signatures.
|
|
24
|
+
* Optional if `verifyServer` is configured and only Sentinel-issued (server-signed)
|
|
25
|
+
* payloads are verified remotely.
|
|
19
26
|
*/
|
|
20
|
-
hmacSignatureSecret
|
|
27
|
+
hmacSignatureSecret?: string;
|
|
21
28
|
/**
|
|
22
29
|
* HMAC secret used to sign keys in deterministic mode.
|
|
23
30
|
*/
|
|
@@ -31,6 +38,11 @@ export interface AltchaOptions {
|
|
|
31
38
|
* Store implementation for tracking used challenges and preventing replay attacks.
|
|
32
39
|
*/
|
|
33
40
|
store?: Store;
|
|
41
|
+
/**
|
|
42
|
+
* When set, Sentinel-issued (server-signed) payloads are verified remotely via
|
|
43
|
+
* `POST /v1/verify/signature` instead of locally with `hmacSignatureSecret`.
|
|
44
|
+
*/
|
|
45
|
+
verifyServer?: AltchaVerifyServerOptions;
|
|
34
46
|
}
|
|
35
47
|
export interface AltchaMiddlewareOptions {
|
|
36
48
|
throwOnFailure?: boolean;
|
|
@@ -38,7 +50,7 @@ export interface AltchaMiddlewareOptions {
|
|
|
38
50
|
export interface AltchaResult {
|
|
39
51
|
error: string | null;
|
|
40
52
|
payload: Payload | ServerSignaturePayload | null;
|
|
41
|
-
verification: VerifySolutionResult | null;
|
|
53
|
+
verification: VerifySolutionResult | VerifyServerResult | null;
|
|
42
54
|
}
|
|
43
55
|
export type RequireField<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
|
44
56
|
export interface Store {
|
package/dist/cjs/v2/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createChallenge, solveChallenge, solveChallengeWorkers, verifySolution } from './pow.js';
|
|
2
2
|
import { verifyFieldsHash, verifyServerSignature } from './server-signature.js';
|
|
3
|
+
import { verifyServer } from './verify-server.js';
|
|
3
4
|
import { CappedMap } from './capped-map.js';
|
|
4
5
|
import { randomInt } from './helpers.js';
|
|
5
6
|
import { HmacAlgorithm } from './types.js';
|
|
6
7
|
export type * from './types.js';
|
|
7
|
-
export { CappedMap, HmacAlgorithm, createChallenge, randomInt, solveChallenge, solveChallengeWorkers, verifyFieldsHash, verifyServerSignature, verifySolution, };
|
|
8
|
+
export { CappedMap, HmacAlgorithm, createChallenge, randomInt, solveChallenge, solveChallengeWorkers, verifyFieldsHash, verifyServer, verifyServerSignature, verifySolution, };
|
|
8
9
|
declare const _default: {
|
|
9
10
|
CappedMap: typeof CappedMap;
|
|
10
11
|
HmacAlgorithm: typeof HmacAlgorithm;
|
|
@@ -13,6 +14,7 @@ declare const _default: {
|
|
|
13
14
|
solveChallenge: typeof solveChallenge;
|
|
14
15
|
solveChallengeWorkers: typeof solveChallengeWorkers;
|
|
15
16
|
verifyFieldsHash: typeof verifyFieldsHash;
|
|
17
|
+
verifyServer: typeof verifyServer;
|
|
16
18
|
verifyServerSignature: typeof verifyServerSignature;
|
|
17
19
|
verifySolution: typeof verifySolution;
|
|
18
20
|
};
|
package/dist/cjs/v2/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.verifySolution = exports.verifyServerSignature = exports.verifyFieldsHash = exports.solveChallengeWorkers = exports.solveChallenge = exports.randomInt = exports.createChallenge = exports.HmacAlgorithm = exports.CappedMap = void 0;
|
|
3
|
+
exports.verifySolution = exports.verifyServerSignature = exports.verifyServer = exports.verifyFieldsHash = exports.solveChallengeWorkers = exports.solveChallenge = exports.randomInt = exports.createChallenge = exports.HmacAlgorithm = exports.CappedMap = void 0;
|
|
4
4
|
const pow_js_1 = require("./pow.js");
|
|
5
5
|
Object.defineProperty(exports, "createChallenge", { enumerable: true, get: function () { return pow_js_1.createChallenge; } });
|
|
6
6
|
Object.defineProperty(exports, "solveChallenge", { enumerable: true, get: function () { return pow_js_1.solveChallenge; } });
|
|
@@ -9,6 +9,8 @@ Object.defineProperty(exports, "verifySolution", { enumerable: true, get: functi
|
|
|
9
9
|
const server_signature_js_1 = require("./server-signature.js");
|
|
10
10
|
Object.defineProperty(exports, "verifyFieldsHash", { enumerable: true, get: function () { return server_signature_js_1.verifyFieldsHash; } });
|
|
11
11
|
Object.defineProperty(exports, "verifyServerSignature", { enumerable: true, get: function () { return server_signature_js_1.verifyServerSignature; } });
|
|
12
|
+
const verify_server_js_1 = require("./verify-server.js");
|
|
13
|
+
Object.defineProperty(exports, "verifyServer", { enumerable: true, get: function () { return verify_server_js_1.verifyServer; } });
|
|
12
14
|
const capped_map_js_1 = require("./capped-map.js");
|
|
13
15
|
Object.defineProperty(exports, "CappedMap", { enumerable: true, get: function () { return capped_map_js_1.CappedMap; } });
|
|
14
16
|
const helpers_js_1 = require("./helpers.js");
|
|
@@ -23,6 +25,7 @@ exports.default = {
|
|
|
23
25
|
solveChallenge: pow_js_1.solveChallenge,
|
|
24
26
|
solveChallengeWorkers: pow_js_1.solveChallengeWorkers,
|
|
25
27
|
verifyFieldsHash: server_signature_js_1.verifyFieldsHash,
|
|
28
|
+
verifyServer: verify_server_js_1.verifyServer,
|
|
26
29
|
verifyServerSignature: server_signature_js_1.verifyServerSignature,
|
|
27
30
|
verifySolution: pow_js_1.verifySolution,
|
|
28
31
|
};
|
package/dist/cjs/v2/types.d.ts
CHANGED
|
@@ -233,6 +233,60 @@ export interface VerifyResult {
|
|
|
233
233
|
export interface VerifyServerSignatureResult extends VerifySolutionResult {
|
|
234
234
|
verificationData?: ServerSignatureVerificationData | null;
|
|
235
235
|
}
|
|
236
|
+
export interface VerifyServerOptions {
|
|
237
|
+
/**
|
|
238
|
+
* The payload to verify, as received from `POST /v1/verify`
|
|
239
|
+
* (either the raw string or the decoded object).
|
|
240
|
+
*/
|
|
241
|
+
payload: string | ServerSignaturePayload | Record<string, unknown>;
|
|
242
|
+
/**
|
|
243
|
+
* Full URL of the Sentinel `/v1/verify/signature` endpoint.
|
|
244
|
+
*/
|
|
245
|
+
url: string;
|
|
246
|
+
/**
|
|
247
|
+
* API key secret. If provided, Sentinel checks that it matches the
|
|
248
|
+
* API key associated with the payload.
|
|
249
|
+
*/
|
|
250
|
+
secret?: string;
|
|
251
|
+
/**
|
|
252
|
+
* Custom fetch implementation. Defaults to the global `fetch`.
|
|
253
|
+
*/
|
|
254
|
+
fetch?: typeof fetch;
|
|
255
|
+
/**
|
|
256
|
+
* Additional headers to send with the request.
|
|
257
|
+
*/
|
|
258
|
+
headers?: Record<string, string>;
|
|
259
|
+
/**
|
|
260
|
+
* AbortController for cancelling the verification request.
|
|
261
|
+
*/
|
|
262
|
+
controller?: AbortController;
|
|
263
|
+
/**
|
|
264
|
+
* Per-attempt request timeout in milliseconds.
|
|
265
|
+
* Defaults to `10_000` (10 seconds).
|
|
266
|
+
*/
|
|
267
|
+
timeout?: number;
|
|
268
|
+
/**
|
|
269
|
+
* Number of retry attempts after the first try.
|
|
270
|
+
* Defaults to `0` (no retries).
|
|
271
|
+
*/
|
|
272
|
+
retries?: number;
|
|
273
|
+
/**
|
|
274
|
+
* Base delay in milliseconds between retry attempts.
|
|
275
|
+
* Defaults to `300`.
|
|
276
|
+
*/
|
|
277
|
+
retryDelay?: number;
|
|
278
|
+
/**
|
|
279
|
+
* Backoff strategy applied to `retryDelay` between retries.
|
|
280
|
+
* Defaults to `'exponential'`.
|
|
281
|
+
*/
|
|
282
|
+
retryBackoff?: 'fixed' | 'exponential';
|
|
283
|
+
}
|
|
284
|
+
export interface VerifyServerResult {
|
|
285
|
+
apiKey?: string | null;
|
|
286
|
+
reason?: string;
|
|
287
|
+
verificationData?: ServerSignatureVerificationData | null;
|
|
288
|
+
verified: boolean;
|
|
289
|
+
}
|
|
236
290
|
export interface VerifySolutionOptions {
|
|
237
291
|
/**
|
|
238
292
|
* Challenge object to verify against, either decoded from the client payload
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyServer = verifyServer;
|
|
4
|
+
const helpers_js_1 = require("./helpers.js");
|
|
5
|
+
/** Verifies a payload remotely via the ALTCHA Sentinel `/v1/verify/signature` API. */
|
|
6
|
+
async function verifyServer(options) {
|
|
7
|
+
const { controller, fetch: fetchImpl = fetch, headers, payload, retries = 0, retryBackoff = 'exponential', retryDelay = 300, secret, timeout = 10_000, url, } = options;
|
|
8
|
+
const body = JSON.stringify({ payload, secret });
|
|
9
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
10
|
+
if (controller?.signal.aborted) {
|
|
11
|
+
return { verified: false, reason: 'ABORTED' };
|
|
12
|
+
}
|
|
13
|
+
const attemptController = new AbortController();
|
|
14
|
+
const onAbort = () => attemptController.abort();
|
|
15
|
+
controller?.signal.addEventListener('abort', onAbort);
|
|
16
|
+
const timer = setTimeout(() => attemptController.abort(), timeout);
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetchImpl(url, {
|
|
19
|
+
body,
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
...headers,
|
|
23
|
+
},
|
|
24
|
+
method: 'POST',
|
|
25
|
+
signal: attemptController.signal,
|
|
26
|
+
});
|
|
27
|
+
if (response.status === 400) {
|
|
28
|
+
const data = await response
|
|
29
|
+
.json()
|
|
30
|
+
.catch(() => null);
|
|
31
|
+
return {
|
|
32
|
+
verified: false,
|
|
33
|
+
reason: data?.error ?? `HTTP_${response.status}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`HTTP_${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
return (await response.json());
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (attempt >= retries) {
|
|
43
|
+
return {
|
|
44
|
+
verified: false,
|
|
45
|
+
reason: err instanceof Error ? err.message : 'NETWORK_ERROR',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const backoffDelay = retryBackoff === 'fixed' ? retryDelay : retryDelay * 2 ** attempt;
|
|
49
|
+
await (0, helpers_js_1.delay)(backoffDelay);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
controller?.signal.removeEventListener('abort', onAbort);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { verified: false, reason: 'NETWORK_ERROR' };
|
|
57
|
+
}
|