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.
Files changed (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. 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 AccountStore} / {@link MessageStore} pattern).
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…>`. Currently only `ENFORCE none|ghost|kill`
322
- * is supported; unknown subkeys fall back to the ENFORCE syntax notice.
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
- * Requires the caller to be identified as the account bound to the current
325
- * nick: enforcement is a per-account policy, so an unidentified client
326
- * (or one identified as a different account) cannot mutate it.
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, 'You must identify before changing enforcement.'));
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, 'You must identify before changing enforcement.'));
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(state, 'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET');
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 AccountStore is bound.
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.accounts`. Every
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 handed to the injected {@link AccountStore}; on
17
- * success the connection records the account name and the reducer emits
18
- * `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure `904 ERR_SASLFAIL`.
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 `AccountStore` port is **synchronous** (see {@link AccountStore} docs):
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 { SaslResult } from '../ports.js';
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 runs it through the AccountStore. */
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
- // Prefer the static AccountStore; fall back to the ServicesStore so a
173
- // nick registered via NickServ can authenticate via SASL PLAIN without a
174
- // separate static credential table. When neither verifies, fail.
175
- const store = ctx.accounts;
176
- let result = store?.verify('PLAIN', {
177
- kind: 'PLAIN',
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
- const store = ctx.accounts;
211
- if (store === undefined) {
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: SaslResult,
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 an
38
- * {@link InMemoryAccountStore} from this list at boot.
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 account credentials. The adapter seeds an
275
- * `InMemoryAccountStore` from this list at boot so `AUTHENTICATE PLAIN`
276
- * succeeds end-to-end for the listed accounts. Empty array (the default)
277
- * disables SASL account verification: `ctx.accounts` is then omitted and
278
- * a client completing `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL`.
279
- * A persistent variant (DynamoDB `Accounts`, D1) is a documented
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 (`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.
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 SASL PLAIN credential stored as a scrypt hash.
22
+ * A single credential stored as a scrypt hash.
24
23
  *
25
- * Every field is a persistence-backend attribute — this object IS the table
26
- * row (DynamoDB `Accounts` item / D1 `accounts` row, keyed by `account`).
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 accounts backend (`putAccountCredential` /
40
- * `seed-cf-accounts`).
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';