serverless-ircd 0.10.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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -893,3 +893,77 @@ describe('InMemoryRuntime — broadcastWallops', () => {
893
893
  expect(c1Received).toBe(0);
894
894
  });
895
895
  });
896
+
897
+ describe('InMemoryRuntime — broadcastOperNotice', () => {
898
+ it('delivers lines to every connection with the +o user mode set', async () => {
899
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
900
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
901
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
902
+ // c1 has +w but NOT +o; c2 and c3 are opers.
903
+ const c1 = makeConn('c1', 'alice');
904
+ c1.userModes.wallops = true;
905
+ rt.registerConnection(c1, { send: push('c1'), disconnect: () => {} });
906
+ const c2 = makeConn('c2', 'bob');
907
+ c2.userModes.oper = true;
908
+ rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
909
+ const c3 = makeConn('c3', 'carol');
910
+ c3.userModes.oper = true;
911
+ rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
912
+
913
+ await rt.broadcastOperNotice([L(':srv NOTICE * :OPER lockout triggered for 10.0.0.1')]);
914
+
915
+ // +w without +o is NOT enough — the oper notice is oper-only.
916
+ expect(received.c1).toHaveLength(0);
917
+ expect(received.c2).toHaveLength(1);
918
+ expect(received.c3).toHaveLength(1);
919
+ });
920
+
921
+ it('skips the except connection even when it is an oper', async () => {
922
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
923
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
924
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
925
+ const oper = makeConn('c1', 'alice');
926
+ oper.userModes.oper = true;
927
+ rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
928
+ const peer = makeConn('c2', 'bob');
929
+ peer.userModes.oper = true;
930
+ rt.registerConnection(peer, { send: push('c2'), disconnect: () => {} });
931
+
932
+ await rt.broadcastOperNotice([L(':srv NOTICE * :hi')], 'c1');
933
+
934
+ expect(received.c1).toHaveLength(0);
935
+ expect(received.c2).toHaveLength(1);
936
+ });
937
+
938
+ it('is a no-op when no connection has +o', async () => {
939
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
940
+ let c1Received = 0;
941
+ rt.registerConnection(makeConn('c1', 'alice'), {
942
+ send: () => {
943
+ c1Received++;
944
+ },
945
+ disconnect: () => {},
946
+ });
947
+
948
+ await rt.broadcastOperNotice([L(':srv NOTICE * :hi')]);
949
+ expect(c1Received).toBe(0);
950
+ });
951
+ });
952
+
953
+ describe('InMemoryRuntime — operFailures tracker', () => {
954
+ it('exposes one shared OperFailureStats instance bound to the runtime clock', async () => {
955
+ const clock = new FakeClock(1_000);
956
+ const rt = new InMemoryRuntime({ clock });
957
+
958
+ const tracker = rt.operFailures;
959
+ expect(tracker).toBeDefined();
960
+ // Same instance every read: adapters hand it to every connection's
961
+ // actor so the per-IP budget is shared fleet-wide.
962
+ expect(rt.operFailures).toBe(tracker);
963
+
964
+ tracker.recordFailure('10.0.0.1');
965
+ clock.advance(1_000);
966
+ tracker.recordFailure('10.0.0.1');
967
+ expect(tracker.recentFailures('10.0.0.1', 300_000)).toBe(2);
968
+ });
969
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -16,7 +16,8 @@ export interface Capability {
16
16
  /**
17
17
  * Optional value parameter. For `sasl` this is the base mechanism list
18
18
  * (`PLAIN`); the {@link saslCapValue} helper upgrades it to include
19
- * `EXTERNAL` when mTLS is configured for a connection.
19
+ * `EXTERNAL` when the mechanism is available for the connection (mTLS
20
+ * source + operator opt-in + secure transport).
20
21
  */
21
22
  value?: string;
22
23
  }
@@ -40,6 +41,14 @@ export const PRE_AWAY_CAP_NAME = 'draft/pre-away';
40
41
  */
41
42
  export const DEFAULT_MULTILINE_MAX_BYTES = 4096;
42
43
 
44
+ /**
45
+ * Default per-batch entry ceiling enforced incrementally while a
46
+ * `draft/multiline` batch is open. Defense-in-depth against a client that
47
+ * streams many tiny lines (each under the byte budget) and never closes the
48
+ * batch; deployments override via `ServerConfig.multilineMaxEntries`.
49
+ */
50
+ export const DEFAULT_MULTILINE_MAX_ENTRIES = 100;
51
+
43
52
  /**
44
53
  * Builds the `draft/multiline` cap *value* (the substring after `=`) for the
45
54
  * advertised byte budget. The {@link CapReducer} and the multi-line reducer
@@ -79,17 +88,18 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
79
88
  ]);
80
89
 
81
90
  /**
82
- * Returns the `sasl` cap value to advertise given whether an mTLS identity
83
- * provider is bound for the connection.
91
+ * Returns the `sasl` cap value to advertise given whether the SASL
92
+ * EXTERNAL mechanism is available for the connection.
84
93
  *
85
- * PLAIN is always available. EXTERNAL is advertised only when mTLS is
86
- * configured (an `MtlsIdentityProvider` is bound) so clients on
87
- * non-mTLS connections do not attempt a mechanism that cannot succeed.
88
- * Operators enable EXTERNAL by configuring mTLS at the edge (CF API Shield
89
- * mTLS, AWS custom-domain mTLS) no code change required.
94
+ * PLAIN is always available. EXTERNAL is advertised only when the
95
+ * caller's three-way gate holds (mTLS identity source bound +
96
+ * `sasl.externalEnabled` operator opt-in + secure transport see
97
+ * `commands/sasl.ts`), so clients on connections where the mechanism
98
+ * cannot succeed do not attempt an exchange the server would refuse
99
+ * with `908 ERR_SASLMECHS`.
90
100
  */
91
- export function saslCapValue(hasMtls: boolean): string {
92
- return hasMtls ? 'PLAIN,EXTERNAL' : 'PLAIN';
101
+ export function saslCapValue(externalAvailable: boolean): string {
102
+ return externalAvailable ? 'PLAIN,EXTERNAL' : 'PLAIN';
93
103
  }
94
104
 
95
105
  /** Set form for fast membership checks during `CAP REQ`. */
@@ -0,0 +1,178 @@
1
+ /**
2
+ * CertFP — canonical client-certificate identity for SASL EXTERNAL.
3
+ *
4
+ * The edge platforms terminate TLS and surface the verified client
5
+ * certificate to the adapter, but neither Cloudflare Workers nor AWS API
6
+ * Gateway hands over the certificate DER itself:
7
+ *
8
+ * - Cloudflare surfaces the SHA-256 fingerprint of the DER on
9
+ * `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase hex) — the
10
+ * strongest identifier available and the preferred account binding.
11
+ * - AWS API Gateway surfaces only `requestContext.identity.clientCertSubjectDN`
12
+ * (no fingerprint, no DER) — the subject DN is the only identifier.
13
+ *
14
+ * To make subject-DN bindings robust across those platforms (and across
15
+ * cert re-issuances that reorder or re-space the same DN), the DN is
16
+ * canonicalised before comparison:
17
+ *
18
+ * 1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
19
+ * **values** keep their case (values are compared case-sensitively).
20
+ * 2. Whitespace around types/values is trimmed and runs of whitespace
21
+ * inside a value collapse to a single space (escaped whitespace, e.g.
22
+ * a trailing `\ `, is preserved verbatim).
23
+ * 3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted,
24
+ * so two semantically-equal DNs written in different orders collapse
25
+ * to one canonical spelling.
26
+ * 4. RFC 4514 escapes (`\,`, `\+`, `\=` …) are kept intact — the
27
+ * canonicaliser never splits on an escaped separator.
28
+ *
29
+ * Accounts bind to either a DER fingerprint (`fp:<hex>` entries in
30
+ * `RegisteredNick.certSubjects`, preferred) or a canonical DN. Legacy
31
+ * verbatim subject bindings still verify — `ServicesStore.verifyCertFP`
32
+ * canonicalises stored entries at compare time and rewrites them to the
33
+ * canonical spelling on first use.
34
+ */
35
+
36
+ /**
37
+ * Marker prefix for fingerprint bindings stored in
38
+ * `RegisteredNick.certSubjects` (vs canonical-DN bindings).
39
+ */
40
+ export const CERT_FP_PREFIX = 'fp:';
41
+
42
+ /**
43
+ * The verified client-certificate identity of one connection, as resolved
44
+ * by an {@link MtlsIdentityProvider}.
45
+ *
46
+ * - `subject` — the raw platform-surfaced subject string, verbatim.
47
+ * - `canonicalDn` — {@link canonicalizeCertSubject} of `subject`.
48
+ * - `fingerprint` — the SHA-256 fingerprint of the certificate DER
49
+ * (lowercase hex) when the platform surfaces one; absent otherwise
50
+ * (API Gateway surfaces no fingerprint today).
51
+ */
52
+ export interface CertIdentity {
53
+ readonly subject: string;
54
+ readonly canonicalDn: string;
55
+ readonly fingerprint?: string;
56
+ }
57
+
58
+ /**
59
+ * Splits `s` on unescaped `sep` characters, keeping RFC 4514 `\x` escape
60
+ * pairs intact within each part.
61
+ */
62
+ function splitUnescaped(s: string, sep: string): string[] {
63
+ const parts: string[] = [];
64
+ let current = '';
65
+ for (let i = 0; i < s.length; i += 1) {
66
+ const ch = s.charAt(i);
67
+ const next = s.charAt(i + 1);
68
+ if (ch === '\\' && next !== '') {
69
+ current += ch + next;
70
+ i += 1;
71
+ } else if (ch === sep) {
72
+ parts.push(current);
73
+ current = '';
74
+ } else {
75
+ current += ch;
76
+ }
77
+ }
78
+ parts.push(current);
79
+ return parts;
80
+ }
81
+
82
+ /**
83
+ * Index of the first unescaped occurrence of `ch`, or `-1`. Used to split
84
+ * an AVA into type and value without splitting on `\=`.
85
+ */
86
+ function indexUnescaped(s: string, ch: string): number {
87
+ for (let i = 0; i < s.length; i += 1) {
88
+ if (s.charAt(i) === '\\') {
89
+ i += 1;
90
+ continue;
91
+ }
92
+ if (s.charAt(i) === ch) return i;
93
+ }
94
+ return -1;
95
+ }
96
+
97
+ /**
98
+ * Collapses runs of unescaped whitespace to single spaces and trims
99
+ * leading/trailing unescaped whitespace. Escape pairs (`\x`) pass through
100
+ * verbatim and terminate any pending whitespace run.
101
+ */
102
+ function collapseWhitespace(s: string): string {
103
+ let out = '';
104
+ let pendingSpace = false;
105
+ let started = false;
106
+ for (let i = 0; i < s.length; i += 1) {
107
+ const ch = s.charAt(i);
108
+ const next = s.charAt(i + 1);
109
+ if (ch === '\\' && next !== '') {
110
+ if (pendingSpace) {
111
+ out += ' ';
112
+ pendingSpace = false;
113
+ }
114
+ out += ch + next;
115
+ i += 1;
116
+ started = true;
117
+ continue;
118
+ }
119
+ if (/\s/.test(ch)) {
120
+ if (started) pendingSpace = true;
121
+ continue;
122
+ }
123
+ if (pendingSpace) {
124
+ out += ' ';
125
+ pendingSpace = false;
126
+ }
127
+ out += ch;
128
+ started = true;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Canonicalises one attribute-value assertion (`type=value`). */
134
+ function canonicalAva(ava: string): string {
135
+ const eq = indexUnescaped(ava, '=');
136
+ if (eq === -1) return collapseWhitespace(ava);
137
+ const type = collapseWhitespace(ava.slice(0, eq)).toLowerCase();
138
+ const value = collapseWhitespace(ava.slice(eq + 1));
139
+ return `${type}=${value}`;
140
+ }
141
+
142
+ /**
143
+ * Canonicalises an RFC 4514-style DN for byte-stable comparison:
144
+ * lowercase attribute types (values keep their case), collapsed/trimmed
145
+ * whitespace, sorted RDNs, and sorted AVAs inside multi-valued RDNs.
146
+ * Escape sequences pass through unchanged.
147
+ */
148
+ export function canonicalizeCertSubject(dn: string): string {
149
+ return splitUnescaped(dn, ',')
150
+ .map((rdn) =>
151
+ splitUnescaped(rdn, '+')
152
+ .map((ava) => canonicalAva(ava))
153
+ .sort()
154
+ .join('+'),
155
+ )
156
+ .sort()
157
+ .join(',');
158
+ }
159
+
160
+ /**
161
+ * Normalises a DER SHA-256 fingerprint into the stored binding form:
162
+ * `fp:` + trimmed, lowercased, colon-free hex.
163
+ */
164
+ export function certFingerprintId(fingerprint: string): string {
165
+ return `${CERT_FP_PREFIX}${fingerprint.trim().toLowerCase().replace(/:/g, '')}`;
166
+ }
167
+
168
+ /**
169
+ * Builds the {@link CertIdentity} for a platform-surfaced subject (and the
170
+ * platform fingerprint, when one was provided). Pure; adapters call this
171
+ * at admission time.
172
+ */
173
+ export function certIdentityFromSubject(subject: string, fingerprint?: string): CertIdentity {
174
+ const canonicalDn = canonicalizeCertSubject(subject);
175
+ return fingerprint === undefined
176
+ ? { subject, canonicalDn }
177
+ : { subject, canonicalDn, fingerprint };
178
+ }
@@ -94,11 +94,19 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
94
94
  state.capNegotiating = true;
95
95
 
96
96
  const target = state.nick ?? '*';
97
- const hasMtls = ctx.mtlsIdentity !== undefined;
97
+ // Same gate as the AUTHENTICATE reducer (no transport-level override +
98
+ // mTLS source + operator opt-in + secure transport), so the `sasl` cap
99
+ // never advertises a mechanism the reducer would refuse — with 908, or
100
+ // with the transport-specific 904 under the override.
101
+ const external =
102
+ ctx.serverConfig.sasl?.externalUnsupportedMessage === undefined &&
103
+ ctx.mtlsIdentity !== undefined &&
104
+ (ctx.serverConfig.sasl?.externalEnabled ?? false) &&
105
+ ctx.connection.secure;
98
106
  const multilineMax = ctx.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
99
107
  const tokens = SUPPORTED_CAPABILITIES.map((cap) => {
100
108
  if (cap.name === SASL_CAP_NAME) {
101
- return capToLsString({ ...cap, value: saslCapValue(hasMtls) });
109
+ return capToLsString({ ...cap, value: saslCapValue(external) });
102
110
  }
103
111
  if (cap.name === MULTILINE_CAP_NAME) {
104
112
  return capToLsString({ ...cap, value: multilineCapValue(multilineMax) });
@@ -36,6 +36,14 @@
36
36
  * threshold the JOIN hook compares against for the `AUTO*` ops.
37
37
  * `SET <op> <level>` overrides; `LIST` enumerates; `RESET` clears
38
38
  * every override so the {@link DEFAULT_CHANNEL_LEVELS} take effect.
39
+ * - `OP|DEOP|VOICE|DEVOICE <#channel> <nick>` — grant or strip a channel
40
+ * prefix via a ChanServ-sourced MODE broadcast (founder or
41
+ * AUTOOP-threshold access entry). DEOP additionally enforces a
42
+ * caller-vs-target rank guard: the target must not outrank the caller
43
+ * (see {@link rankOf} / {@link FOUNDER_RANK}), so an access-list entry
44
+ * cannot strip the founder — or any higher-ranked entry — mid-session.
45
+ * - `KICK <#channel> <nick> [:<reason>]` — removes a member (founder
46
+ * only) under the same target-rank guard.
39
47
  *
40
48
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
41
49
  * emitted as {@link Effect}s. Channel-mode mutations are emitted as
@@ -47,6 +55,7 @@ import { caseFold } from '../case-fold.js';
47
55
  import { Effect } from '../effects.js';
48
56
  import type { Effect as EffectType, RawLine } from '../effects.js';
49
57
  import type { ChannelLevelOp, ServicesStore } from '../ports.js';
58
+ import { isValidChannelName } from '../protocol/channel-name.js';
50
59
  import type { ChannelDelta, Roster } from '../state/channel.js';
51
60
  import type { ConnId, ConnectionState } from '../state/connection.js';
52
61
  import type { Ctx, Reducer } from '../types.js';
@@ -60,15 +69,6 @@ const SERVICES_HOST = 'services';
60
69
  /** Hostmask used as the source of ChanServ-emitted MODE prefix grants. */
61
70
  export const CHANSERV_HOSTMASK = `${CHANSERV_NICK}!${CHANSERV_NICK}@${SERVICES_HOST}`;
62
71
 
63
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
64
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
65
-
66
- /** Returns true iff `name` looks like a channel target. */
67
- function isValidChannelName(name: string, maxLen: number): boolean {
68
- if (name.length === 0 || name.length > maxLen) return false;
69
- return CHANNEL_NAME_RE.test(name);
70
- }
71
-
72
72
  /** ON / OFF values accepted by SET RESTRICTED / SET KEEPTOPIC. */
73
73
  const ON_OFF = new Set<string>(['ON', 'OFF']);
74
74
 
@@ -111,6 +111,26 @@ export const SHORTHAND_LEVELS: Readonly<Record<'SOP' | 'AOP' | 'HOP' | 'VOP', nu
111
111
  /** Set of recognised shorthand verbs (uppercased). */
112
112
  const SHORTHAND_VERBS = new Set<string>(['SOP', 'AOP', 'HOP', 'VOP']);
113
113
 
114
+ /**
115
+ * Rank of the channel founder in the DEOP/KICK caller-vs-target hierarchy.
116
+ * `Infinity` outranks every numeric access level, so no access-list entry
117
+ * can ever reach it — the only caller who can affect a founder-ranked
118
+ * target is the founder (an equal-rank, self-targeting action).
119
+ */
120
+ export const FOUNDER_RANK = Number.POSITIVE_INFINITY;
121
+
122
+ /**
123
+ * Maps a numeric ChanServ access level to the rank ordering used by the
124
+ * DEOP/KICK target guard: founder ({@link FOUNDER_RANK}) > SOP (10) > AOP
125
+ * (5) > HOP (4) > VOP (3) > no entry (0). The mapping is the identity for
126
+ * positive levels today; it exists as the single seam where a future
127
+ * privilege tier (e.g. a distinct SOP rank scale) can be introduced
128
+ * without touching the comparison call sites.
129
+ */
130
+ export function rankOf(level: number): number {
131
+ return level > 0 ? level : 0;
132
+ }
133
+
114
134
  /** Ordered list of level op names, for LEVELS LIST and validation. */
115
135
  const LEVEL_OPS: readonly ChannelLevelOp[] = ['AUTOOP', 'AUTOHALFOP', 'AUTOVOICE'];
116
136
 
@@ -794,16 +814,24 @@ interface PrefixCommandOpts {
794
814
  * - `founderOnly: true` → "Permission denied."
795
815
  * - `founderOnly: false` → allowed when caller's access level meets the
796
816
  * founder-configured `AUTOOP` threshold, otherwise "Permission denied."
817
+ * - Target-rank guard (`guardTargetRankOf`): the nick named by the command
818
+ * must not outrank the caller, otherwise "You do not have sufficient
819
+ * privileges on <chan> to affect <nick>." Used by DEOP and KICK so an
820
+ * access-list entry cannot strip the founder (or any higher-ranked
821
+ * entry) mid-session. Equal ranks — self-targeting and peers — are
822
+ * permitted; only a strictly higher-ranked target is protected.
797
823
  *
798
824
  * Privilege model: OP/DEOP/VOICE/DEVOICE accept the founder OR any
799
- * AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only.
825
+ * AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only. The
826
+ * target-rank guard applies only where the caller names a nick whose
827
+ * powers would be *removed* (DEOP/KICK), not where powers are granted.
800
828
  */
801
829
  function requirePrivilegedChanServ(
802
830
  state: ConnectionState,
803
831
  channel: string,
804
832
  ctx: Ctx,
805
833
  effects: EffectType[],
806
- opts: { founderOnly: boolean },
834
+ opts: { founderOnly: boolean; guardTargetRankOf?: string },
807
835
  ): ServicesStore | null {
808
836
  const services = ctx.services as ServicesStore;
809
837
  if (state.account === undefined) {
@@ -815,18 +843,89 @@ function requirePrivilegedChanServ(
815
843
  effects.push(notice(state, `Channel ${channel} is not registered.`));
816
844
  return null;
817
845
  }
818
- if (caseFold('rfc1459', founder) === caseFold('rfc1459', state.account)) {
819
- return services;
846
+ const callerIsFounder = caseFold('rfc1459', founder) === caseFold('rfc1459', state.account);
847
+ if (callerIsFounder) {
848
+ return admitTargetRankGuard(state, services, FOUNDER_RANK, channel, founder, opts, effects);
820
849
  }
821
850
  if (!opts.founderOnly) {
822
851
  const level = services.getChannelAccess(channel, state.account);
823
852
  const autoOp = services.getChannelLevel(channel, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
824
- if (level >= autoOp) return services;
853
+ if (level >= autoOp) {
854
+ return admitTargetRankGuard(state, services, rankOf(level), channel, founder, opts, effects);
855
+ }
825
856
  }
826
857
  effects.push(notice(state, 'Permission denied.'));
827
858
  return null;
828
859
  }
829
860
 
861
+ /**
862
+ * Applies the DEOP/KICK target-rank guard at the tail of
863
+ * {@link requirePrivilegedChanServ}: the caller may affect the target nick
864
+ * only when the target's rank does not exceed theirs. Returns the store on
865
+ * success, `null` (with an insufficient-privileges NOTICE pushed) on
866
+ * failure.
867
+ *
868
+ * Rank rule: permitted iff `callerRank >= targetRank`. Equal ranks cover
869
+ * both self-targeting (the founder — or any access entry — releasing
870
+ * their own status) and peer-level actions (an AOP deopping a fellow
871
+ * AOP, who can simply re-op); any strictly higher-ranked target — the
872
+ * founder above every access entry, an SOP above an AOP — is protected.
873
+ */
874
+ function admitTargetRankGuard(
875
+ state: ConnectionState,
876
+ services: ServicesStore,
877
+ callerRank: number,
878
+ channel: string,
879
+ founder: string,
880
+ opts: { founderOnly: boolean; guardTargetRankOf?: string },
881
+ effects: EffectType[],
882
+ ): ServicesStore | null {
883
+ if (
884
+ opts.guardTargetRankOf !== undefined &&
885
+ callerRank < targetRankOf(services, channel, founder, opts.guardTargetRankOf)
886
+ ) {
887
+ effects.push(
888
+ notice(
889
+ state,
890
+ `You do not have sufficient privileges on ${channel} to affect ${opts.guardTargetRankOf}.`,
891
+ ),
892
+ );
893
+ return null;
894
+ }
895
+ return services;
896
+ }
897
+
898
+ /**
899
+ * Resolves the rank of a target NICK for the DEOP/KICK guard.
900
+ *
901
+ * The ChanServ reducer operates on the caller's ConnectionState and cannot
902
+ * see the connection behind the target nick, so the target's account is
903
+ * resolved via the nick registry (`getNick`):
904
+ * - a nick whose registered account matches the channel founder ranks as
905
+ * the founder ({@link FOUNDER_RANK}) and is protected from every
906
+ * non-founder caller;
907
+ * - any other registered nick ranks at its access-list level;
908
+ * - an unregistered (therefore unidentified) nick carries no account and
909
+ * ranks at the minimum.
910
+ *
911
+ * Documented simplification: protection is keyed on the nick's registered
912
+ * account, not on whether the connection currently using the nick is
913
+ * identified — the reducer has no way to observe the latter, and erring
914
+ * toward protection cannot be abused to escalate privileges.
915
+ */
916
+ function targetRankOf(
917
+ services: ServicesStore,
918
+ channel: string,
919
+ founder: string,
920
+ targetNick: string,
921
+ ): number {
922
+ const account = services.getNick(targetNick)?.account;
923
+ if (account !== undefined && caseFold('rfc1459', founder) === caseFold('rfc1459', account)) {
924
+ return FOUNDER_RANK;
925
+ }
926
+ return account === undefined ? rankOf(0) : rankOf(services.getChannelAccess(channel, account));
927
+ }
928
+
830
929
  /**
831
930
  * Handles `OP|DEOP|VOICE|DEVOICE <#channel> <nick>`.
832
931
  *
@@ -862,6 +961,9 @@ function handlePrefixCommand(
862
961
 
863
962
  const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
864
963
  founderOnly: false,
964
+ // Only DEOP removes powers, so only it carries the target-rank guard;
965
+ // OP/VOICE/DEVOICE never demote a higher-ranked user.
966
+ ...(opts.verb === 'DEOP' ? { guardTargetRankOf: targetNick } : {}),
865
967
  });
866
968
  if (services === null) return { state, effects };
867
969
 
@@ -915,6 +1017,7 @@ function handleKick(
915
1017
 
916
1018
  const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
917
1019
  founderOnly: true,
1020
+ guardTargetRankOf: targetNick,
918
1021
  });
919
1022
  if (services === null) return { state, effects };
920
1023
 
@@ -20,6 +20,7 @@
20
20
  * actor layer); `undefined` means "no such channel" → `403`.
21
21
  */
22
22
 
23
+ import { DEFAULT_MAX_CHATHISTORY_LIMIT } from '../config.js';
23
24
  import { Effect } from '../effects.js';
24
25
  import type { Effect as EffectType, RawLine } from '../effects.js';
25
26
  import type { StoredMessage } from '../ports.js';
@@ -154,7 +155,8 @@ function handleChannelSub(
154
155
  if (sub !== 'LATEST' && pivot === undefined && markerPivot === undefined) {
155
156
  return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
156
157
  }
157
- const limit = parseLimit(limitRaw);
158
+ const maxLimit = ctx.serverConfig.chathistory?.maxLimit ?? DEFAULT_MAX_CHATHISTORY_LIMIT;
159
+ const limit = parseLimit(limitRaw, maxLimit);
158
160
  if (limit === undefined) {
159
161
  return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
160
162
  }
@@ -295,12 +297,18 @@ function invalidParamsLine(ctx: Ctx): RawLine {
295
297
  return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
296
298
  }
297
299
 
298
- /** Parses a non-negative integer limit; returns undefined on garbage. */
299
- function parseLimit(raw: string | undefined): number | undefined {
300
- if (raw === undefined) return DEFAULT_QUERY_LIMIT;
300
+ /**
301
+ * Parses a non-negative integer limit, silently capped at `maxLimit` (the
302
+ * deployment's chathistory ceiling); returns undefined on garbage. The cap
303
+ * keeps an oversized request (`CHATHISTORY LATEST #chan 999999999`) from
304
+ * asking the store to materialize a huge result set, regardless of the
305
+ * store's backing capacity.
306
+ */
307
+ function parseLimit(raw: string | undefined, maxLimit: number): number | undefined {
308
+ if (raw === undefined) return Math.min(DEFAULT_QUERY_LIMIT, maxLimit);
301
309
  const n = Number(raw);
302
310
  if (!Number.isInteger(n) || n < 0) return undefined;
303
- return n;
311
+ return Math.min(n, maxLimit);
304
312
  }
305
313
 
306
314
  /**