serverless-ircd 0.9.0 → 0.10.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/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +305 -0
- package/README.md +134 -41
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +12 -9
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +21 -17
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +266 -109
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +122 -11
- package/packages/irc-core/src/commands/registration.ts +2 -2
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +140 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +0 -14
- package/packages/irc-server/tests/actor.test.ts +19 -47
- package/packages/irc-test-support/package.json +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
*
|
|
27
27
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
28
28
|
* emitted as {@link Effect}s. The services store is synchronous (mirrors
|
|
29
|
-
* the {@link
|
|
29
|
+
* the {@link MessageStore} pattern).
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
|
|
32
|
+
import { DEFAULT_MIN_PASSWORD_LENGTH, DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
|
|
33
33
|
import { Effect } from '../effects.js';
|
|
34
34
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
35
35
|
import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
|
|
@@ -48,6 +48,30 @@ const SERVICES_HOST = 'services';
|
|
|
48
48
|
/** Policies accepted by `SET ENFORCE` (lowercased input forms). */
|
|
49
49
|
const ENFORCE_POLICIES: ReadonlySet<NickEnforcePolicy> = new Set(['none', 'ghost', 'kill']);
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Fixed maximum password length accepted by `SET PASSWORD`. Bounds the
|
|
53
|
+
* scrypt input budget (256 is the practical limit of the scrypt input);
|
|
54
|
+
* not configurable. Over-long passwords are rejected before hashing.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_PASSWORD_LENGTH = 256;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gate NOTICE sent to an unidentified caller (or one identified as a
|
|
60
|
+
* different account) attempting `SET ENFORCE`. Wording kept verbatim from
|
|
61
|
+
* the original inline literal so the existing ENFORCE test suite is not
|
|
62
|
+
* churned by the addition of `SET PASSWORD` (a sibling constant covers the
|
|
63
|
+
* PASSWORD gate).
|
|
64
|
+
*/
|
|
65
|
+
const ENFORCE_GATE_NOTICE = 'You must identify before changing enforcement.';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Gate NOTICE sent to an unidentified caller (or one identified as a
|
|
69
|
+
* different account) attempting `SET PASSWORD`. Wording mirrors the ENFORCE
|
|
70
|
+
* gate's intent but speaks to "password" rather than "enforcement" so a
|
|
71
|
+
* caller who hits it understands which subcommand they tripped.
|
|
72
|
+
*/
|
|
73
|
+
const PASSWORD_GATE_NOTICE = 'You must identify before changing your password.';
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
|
|
53
77
|
* Authority: the invoking connection's {@link ConnectionState}.
|
|
@@ -318,12 +342,24 @@ export function enforceRegisteredNick(state: ConnectionState, ctx: Ctx): EffectT
|
|
|
318
342
|
// ============================================================================
|
|
319
343
|
|
|
320
344
|
/**
|
|
321
|
-
* Handles `SET <subkey> <args…>`.
|
|
322
|
-
*
|
|
345
|
+
* Handles `SET <subkey> <args…>`. Supported subkeys:
|
|
346
|
+
*
|
|
347
|
+
* - `ENFORCE none|ghost|kill` — sets the per-account enforcement policy.
|
|
348
|
+
* - `PASSWORD <old> <new>` — self-service password change. Requires the
|
|
349
|
+
* current password to be re-supplied as a defence against a hijacked
|
|
350
|
+
* identified session (a stolen `+r` session should not let an attacker
|
|
351
|
+
* silently rotate the password and lock the real owner out — this is
|
|
352
|
+
* Atheme's shape, not the alternative "trust the gate" one-arg form).
|
|
353
|
+
* The new password is re-hashed via `hashAccountCredential` and
|
|
354
|
+
* overwrites the stored credential via
|
|
355
|
+
* {@link ServicesStore.setNickPassword}; the current session stays
|
|
356
|
+
* `+r` and other sessions of the same account are NOT kicked.
|
|
357
|
+
*
|
|
358
|
+
* Unknown subkeys fall back to the ENFORCE syntax notice.
|
|
323
359
|
*
|
|
324
|
-
*
|
|
325
|
-
* nick:
|
|
326
|
-
*
|
|
360
|
+
* Both subkeys require the caller to be identified as the account bound to
|
|
361
|
+
* the current nick: an unidentified client (or one identified as a
|
|
362
|
+
* different account) cannot mutate the account.
|
|
327
363
|
*/
|
|
328
364
|
function handleSet(
|
|
329
365
|
state: ConnectionState,
|
|
@@ -331,8 +367,10 @@ function handleSet(
|
|
|
331
367
|
ctx: Ctx,
|
|
332
368
|
effects: EffectType[],
|
|
333
369
|
): { state: ConnectionState; effects: EffectType[] } {
|
|
334
|
-
const services = ctx.services as ServicesStore;
|
|
335
370
|
const subkey = args[0]?.toUpperCase();
|
|
371
|
+
if (subkey === 'PASSWORD') {
|
|
372
|
+
return handleSetPassword(state, args.slice(1), ctx, effects);
|
|
373
|
+
}
|
|
336
374
|
if (subkey !== 'ENFORCE') {
|
|
337
375
|
effects.push(enforceSyntaxNotice(state));
|
|
338
376
|
return { state, effects };
|
|
@@ -347,10 +385,11 @@ function handleSet(
|
|
|
347
385
|
|
|
348
386
|
const nick = state.nick;
|
|
349
387
|
if (nick === undefined || state.account === undefined) {
|
|
350
|
-
effects.push(notice(state,
|
|
388
|
+
effects.push(notice(state, ENFORCE_GATE_NOTICE));
|
|
351
389
|
return { state, effects };
|
|
352
390
|
}
|
|
353
391
|
|
|
392
|
+
const services = ctx.services as ServicesStore;
|
|
354
393
|
const rec = services.getNick(nick);
|
|
355
394
|
if (rec === undefined) {
|
|
356
395
|
effects.push(notice(state, `Nick ${nick} is not registered.`));
|
|
@@ -358,7 +397,7 @@ function handleSet(
|
|
|
358
397
|
}
|
|
359
398
|
// The caller must be identified as the account that owns this nick.
|
|
360
399
|
if (state.account !== rec.account) {
|
|
361
|
-
effects.push(notice(state,
|
|
400
|
+
effects.push(notice(state, ENFORCE_GATE_NOTICE));
|
|
362
401
|
return { state, effects };
|
|
363
402
|
}
|
|
364
403
|
|
|
@@ -367,11 +406,80 @@ function handleSet(
|
|
|
367
406
|
return { state, effects };
|
|
368
407
|
}
|
|
369
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Handles `SET PASSWORD <old> <new>`. Mirrors the `SET ENFORCE` identify
|
|
411
|
+
* gate (`state.account === rec.account`), then re-verifies the current
|
|
412
|
+
* password (no-leak parity with `IDENTIFY`'s `Invalid password.` notice)
|
|
413
|
+
* and enforces the configurable minimum / fixed maximum length on the new
|
|
414
|
+
* password before re-hashing and persisting.
|
|
415
|
+
*/
|
|
416
|
+
function handleSetPassword(
|
|
417
|
+
state: ConnectionState,
|
|
418
|
+
args: string[],
|
|
419
|
+
ctx: Ctx,
|
|
420
|
+
effects: EffectType[],
|
|
421
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
422
|
+
const oldPassword = args[0];
|
|
423
|
+
const newPassword = args[1];
|
|
424
|
+
if (oldPassword === undefined || newPassword === undefined) {
|
|
425
|
+
effects.push(passwordSyntaxNotice(state));
|
|
426
|
+
return { state, effects };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const nick = state.nick;
|
|
430
|
+
if (nick === undefined || state.account === undefined) {
|
|
431
|
+
effects.push(notice(state, PASSWORD_GATE_NOTICE));
|
|
432
|
+
return { state, effects };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const services = ctx.services as ServicesStore;
|
|
436
|
+
const rec = services.getNick(nick);
|
|
437
|
+
if (rec === undefined) {
|
|
438
|
+
effects.push(notice(state, `Nick ${nick} is not registered.`));
|
|
439
|
+
return { state, effects };
|
|
440
|
+
}
|
|
441
|
+
// The caller must be identified as the account that owns this nick.
|
|
442
|
+
if (state.account !== rec.account) {
|
|
443
|
+
effects.push(notice(state, PASSWORD_GATE_NOTICE));
|
|
444
|
+
return { state, effects };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Re-verify the current password (defence against a hijacked `+r`
|
|
448
|
+
// session). Same `verifyNick` path `IDENTIFY` uses → same `Invalid
|
|
449
|
+
// password.` notice → no information leak about whether the account
|
|
450
|
+
// exists (the gate above already proved it does).
|
|
451
|
+
if (!services.verifyNick(nick, oldPassword).ok) {
|
|
452
|
+
effects.push(notice(state, 'Invalid password.'));
|
|
453
|
+
return { state, effects };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const minLen = ctx.serverConfig.nickServ?.minPasswordLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
|
|
457
|
+
if (newPassword.length < minLen) {
|
|
458
|
+
effects.push(notice(state, `Password is too short (minimum ${minLen} characters).`));
|
|
459
|
+
return { state, effects };
|
|
460
|
+
}
|
|
461
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
462
|
+
effects.push(
|
|
463
|
+
notice(state, `Password is too long (maximum ${MAX_PASSWORD_LENGTH} characters).`),
|
|
464
|
+
);
|
|
465
|
+
return { state, effects };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
services.setNickPassword(nick, newPassword);
|
|
469
|
+
effects.push(notice(state, 'Password changed.'));
|
|
470
|
+
return { state, effects };
|
|
471
|
+
}
|
|
472
|
+
|
|
370
473
|
/** Builds the `SET ENFORCE` syntax NOTICE. */
|
|
371
474
|
function enforceSyntaxNotice(state: ConnectionState): EffectType {
|
|
372
475
|
return notice(state, 'Syntax: SET ENFORCE none|ghost|kill');
|
|
373
476
|
}
|
|
374
477
|
|
|
478
|
+
/** Builds the `SET PASSWORD` syntax NOTICE. */
|
|
479
|
+
function passwordSyntaxNotice(state: ConnectionState): EffectType {
|
|
480
|
+
return notice(state, 'Syntax: SET PASSWORD <old-password> <new-password>');
|
|
481
|
+
}
|
|
482
|
+
|
|
375
483
|
// ============================================================================
|
|
376
484
|
// Notice helpers
|
|
377
485
|
// ============================================================================
|
|
@@ -387,7 +495,10 @@ function helpNotice(state: ConnectionState): EffectType {
|
|
|
387
495
|
}
|
|
388
496
|
|
|
389
497
|
function unknownNotice(state: ConnectionState): EffectType {
|
|
390
|
-
return notice(
|
|
498
|
+
return notice(
|
|
499
|
+
state,
|
|
500
|
+
'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
|
|
501
|
+
);
|
|
391
502
|
}
|
|
392
503
|
|
|
393
504
|
/**
|
|
@@ -119,7 +119,7 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
119
119
|
// the account-login numerics (900/903 + downstream effects), satisfying
|
|
120
120
|
// the gate as a side effect. On every other outcome it returns `[]` and
|
|
121
121
|
// the gate below decides. No-op when already identified (SASL), when no
|
|
122
|
-
// `<nick>:<password>` form is present, or when no
|
|
122
|
+
// `<nick>:<password>` form is present, or when no ServicesStore is bound.
|
|
123
123
|
const authEffects = passBasedAccountAuth(state, ctx);
|
|
124
124
|
|
|
125
125
|
const gateFailure = passwordGateFailure(state, ctx);
|
|
@@ -351,7 +351,7 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
351
351
|
*
|
|
352
352
|
* Post-registration: a bare server password is meaningless, so only the
|
|
353
353
|
* `<nick>:<password>` form is honoured — as a late account login via
|
|
354
|
-
* {@link attemptPassAccountAuth} against the bound `ctx.
|
|
354
|
+
* {@link attemptPassAccountAuth} against the bound `ctx.services`. Every
|
|
355
355
|
* other outcome (bare value, no store, already identified, verify failure)
|
|
356
356
|
* is a silent no-op: no `462`, no disconnect. This lets a client that
|
|
357
357
|
* completed `NICK`+`USER` before sending `PASS` identify after the fact.
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
* Anything else gets `908 ERR_SASLMECHS` listing what is available.
|
|
14
14
|
* 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
|
|
15
15
|
* or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
|
|
16
|
-
* decoded bytes are
|
|
17
|
-
* success the connection records the account name and the reducer
|
|
18
|
-
* `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
|
|
16
|
+
* decoded bytes are verified against the bound {@link ServicesStore};
|
|
17
|
+
* on success the connection records the account name and the reducer
|
|
18
|
+
* emits `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
|
|
19
|
+
* `904 ERR_SASLFAIL`. PLAIN consults `services.verifyNick`, EXTERNAL
|
|
20
|
+
* consults `services.verifyCertFP` against the mTLS-resolved subject.
|
|
19
21
|
* 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
|
|
20
22
|
*
|
|
21
|
-
* The
|
|
23
|
+
* The {@link ServicesStore} port is **synchronous** (reducers stay pure):
|
|
22
24
|
* adapters pre-load credentials so the reducer stays pure. Async backends
|
|
23
25
|
* resolve their lookups before the actor invokes this reducer.
|
|
24
26
|
*
|
|
@@ -29,7 +31,7 @@
|
|
|
29
31
|
|
|
30
32
|
import { Effect } from '../effects.js';
|
|
31
33
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
-
import type {
|
|
34
|
+
import type { ServicesAuthResult } from '../ports.js';
|
|
33
35
|
import { decodeBase64 } from '../protocol/base64.js';
|
|
34
36
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
37
|
import type { ConnectionState } from '../state/connection.js';
|
|
@@ -143,7 +145,7 @@ function handlePayload(
|
|
|
143
145
|
return finalize(state, next, mech, ctx);
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
/** Decodes the accumulated payload and
|
|
148
|
+
/** Decodes the accumulated payload and verifies it against the ServicesStore. */
|
|
147
149
|
function finalize(
|
|
148
150
|
state: ConnectionState,
|
|
149
151
|
payloadB64: string,
|
|
@@ -169,28 +171,16 @@ function finalizePlain(
|
|
|
169
171
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
username: parsed.username,
|
|
179
|
-
password: parsed.password,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
if (result === undefined || !result.ok) {
|
|
183
|
-
const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
|
|
184
|
-
if (servicesResult?.ok) {
|
|
185
|
-
result = servicesResult;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (result === undefined) {
|
|
174
|
+
// Single credential home: services.verifyNick covers every NickServ
|
|
175
|
+
// registration, every SASL_ACCOUNTS-seeded account (carried into
|
|
176
|
+
// services via the boot ingest), and every PASS-auth share. No separate
|
|
177
|
+
// static credential fallback — there is no second credential table.
|
|
178
|
+
const services = ctx.services;
|
|
179
|
+
if (services === undefined) {
|
|
190
180
|
clearSasl(state);
|
|
191
181
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
192
182
|
}
|
|
193
|
-
|
|
183
|
+
const result = services.verifyNick(parsed.username, parsed.password);
|
|
194
184
|
return applyResult(state, result, ctx);
|
|
195
185
|
}
|
|
196
186
|
|
|
@@ -207,21 +197,22 @@ function finalizeExternal(
|
|
|
207
197
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
208
198
|
}
|
|
209
199
|
|
|
210
|
-
|
|
211
|
-
|
|
200
|
+
// Cert subjects are mapped to accounts via services.verifyCertFP — the
|
|
201
|
+
// same single credential home as SASL PLAIN / PASS-auth. No separate
|
|
202
|
+
// services lookup.
|
|
203
|
+
const services = ctx.services;
|
|
204
|
+
if (services === undefined) {
|
|
212
205
|
clearSasl(state);
|
|
213
206
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
214
207
|
}
|
|
215
|
-
|
|
216
|
-
const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
|
|
217
|
-
|
|
208
|
+
const result = services.verifyCertFP(identity);
|
|
218
209
|
return applyResult(state, result, ctx);
|
|
219
210
|
}
|
|
220
211
|
|
|
221
212
|
/** Shared success/failure handling after a `verify` call. */
|
|
222
213
|
function applyResult(
|
|
223
214
|
state: ConnectionState,
|
|
224
|
-
result:
|
|
215
|
+
result: ServicesAuthResult,
|
|
225
216
|
ctx: Ctx,
|
|
226
217
|
): { state: ConnectionState; effects: EffectType[] } {
|
|
227
218
|
clearSasl(state);
|
|
@@ -34,8 +34,8 @@ export const OperCredSchema = z.object({
|
|
|
34
34
|
/**
|
|
35
35
|
* Schema for a single SASL PLAIN account credential. The `username` is also
|
|
36
36
|
* the canonical account name recorded on the connection (for `extended-join`
|
|
37
|
-
* / `account-tag`). Both fields are non-empty strings; adapters seed
|
|
38
|
-
*
|
|
37
|
+
* / `account-tag`). Both fields are non-empty strings; adapters seed the
|
|
38
|
+
* `InMemoryServicesStore` from this list at boot.
|
|
39
39
|
*/
|
|
40
40
|
export const SaslAccountSchema = z.object({
|
|
41
41
|
username: z.string().min(1),
|
|
@@ -173,6 +173,15 @@ export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
|
|
|
173
173
|
*/
|
|
174
174
|
export const DEFAULT_NICK_ENFORCE_GRACE_MS = 30_000;
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Default minimum password length enforced by NickServ `SET PASSWORD`.
|
|
178
|
+
* Mirrors common IRC services (Atheme/Anope) and modern guidance (NIST
|
|
179
|
+
* SP 800-63B). Overridable per deployment via
|
|
180
|
+
* `ServerConfig.nickServ.minPasswordLength`. A new password shorter than
|
|
181
|
+
* the floor is rejected before hashing.
|
|
182
|
+
*/
|
|
183
|
+
export const DEFAULT_MIN_PASSWORD_LENGTH = 8;
|
|
184
|
+
|
|
176
185
|
/**
|
|
177
186
|
* The full server-config schema. Optional fields default to deployment-
|
|
178
187
|
* sensible values so a minimal `{ serverName, networkName }` config is
|
|
@@ -271,13 +280,12 @@ export const ServerConfigSchema = z.object({
|
|
|
271
280
|
operCreds: z.array(OperCredSchema).default([]),
|
|
272
281
|
|
|
273
282
|
/**
|
|
274
|
-
* SASL PLAIN
|
|
275
|
-
* `
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* follow-up; swapping it in means replacing the construction call.
|
|
283
|
+
* SASL PLAIN seed credentials. The adapter ingests these into the
|
|
284
|
+
* `ServicesStore` at boot via `registerNick` (idempotent — existing
|
|
285
|
+
* registrations win). `AUTHENTICATE PLAIN`, `PASS <nick>:<password>`,
|
|
286
|
+
* and NickServ `IDENTIFY` all verify against the resulting
|
|
287
|
+
* `nickserv_accounts` rows. Empty array (the default) leaves the seed
|
|
288
|
+
* empty; accounts can still be created at runtime via NickServ REGISTER.
|
|
281
289
|
*/
|
|
282
290
|
saslAccounts: z.array(SaslAccountSchema).default([]),
|
|
283
291
|
|
|
@@ -357,6 +365,24 @@ export const ServerConfigSchema = z.object({
|
|
|
357
365
|
*/
|
|
358
366
|
nickEnforceGraceMs: z.number().int().nonnegative().optional(),
|
|
359
367
|
|
|
368
|
+
/**
|
|
369
|
+
* NickServ-specific tunables. Currently only the minimum length enforced
|
|
370
|
+
* by `SET PASSWORD` is configurable; the maximum (256) is fixed to bound
|
|
371
|
+
* the scrypt input budget. Omit entirely to inherit the documented
|
|
372
|
+
* defaults.
|
|
373
|
+
*/
|
|
374
|
+
nickServ: z
|
|
375
|
+
.object({
|
|
376
|
+
/**
|
|
377
|
+
* Minimum length a new password must meet to be accepted by
|
|
378
|
+
* `SET PASSWORD`. Defaults to {@link DEFAULT_MIN_PASSWORD_LENGTH}
|
|
379
|
+
* (8) when unset. A shorter new password is rejected before hashing
|
|
380
|
+
* with `Password is too short (minimum <n> characters).`.
|
|
381
|
+
*/
|
|
382
|
+
minPasswordLength: z.number().int().positive().optional(),
|
|
383
|
+
})
|
|
384
|
+
.optional(),
|
|
385
|
+
|
|
360
386
|
/**
|
|
361
387
|
* HostServ approval mode for `REQUEST <vhost>`. `true` (the default)
|
|
362
388
|
* preserves the legacy auto-approve behaviour — the requested vhost is
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared SASL credential hashing primitives.
|
|
3
3
|
*
|
|
4
|
-
* Both the AWS adapter (`
|
|
5
|
-
* (`
|
|
6
|
-
* These helpers live in `irc-core` so both
|
|
7
|
-
*
|
|
4
|
+
* Both the AWS adapter (`DynamoServicesStore`) and the CF adapter
|
|
5
|
+
* (`D1ServicesStore`) store NickServ/SASL credentials as **scrypt hashes**
|
|
6
|
+
* via the unified `ServicesStore`. These helpers live in `irc-core` so both
|
|
7
|
+
* adapters share one hashing implementation.
|
|
8
8
|
*
|
|
9
9
|
* Uses `node:crypto` (`scryptSync`), which is available on Node (AWS Lambda)
|
|
10
10
|
* natively and on Cloudflare Workers via the `nodejs_compat` flag. The public
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
17
|
-
import type { AccountStore, SaslPayload, SaslResult } from './ports.js';
|
|
18
17
|
|
|
19
18
|
const SCRYPT_KEY_LEN = 64;
|
|
20
19
|
const SALT_LEN = 16;
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
|
-
* A single
|
|
22
|
+
* A single credential stored as a scrypt hash.
|
|
24
23
|
*
|
|
25
|
-
* Every field is a persistence-backend attribute — this object IS the
|
|
26
|
-
*
|
|
24
|
+
* Every field is a persistence-backend attribute — this object IS the
|
|
25
|
+
* credential portion of a `nickserv_accounts` row (DynamoDB `Services`
|
|
26
|
+
* item / D1 `nickserv_accounts` row, keyed by the folded nick).
|
|
27
27
|
* `algorithm` is retained so future migrations to Argon2/bcrypt can be
|
|
28
28
|
* detected and handled gracefully by {@link verifyHashedPassword}.
|
|
29
29
|
*/
|
|
@@ -36,8 +36,9 @@ export interface HashedAccountCredential {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Hashes a plaintext password into a {@link HashedAccountCredential}
|
|
39
|
-
* suitable for writing to the
|
|
40
|
-
* `
|
|
39
|
+
* suitable for writing to the services backend
|
|
40
|
+
* (`ServicesStore.registerNick` stores the result on the registered-nick
|
|
41
|
+
* row).
|
|
41
42
|
*
|
|
42
43
|
* Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
|
|
43
44
|
* Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
|
|
@@ -78,47 +79,3 @@ export function verifyHashedPassword(password: string, entry: HashedAccountCrede
|
|
|
78
79
|
const computed = scryptSync(password, salt, expected.length);
|
|
79
80
|
return computed.length === expected.length && timingSafeEqual(computed, expected);
|
|
80
81
|
}
|
|
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
|
-
}
|
|
@@ -11,6 +11,7 @@ export * from './commands/index.js';
|
|
|
11
11
|
export * from './effects.js';
|
|
12
12
|
export * from './ports.js';
|
|
13
13
|
export * from './credential-hashing.js';
|
|
14
|
+
export * from './account-migration.js';
|
|
14
15
|
export * from './flood-control.js';
|
|
15
16
|
export * from './cloak.js';
|
|
16
17
|
export * from './case-fold.js';
|