serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -7,6 +7,12 @@
7
7
  */
8
8
 
9
9
  import { caseFold } from './case-fold.js';
10
+ import {
11
+ CERT_FP_PREFIX,
12
+ type CertIdentity,
13
+ canonicalizeCertSubject,
14
+ certFingerprintId,
15
+ } from './certfp.js';
10
16
  import {
11
17
  type HashedAccountCredential,
12
18
  hashAccountCredential,
@@ -15,6 +21,8 @@ import {
15
21
  import type { ChannelState } from './state/channel.js';
16
22
  import type { ConnId, ConnectionState } from './state/connection.js';
17
23
 
24
+ export type { CertIdentity } from './certfp.js';
25
+
18
26
  /** Read-only wall clock, epoch milliseconds. */
19
27
  export interface Clock {
20
28
  now(): number;
@@ -63,10 +71,11 @@ export interface IdFactory {
63
71
  }
64
72
 
65
73
  /**
66
- * Production id factory. Generates UUIDv4-shaped strings without relying
67
- * on `crypto` so the same code runs on Node 24, Cloudflare Workers, and
68
- * Lambda without per-platform type wrangling. Uniqueness is statistical;
69
- * for cryptographic IDs, adapters can supply their own {@link IdFactory}.
74
+ * Production id factory. Generates UUIDv4-shaped strings from a CSPRNG
75
+ * (`globalThis.crypto.getRandomValues`), which is available on Node 19+
76
+ * and Cloudflare Workers as a global. The randomness is cryptographic-
77
+ * grade, so generated ids are suitable as unforgeable msgids / session
78
+ * ids as well as opaque batch references.
70
79
  */
71
80
  export class UuidIdFactory implements IdFactory {
72
81
  batchId(): string {
@@ -83,22 +92,29 @@ export class UuidIdFactory implements IdFactory {
83
92
  }
84
93
  }
85
94
 
86
- // RFC 4122 v4 shape (random). Six fixed bits identify the version/variant;
87
- // the remaining 122 bits come from Math.random. Good enough for non-security
88
- // identifiers like BATCH ref ids and msgid tags.
89
95
  const HEX = '0123456789abcdef';
96
+
97
+ /**
98
+ * RFC 4122 v4 (random) UUID. Six fixed bits identify the version/variant;
99
+ * the remaining 122 bits are sourced from `globalThis.crypto.getRandomValues`
100
+ * as a single 16-byte buffer (one syscall per id). The global is used — not
101
+ * `node:crypto` — so the same code runs unmodified on Node and Cloudflare
102
+ * Workers.
103
+ */
90
104
  function uuidV4(): string {
105
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
91
106
  let out = '';
92
- for (let i = 0; i < 36; i++) {
93
- if (i === 8 || i === 13 || i === 18 || i === 23) {
94
- out += '-';
95
- } else if (i === 14) {
96
- out += '4';
97
- } else if (i === 19) {
98
- out += HEX[(Math.random() * 4) | 0 | 0x8];
99
- } else {
100
- out += HEX[(Math.random() * 16) | 0];
101
- }
107
+ let i = 0;
108
+ for (let b of bytes) {
109
+ // Version nibble (bits 48-51 of the 128-bit id) = 0b0100 (v4).
110
+ if (i === 6) b = (b & 0x0f) | 0x40;
111
+ // Variant nibble (bits 64-65) = 0b10 (RFC 4122 / Leach-Salz).
112
+ else if (i === 8) b = (b & 0x3f) | 0x80;
113
+ // Dashes sit at byte offsets 4, 6, 8, 10 between the 4-2-2-2-6 byte groups.
114
+ if (i === 4 || i === 6 || i === 8 || i === 10) out += '-';
115
+ out += HEX.charAt(b >> 4);
116
+ out += HEX.charAt(b & 0x0f);
117
+ i++;
102
118
  }
103
119
  return out;
104
120
  }
@@ -124,6 +140,31 @@ export class SequentialIdFactory implements IdFactory {
124
140
  }
125
141
  }
126
142
 
143
+ /**
144
+ * Constant-time string equality.
145
+ *
146
+ * Walks both inputs position by position (UTF-16 code units, a bijection
147
+ * with UTF-8 bytes for equality purposes) out to the longer input's length,
148
+ * XOR-accumulating every difference. The amount of work — and therefore the
149
+ * wall-clock profile — does not depend on where the first mismatch sits, so
150
+ * a remote timing attacker cannot recover a matching-prefix length the way a
151
+ * short-circuiting `===` would reveal. `charCodeAt` past the end yields
152
+ * `NaN`, which XOR-folds to 0, so a length mismatch is caught purely by the
153
+ * length-difference seed in `diff`.
154
+ *
155
+ * Deliberately free of `node:crypto` (pure arithmetic) so the same code
156
+ * runs unmodified on Node and Cloudflare Workers. Used for secret
157
+ * comparisons such as the server-password gate at registration.
158
+ */
159
+ export function constantTimeEquals(a: string, b: string): boolean {
160
+ const maxLen = Math.max(a.length, b.length);
161
+ let diff = a.length ^ b.length;
162
+ for (let i = 0; i < maxLen; i++) {
163
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
164
+ }
165
+ return diff === 0;
166
+ }
167
+
127
168
  /**
128
169
  * Port supplying the server's Message-of-the-Day text.
129
170
  *
@@ -169,121 +210,6 @@ export class StaticMotdProvider implements MotdProvider {
169
210
  /** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
170
211
  export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
171
212
 
172
- /**
173
- * Parsed SASL credentials handed to an {@link AccountStore}.
174
- *
175
- * `PLAIN` is the structured form produced by base64-decoding and splitting
176
- * the `\0authzid\0authcid\0passwd` payload. `RAW` carries the decoded bytes
177
- * for any other mechanism the server wants to forward verbatim.
178
- */
179
- export type SaslPayload =
180
- | { kind: 'PLAIN'; username: string; password: string }
181
- | { kind: 'RAW'; data: string }
182
- | { kind: 'EXTERNAL'; identity: string };
183
-
184
- /**
185
- * Outcome of an {@link AccountStore.verify} call: either the canonical
186
- * account name to record on the connection, or a human-readable reason
187
- * for the failure that surfaces as `904 ERR_SASLFAIL`.
188
- */
189
- export type SaslResult = { ok: true; account: string } | { ok: false; reason: string };
190
-
191
- /**
192
- * Port that verifies SASL credentials against the deployment's account
193
- * backend.
194
- *
195
- * The method is **synchronous** on purpose: reducers must stay pure and
196
- * cannot await. Adapters that need an async lookup (D1, DynamoDB, Secrets
197
- * Manager) pre-load the credentials they need into a synchronously-readable
198
- * store (mirroring the {@link MotdProvider} pattern). For Phase 1 the
199
- * in-memory reference adapter and tests inject a fake directly.
200
- */
201
- export interface AccountStore {
202
- verify(mech: string, payload: SaslPayload): SaslResult;
203
- }
204
-
205
- /**
206
- * One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
207
- * The `username` is also the canonical account name recorded on the
208
- * connection for `extended-join` / `account-tag`.
209
- */
210
- export interface SaslAccountCredential {
211
- username: string;
212
- password: string;
213
- /**
214
- * Optional mTLS client-certificate subject (e.g. `CN=alice` or a
215
- * fingerprint hash). When set, SASL `EXTERNAL` authentications that
216
- * present this subject are mapped to {@link username} as the account.
217
- */
218
- certSubject?: string;
219
- }
220
-
221
- /**
222
- * Reference in-memory {@link AccountStore} backed by a static list of PLAIN
223
- * credentials seeded at construction.
224
- *
225
- * Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
226
- * pattern: the port is synchronous, so adapters that need an async backend
227
- * (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
228
- * at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
229
- * viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
230
- * is a documented follow-up that swaps in by replacing the construction call.
231
- *
232
- * Password comparison is constant-time to avoid the early-return timing
233
- * oracle a naive `===` would expose. Only `PLAIN` is verified; any other
234
- * mechanism returns failure (matching the reducer's mech gating).
235
- */
236
- export class InMemoryAccountStore implements AccountStore {
237
- private readonly creds: ReadonlyMap<string, string>;
238
- private readonly certMap: ReadonlyMap<string, string>;
239
-
240
- constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
241
- this.creds = new Map(credentials.map((c) => [c.username, c.password]));
242
- this.certMap = new Map(
243
- credentials
244
- .filter((c) => c.certSubject !== undefined)
245
- .map((c) => [c.certSubject as string, c.username]),
246
- );
247
- }
248
-
249
- verify(mech: string, payload: SaslPayload): SaslResult {
250
- const m = mech.toUpperCase();
251
- if (m === 'PLAIN' && payload.kind === 'PLAIN') {
252
- const expected = this.creds.get(payload.username);
253
- if (expected === undefined) {
254
- return { ok: false, reason: 'invalid credentials' };
255
- }
256
- if (!constantTimeEquals(payload.password, expected)) {
257
- return { ok: false, reason: 'invalid credentials' };
258
- }
259
- return { ok: true, account: payload.username };
260
- }
261
- if (m === 'EXTERNAL' && payload.kind === 'EXTERNAL') {
262
- const account = this.certMap.get(payload.identity);
263
- if (account === undefined) {
264
- return { ok: false, reason: 'untrusted certificate' };
265
- }
266
- return { ok: true, account };
267
- }
268
- return { ok: false, reason: `unsupported mechanism: ${mech}` };
269
- }
270
- }
271
-
272
- /**
273
- * Constant-time string equality. Compares every byte regardless of early
274
- * mismatches so a remote attacker cannot short-circuit the comparison via a
275
- * timing side-channel. Returns `false` when the lengths differ (the length
276
- * is not considered secret for SASL PLAIN passwords).
277
- */
278
- function constantTimeEquals(a: string, b: string): boolean {
279
- if (a.length !== b.length) return false;
280
- let diff = 0;
281
- for (let i = 0; i < a.length; i++) {
282
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
283
- }
284
- return diff === 0;
285
- }
286
-
287
213
  // ============================================================================
288
214
  // mTLS identity (SASL EXTERNAL)
289
215
  // ============================================================================
@@ -294,9 +220,13 @@ function constantTimeEquals(a: string, b: string): boolean {
294
220
  *
295
221
  * At the edge (CF API Shield mTLS, AWS API Gateway custom-domain mTLS) the
296
222
  * platform terminates TLS, validates the client certificate against the
297
- * configured trust store, and surfaces the certificate subject to the
223
+ * configured trust store, and surfaces the certificate identity to the
298
224
  * Worker/Lambda. The adapter captures this at connection-admission time
299
- * and stores it keyed by connection ID.
225
+ * and stores it keyed by connection ID as a {@link CertIdentity}: the raw
226
+ * subject string, its canonical DN, and — where the platform provides one
227
+ * (Cloudflare: `cf.tlsClientAuth.certFingerprintSHA256`) — the SHA-256
228
+ * fingerprint of the certificate DER, which is the preferred account
229
+ * binding. See `docs/SASL-EXTERNAL.md`.
300
230
  *
301
231
  * The method is **synchronous** — adapters pre-populate the identity at
302
232
  * admission time (before the first reducer runs), so the SASL EXTERNAL
@@ -306,26 +236,26 @@ function constantTimeEquals(a: string, b: string): boolean {
306
236
  */
307
237
  export interface MtlsIdentityProvider {
308
238
  /**
309
- * @returns the verified client-cert subject for `connId`, or `undefined`
239
+ * @returns the verified client-cert identity for `connId`, or `undefined`
310
240
  * when no trusted certificate was presented.
311
241
  */
312
- getIdentity(connId: string): string | undefined;
242
+ getIdentity(connId: string): CertIdentity | undefined;
313
243
  }
314
244
 
315
245
  /**
316
246
  * Reference in-memory {@link MtlsIdentityProvider} backed by a static map
317
- * of connection IDs to certificate subjects. Useful for tests and the
247
+ * of connection IDs to certificate identities. Useful for tests and the
318
248
  * local-cli adapter; cloud adapters replace this with a lookup keyed by
319
249
  * the platform's connection identifier.
320
250
  */
321
251
  export class InMemoryMtlsIdentityProvider implements MtlsIdentityProvider {
322
- private readonly identities: ReadonlyMap<string, string>;
252
+ private readonly identities: ReadonlyMap<string, CertIdentity>;
323
253
 
324
- constructor(entries: ReadonlyArray<{ connId: string; identity: string }>) {
254
+ constructor(entries: ReadonlyArray<{ connId: string; identity: CertIdentity }>) {
325
255
  this.identities = new Map(entries.map((e) => [e.connId, e.identity]));
326
256
  }
327
257
 
328
- getIdentity(connId: string): string | undefined {
258
+ getIdentity(connId: string): CertIdentity | undefined {
329
259
  return this.identities.get(connId);
330
260
  }
331
261
  }
@@ -395,7 +325,7 @@ export interface TargetEntry {
395
325
  /**
396
326
  * Persistence port for IRCv3 `draft/chathistory` playback.
397
327
  *
398
- * Mirrors the {@link MotdProvider} / {@link AccountStore} pattern: the port
328
+ * Mirrors the {@link MotdProvider} pattern: the port
399
329
  * is **synchronous** (reducers must stay pure and free of IO) and injected
400
330
  * via {@link Ctx}. Adapters that need async storage (D1, R2, DynamoDB)
401
331
  * pre-load the relevant slice into a synchronously-readable store; Phase 1
@@ -744,6 +674,22 @@ export type NickEnforcePolicy = 'none' | 'ghost' | 'kill';
744
674
  * lands). Lookups against the store are case-insensitive (rfc1459-folded);
745
675
  * the returned `nick` / `account` preserve the original spelling so the
746
676
  * wire renders the user's preferred casing.
677
+ *
678
+ * `certSubjects` is the list of mTLS client-certificate bindings attached
679
+ * to the account via {@link ServicesStore.addCertFP} /
680
+ * {@link ServicesStore.addCertFingerprint}. An entry is either a DER
681
+ * SHA-256 fingerprint binding (`fp:<hex>`, the preferred identifier where
682
+ * the platform surfaces a fingerprint) or a subject DN. SASL EXTERNAL
683
+ * resolves the verified edge identity against this list via
684
+ * {@link ServicesStore.verifyCertFP}: the fingerprint arm matches
685
+ * byte-exactly, DN entries compare on the canonical DN (values
686
+ * case-sensitive), and legacy verbatim subject spellings are rewritten to
687
+ * their canonical form on first use. Defaults to an empty array on a
688
+ * fresh registration; an upcoming NickServ `CERT` command (Atheme parity)
689
+ * will surface the mutators to the user. Until then bindings are attached
690
+ * out-of-band via {@link ServicesStore.addCertFP} /
691
+ * {@link ServicesStore.addCertFingerprint} (seed tooling or direct
692
+ * backend access) — the seam is the store API, not the wire.
747
693
  */
748
694
  export interface RegisteredNick {
749
695
  /** Display spelling stored at registration. */
@@ -756,6 +702,14 @@ export interface RegisteredNick {
756
702
  createdAt: number;
757
703
  /** Per-account nick-enforcement policy. */
758
704
  enforce: NickEnforcePolicy;
705
+ /**
706
+ * Client-certificate bindings attached to this account for SASL EXTERNAL
707
+ * (CertFP): `fp:<hex>` DER SHA-256 fingerprint bindings and/or subject
708
+ * DNs (compared via their canonical form; values never case-folded).
709
+ * Empty on a fresh registration; populated via
710
+ * {@link ServicesStore.addCertFP} / {@link ServicesStore.addCertFingerprint}.
711
+ */
712
+ certSubjects: string[];
759
713
  }
760
714
 
761
715
  /**
@@ -929,6 +883,11 @@ export interface ServicesStore {
929
883
  * `{ ok: true, account }` on a match (the canonical spelling), or
930
884
  * `{ ok: false, reason }` when the nick is unknown or the password is
931
885
  * wrong.
886
+ *
887
+ * Timing contract: implementations MUST cost the unknown-nick and
888
+ * wrong-password paths equally (one scrypt verify each — see
889
+ * {@link VERIFY_NICK_PRECOMPUTED_DUMMY}) so latency never reveals
890
+ * whether a nick is registered.
932
891
  */
933
892
  verifyNick(nick: string, password: string): ServicesAuthResult;
934
893
  /**
@@ -943,6 +902,14 @@ export interface ServicesStore {
943
902
  * not registered (the policy stays {@link NickEnforcePolicy.None}).
944
903
  */
945
904
  setNickEnforce(nick: string, policy: NickEnforcePolicy): void;
905
+ /**
906
+ * Overwrites the stored credential for `nick` with a fresh scrypt hash of
907
+ * `newPassword` (the plaintext is NEVER persisted). Used by NickServ
908
+ * `SET PASSWORD`. No-op when the nick is not registered. Write-behind
909
+ * persistent subclasses re-persist the full nick row (hashed credential
910
+ * included) so a backend reload keeps `verifyNick` working.
911
+ */
912
+ setNickPassword(nick: string, newPassword: string): void;
946
913
  /**
947
914
  * Returns the nick-enforcement policy for `nick`, or
948
915
  * {@link NickEnforcePolicy.None} when the nick is not registered.
@@ -956,6 +923,94 @@ export interface ServicesStore {
956
923
  */
957
924
  getNick(nick: string): RegisteredNick | undefined;
958
925
 
926
+ /**
927
+ * Records a failed `IDENTIFY` attempt against `nick`: appends the
928
+ * current clock timestamp to the sliding-window failure list and bumps
929
+ * the pending owner-notice counter. No-op when the nick is not
930
+ * registered. Called by the NickServ IDENTIFY reducer AFTER a failed
931
+ * `verifyNick` (frozen rejections never reach this method — they are
932
+ * short-circuited before scrypt runs).
933
+ */
934
+ recordIdentifyFailure(nick: string): void;
935
+ /**
936
+ * Returns `true` iff `nick` is identify-frozen: at least
937
+ * `opts.maxFailures` failed identifies were recorded within the last
938
+ * `opts.windowMs` milliseconds. Prunes expired timestamps from the
939
+ * stored list in place (the sliding window), so the freeze lifts once
940
+ * the failures age out of the window. Returns `false` for an unknown
941
+ * nick. Called BEFORE `verifyNick` so a frozen attempt never runs
942
+ * scrypt.
943
+ */
944
+ isNickIdentifyFrozen(nick: string, opts: { maxFailures: number; windowMs: number }): boolean;
945
+ /**
946
+ * Clears the failed-identify state for `nick` (the sliding-window
947
+ * timestamps and the pending owner-notice counter) and returns the
948
+ * number of failed identifies that were queued for the owner NOTICE
949
+ * (`0` when none were pending, including for an unknown nick). Called
950
+ * by the NickServ IDENTIFY reducer on a SUCCESSFUL `verifyNick` — a
951
+ * successful login both resets the freeze counter and delivers the
952
+ * queued owner notice.
953
+ */
954
+ clearIdentifyFailures(nick: string): number;
955
+
956
+ /**
957
+ * Verifies a client-certificate identity (CertFP) for SASL EXTERNAL.
958
+ *
959
+ * Matching arms, in preference order:
960
+ * 1. **Fingerprint (primary):** when `identity.fingerprint` is present
961
+ * (Cloudflare surfaces the DER SHA-256), the normalised
962
+ * `fp:<hex>` form is matched byte-exactly against the account's
963
+ * {@link RegisteredNick.certSubjects} entries.
964
+ * 2. **Canonical DN (fallback):** each stored DN entry is canonicalised
965
+ * (lowercased attribute types, sorted RDNs/AVAs, collapsed
966
+ * whitespace — values stay case-sensitive) and compared to
967
+ * `identity.canonicalDn`. This is the only arm available on
968
+ * platforms that surface just the subject DN (AWS API Gateway).
969
+ *
970
+ * First-use migration: a stored DN entry kept in a legacy verbatim
971
+ * spelling is rewritten to its canonical form, and — when the platform
972
+ * supplied a fingerprint — the `fp:<hex>` binding is pinned alongside
973
+ * it, so subsequent verifies hit the fingerprint arm. Both rewrites
974
+ * notify {@link onCertBindingsChanged} (write-behind subclasses
975
+ * re-persist the nick row).
976
+ *
977
+ * The edge (CF API Shield / AWS APIGW mTLS) still resolves the verified
978
+ * identity via {@link MtlsIdentityProvider}; only the identity→account
979
+ * lookup lives here.
980
+ *
981
+ * @returns `{ ok: true, account }` on a match, or `{ ok: false, reason }`
982
+ * when no registered nick carries a binding for the certificate.
983
+ */
984
+ verifyCertFP(identity: CertIdentity): ServicesAuthResult;
985
+ /**
986
+ * Attaches a client-certificate subject DN to `nick` for SASL EXTERNAL.
987
+ * No-op (and returns `false`) when the nick is not registered.
988
+ * Byte-exact: a duplicate subject (case-sensitive) is a no-op (idempotent
989
+ * returns `true`). Canonicalisation happens at verify time (with the
990
+ * first-use migration above). Future NickServ `CERT ADD` will surface
991
+ * this. Returns `true` when the subject was newly attached (or already
992
+ * present), `false` when the nick is unknown.
993
+ */
994
+ addCertFP(nick: string, subject: string): boolean;
995
+ /**
996
+ * Attaches a DER SHA-256 fingerprint binding (`fp:<hex>` — the primary
997
+ * CertFP identifier where the platform surfaces a fingerprint) to
998
+ * `nick`. The fingerprint is normalised (lowercased, colon-free) before
999
+ * storing, so `AA:11` and `aa11` are the same binding. Idempotent.
1000
+ * Returns `true` when the binding was newly attached (or already
1001
+ * present), `false` when the nick is unknown.
1002
+ */
1003
+ addCertFingerprint(nick: string, fingerprint: string): boolean;
1004
+ /**
1005
+ * Detaches a client-certificate binding from `nick`. `subject` matches
1006
+ * the stored entry byte-exactly — pass either a stored DN entry or a
1007
+ * stored `fp:<hex>` entry as read from {@link RegisteredNick.certSubjects}.
1008
+ * Returns `true` when an entry was removed, `false` when the nick is
1009
+ * unknown or the entry was not attached. Future NickServ `CERT DEL` will
1010
+ * surface this.
1011
+ */
1012
+ removeCertFP(nick: string, subject: string): boolean;
1013
+
959
1014
  // ----- ChanServ -----
960
1015
 
961
1016
  /**
@@ -1180,6 +1235,29 @@ export interface ServicesStore {
1180
1235
  listJupes(): ReadonlyArray<JupeEntry>;
1181
1236
  }
1182
1237
 
1238
+ /**
1239
+ * Per-account failed-IDENTIFY throttle state persisted on the nick row.
1240
+ *
1241
+ * Shape decision: a small append-only array of recent failure timestamps
1242
+ * (`identifyFailures`, epoch-ms) plus a pending owner-notice counter. The
1243
+ * timestamp array makes the sliding-window freeze test trivially correct
1244
+ * (prune entries older than the configured window, then compare the
1245
+ * surviving count against the threshold) without a separate "window
1246
+ * start" bookkeeping field, and stays small in practice: once the
1247
+ * threshold is reached the account is frozen (no further failures are
1248
+ * recorded), so the array holds at most threshold + a handful of entries
1249
+ * at any time.
1250
+ */
1251
+ export interface IdentifyThrottleState {
1252
+ /** Epoch-ms timestamps of recent failed IDENTIFY attempts. */
1253
+ identifyFailures: number[];
1254
+ /**
1255
+ * Failed-identify count queued for the owner NOTICE delivered on the
1256
+ * next successful identify. Reset to 0 once the notice is delivered.
1257
+ */
1258
+ pendingIdentifyFailureNotice: number;
1259
+ }
1260
+
1183
1261
  /**
1184
1262
  * Snapshot row for a NickServ registration, persisted by adapter backends
1185
1263
  * (D1 / DynamoDB) and round-tripped through {@link ServicesSnapshot}.
@@ -1197,9 +1275,13 @@ export interface ServicesStore {
1197
1275
  * The credential never leaves the store via
1198
1276
  * {@link InMemoryServicesStore.getNick} (which returns a defensive copy
1199
1277
  * without it), but it MUST be present in the snapshot so the
1200
- * `verifyNick` round-trip keeps working across a reload.
1278
+ * `verifyNick` round-trip keeps working across a reload. The same holds
1279
+ * for the {@link IdentifyThrottleState} fields: the failed-identify
1280
+ * counter survives connections and deploys so a re-identify from a fresh
1281
+ * connection still sees the freeze.
1201
1282
  */
1202
- export type ServicesNickRow = RegisteredNick & { credential: HashedAccountCredential };
1283
+ export type ServicesNickRow = RegisteredNick &
1284
+ IdentifyThrottleState & { credential: HashedAccountCredential };
1203
1285
 
1204
1286
  /**
1205
1287
  * Snapshot row for a ChanServ access entry: `(channel, account, level)`.
@@ -1337,6 +1419,77 @@ type StoredAkill = AkillEntry & { maskKey: string };
1337
1419
  */
1338
1420
  type StoredJupe = JupeEntry & { nameKey: string };
1339
1421
 
1422
+ // ============================================================================
1423
+ // verifyNick timing equalisation — unknown-nick lookups must cost the same
1424
+ // scrypt work as a wrong-password verify so latency cannot enumerate
1425
+ // registered nicks. Mirrors the OPER_PRECOMPUTED_DUMMY_HASHED pattern in
1426
+ // commands/oper.ts (and the PASS-auth dummy verify in commands/account-auth.ts).
1427
+ // ============================================================================
1428
+
1429
+ /**
1430
+ * Fixed salt used to bake the precomputed dummy credential below. 16 bytes
1431
+ * of `0x42` so the resulting scrypt derivation is deterministic across Node
1432
+ * versions and test runs (the salt is never used to verify real traffic —
1433
+ * only the dummy entry).
1434
+ */
1435
+ const VERIFY_NICK_FIXTURE_SALT = new Uint8Array(16).fill(0x42);
1436
+
1437
+ /**
1438
+ * Nick baked into the precomputed dummy credential. Contains a NUL so it can
1439
+ * never be a registered nick (registration rejects control characters) and
1440
+ * the dummy verify never collides with a real credential.
1441
+ */
1442
+ const VERIFY_NICK_DUMMY_NICK = '\u0000nick-verify-timing-dummy';
1443
+
1444
+ /**
1445
+ * Password baked into the precomputed dummy credential. Contains a NUL so
1446
+ * the dummy verify always fails — its purpose is to spend the same scrypt
1447
+ * work as the known-nick-wrong-password path, NOT to authenticate.
1448
+ */
1449
+ const VERIFY_NICK_DUMMY_PASSWORD = '\u0000nick-verify-timing-dummy';
1450
+
1451
+ /**
1452
+ * Fixed random scrypt digest (precomputed once at module load from the
1453
+ * NUL-prefixed dummy pair above — never derived from any real credential)
1454
+ * used to equalise timing on the unknown-nick path of
1455
+ * {@link InMemoryServicesStore.verifyNick}. Every caller of `verifyNick`
1456
+ * (SASL PLAIN, PASS-based account auth, NickServ IDENTIFY/SETPASS) then
1457
+ * costs exactly one scrypt verify whether the nick is known or not, so a
1458
+ * remote attacker measuring latency cannot distinguish "nick exists, wrong
1459
+ * password" from "nick unknown".
1460
+ */
1461
+ export const VERIFY_NICK_PRECOMPUTED_DUMMY: HashedAccountCredential = hashAccountCredential(
1462
+ VERIFY_NICK_DUMMY_NICK,
1463
+ VERIFY_NICK_DUMMY_PASSWORD,
1464
+ { salt: VERIFY_NICK_FIXTURE_SALT },
1465
+ );
1466
+
1467
+ /**
1468
+ * Indirection over {@link verifyHashedPassword} so tests can swap in a stub
1469
+ * to assert call counts (timing-equalisation parity tests). Defaults to the
1470
+ * real primitive; `null` restores the default.
1471
+ */
1472
+ let verifyNickHashedPasswordFn: (password: string, entry: HashedAccountCredential) => boolean =
1473
+ verifyHashedPassword;
1474
+
1475
+ /**
1476
+ * Test seam: replaces the verify primitive with `fn` (or restores the
1477
+ * default when `fn === null`). Exported for tests; production callers MUST
1478
+ * NOT use.
1479
+ *
1480
+ * @internal
1481
+ */
1482
+ export function setVerifyNickHashedPassword(
1483
+ fn: ((password: string, entry: HashedAccountCredential) => boolean) | null,
1484
+ ): void {
1485
+ verifyNickHashedPasswordFn = fn ?? verifyHashedPassword;
1486
+ }
1487
+
1488
+ /** @internal test seam: clears any spy registered above. */
1489
+ export function resetVerifyNickTimingSpy(): void {
1490
+ verifyNickHashedPasswordFn = verifyHashedPassword;
1491
+ }
1492
+
1340
1493
  /**
1341
1494
  * Reference in-memory {@link ServicesStore} used by the local CLI,
1342
1495
  * integration tests, and any deployment that has not bound a distributed
@@ -1344,7 +1497,7 @@ type StoredJupe = JupeEntry & { nameKey: string };
1344
1497
  *
1345
1498
  * Backed by plain `Map`s keyed by rfc1459-folded nick / account / channel.
1346
1499
  * Password verification uses the same scrypt path as the SASL
1347
- * {@link HashedAccountStore} ({@link verifyHashedPassword}) so a NickServ
1500
+ * {@link verifyHashedPassword} so a NickServ
1348
1501
  * registration IS a SASL account — both stores carry credentials in the
1349
1502
  * same hashed shape, and SASL PLAIN's services fallback verifies against
1350
1503
  * a row created by `NickServ REGISTER` without a second registration.
@@ -1405,7 +1558,13 @@ export class InMemoryServicesStore implements ServicesStore {
1405
1558
  */
1406
1559
  protected hydrate(snap: ServicesSnapshot): void {
1407
1560
  for (const row of snap.nicks) {
1408
- this.nicks.set(folded(row.nick), { ...row, credential: { ...row.credential } });
1561
+ this.nicks.set(folded(row.nick), {
1562
+ ...row,
1563
+ certSubjects: [...(row.certSubjects ?? [])],
1564
+ identifyFailures: [...(row.identifyFailures ?? [])],
1565
+ pendingIdentifyFailureNotice: row.pendingIdentifyFailureNotice ?? 0,
1566
+ credential: { ...row.credential },
1567
+ });
1409
1568
  }
1410
1569
  for (const row of snap.channels) {
1411
1570
  const stored: RegisteredChannel = { ...row };
@@ -1477,7 +1636,12 @@ export class InMemoryServicesStore implements ServicesStore {
1477
1636
  snapshot(): ServicesSnapshot {
1478
1637
  const nicks: ServicesNickRow[] = [];
1479
1638
  for (const rec of this.nicks.values()) {
1480
- nicks.push({ ...rec, credential: { ...rec.credential } });
1639
+ nicks.push({
1640
+ ...rec,
1641
+ certSubjects: [...rec.certSubjects],
1642
+ identifyFailures: [...rec.identifyFailures],
1643
+ credential: { ...rec.credential },
1644
+ });
1481
1645
  }
1482
1646
  const channels: RegisteredChannel[] = [];
1483
1647
  for (const rec of this.channels.values()) {
@@ -1554,6 +1718,9 @@ export class InMemoryServicesStore implements ServicesStore {
1554
1718
  email,
1555
1719
  createdAt: now,
1556
1720
  enforce: 'none',
1721
+ certSubjects: [],
1722
+ identifyFailures: [],
1723
+ pendingIdentifyFailureNotice: 0,
1557
1724
  credential,
1558
1725
  });
1559
1726
  return { ok: true, account: nick };
@@ -1562,9 +1729,15 @@ export class InMemoryServicesStore implements ServicesStore {
1562
1729
  verifyNick(nick: string, password: string): ServicesAuthResult {
1563
1730
  const rec = this.nicks.get(folded(nick));
1564
1731
  if (rec === undefined) {
1732
+ // Equalise timing: unknown-nick must cost the same scrypt work as a
1733
+ // wrong password so callers (SASL PLAIN, PASS auth, NickServ
1734
+ // IDENTIFY/SETPASS) cannot leak nick existence via latency. The
1735
+ // dummy verify runs against a fixed precomputed digest — the result
1736
+ // is discarded, the cost is the point.
1737
+ verifyNickHashedPasswordFn(password, VERIFY_NICK_PRECOMPUTED_DUMMY);
1565
1738
  return { ok: false, reason: 'invalid credentials' };
1566
1739
  }
1567
- if (!verifyHashedPassword(password, rec.credential)) {
1740
+ if (!verifyNickHashedPasswordFn(password, rec.credential)) {
1568
1741
  return { ok: false, reason: 'invalid credentials' };
1569
1742
  }
1570
1743
  return { ok: true, account: rec.account };
@@ -1585,6 +1758,12 @@ export class InMemoryServicesStore implements ServicesStore {
1585
1758
  rec.enforce = policy;
1586
1759
  }
1587
1760
 
1761
+ setNickPassword(nick: string, newPassword: string): void {
1762
+ const rec = this.nicks.get(folded(nick));
1763
+ if (rec === undefined) return;
1764
+ rec.credential = hashAccountCredential(rec.account, newPassword);
1765
+ }
1766
+
1588
1767
  getNickEnforce(nick: string): NickEnforcePolicy {
1589
1768
  return this.nicks.get(folded(nick))?.enforce ?? 'none';
1590
1769
  }
@@ -1600,46 +1779,113 @@ export class InMemoryServicesStore implements ServicesStore {
1600
1779
  email: rec.email,
1601
1780
  createdAt: rec.createdAt,
1602
1781
  enforce: rec.enforce,
1782
+ certSubjects: [...rec.certSubjects],
1603
1783
  };
1604
1784
  }
1605
1785
 
1606
- /**
1607
- * Adopts a pre-hashed SASL account credential as a registered nick.
1608
- *
1609
- * This is the unification seam: an account seeded into the SASL
1610
- * `AccountStore` (the `accounts`/`Accounts` table, populated by the
1611
- * seed tooling) is treated as a NickServ registration so `INFO`
1612
- * resolves it, `SET ENFORCE` applies, and NickServ `IDENTIFY` succeeds
1613
- * with the seeded password. The resulting {@link RegisteredNick} carries
1614
- * an empty email, the default `'none'` enforcement policy, and
1615
- * `createdAt = clock.now()`; the supplied {@link HashedAccountCredential}
1616
- * is stored verbatim so `verifyNick` re-derives via the same scrypt
1617
- * path as `registerNick`.
1618
- *
1619
- * Returns `true` when a new registration was created; `false` when the
1620
- * nick (case-insensitive) is already registered — the existing record
1621
- * wins and the supplied credential is NOT overwritten. Idempotent in
1622
- * the sense that re-ingesting the same credential is a no-op.
1623
- *
1624
- * Adapters call this once per cold start, after both the SASL store and
1625
- * the services store have been loaded, to merge the SASL account set
1626
- * into the unified services account set.
1627
- */
1628
- ingestAccountCredential(credential: HashedAccountCredential): boolean {
1629
- const key = folded(credential.account);
1630
- if (this.nicks.has(key)) return false;
1631
- const account = credential.account;
1632
- this.nicks.set(key, {
1633
- nick: account,
1634
- account,
1635
- email: '',
1636
- createdAt: this.clock.now(),
1637
- enforce: 'none',
1638
- credential: { ...credential },
1639
- });
1786
+ recordIdentifyFailure(nick: string): void {
1787
+ const rec = this.nicks.get(folded(nick));
1788
+ if (rec === undefined) return;
1789
+ rec.identifyFailures.push(this.clock.now());
1790
+ rec.pendingIdentifyFailureNotice += 1;
1791
+ }
1792
+
1793
+ isNickIdentifyFrozen(nick: string, opts: { maxFailures: number; windowMs: number }): boolean {
1794
+ const rec = this.nicks.get(folded(nick));
1795
+ if (rec === undefined) return false;
1796
+ // Sliding window: drop failures that have aged out (age >= windowMs),
1797
+ // then freeze iff the surviving count still meets the threshold. The
1798
+ // prune is in place, so the freeze lifts automatically as failures
1799
+ // expire even if no further attempts arrive.
1800
+ const cutoff = this.clock.now() - opts.windowMs;
1801
+ rec.identifyFailures = rec.identifyFailures.filter((t) => t > cutoff);
1802
+ return rec.identifyFailures.length >= opts.maxFailures;
1803
+ }
1804
+
1805
+ clearIdentifyFailures(nick: string): number {
1806
+ const rec = this.nicks.get(folded(nick));
1807
+ if (rec === undefined) return 0;
1808
+ const pending = rec.pendingIdentifyFailureNotice;
1809
+ rec.pendingIdentifyFailureNotice = 0;
1810
+ rec.identifyFailures = [];
1811
+ return pending;
1812
+ }
1813
+
1814
+ verifyCertFP(identity: CertIdentity): ServicesAuthResult {
1815
+ // Arm 1 — fingerprint (primary identifier where the platform surfaces
1816
+ // the DER SHA-256, e.g. Cloudflare's tlsClientAuth.certFingerprintSHA256).
1817
+ const fpId =
1818
+ identity.fingerprint !== undefined ? certFingerprintId(identity.fingerprint) : undefined;
1819
+ if (fpId !== undefined) {
1820
+ for (const rec of this.nicks.values()) {
1821
+ if (rec.certSubjects.includes(fpId)) {
1822
+ return { ok: true, account: rec.account };
1823
+ }
1824
+ }
1825
+ }
1826
+ // Arm 2 — canonical DN (the only arm on subject-DN-only platforms like
1827
+ // API Gateway). Stored DN entries are canonicalised at compare time so
1828
+ // legacy verbatim spellings still match; first use rewrites them (and
1829
+ // pins the fingerprint, when available).
1830
+ for (const rec of this.nicks.values()) {
1831
+ const idx = rec.certSubjects.findIndex(
1832
+ (entry) =>
1833
+ !entry.startsWith(CERT_FP_PREFIX) &&
1834
+ canonicalizeCertSubject(entry) === identity.canonicalDn,
1835
+ );
1836
+ if (idx === -1) continue;
1837
+ let mutated = false;
1838
+ if (rec.certSubjects[idx] !== identity.canonicalDn) {
1839
+ rec.certSubjects[idx] = identity.canonicalDn;
1840
+ mutated = true;
1841
+ }
1842
+ if (fpId !== undefined && !rec.certSubjects.includes(fpId)) {
1843
+ rec.certSubjects.push(fpId);
1844
+ mutated = true;
1845
+ }
1846
+ if (mutated) this.onCertBindingsChanged(rec.nick);
1847
+ return { ok: true, account: rec.account };
1848
+ }
1849
+ return { ok: false, reason: 'untrusted certificate' };
1850
+ }
1851
+
1852
+ addCertFP(nick: string, subject: string): boolean {
1853
+ const rec = this.nicks.get(folded(nick));
1854
+ if (rec === undefined) return false;
1855
+ if (rec.certSubjects.includes(subject)) return true;
1856
+ rec.certSubjects.push(subject);
1857
+ return true;
1858
+ }
1859
+
1860
+ addCertFingerprint(nick: string, fingerprint: string): boolean {
1861
+ const rec = this.nicks.get(folded(nick));
1862
+ if (rec === undefined) return false;
1863
+ const fpId = certFingerprintId(fingerprint);
1864
+ if (rec.certSubjects.includes(fpId)) return true;
1865
+ rec.certSubjects.push(fpId);
1866
+ return true;
1867
+ }
1868
+
1869
+ removeCertFP(nick: string, subject: string): boolean {
1870
+ const rec = this.nicks.get(folded(nick));
1871
+ if (rec === undefined) return false;
1872
+ const idx = rec.certSubjects.indexOf(subject);
1873
+ if (idx === -1) return false;
1874
+ rec.certSubjects.splice(idx, 1);
1640
1875
  return true;
1641
1876
  }
1642
1877
 
1878
+ /**
1879
+ * Hook fired when a `verifyCertFP` call rewrote the account's stored
1880
+ * cert bindings (legacy-spelling migration and/or fingerprint pinning).
1881
+ * No-op in the plain in-memory store; the write-behind
1882
+ * {@link PersistentServicesStore} subclass overrides it to re-persist
1883
+ * the nick row so the migration survives a backend reload.
1884
+ */
1885
+ protected onCertBindingsChanged(nick: string): void {
1886
+ void nick;
1887
+ }
1888
+
1643
1889
  /**
1644
1890
  * Returns the full stored nick row (including the scrypt-hashed
1645
1891
  * credential), or `undefined` when the nick is not registered. Defensive
@@ -1654,7 +1900,14 @@ export class InMemoryServicesStore implements ServicesStore {
1654
1900
  */
1655
1901
  protected getStoredNick(nick: string): ServicesNickRow | undefined {
1656
1902
  const rec = this.nicks.get(folded(nick));
1657
- return rec === undefined ? undefined : { ...rec, credential: { ...rec.credential } };
1903
+ return rec === undefined
1904
+ ? undefined
1905
+ : {
1906
+ ...rec,
1907
+ certSubjects: [...rec.certSubjects],
1908
+ identifyFailures: [...rec.identifyFailures],
1909
+ credential: { ...rec.credential },
1910
+ };
1658
1911
  }
1659
1912
 
1660
1913
  // ----- ChanServ -----
@@ -2127,13 +2380,99 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
2127
2380
  this.enqueue({ kind: 'upsertNick', row });
2128
2381
  }
2129
2382
 
2130
- override ingestAccountCredential(credential: HashedAccountCredential): boolean {
2131
- const created = super.ingestAccountCredential(credential);
2132
- if (created) {
2133
- const row = this.getStoredNick(credential.account) as ServicesNickRow | undefined;
2134
- if (row !== undefined) this.enqueue({ kind: 'upsertNick', row });
2383
+ override setNickPassword(nick: string, newPassword: string): void {
2384
+ if (!this.isRegisteredNick(nick)) return;
2385
+ super.setNickPassword(nick, newPassword);
2386
+ // The `isRegisteredNick` guard above guarantees the nick is present, so
2387
+ // `getStoredNick` is defined read it back to capture the freshly-hashed
2388
+ // credential so a backend reload keeps `verifyNick` working.
2389
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2390
+ this.enqueue({ kind: 'upsertNick', row });
2391
+ }
2392
+
2393
+ override recordIdentifyFailure(nick: string): void {
2394
+ if (!this.isRegisteredNick(nick)) return;
2395
+ super.recordIdentifyFailure(nick);
2396
+ // Re-persist the full row so the failure timestamps + pending owner
2397
+ // notice survive a deploy: a re-identify from a fresh connection (or
2398
+ // store) must still see the freeze.
2399
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2400
+ this.enqueue({ kind: 'upsertNick', row });
2401
+ }
2402
+
2403
+ override clearIdentifyFailures(nick: string): number {
2404
+ if (!this.isRegisteredNick(nick)) return 0;
2405
+ // Suppress the spurious write when there is no throttle state queued
2406
+ // (the common case on an unmolested account). The `isRegisteredNick`
2407
+ // guard above guarantees the stored row is defined.
2408
+ const before = this.getStoredNick(nick) as ServicesNickRow;
2409
+ if (before.identifyFailures.length === 0 && before.pendingIdentifyFailureNotice === 0) {
2410
+ return 0;
2411
+ }
2412
+ const pending = super.clearIdentifyFailures(nick);
2413
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2414
+ this.enqueue({ kind: 'upsertNick', row });
2415
+ return pending;
2416
+ }
2417
+
2418
+ // NOTE: `isNickIdentifyFrozen` is intentionally NOT overridden. Its only
2419
+ // mutation is pruning timestamps that have aged out of the window — a
2420
+ // deterministic function of the stored timestamps and the clock, so a
2421
+ // reloaded backend row re-prunes to the same state on the next check.
2422
+ // Skipping its write keeps every read-only IDENTIFY attempt off the
2423
+ // write-behind queue.
2424
+
2425
+ override addCertFP(nick: string, subject: string): boolean {
2426
+ if (!this.isRegisteredNick(nick)) return false;
2427
+ // Suppress the spurious write on an idempotent re-add (the subject is
2428
+ // already attached → super is a no-op). `getNick` returns a defensive
2429
+ // copy, so this check reflects the pre-call state. The
2430
+ // `isRegisteredNick` guard above guarantees the nick is present, so
2431
+ // `getNick` returns a defined record.
2432
+ const before = this.getNick(nick);
2433
+ const alreadyPresent = before?.certSubjects.includes(subject) === true;
2434
+ const attached = super.addCertFP(nick, subject);
2435
+ if (attached && !alreadyPresent) {
2436
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2437
+ this.enqueue({ kind: 'upsertNick', row });
2438
+ }
2439
+ return attached;
2440
+ }
2441
+
2442
+ override addCertFingerprint(nick: string, fingerprint: string): boolean {
2443
+ if (!this.isRegisteredNick(nick)) return false;
2444
+ // Suppress the spurious write on an idempotent re-add (the normalised
2445
+ // fingerprint is already attached → super is a no-op). Mirrors the
2446
+ // addCertFP override above.
2447
+ const before = this.getNick(nick);
2448
+ const fpId = certFingerprintId(fingerprint);
2449
+ const alreadyPresent = before?.certSubjects.includes(fpId) === true;
2450
+ const attached = super.addCertFingerprint(nick, fingerprint);
2451
+ if (attached && !alreadyPresent) {
2452
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2453
+ this.enqueue({ kind: 'upsertNick', row });
2135
2454
  }
2136
- return created;
2455
+ return attached;
2456
+ }
2457
+
2458
+ override removeCertFP(nick: string, subject: string): boolean {
2459
+ const removed = super.removeCertFP(nick, subject);
2460
+ if (removed) {
2461
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2462
+ this.enqueue({ kind: 'upsertNick', row });
2463
+ }
2464
+ return removed;
2465
+ }
2466
+
2467
+ /**
2468
+ * Re-persists the nick row after a `verifyCertFP` first-use migration
2469
+ * (legacy DN spelling rewrite and/or fingerprint pinning) so the
2470
+ * upgraded binding survives a backend reload. The hook only fires for
2471
+ * a record `verifyCertFP` just iterated, so `getStoredNick` is defined.
2472
+ */
2473
+ protected override onCertBindingsChanged(nick: string): void {
2474
+ const row = this.getStoredNick(nick) as ServicesNickRow;
2475
+ this.enqueue({ kind: 'upsertNick', row });
2137
2476
  }
2138
2477
 
2139
2478
  // ----- ChanServ -----
@@ -2454,7 +2793,7 @@ export interface MonitorPush {
2454
2793
  * (nick → set of watcher connIds) and the **online set** the actor consults
2455
2794
  * when synchronously resolving `MONITOR +` / `MONITOR S`.
2456
2795
  *
2457
- * Mirrors the {@link MessageStore} / {@link AccountStore} pattern: the port
2796
+ * Mirrors the {@link MessageStore} pattern: the port
2458
2797
  * is **synchronous** (reducers must stay pure and free of IO). The actor
2459
2798
  * layer drains pending pushes after each command via {@link drainPushes};
2460
2799
  * adapters that need to fan notifications out across processes (CF
@@ -2681,7 +3020,7 @@ export interface ServerStatsSnapshot {
2681
3020
  /**
2682
3021
  * Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
2683
3022
  *
2684
- * Unlike the synchronous {@link MotdProvider} / {@link AccountStore} ports
3023
+ * Unlike the synchronous {@link MotdProvider} ports
2685
3024
  * (which reducers consume directly via {@link Ctx}), this port is
2686
3025
  * **asynchronous** (`Promise`-returning) because the aggregation crosses
2687
3026
  * every connection and channel in the deployment — it cannot be pre-loaded