serverless-ircd 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +305 -0
- package/README.md +134 -41
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +12 -9
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +21 -17
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +266 -109
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +122 -11
- package/packages/irc-core/src/commands/registration.ts +2 -2
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +140 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +0 -14
- package/packages/irc-server/tests/actor.test.ts +19 -47
- package/packages/irc-test-support/package.json +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -63,10 +63,11 @@ export interface IdFactory {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Production id factory. Generates UUIDv4-shaped strings
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* Production id factory. Generates UUIDv4-shaped strings from a CSPRNG
|
|
67
|
+
* (`globalThis.crypto.getRandomValues`), which is available on Node 19+
|
|
68
|
+
* and Cloudflare Workers as a global. The randomness is cryptographic-
|
|
69
|
+
* grade, so generated ids are suitable as unforgeable msgids / session
|
|
70
|
+
* ids as well as opaque batch references.
|
|
70
71
|
*/
|
|
71
72
|
export class UuidIdFactory implements IdFactory {
|
|
72
73
|
batchId(): string {
|
|
@@ -83,22 +84,29 @@ export class UuidIdFactory implements IdFactory {
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
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
87
|
const HEX = '0123456789abcdef';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* RFC 4122 v4 (random) UUID. Six fixed bits identify the version/variant;
|
|
91
|
+
* the remaining 122 bits are sourced from `globalThis.crypto.getRandomValues`
|
|
92
|
+
* as a single 16-byte buffer (one syscall per id). The global is used — not
|
|
93
|
+
* `node:crypto` — so the same code runs unmodified on Node and Cloudflare
|
|
94
|
+
* Workers.
|
|
95
|
+
*/
|
|
90
96
|
function uuidV4(): string {
|
|
97
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
91
98
|
let out = '';
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
let i = 0;
|
|
100
|
+
for (let b of bytes) {
|
|
101
|
+
// Version nibble (bits 48-51 of the 128-bit id) = 0b0100 (v4).
|
|
102
|
+
if (i === 6) b = (b & 0x0f) | 0x40;
|
|
103
|
+
// Variant nibble (bits 64-65) = 0b10 (RFC 4122 / Leach-Salz).
|
|
104
|
+
else if (i === 8) b = (b & 0x3f) | 0x80;
|
|
105
|
+
// Dashes sit at byte offsets 4, 6, 8, 10 between the 4-2-2-2-6 byte groups.
|
|
106
|
+
if (i === 4 || i === 6 || i === 8 || i === 10) out += '-';
|
|
107
|
+
out += HEX.charAt(b >> 4);
|
|
108
|
+
out += HEX.charAt(b & 0x0f);
|
|
109
|
+
i++;
|
|
102
110
|
}
|
|
103
111
|
return out;
|
|
104
112
|
}
|
|
@@ -169,121 +177,6 @@ export class StaticMotdProvider implements MotdProvider {
|
|
|
169
177
|
/** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
|
|
170
178
|
export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
|
|
171
179
|
|
|
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
180
|
// ============================================================================
|
|
288
181
|
// mTLS identity (SASL EXTERNAL)
|
|
289
182
|
// ============================================================================
|
|
@@ -395,7 +288,7 @@ export interface TargetEntry {
|
|
|
395
288
|
/**
|
|
396
289
|
* Persistence port for IRCv3 `draft/chathistory` playback.
|
|
397
290
|
*
|
|
398
|
-
* Mirrors the {@link MotdProvider}
|
|
291
|
+
* Mirrors the {@link MotdProvider} pattern: the port
|
|
399
292
|
* is **synchronous** (reducers must stay pure and free of IO) and injected
|
|
400
293
|
* via {@link Ctx}. Adapters that need async storage (D1, R2, DynamoDB)
|
|
401
294
|
* pre-load the relevant slice into a synchronously-readable store; Phase 1
|
|
@@ -744,6 +637,13 @@ export type NickEnforcePolicy = 'none' | 'ghost' | 'kill';
|
|
|
744
637
|
* lands). Lookups against the store are case-insensitive (rfc1459-folded);
|
|
745
638
|
* the returned `nick` / `account` preserve the original spelling so the
|
|
746
639
|
* wire renders the user's preferred casing.
|
|
640
|
+
*
|
|
641
|
+
* `certSubjects` is the list of mTLS client-certificate subjects (e.g.
|
|
642
|
+
* `CN=alice`) attached to the account via {@link ServicesStore.addCertFP}.
|
|
643
|
+
* SASL EXTERNAL resolves the verified edge subject against this list
|
|
644
|
+
* (byte-exact comparison; subjects are NOT case-folded). Defaults to an
|
|
645
|
+
* empty array on a fresh registration; an upcoming NickServ `CERT` command
|
|
646
|
+
* surfaces the mutators to the user (Atheme parity).
|
|
747
647
|
*/
|
|
748
648
|
export interface RegisteredNick {
|
|
749
649
|
/** Display spelling stored at registration. */
|
|
@@ -756,6 +656,12 @@ export interface RegisteredNick {
|
|
|
756
656
|
createdAt: number;
|
|
757
657
|
/** Per-account nick-enforcement policy. */
|
|
758
658
|
enforce: NickEnforcePolicy;
|
|
659
|
+
/**
|
|
660
|
+
* Client-certificate subjects attached to this account for SASL EXTERNAL
|
|
661
|
+
* (CertFP). Byte-exact comparisons; never case-folded. Empty on a fresh
|
|
662
|
+
* registration; populated via {@link ServicesStore.addCertFP}.
|
|
663
|
+
*/
|
|
664
|
+
certSubjects: string[];
|
|
759
665
|
}
|
|
760
666
|
|
|
761
667
|
/**
|
|
@@ -943,6 +849,14 @@ export interface ServicesStore {
|
|
|
943
849
|
* not registered (the policy stays {@link NickEnforcePolicy.None}).
|
|
944
850
|
*/
|
|
945
851
|
setNickEnforce(nick: string, policy: NickEnforcePolicy): void;
|
|
852
|
+
/**
|
|
853
|
+
* Overwrites the stored credential for `nick` with a fresh scrypt hash of
|
|
854
|
+
* `newPassword` (the plaintext is NEVER persisted). Used by NickServ
|
|
855
|
+
* `SET PASSWORD`. No-op when the nick is not registered. Write-behind
|
|
856
|
+
* persistent subclasses re-persist the full nick row (hashed credential
|
|
857
|
+
* included) so a backend reload keeps `verifyNick` working.
|
|
858
|
+
*/
|
|
859
|
+
setNickPassword(nick: string, newPassword: string): void;
|
|
946
860
|
/**
|
|
947
861
|
* Returns the nick-enforcement policy for `nick`, or
|
|
948
862
|
* {@link NickEnforcePolicy.None} when the nick is not registered.
|
|
@@ -956,6 +870,34 @@ export interface ServicesStore {
|
|
|
956
870
|
*/
|
|
957
871
|
getNick(nick: string): RegisteredNick | undefined;
|
|
958
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Verifies a client-certificate subject (CertFP) for SASL EXTERNAL.
|
|
875
|
+
* Returns `{ ok: true, account }` for the account whose
|
|
876
|
+
* {@link RegisteredNick.certSubjects} contains `subject` (byte-exact,
|
|
877
|
+
* case-sensitive comparison — cert subjects are NOT folded), or
|
|
878
|
+
* `{ ok: false, reason }` when no registered nick carries `subject`.
|
|
879
|
+
*
|
|
880
|
+
* The edge (CF API Shield / AWS APIGW mTLS) still resolves the verified
|
|
881
|
+
* subject via {@link MtlsIdentityProvider}; only the subject→account
|
|
882
|
+
* lookup lives here.
|
|
883
|
+
*/
|
|
884
|
+
verifyCertFP(subject: string): ServicesAuthResult;
|
|
885
|
+
/**
|
|
886
|
+
* Attaches a client-certificate subject to `nick` for SASL EXTERNAL.
|
|
887
|
+
* No-op (and returns `false`) when the nick is not registered.
|
|
888
|
+
* Byte-exact: a duplicate subject (case-sensitive) is a no-op (idempotent
|
|
889
|
+
* returns `true`). Future NickServ `CERT ADD` will surface this.
|
|
890
|
+
* Returns `true` when the subject was newly attached (or already
|
|
891
|
+
* present), `false` when the nick is unknown.
|
|
892
|
+
*/
|
|
893
|
+
addCertFP(nick: string, subject: string): boolean;
|
|
894
|
+
/**
|
|
895
|
+
* Detaches a client-certificate subject from `nick`. Returns `true` when
|
|
896
|
+
* a subject was removed, `false` when the nick is unknown or the subject
|
|
897
|
+
* was not attached. Future NickServ `CERT DEL` will surface this.
|
|
898
|
+
*/
|
|
899
|
+
removeCertFP(nick: string, subject: string): boolean;
|
|
900
|
+
|
|
959
901
|
// ----- ChanServ -----
|
|
960
902
|
|
|
961
903
|
/**
|
|
@@ -1344,7 +1286,7 @@ type StoredJupe = JupeEntry & { nameKey: string };
|
|
|
1344
1286
|
*
|
|
1345
1287
|
* Backed by plain `Map`s keyed by rfc1459-folded nick / account / channel.
|
|
1346
1288
|
* Password verification uses the same scrypt path as the SASL
|
|
1347
|
-
* {@link
|
|
1289
|
+
* {@link verifyHashedPassword} so a NickServ
|
|
1348
1290
|
* registration IS a SASL account — both stores carry credentials in the
|
|
1349
1291
|
* same hashed shape, and SASL PLAIN's services fallback verifies against
|
|
1350
1292
|
* a row created by `NickServ REGISTER` without a second registration.
|
|
@@ -1405,7 +1347,11 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1405
1347
|
*/
|
|
1406
1348
|
protected hydrate(snap: ServicesSnapshot): void {
|
|
1407
1349
|
for (const row of snap.nicks) {
|
|
1408
|
-
this.nicks.set(folded(row.nick), {
|
|
1350
|
+
this.nicks.set(folded(row.nick), {
|
|
1351
|
+
...row,
|
|
1352
|
+
certSubjects: [...(row.certSubjects ?? [])],
|
|
1353
|
+
credential: { ...row.credential },
|
|
1354
|
+
});
|
|
1409
1355
|
}
|
|
1410
1356
|
for (const row of snap.channels) {
|
|
1411
1357
|
const stored: RegisteredChannel = { ...row };
|
|
@@ -1477,7 +1423,11 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1477
1423
|
snapshot(): ServicesSnapshot {
|
|
1478
1424
|
const nicks: ServicesNickRow[] = [];
|
|
1479
1425
|
for (const rec of this.nicks.values()) {
|
|
1480
|
-
nicks.push({
|
|
1426
|
+
nicks.push({
|
|
1427
|
+
...rec,
|
|
1428
|
+
certSubjects: [...rec.certSubjects],
|
|
1429
|
+
credential: { ...rec.credential },
|
|
1430
|
+
});
|
|
1481
1431
|
}
|
|
1482
1432
|
const channels: RegisteredChannel[] = [];
|
|
1483
1433
|
for (const rec of this.channels.values()) {
|
|
@@ -1554,6 +1504,7 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1554
1504
|
email,
|
|
1555
1505
|
createdAt: now,
|
|
1556
1506
|
enforce: 'none',
|
|
1507
|
+
certSubjects: [],
|
|
1557
1508
|
credential,
|
|
1558
1509
|
});
|
|
1559
1510
|
return { ok: true, account: nick };
|
|
@@ -1585,6 +1536,12 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1585
1536
|
rec.enforce = policy;
|
|
1586
1537
|
}
|
|
1587
1538
|
|
|
1539
|
+
setNickPassword(nick: string, newPassword: string): void {
|
|
1540
|
+
const rec = this.nicks.get(folded(nick));
|
|
1541
|
+
if (rec === undefined) return;
|
|
1542
|
+
rec.credential = hashAccountCredential(rec.account, newPassword);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1588
1545
|
getNickEnforce(nick: string): NickEnforcePolicy {
|
|
1589
1546
|
return this.nicks.get(folded(nick))?.enforce ?? 'none';
|
|
1590
1547
|
}
|
|
@@ -1600,43 +1557,33 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1600
1557
|
email: rec.email,
|
|
1601
1558
|
createdAt: rec.createdAt,
|
|
1602
1559
|
enforce: rec.enforce,
|
|
1560
|
+
certSubjects: [...rec.certSubjects],
|
|
1603
1561
|
};
|
|
1604
1562
|
}
|
|
1605
1563
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
});
|
|
1564
|
+
verifyCertFP(subject: string): ServicesAuthResult {
|
|
1565
|
+
for (const rec of this.nicks.values()) {
|
|
1566
|
+
if (rec.certSubjects.includes(subject)) {
|
|
1567
|
+
return { ok: true, account: rec.account };
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return { ok: false, reason: 'untrusted certificate' };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
addCertFP(nick: string, subject: string): boolean {
|
|
1574
|
+
const rec = this.nicks.get(folded(nick));
|
|
1575
|
+
if (rec === undefined) return false;
|
|
1576
|
+
if (rec.certSubjects.includes(subject)) return true;
|
|
1577
|
+
rec.certSubjects.push(subject);
|
|
1578
|
+
return true;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
removeCertFP(nick: string, subject: string): boolean {
|
|
1582
|
+
const rec = this.nicks.get(folded(nick));
|
|
1583
|
+
if (rec === undefined) return false;
|
|
1584
|
+
const idx = rec.certSubjects.indexOf(subject);
|
|
1585
|
+
if (idx === -1) return false;
|
|
1586
|
+
rec.certSubjects.splice(idx, 1);
|
|
1640
1587
|
return true;
|
|
1641
1588
|
}
|
|
1642
1589
|
|
|
@@ -1654,7 +1601,13 @@ export class InMemoryServicesStore implements ServicesStore {
|
|
|
1654
1601
|
*/
|
|
1655
1602
|
protected getStoredNick(nick: string): ServicesNickRow | undefined {
|
|
1656
1603
|
const rec = this.nicks.get(folded(nick));
|
|
1657
|
-
return rec === undefined
|
|
1604
|
+
return rec === undefined
|
|
1605
|
+
? undefined
|
|
1606
|
+
: {
|
|
1607
|
+
...rec,
|
|
1608
|
+
certSubjects: [...rec.certSubjects],
|
|
1609
|
+
credential: { ...rec.credential },
|
|
1610
|
+
};
|
|
1658
1611
|
}
|
|
1659
1612
|
|
|
1660
1613
|
// ----- ChanServ -----
|
|
@@ -2127,13 +2080,40 @@ export abstract class PersistentServicesStore extends InMemoryServicesStore {
|
|
|
2127
2080
|
this.enqueue({ kind: 'upsertNick', row });
|
|
2128
2081
|
}
|
|
2129
2082
|
|
|
2130
|
-
override
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2083
|
+
override setNickPassword(nick: string, newPassword: string): void {
|
|
2084
|
+
if (!this.isRegisteredNick(nick)) return;
|
|
2085
|
+
super.setNickPassword(nick, newPassword);
|
|
2086
|
+
// The `isRegisteredNick` guard above guarantees the nick is present, so
|
|
2087
|
+
// `getStoredNick` is defined — read it back to capture the freshly-hashed
|
|
2088
|
+
// credential so a backend reload keeps `verifyNick` working.
|
|
2089
|
+
const row = this.getStoredNick(nick) as ServicesNickRow;
|
|
2090
|
+
this.enqueue({ kind: 'upsertNick', row });
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
override addCertFP(nick: string, subject: string): boolean {
|
|
2094
|
+
if (!this.isRegisteredNick(nick)) return false;
|
|
2095
|
+
// Suppress the spurious write on an idempotent re-add (the subject is
|
|
2096
|
+
// already attached → super is a no-op). `getNick` returns a defensive
|
|
2097
|
+
// copy, so this check reflects the pre-call state. The
|
|
2098
|
+
// `isRegisteredNick` guard above guarantees the nick is present, so
|
|
2099
|
+
// `getNick` returns a defined record.
|
|
2100
|
+
const before = this.getNick(nick);
|
|
2101
|
+
const alreadyPresent = before?.certSubjects.includes(subject) === true;
|
|
2102
|
+
const attached = super.addCertFP(nick, subject);
|
|
2103
|
+
if (attached && !alreadyPresent) {
|
|
2104
|
+
const row = this.getStoredNick(nick) as ServicesNickRow;
|
|
2105
|
+
this.enqueue({ kind: 'upsertNick', row });
|
|
2135
2106
|
}
|
|
2136
|
-
return
|
|
2107
|
+
return attached;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
override removeCertFP(nick: string, subject: string): boolean {
|
|
2111
|
+
const removed = super.removeCertFP(nick, subject);
|
|
2112
|
+
if (removed) {
|
|
2113
|
+
const row = this.getStoredNick(nick) as ServicesNickRow;
|
|
2114
|
+
this.enqueue({ kind: 'upsertNick', row });
|
|
2115
|
+
}
|
|
2116
|
+
return removed;
|
|
2137
2117
|
}
|
|
2138
2118
|
|
|
2139
2119
|
// ----- ChanServ -----
|
|
@@ -2454,7 +2434,7 @@ export interface MonitorPush {
|
|
|
2454
2434
|
* (nick → set of watcher connIds) and the **online set** the actor consults
|
|
2455
2435
|
* when synchronously resolving `MONITOR +` / `MONITOR S`.
|
|
2456
2436
|
*
|
|
2457
|
-
* Mirrors the {@link MessageStore}
|
|
2437
|
+
* Mirrors the {@link MessageStore} pattern: the port
|
|
2458
2438
|
* is **synchronous** (reducers must stay pure and free of IO). The actor
|
|
2459
2439
|
* layer drains pending pushes after each command via {@link drainPushes};
|
|
2460
2440
|
* adapters that need to fan notifications out across processes (CF
|
|
@@ -2681,7 +2661,7 @@ export interface ServerStatsSnapshot {
|
|
|
2681
2661
|
/**
|
|
2682
2662
|
* Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
|
|
2683
2663
|
*
|
|
2684
|
-
* Unlike the synchronous {@link MotdProvider}
|
|
2664
|
+
* Unlike the synchronous {@link MotdProvider} ports
|
|
2685
2665
|
* (which reducers consume directly via {@link Ctx}), this port is
|
|
2686
2666
|
* **asynchronous** (`Promise`-returning) because the aggregation crosses
|
|
2687
2667
|
* every connection and channel in the deployment — it cannot be pre-loaded
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import type { Effect } from './effects.js';
|
|
10
10
|
import type { FloodControlConfig } from './flood-control.js';
|
|
11
11
|
import type {
|
|
12
|
-
AccountStore,
|
|
13
12
|
AwayStore,
|
|
14
13
|
Clock,
|
|
15
14
|
IdFactory,
|
|
@@ -63,6 +62,24 @@ export interface OperCred {
|
|
|
63
62
|
readonly password: string;
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
/**
|
|
66
|
+
* NickServ-specific tunables as seen by reducers. Mirrors the
|
|
67
|
+
* `nickServ` sub-schema of {@link ServerConfigSchema}; the NickServ
|
|
68
|
+
* `SET PASSWORD` reducer reads `minPasswordLength` (falling back to
|
|
69
|
+
* {@link DEFAULT_MIN_PASSWORD_LENGTH} when unset).
|
|
70
|
+
*
|
|
71
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
72
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
73
|
+
*/
|
|
74
|
+
export interface NickServConfig {
|
|
75
|
+
/**
|
|
76
|
+
* Minimum length a new password must meet to be accepted by
|
|
77
|
+
* `SET PASSWORD`. Omitted → the reducer falls back to
|
|
78
|
+
* {@link DEFAULT_MIN_PASSWORD_LENGTH} (8).
|
|
79
|
+
*/
|
|
80
|
+
readonly minPasswordLength?: number | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
/**
|
|
67
84
|
* Per-deployment server configuration. Adapters load this from a KV store
|
|
68
85
|
* (CF) or Secrets Manager / SSM (AWS); the core only reads it.
|
|
@@ -209,6 +226,19 @@ export interface ServerConfig {
|
|
|
209
226
|
* `exactOptionalPropertyTypes: true`.
|
|
210
227
|
*/
|
|
211
228
|
readonly nickEnforceGraceMs?: number | undefined;
|
|
229
|
+
/**
|
|
230
|
+
* NickServ-specific tunables consulted by the NickServ reducer. Currently
|
|
231
|
+
* only the minimum length enforced by `SET PASSWORD` is configurable
|
|
232
|
+
* (`minPasswordLength`); the maximum (256) is fixed to bound the scrypt
|
|
233
|
+
* input budget.
|
|
234
|
+
*
|
|
235
|
+
* Typed `NickServConfig | undefined` to mirror Zod's optional output
|
|
236
|
+
* shape under `exactOptionalPropertyTypes: true` so inline
|
|
237
|
+
* `ServerConfig` literals in tests may omit it (the reducer falls back
|
|
238
|
+
* to {@link DEFAULT_MIN_PASSWORD_LENGTH} when `minPasswordLength` is
|
|
239
|
+
* unset).
|
|
240
|
+
*/
|
|
241
|
+
readonly nickServ?: NickServConfig | undefined;
|
|
212
242
|
/**
|
|
213
243
|
* HostServ approval mode for `REQUEST <vhost>`. `true` (the parsed
|
|
214
244
|
* default) preserves the legacy auto-approve behaviour — the vhost is
|
|
@@ -235,8 +265,6 @@ export interface Ctx {
|
|
|
235
265
|
readonly ids: IdFactory;
|
|
236
266
|
/** Pre-loaded MOTD source for the `MOTD` reducer. */
|
|
237
267
|
readonly motd: MotdProvider;
|
|
238
|
-
/** SASL account verification source (absent when no accounts are configured). */
|
|
239
|
-
readonly accounts?: AccountStore;
|
|
240
268
|
/**
|
|
241
269
|
* mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
|
|
242
270
|
* is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
|
|
@@ -280,11 +308,13 @@ export interface Ctx {
|
|
|
280
308
|
* marker via {@link ServicesStore.setLastReadMarker}, SASL identify seeds
|
|
281
309
|
* the connection's `lastReadMarkers` from
|
|
282
310
|
* {@link ServicesStore.listReadMarkers} so a reconnect restores the user's
|
|
283
|
-
* read position,
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
311
|
+
* read position, SASL PLAIN consults `services.verifyNick`, SASL EXTERNAL
|
|
312
|
+
* consults `services.verifyCertFP`, and PASS-based account auth
|
|
313
|
+
* (`PASS <nick>:<password>`) consults `services.verifyNick`. Omitted (→
|
|
314
|
+
* `ctx.services` undefined) preserves the no-services behaviour:
|
|
315
|
+
* `PRIVMSG NickServ :…` is not intercepted, read-marker persistence stays
|
|
316
|
+
* session-only, and every SASL/PASS credential verify fails with
|
|
317
|
+
* `904` / silent no-op.
|
|
288
318
|
*/
|
|
289
319
|
readonly services?: ServicesStore;
|
|
290
320
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
@@ -323,7 +353,6 @@ export interface BuildCtxOptions {
|
|
|
323
353
|
clock: Clock;
|
|
324
354
|
ids: IdFactory;
|
|
325
355
|
motd: MotdProvider;
|
|
326
|
-
accounts?: AccountStore;
|
|
327
356
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
328
357
|
messages?: MessageStore;
|
|
329
358
|
history?: NickHistoryStore;
|
|
@@ -349,7 +378,6 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
349
378
|
motd: opts.motd,
|
|
350
379
|
connection: opts.connection,
|
|
351
380
|
connId: opts.connection.id,
|
|
352
|
-
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
353
381
|
...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
|
|
354
382
|
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
355
383
|
...(opts.history !== undefined ? { history: opts.history } : {}),
|