serverless-ircd 0.3.0 → 0.4.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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* PLAN §2.1 location-of-authority: SASL runs in the Connection entity.
|
|
5
5
|
* The reducer drives the multi-frame SASL exchange purely:
|
|
6
6
|
*
|
|
7
|
-
* 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is
|
|
8
|
-
* answers with `AUTHENTICATE +` (requesting the payload).
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is always
|
|
8
|
+
* supported and answers with `AUTHENTICATE +` (requesting the payload).
|
|
9
|
+
* `EXTERNAL` is supported only when an mTLS identity provider is
|
|
10
|
+
* configured for the connection; without one, EXTERNAL falls through
|
|
11
|
+
* to `908 ERR_SASLMECHS` (mechanism not available) listing what is.
|
|
12
|
+
* The verified client-cert subject is resolved at finalise time.
|
|
13
|
+
* Anything else gets `908 ERR_SASLMECHS` listing what is available.
|
|
11
14
|
* 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
|
|
12
15
|
* or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
|
|
13
16
|
* decoded bytes are handed to the injected {@link AccountStore}; on
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
|
|
27
30
|
import { Effect } from '../effects.js';
|
|
28
31
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
+
import type { SaslResult } from '../ports.js';
|
|
29
33
|
import { decodeBase64 } from '../protocol/base64.js';
|
|
30
34
|
import { Numerics } from '../protocol/numerics.js';
|
|
31
35
|
import { hostmaskOf } from '../state/connection.js';
|
|
@@ -38,8 +42,17 @@ const SASL_CHUNK_BYTES = 400;
|
|
|
38
42
|
/** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
|
|
39
43
|
const SASL_MAX_BYTES = 8192;
|
|
40
44
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Mechanisms advertised in `908 ERR_SASLMECHS` and the `sasl` cap value,
|
|
47
|
+
* derived from whether mTLS is configured for this connection.
|
|
48
|
+
*
|
|
49
|
+
* PLAIN is always available; EXTERNAL only when an `MtlsIdentityProvider`
|
|
50
|
+
* is bound. Operators enable EXTERNAL by configuring mTLS at the edge
|
|
51
|
+
* — no code change required.
|
|
52
|
+
*/
|
|
53
|
+
function supportedMechs(ctx: Ctx): string {
|
|
54
|
+
return ctx.mtlsIdentity !== undefined ? 'PLAIN,EXTERNAL' : 'PLAIN';
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
/**
|
|
45
58
|
* Reducer for `AUTHENTICATE [<param>]`. See module docs for the state
|
|
@@ -93,7 +106,12 @@ function handleMechanism(
|
|
|
93
106
|
return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
|
|
94
107
|
}
|
|
95
108
|
if (mech === 'EXTERNAL') {
|
|
96
|
-
|
|
109
|
+
if (ctx.mtlsIdentity === undefined) {
|
|
110
|
+
return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
|
|
111
|
+
}
|
|
112
|
+
state.saslMech = 'EXTERNAL';
|
|
113
|
+
state.saslBuffer = '';
|
|
114
|
+
return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
|
|
97
115
|
}
|
|
98
116
|
return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
|
|
99
117
|
}
|
|
@@ -131,6 +149,18 @@ function finalize(
|
|
|
131
149
|
payloadB64: string,
|
|
132
150
|
mech: string,
|
|
133
151
|
ctx: Ctx,
|
|
152
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
153
|
+
if (mech === 'EXTERNAL') {
|
|
154
|
+
return finalizeExternal(state, ctx);
|
|
155
|
+
}
|
|
156
|
+
return finalizePlain(state, payloadB64, ctx);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** SASL PLAIN: decode base64 payload, verify username/password. */
|
|
160
|
+
function finalizePlain(
|
|
161
|
+
state: ConnectionState,
|
|
162
|
+
payloadB64: string,
|
|
163
|
+
ctx: Ctx,
|
|
134
164
|
): { state: ConnectionState; effects: EffectType[] } {
|
|
135
165
|
const parsed = parsePlain(payloadB64);
|
|
136
166
|
|
|
@@ -145,12 +175,45 @@ function finalize(
|
|
|
145
175
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
const result = store.verify(
|
|
178
|
+
const result = store.verify('PLAIN', {
|
|
149
179
|
kind: 'PLAIN',
|
|
150
180
|
username: parsed.username,
|
|
151
181
|
password: parsed.password,
|
|
152
182
|
});
|
|
153
183
|
|
|
184
|
+
return applyResult(state, result, ctx);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** SASL EXTERNAL: resolve the mTLS cert subject and verify it. */
|
|
188
|
+
function finalizeExternal(
|
|
189
|
+
state: ConnectionState,
|
|
190
|
+
ctx: Ctx,
|
|
191
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
192
|
+
const provider = ctx.mtlsIdentity;
|
|
193
|
+
const identity = provider?.getIdentity(ctx.connId);
|
|
194
|
+
|
|
195
|
+
if (identity === undefined) {
|
|
196
|
+
clearSasl(state);
|
|
197
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const store = ctx.accounts;
|
|
201
|
+
if (store === undefined) {
|
|
202
|
+
clearSasl(state);
|
|
203
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
|
|
207
|
+
|
|
208
|
+
return applyResult(state, result, ctx);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Shared success/failure handling after a `verify` call. */
|
|
212
|
+
function applyResult(
|
|
213
|
+
state: ConnectionState,
|
|
214
|
+
result: SaslResult,
|
|
215
|
+
ctx: Ctx,
|
|
216
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
154
217
|
clearSasl(state);
|
|
155
218
|
|
|
156
219
|
if (result.ok) {
|
|
@@ -228,7 +291,7 @@ function saslAlready(ctx: Ctx): RawLine {
|
|
|
228
291
|
|
|
229
292
|
function saslMechs(ctx: Ctx): RawLine {
|
|
230
293
|
return L(
|
|
231
|
-
`:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${
|
|
294
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${supportedMechs(ctx)} :are the available SASL mechanisms`,
|
|
232
295
|
);
|
|
233
296
|
}
|
|
234
297
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducers for the server-information query commands `VERSION`, `TIME`,
|
|
3
|
+
* `ADMIN`, and `INFO`.
|
|
4
|
+
*
|
|
5
|
+
* PLAN §2.1 location-of-authority: these are read-only Connection-entity
|
|
6
|
+
* commands — they do not mutate connection or channel state, only emit
|
|
7
|
+
* `Send` numeric replies addressed to the requester. Each is a thin
|
|
8
|
+
* config/clock-derived reply, so they share a numeric-formatting helper.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 1459/2812):
|
|
11
|
+
* - `VERSION [server]` → `351 RPL_VERSION`:
|
|
12
|
+
* `:<server> 351 <nick> <version> <server> :<comments>`
|
|
13
|
+
* - `TIME [server]` → `391 RPL_TIME`:
|
|
14
|
+
* `:<server> 391 <nick> <server> :<human-readable time>`
|
|
15
|
+
* - `ADMIN [server]` → `256 RPL_ADMINME`:
|
|
16
|
+
* `:<server> 256 <nick> <server> :Administrative info`
|
|
17
|
+
* - `INFO [server]` → `371 RPL_INFO` ×N + `374 RPL_ENDOFINFO`:
|
|
18
|
+
* `:<server> 371 <nick> :<info line>`
|
|
19
|
+
* `:<server> 374 <nick> :End of INFO list`
|
|
20
|
+
*
|
|
21
|
+
* The optional `<server>` target parameter is ignored on all four: this is a
|
|
22
|
+
* single-server implementation (PLAN §1 non-goal: no S2S linking), so a
|
|
23
|
+
* request addressed to another server still gets the local info.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { resolveServerVersion } from '../config.js';
|
|
27
|
+
import { Effect } from '../effects.js';
|
|
28
|
+
import type { RawLine } from '../effects.js';
|
|
29
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
30
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
31
|
+
import type { Ctx, Reducer, ReducerResult } from '../types.js';
|
|
32
|
+
|
|
33
|
+
/** Fixed trailing text for `256 RPL_ADMINME`. */
|
|
34
|
+
const ADMINME_TRAILING_TEXT = 'Administrative info';
|
|
35
|
+
|
|
36
|
+
/** Fixed trailing text for `374 RPL_ENDOFINFO`. */
|
|
37
|
+
const ENDOFINFO_TRAILING_TEXT = 'End of INFO list';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handles `VERSION [server]`. Emits `351 RPL_VERSION` with the server version,
|
|
41
|
+
* server name, and network name as the comment.
|
|
42
|
+
*/
|
|
43
|
+
export const versionReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
44
|
+
state.lastSeen = ctx.clock.now();
|
|
45
|
+
|
|
46
|
+
const nick = ctx.connection.nick ?? '*';
|
|
47
|
+
const version = resolveServerVersion(ctx.serverConfig);
|
|
48
|
+
const line: RawLine = numericLine(
|
|
49
|
+
ctx,
|
|
50
|
+
Numerics.RPL_VERSION,
|
|
51
|
+
nick,
|
|
52
|
+
`${version} ${ctx.serverName} :${ctx.networkName}`,
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
state,
|
|
56
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handles `TIME [server]`. Emits `391 RPL_TIME` with an ISO-8601 timestamp
|
|
62
|
+
* derived from the injected {@link Clock} (deterministic under the fake clock).
|
|
63
|
+
*/
|
|
64
|
+
export const timeReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
65
|
+
state.lastSeen = ctx.clock.now();
|
|
66
|
+
|
|
67
|
+
const nick = ctx.connection.nick ?? '*';
|
|
68
|
+
const iso = new Date(ctx.clock.now()).toISOString();
|
|
69
|
+
const line = numericLine(ctx, Numerics.RPL_TIME, nick, `${ctx.serverName} :${iso}`);
|
|
70
|
+
return {
|
|
71
|
+
state,
|
|
72
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Handles `ADMIN [server]`. Emits `256 RPL_ADMINME` with the server name.
|
|
78
|
+
* No admin contact info is configured in v1, so the deeper `257`/`258`/`259`
|
|
79
|
+
* numerics are omitted (the server has no admin-email data to report).
|
|
80
|
+
*/
|
|
81
|
+
export const adminReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
82
|
+
state.lastSeen = ctx.clock.now();
|
|
83
|
+
|
|
84
|
+
const nick = ctx.connection.nick ?? '*';
|
|
85
|
+
const line = numericLine(
|
|
86
|
+
ctx,
|
|
87
|
+
Numerics.RPL_ADMINME,
|
|
88
|
+
nick,
|
|
89
|
+
`${ctx.serverName} :${ADMINME_TRAILING_TEXT}`,
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
state,
|
|
93
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Handles `INFO [server]`. Emits one or more `371 RPL_INFO` lines with
|
|
99
|
+
* server metadata followed by the `374 RPL_ENDOFINFO` terminator.
|
|
100
|
+
*/
|
|
101
|
+
export const infoReducer: Reducer<ConnectionState> = (
|
|
102
|
+
state,
|
|
103
|
+
_msg,
|
|
104
|
+
ctx,
|
|
105
|
+
): ReducerResult<ConnectionState> => {
|
|
106
|
+
state.lastSeen = ctx.clock.now();
|
|
107
|
+
|
|
108
|
+
const nick = ctx.connection.nick ?? '*';
|
|
109
|
+
const version = resolveServerVersion(ctx.serverConfig);
|
|
110
|
+
const out: RawLine[] = [
|
|
111
|
+
numericLine(ctx, Numerics.RPL_INFO, nick, `:ServerlessIRCd - version ${version}`),
|
|
112
|
+
numericLine(ctx, Numerics.RPL_INFO, nick, `:Running on network ${ctx.networkName}`),
|
|
113
|
+
numericLine(ctx, Numerics.RPL_ENDOFINFO, nick, `:${ENDOFINFO_TRAILING_TEXT}`),
|
|
114
|
+
];
|
|
115
|
+
return {
|
|
116
|
+
state,
|
|
117
|
+
effects: [Effect.send(ctx.connId, out)],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Builds `:<server> <code> <nick> <rest>` where `rest` already carries its
|
|
123
|
+
* own leading colon / spacing for the trailing parameter. This keeps the
|
|
124
|
+
* three reducers DRY without duplicating the prefix logic.
|
|
125
|
+
*/
|
|
126
|
+
function numericLine(ctx: Ctx, code: number, nick: string, rest: string): RawLine {
|
|
127
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
128
|
+
return { text: `:${ctx.serverName} ${codeStr} ${nick} ${rest}` };
|
|
129
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `USERHOST` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: USERHOST is read-only — it queries nick
|
|
5
|
+
* → connection state and emits a single `302 RPL_USERHOST` line. The reducer
|
|
6
|
+
* itself does not perform lookups (those are async and belong to the actor /
|
|
7
|
+
* runtime layer); it receives the pre-resolved snapshots and formats the
|
|
8
|
+
* reply.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 2812 §5.5):
|
|
11
|
+
* - `USERHOST <nick>{ <nick>}` (up to {@link ServerConfig.maxTargetsPerCommand})
|
|
12
|
+
* - `302 RPL_USERHOST`:
|
|
13
|
+
* `:<server> 302 <nick> :<reply1> <reply2> …`
|
|
14
|
+
* - Each reply: `<nick>[*]=<+|-><user>@<host>`
|
|
15
|
+
* - `*` between nick and `=` marks an IRC operator.
|
|
16
|
+
* - `+` means available; `-` means away.
|
|
17
|
+
* - Offline nicks are omitted from the reply entirely.
|
|
18
|
+
*
|
|
19
|
+
* The actor resolves each requested nick via `runtime.lookupNick` +
|
|
20
|
+
* `runtime.getConnection` before calling this reducer, passing `null` for
|
|
21
|
+
* nicks that are offline.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Effect } from '../effects.js';
|
|
25
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
26
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
27
|
+
import type { ConnSnapshot, ConnectionState } from '../state/connection.js';
|
|
28
|
+
import type { Ctx, ReducerResult } from '../types.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Formats a single USERHOST reply token for a resolved connection snapshot.
|
|
32
|
+
*
|
|
33
|
+
* `nick[*]=[+|-]user@host` — `*` for operators, `+` for available, `-` for
|
|
34
|
+
* away. Returns `null` for snapshots missing the required `nick` / `user` /
|
|
35
|
+
* `host` fields (should not happen for registered connections, but guarded).
|
|
36
|
+
*/
|
|
37
|
+
export function formatUserhostReply(snap: ConnSnapshot): string | null {
|
|
38
|
+
if (snap.nick === undefined || snap.user === undefined || snap.host === undefined) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const operMark = snap.userModes.oper ? '*' : '';
|
|
42
|
+
const awayMark = snap.away !== undefined ? '-' : '+';
|
|
43
|
+
return `${snap.nick}${operMark}=${awayMark}${snap.user}@${snap.host}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handles `USERHOST <nick>{ <nick>}`.
|
|
48
|
+
*
|
|
49
|
+
* `resolved` maps each requested nick (original case) to its snapshot, or
|
|
50
|
+
* `null` when the nick is offline. The reducer formats each online entry into
|
|
51
|
+
* a USERHOST reply token and emits a single `302` line. Offline nicks are
|
|
52
|
+
* omitted. The number of queried nicks is capped at
|
|
53
|
+
* `ctx.serverConfig.maxTargetsPerCommand`.
|
|
54
|
+
*
|
|
55
|
+
* Signature is bespoke (not {@link Reducer}) because the actor must resolve
|
|
56
|
+
* the nicks asynchronously before formatting — mirrors `whoReducer` /
|
|
57
|
+
* `whoisReducer`.
|
|
58
|
+
*/
|
|
59
|
+
export function userhostReducer(
|
|
60
|
+
resolved: ReadonlyMap<string, ConnSnapshot | null>,
|
|
61
|
+
msg: { params: readonly string[] },
|
|
62
|
+
ctx: Ctx,
|
|
63
|
+
): ReducerResult<ConnectionState> {
|
|
64
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
65
|
+
|
|
66
|
+
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
67
|
+
const requested = msg.params.slice(0, cap);
|
|
68
|
+
const tokens: string[] = [];
|
|
69
|
+
for (const nick of requested) {
|
|
70
|
+
if (nick.length === 0) continue;
|
|
71
|
+
const snap = resolved.get(nick);
|
|
72
|
+
if (snap === null || snap === undefined) continue;
|
|
73
|
+
const token = formatUserhostReply(snap);
|
|
74
|
+
if (token !== null) tokens.push(token);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ircNick = ctx.connection.nick ?? '*';
|
|
78
|
+
const codeStr = Numerics.RPL_USERHOST.toString().padStart(3, '0');
|
|
79
|
+
const trailing = tokens.join(' ');
|
|
80
|
+
const line: RawLine = { text: `:${ctx.serverName} ${codeStr} ${ircNick} :${trailing}` };
|
|
81
|
+
|
|
82
|
+
const effects: EffectType[] = [Effect.send(ctx.connId, [line])];
|
|
83
|
+
return { state: ctx.connection, effects };
|
|
84
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `WHOWAS` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: WHOWAS reads the nick-history store but
|
|
5
|
+
* does not mutate connection or channel state. The actor layer resolves
|
|
6
|
+
* `ctx.history` (a synchronously-readable {@link NickHistoryStore}) and
|
|
7
|
+
* passes it here. The requester is `ctx.connection`.
|
|
8
|
+
*
|
|
9
|
+
* Wire format (RFC 1459/2812 §3.6.3):
|
|
10
|
+
* - `314 RPL_WHOWASUSER`:
|
|
11
|
+
* `:<server> 314 <nick> <target> <user> <host> * :<real>`
|
|
12
|
+
* - `369 RPL_ENDOFWHOWAS`:
|
|
13
|
+
* `:<server> 369 <nick> <target> :End of WHOWAS`
|
|
14
|
+
* - `406 ERR_WASNOSUCHNICK`:
|
|
15
|
+
* `:<server> 406 <nick> <target> :There was no such nickname`
|
|
16
|
+
* - `431 ERR_NONICKNAMEGIVEN`:
|
|
17
|
+
* `:<server> 431 <nick> :No nickname given`
|
|
18
|
+
*
|
|
19
|
+
* The reducer takes a bespoke signature (mirroring {@link whoisReducer})
|
|
20
|
+
* because it queries runtime-provided state (the history store) rather than
|
|
21
|
+
* the `Reducer<S>` state argument.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Effect } from '../effects.js';
|
|
25
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
26
|
+
import type { NickHistoryEntry, NickHistoryStore } from '../ports.js';
|
|
27
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
28
|
+
import type { Ctx } from '../types.js';
|
|
29
|
+
|
|
30
|
+
/** Formats a numeric line addressed to the requester. */
|
|
31
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
32
|
+
const nick = ctx.connection.nick ?? '*';
|
|
33
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
34
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
35
|
+
if (middle !== undefined) parts.push(middle);
|
|
36
|
+
parts.push(`:${trailing}`);
|
|
37
|
+
return { text: parts.join(' ') };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Builds the `314 RPL_WHOWASUSER` line for one sign-off entry. */
|
|
41
|
+
function whowasUserLine(ctx: Ctx, entry: NickHistoryEntry): RawLine {
|
|
42
|
+
const requesterNick = ctx.connection.nick ?? '*';
|
|
43
|
+
const nick = entry.nick;
|
|
44
|
+
const user = entry.username ?? '?';
|
|
45
|
+
const host = entry.hostname ?? '?';
|
|
46
|
+
const real = entry.realname ?? nick;
|
|
47
|
+
const code = Numerics.RPL_WHOWASUSER.toString().padStart(3, '0');
|
|
48
|
+
return { text: `:${ctx.serverName} ${code} ${requesterNick} ${nick} ${user} ${host} * :${real}` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handles `WHOWAS <nick>{,<nick>} [<count>]`.
|
|
53
|
+
*
|
|
54
|
+
* The actor layer resolves `ctx.history` (absent when no nick-history store
|
|
55
|
+
* is bound) and passes the store (or `undefined`) here. For each queried
|
|
56
|
+
* nick the reducer emits one `314 RPL_WHOWASUSER` per matching history
|
|
57
|
+
* entry (capped at `count`, most-recent-first), then exactly one
|
|
58
|
+
* `369 RPL_ENDOFWHOWAS`. When a nick has no matches it emits
|
|
59
|
+
* `406 ERR_WASNOSUCHNICK` before the `369`. When no nick argument is
|
|
60
|
+
* supplied it emits `431 ERR_NONICKNAMEGIVEN` and returns.
|
|
61
|
+
*/
|
|
62
|
+
export function whowasReducer(
|
|
63
|
+
history: NickHistoryStore | undefined,
|
|
64
|
+
msg: { command: string; params: string[] },
|
|
65
|
+
ctx: Ctx,
|
|
66
|
+
): { effects: EffectType[] } {
|
|
67
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
68
|
+
|
|
69
|
+
const effects: EffectType[] = [];
|
|
70
|
+
const nickParam = msg.params[0];
|
|
71
|
+
if (nickParam === undefined || nickParam.length === 0) {
|
|
72
|
+
effects.push(
|
|
73
|
+
Effect.send(ctx.connId, [
|
|
74
|
+
numericLine(ctx, Numerics.ERR_NONICKNAMEGIVEN, 'No nickname given'),
|
|
75
|
+
]),
|
|
76
|
+
);
|
|
77
|
+
return { effects };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const countParam = msg.params[1];
|
|
81
|
+
const nicks = nickParam.split(',').filter((n) => n.length > 0);
|
|
82
|
+
const count = countParam !== undefined ? parseCount(countParam) : Number.POSITIVE_INFINITY;
|
|
83
|
+
|
|
84
|
+
for (const nick of nicks) {
|
|
85
|
+
const matches = history === undefined ? [] : history.query(nick, count);
|
|
86
|
+
for (const entry of matches) {
|
|
87
|
+
effects.push(Effect.send(ctx.connId, [whowasUserLine(ctx, entry)]));
|
|
88
|
+
}
|
|
89
|
+
if (matches.length === 0) {
|
|
90
|
+
effects.push(
|
|
91
|
+
Effect.send(ctx.connId, [
|
|
92
|
+
numericLine(ctx, Numerics.ERR_WASNOSUCHNICK, 'There was no such nickname', nick),
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
effects.push(
|
|
97
|
+
Effect.send(ctx.connId, [numericLine(ctx, Numerics.RPL_ENDOFWHOWAS, 'End of WHOWAS', nick)]),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { effects };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parses the `WHOWAS` count argument. RFC 1459 allows a non-positive or
|
|
106
|
+
* missing count to mean "unlimited"; a malformed (non-numeric) count is
|
|
107
|
+
* treated as unlimited as well so the command still answers.
|
|
108
|
+
*/
|
|
109
|
+
function parseCount(raw: string): number {
|
|
110
|
+
const n = Number.parseInt(raw, 10);
|
|
111
|
+
if (!Number.isFinite(n) || n <= 0) return Number.POSITIVE_INFINITY;
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
+
import { BUILD_DATE } from './build-info.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Schema for a single IRC operator credential.
|
|
@@ -113,6 +114,26 @@ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
|
|
|
113
114
|
disconnectThreshold: 0,
|
|
114
115
|
};
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Default server version surfaced in `002`/`004`/`351`/`371` when a deployment
|
|
119
|
+
* does not supply one. Kept in sync with `irc-core/package.json` `version`;
|
|
120
|
+
* adapters inject the deploy-time version via the `serverVersion` config
|
|
121
|
+
* knob (see the per-adapter config loaders).
|
|
122
|
+
*/
|
|
123
|
+
export const DEFAULT_SERVER_VERSION = '0.3.0';
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Default "created" text for `003 RPL_CREATED` when a deployment does not
|
|
127
|
+
* supply one. Baked into `dist/index.js` at build time by
|
|
128
|
+
* `scripts/generate-build-info.mjs` (run via `prebuild`); the value is the
|
|
129
|
+
* ISO timestamp of the build that produced the artifact, so a deployed
|
|
130
|
+
* Worker/Lambda reports the date it was built rather than a static
|
|
131
|
+
* placeholder. Production deployments may still override via the `createdAt`
|
|
132
|
+
* config knob (a human-readable string or an epoch-ms number that the
|
|
133
|
+
* reducer formats).
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
|
|
136
|
+
|
|
116
137
|
/**
|
|
117
138
|
* The full server-config schema. Optional fields default to deployment-
|
|
118
139
|
* sensible values so a minimal `{ serverName, networkName }` config is
|
|
@@ -124,6 +145,22 @@ export const ServerConfigSchema = z.object({
|
|
|
124
145
|
/** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
|
|
125
146
|
networkName: z.string().min(1),
|
|
126
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Server version surfaced in the `002`/`004` welcome numerics, `351
|
|
150
|
+
* RPL_VERSION`, and `371 RPL_INFO`. Defaults to the irc-core package
|
|
151
|
+
* version ({@link DEFAULT_SERVER_VERSION}); deployments inject the
|
|
152
|
+
* deploy-time version (e.g. via a build-time constant or env var).
|
|
153
|
+
*/
|
|
154
|
+
serverVersion: z.string().min(1).default(DEFAULT_SERVER_VERSION),
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* "Created" text for `003 RPL_CREATED` (`This server was created in
|
|
158
|
+
* <text>`). Either a human-readable string used verbatim, or an epoch-ms
|
|
159
|
+
* number that the reducer formats as a UTC timestamp. Defaults to
|
|
160
|
+
* {@link DEFAULT_CREATED_TEXT}; deployments inject the deploy/build time.
|
|
161
|
+
*/
|
|
162
|
+
createdAt: z.union([z.string().min(1), z.number()]).default(DEFAULT_CREATED_TEXT),
|
|
163
|
+
|
|
127
164
|
/**
|
|
128
165
|
* Server password. Empty string or undefined disables the password
|
|
129
166
|
* gate. When set, every connection must supply the same value via
|
|
@@ -268,3 +305,24 @@ export const DEFAULT_SERVER_CONFIG: ParsedServerConfig = ServerConfigSchema.pars
|
|
|
268
305
|
serverName: 'irc.example.com',
|
|
269
306
|
networkName: 'ServerlessIRCd',
|
|
270
307
|
});
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Resolves the effective server version for wire output: the configured
|
|
311
|
+
* value when present, otherwise {@link DEFAULT_SERVER_VERSION}. Used by the
|
|
312
|
+
* registration and server-info reducers so they share one source of truth.
|
|
313
|
+
*/
|
|
314
|
+
export function resolveServerVersion(cfg: { readonly serverVersion?: string | undefined }): string {
|
|
315
|
+
return cfg.serverVersion ?? DEFAULT_SERVER_VERSION;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Formats the `003 RPL_CREATED` text from the configured `createdAt`:
|
|
320
|
+
* a string is used verbatim, a number is treated as epoch-ms and rendered
|
|
321
|
+
* as a UTC timestamp, and `undefined` falls back to
|
|
322
|
+
* {@link DEFAULT_CREATED_TEXT}.
|
|
323
|
+
*/
|
|
324
|
+
export function formatCreatedAt(createdAt: string | number | undefined): string {
|
|
325
|
+
if (createdAt === undefined) return DEFAULT_CREATED_TEXT;
|
|
326
|
+
if (typeof createdAt === 'number') return new Date(createdAt).toUTCString();
|
|
327
|
+
return createdAt;
|
|
328
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SASL credential hashing primitives.
|
|
3
|
+
*
|
|
4
|
+
* Both the AWS adapter (`DynamoAccountStore`) and the CF adapter
|
|
5
|
+
* (`D1AccountStore`) store SASL PLAIN credentials as **scrypt hashes**.
|
|
6
|
+
* These helpers live in `irc-core` so both adapters share one hashing
|
|
7
|
+
* implementation — the AWS path previously owned the only copy.
|
|
8
|
+
*
|
|
9
|
+
* Uses `node:crypto` (`scryptSync`), which is available on Node (AWS Lambda)
|
|
10
|
+
* natively and on Cloudflare Workers via the `nodejs_compat` flag. The public
|
|
11
|
+
* API surface is platform-agnostic: salts are passed as `Uint8Array` and the
|
|
12
|
+
* stored `HashedAccountCredential` carries only base64 `string` fields, so
|
|
13
|
+
* consuming packages never need `@types/node` to use these helpers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import type { AccountStore, SaslPayload, SaslResult } from './ports.js';
|
|
18
|
+
|
|
19
|
+
const SCRYPT_KEY_LEN = 64;
|
|
20
|
+
const SALT_LEN = 16;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single SASL PLAIN credential stored as a scrypt hash.
|
|
24
|
+
*
|
|
25
|
+
* Every field is a persistence-backend attribute — this object IS the table
|
|
26
|
+
* row (DynamoDB `Accounts` item / D1 `accounts` row, keyed by `account`).
|
|
27
|
+
* `algorithm` is retained so future migrations to Argon2/bcrypt can be
|
|
28
|
+
* detected and handled gracefully by {@link verifyHashedPassword}.
|
|
29
|
+
*/
|
|
30
|
+
export interface HashedAccountCredential {
|
|
31
|
+
readonly account: string;
|
|
32
|
+
readonly algorithm: 'scrypt';
|
|
33
|
+
readonly salt: string;
|
|
34
|
+
readonly hash: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hashes a plaintext password into a {@link HashedAccountCredential}
|
|
39
|
+
* suitable for writing to the accounts backend (`putAccountCredential` /
|
|
40
|
+
* `seed-cf-accounts`).
|
|
41
|
+
*
|
|
42
|
+
* Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
|
|
43
|
+
* Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
|
|
44
|
+
* override the default 64-byte derived-key length.
|
|
45
|
+
*
|
|
46
|
+
* @param opts.salt Optional salt (deterministic for tests). When omitted a
|
|
47
|
+
* fresh cryptographically-random salt is generated. Typed as `Uint8Array`
|
|
48
|
+
* to keep the public API free of node-specific `Buffer` types.
|
|
49
|
+
*/
|
|
50
|
+
export function hashAccountCredential(
|
|
51
|
+
username: string,
|
|
52
|
+
password: string,
|
|
53
|
+
opts?: { salt?: Uint8Array; keyLen?: number },
|
|
54
|
+
): HashedAccountCredential {
|
|
55
|
+
const salt = opts?.salt ?? randomBytes(SALT_LEN);
|
|
56
|
+
const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
|
|
57
|
+
const hash = scryptSync(password, salt, keyLen);
|
|
58
|
+
return {
|
|
59
|
+
account: username,
|
|
60
|
+
algorithm: 'scrypt',
|
|
61
|
+
salt: Buffer.from(salt).toString('base64'),
|
|
62
|
+
hash: hash.toString('base64'),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Verifies a plaintext password against a {@link HashedAccountCredential}
|
|
68
|
+
* using `timingSafeEqual` (no short-circuit on first mismatched byte).
|
|
69
|
+
*
|
|
70
|
+
* Returns `false` for malformed entries rather than throwing — a corrupt
|
|
71
|
+
* row should not crash the SASL exchange.
|
|
72
|
+
*/
|
|
73
|
+
export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
|
|
74
|
+
if (entry.algorithm !== 'scrypt') return false;
|
|
75
|
+
const salt = Buffer.from(entry.salt, 'base64');
|
|
76
|
+
const expected = Buffer.from(entry.hash, 'base64');
|
|
77
|
+
if (expected.length === 0) return false;
|
|
78
|
+
const computed = scryptSync(password, salt, expected.length);
|
|
79
|
+
return computed.length === expected.length && timingSafeEqual(computed, expected);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
|
|
84
|
+
*
|
|
85
|
+
* Construct with entries produced by {@link hashAccountCredential} (for unit
|
|
86
|
+
* tests / seeding) or pre-loaded from a persistence backend at boot. Both
|
|
87
|
+
* adapters use this class:
|
|
88
|
+
*
|
|
89
|
+
* - AWS: `loadDynamoAccountStore` scans the `Accounts` table at Lambda
|
|
90
|
+
* cold start and constructs this store; `DynamoAccountStore` is a
|
|
91
|
+
* re-export alias for back-compat.
|
|
92
|
+
* - CF: `loadD1AccountStore` queries the D1 `accounts` table at
|
|
93
|
+
* `ConnectionDO` construction (mirroring the two-phase load pattern).
|
|
94
|
+
*
|
|
95
|
+
* The class has zero backend coupling — it works equally well with entries
|
|
96
|
+
* from any source. The `AccountStore.verify` port stays synchronous, so
|
|
97
|
+
* the async pre-load MUST complete before the first frame dispatches.
|
|
98
|
+
*/
|
|
99
|
+
export class HashedAccountStore implements AccountStore {
|
|
100
|
+
private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
|
|
101
|
+
|
|
102
|
+
constructor(entries: ReadonlyArray<HashedAccountCredential>) {
|
|
103
|
+
this.entries = new Map(entries.map((e) => [e.account, e]));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
107
|
+
if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
|
|
108
|
+
return { ok: false, reason: `unsupported mechanism: ${mech}` };
|
|
109
|
+
}
|
|
110
|
+
const entry = this.entries.get(payload.username);
|
|
111
|
+
if (entry === undefined) {
|
|
112
|
+
return { ok: false, reason: 'invalid credentials' };
|
|
113
|
+
}
|
|
114
|
+
if (!verifyHashedPassword(payload.password, entry)) {
|
|
115
|
+
return { ok: false, reason: 'invalid credentials' };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, account: payload.username };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Number of loaded accounts (diagnostics / logging). */
|
|
121
|
+
get size(): number {
|
|
122
|
+
return this.entries.size;
|
|
123
|
+
}
|
|
124
|
+
}
|